| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545 |
- <template>
- <!-- ========== 新增/编辑弹窗 ========== -->
- <el-dialog
- :model-value="modelValue"
- :title="dialogTitle"
- width="1380px"
- class="dialog-form-layout"
- align-center
- destroy-on-close
- :close-on-click-modal="false"
- >
- <DialogFormBody
- :loading="dialogFormLoading"
- body-class="protocol-dialog-body min-h-[200px]"
- >
- <div class="detail-tabs">
- <div
- v-for="tab in formTabs"
- :key="tab.key"
- class="detail-tab"
- :class="{ active: activeFormTab === tab.key }"
- @click="scrollToSection(tab.key)"
- >
- {{ tab.label }}
- </div>
- </div>
- <el-form
- ref="formRef"
- :model="formData"
- :rules="formRules"
- label-position="top"
- scroll-to-error
- class="protocol-form"
- >
- <!-- Tab 1: 基本信息 -->
- <div id="form-section-basic" class="form-tab-pane">
- <div class="section-card">
- <div class="section-title">
- <div class="section-title-left"><h3>基本信息</h3></div>
- </div>
- <div class="form-grid form-grid--col4">
- <el-form-item label="协议号" prop="protocolNo">
- <el-select
- v-model="formData.protocolNoId"
- class="w-full"
- filterable
- remote
- clearable
- reserve-keyword
- :remote-method="remoteSearchProtocolNos"
- :loading="protocolNoLoading"
- placeholder="输入协议号检索"
- @change="onProtocolNoChange"
- @clear="onProtocolNoClear"
- >
- <el-option
- v-for="p in protocolNoOptions"
- :key="p.id"
- :label="protocolNoOptionLabel(p)"
- :value="p.id"
- :disabled="isProtocolNoOptionDisabled(p)"
- />
- </el-select>
- </el-form-item>
- <el-form-item label="协议状态" prop="protocolStatus">
- <el-select
- v-model="formData.protocolStatus"
- placeholder="请选择"
- style="width: 100%"
- >
- <el-option label="待生效" value="dsx" />
- <el-option label="生效中" value="sxz" />
- <el-option label="已过期" value="ygq" />
- <el-option label="已作废" value="yzf" />
- <el-option label="转科研" value="zky" />
- </el-select>
- </el-form-item>
- <el-form-item label="登记日期" prop="registerDate">
- <el-date-picker
- v-model="formData.registerDate"
- type="date"
- value-format="YYYYMMDD"
- format="YYYY-MM-DD"
- placeholder="选择登记日期"
- clearable
- style="width: 100%"
- />
- </el-form-item>
- <el-form-item label="采集专员" prop="collectorUserId">
- <el-select
- v-model="collectorUserId"
- class="w-full"
- filterable
- remote
- clearable
- reserve-keyword
- :remote-method="remoteSearchCollectorUsers"
- :loading="collectorLoading"
- placeholder="输入姓名检索用户"
- @change="onCollectorUserChange"
- >
- <el-option
- v-for="u in collectorUserOptions"
- :key="u.id"
- :label="u.nickName + (u.phoneNo ? ' · ' + u.phoneNo : '')"
- :value="u.id"
- />
- </el-select>
- </el-form-item>
- </div>
- <div class="form-grid form-grid--col2 basic-remark-attach-row">
- <el-form-item label="备注">
- <el-input
- v-model="formData.remark"
- type="textarea"
- :rows="3"
- placeholder="可选"
- />
- </el-form-item>
- <el-form-item label="协议附件">
- <div class="attachment-upload">
- <div class="attachment-list">
- <el-upload
- ref="attachmentUploadRef"
- :auto-upload="false"
- :show-file-list="false"
- :multiple="true"
- accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
- @change="handleAttachmentChange"
- >
- <div class="upload-card">
- <el-icon :size="28" color="#10b981"><Plus /></el-icon>
- <span class="upload-card-text">上传附件</span>
- </div>
- </el-upload>
- <div
- v-for="(file, index) in formData.attachments"
- :key="file.id || index"
- class="attachment-item"
- :class="{ 'is-uploading': file._uploading }"
- >
- <div
- class="attachment-preview"
- @click="
- isImageFile(file) && file.fileUrl
- ? handlePreviewAttachment(file)
- : undefined
- "
- >
- <template v-if="isImageFile(file) && file.fileUrl">
- <img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
- </template>
- <template v-else-if="isImageFile(file) && !file.fileUrl">
- <el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
- </template>
- <template v-else-if="isVideoFile(file)">
- <div class="attachment-preview__icon attachment-preview__icon--video">
- <el-icon :size="28"><VideoPlay /></el-icon>
- </div>
- </template>
- <template v-else>
- <div class="attachment-preview__icon attachment-preview__icon--doc">
- <el-icon :size="28"><Document /></el-icon>
- </div>
- </template>
- </div>
- <div class="attachment-footer">
- <span class="attachment-name" :title="file.fileName">{{
- file.fileName
- }}</span>
- <div class="attachment-actions">
- <el-icon
- class="action-btn"
- title="下载"
- @click="handleDownloadAttachment(file)"
- ><Download
- /></el-icon>
- <el-icon
- class="action-btn action-btn--danger"
- title="删除"
- @click="removeAttachment(index)"
- ><Delete
- /></el-icon>
- </div>
- </div>
- </div>
- </div>
- <span class="upload-hint"
- >支持图片、视频、PDF、Word、Excel等文件</span
- >
- </div>
- </el-form-item>
- </div>
- </div>
- </div>
- <!-- Tab 2: 客户信息 -->
- <div id="form-section-party" class="form-tab-pane">
- <div class="section-card section-card--green">
- <div class="section-title">
- <div class="section-title-left"><h3>客户信息</h3></div>
- </div>
- <div class="client-sub-cards">
- <!-- 母亲信息 -->
- <div class="client-sub-card">
- <div class="sub-card-header">
- <span class="icon">🤱</span>
- <h4>母亲信息</h4>
- <span class="search-link">关联客户 ▸</span>
- </div>
- <el-form-item label="关联客户" style="margin-bottom: 12px">
- <el-select
- v-model="customerId"
- class="w-full"
- filterable
- remote
- clearable
- reserve-keyword
- :remote-method="remoteSearchCustomers"
- :loading="customerLoading"
- placeholder="输入姓名/电话/身份证号检索客户"
- @change="onCustomerChange"
- >
- <el-option
- v-for="c in customerOptions"
- :key="c.id"
- :label="formatCustomerLabel(c)"
- :value="c.id"
- />
- </el-select>
- </el-form-item>
- <div class="form-grid form-grid--col2">
- <el-form-item label="母亲姓名">
- <el-input v-model="formData.motherName" placeholder="请输入" />
- </el-form-item>
- <el-form-item label="母亲联系电话">
- <el-input v-model="formData.motherPhone" placeholder="请输入" />
- </el-form-item>
- <el-form-item label="母亲身份证号" class="form-col-span-2">
- <el-input v-model="formData.motherIdCard" placeholder="请输入" />
- </el-form-item>
- <el-form-item label="母亲出生年月">
- <el-date-picker
- v-model="formData.motherBirthYm"
- type="month"
- value-format="YYYYMM"
- format="YYYY-MM"
- placeholder="选择月份"
- clearable
- style="width: 100%"
- />
- </el-form-item>
- <el-form-item label="母亲民族">
- <el-select
- v-model="formData.motherEthnic"
- class="w-full"
- filterable
- clearable
- :loading="ethnicLoading"
- placeholder="请选择或检索民族"
- >
- <el-option
- v-for="e in ethnicOptions"
- :key="e.ethnicCode"
- :label="e.ethnicName"
- :value="e.ethnicName"
- />
- </el-select>
- </el-form-item>
- </div>
- <el-form-item label="母亲家庭住址" style="margin-top: 12px">
- <el-input v-model="formData.motherAddress" placeholder="请输入" />
- </el-form-item>
- </div>
- <!-- 父亲信息 -->
- <div class="client-sub-card">
- <div class="sub-card-header">
- <span class="icon">👨</span>
- <h4>父亲信息</h4>
- </div>
- <div class="form-grid form-grid--col2">
- <el-form-item label="父亲姓名">
- <el-input v-model="formData.fatherName" placeholder="请输入" />
- </el-form-item>
- <el-form-item label="父亲电话">
- <el-input v-model="formData.fatherPhone" placeholder="请输入" />
- </el-form-item>
- <el-form-item label="父亲身份证号" class="form-col-span-2">
- <el-input v-model="formData.fatherIdCard" placeholder="请输入" />
- </el-form-item>
- </div>
- </div>
- <!-- 新生儿信息 -->
- <div class="client-sub-card">
- <div class="sub-card-header">
- <span class="icon">👶</span>
- <h4>新生儿信息</h4>
- </div>
- <div class="form-grid form-grid--col2">
- <el-form-item label="新生儿姓名">
- <el-input v-model="formData.newbornName" placeholder="请输入" />
- </el-form-item>
- <el-form-item label="新生儿性别">
- <el-radio-group v-model="formData.newbornSex">
- <el-radio label="1">男</el-radio>
- <el-radio label="0">女</el-radio>
- </el-radio-group>
- </el-form-item>
- <el-form-item label="新生儿分娩时间" class="form-col-span-2">
- <el-date-picker
- v-model="formData.newbornDeliveryTime"
- type="datetime"
- value-format="YYYY-MM-DD HH:mm:ss"
- placeholder="选择分娩时间"
- clearable
- style="width: 100%"
- />
- </el-form-item>
- <el-form-item label="新生儿身份证" class="form-col-span-2">
- <el-input v-model="formData.newbornIdCard" placeholder="请输入" />
- </el-form-item>
- <el-form-item label="分娩方式">
- <el-select
- v-model="formData.deliveryMode"
- placeholder="请选择"
- clearable
- style="width: 100%"
- >
- <el-option label="顺产" value="sc" />
- <el-option label="剖宫产" value="pgc" />
- </el-select>
- </el-form-item>
- <el-form-item label="胎次">
- <el-input
- v-model="formData.birthOrder"
- type="number"
- step="any"
- :min="1"
- style="width: 100%"
- />
- </el-form-item>
- <el-form-item label="新生儿体重(克)">
- <el-input
- v-model="formData.newbornWeightGram"
- type="number"
- step="any"
- :min="0"
- style="width: 100%"
- />
- </el-form-item>
- <el-form-item label="新生儿评分1分钟">
- <el-input
- v-model="formData.apgar1min"
- type="number"
- step="any"
- :min="0"
- :max="10"
- style="width: 100%"
- />
- </el-form-item>
- <el-form-item label="新生儿评分5分钟">
- <el-input
- v-model="formData.apgar5min"
- type="number"
- step="any"
- :min="0"
- :max="10"
- style="width: 100%"
- />
- </el-form-item>
- </div>
- </div>
- </div>
- </div>
- </div>
- <!-- Tab 3: 采集信息 -->
- <div id="form-section-collection" class="form-tab-pane">
- <div class="section-card">
- <div class="section-title">
- <div class="section-title-left"><h3>采集信息</h3></div>
- </div>
- <div class="form-grid">
- <el-form-item label="采集地区">
- <el-select
- v-model="formData.regionId"
- class="w-full"
- filterable
- remote
- clearable
- reserve-keyword
- :remote-method="remoteSearchRegions"
- :loading="regionLoading"
- placeholder="输入地区名称检索"
- @change="onRegionChange"
- @clear="onRegionClear"
- >
- <el-option
- v-for="r in regionOptions"
- :key="r.id"
- :label="r.regionName"
- :value="r.id"
- />
- </el-select>
- </el-form-item>
- <el-form-item label="采集医院">
- <el-select
- v-model="formData.hospitalId"
- class="w-full"
- filterable
- remote
- clearable
- reserve-keyword
- :remote-method="remoteSearchHospitals"
- :loading="hospitalLoading"
- :disabled="!formData.regionId"
- placeholder="请先选择采集地区"
- @change="onHospitalChange"
- >
- <el-option
- v-for="h in hospitalOptions"
- :key="h.id"
- :label="h.hospitalName"
- :value="h.id"
- />
- </el-select>
- </el-form-item>
- <el-form-item label="采集医护">
- <el-input v-model="formData.medicalStaff" placeholder="请输入" />
- </el-form-item>
- <el-form-item label="销售人员">
- <el-select
- v-model="salesUserId"
- class="w-full"
- filterable
- remote
- clearable
- reserve-keyword
- :remote-method="remoteSearchSalesUsers"
- :loading="salesLoading"
- placeholder="输入姓名检索用户"
- @change="onSalesUserChange"
- >
- <el-option
- v-for="u in salesUserOptions"
- :key="u.id"
- :label="u.nickName + (u.phoneNo ? ' · ' + u.phoneNo : '')"
- :value="u.id"
- />
- </el-select>
- </el-form-item>
- <el-form-item label="样本采集时间">
- <el-date-picker
- v-model="formData.sampleCollectTime"
- type="datetime"
- value-format="YYYY-MM-DD HH:mm:ss"
- placeholder="选择采集时间"
- clearable
- style="width: 100%"
- />
- </el-form-item>
- <el-form-item label="母血采集时间">
- <el-date-picker
- v-model="formData.maternalBloodCollectTime"
- type="datetime"
- value-format="YYYY-MM-DD HH:mm:ss"
- placeholder="选择采集时间"
- clearable
- style="width: 100%"
- />
- </el-form-item>
- </div>
- </div>
- </div>
- <!-- Tab 4: 样本及运输信息 -->
- <div id="form-section-sample" class="form-tab-pane">
- <div class="section-card">
- <div class="section-title">
- <div class="section-title-left"><h3>样本及运输信息</h3></div>
- </div>
- <div class="sample-toolbar">
- <el-button type="primary" @click="addSample">
- <el-icon><Plus /></el-icon>
- 添加样本
- </el-button>
- <el-button
- class="btn-ghost"
- :disabled="!selectedSamples.length"
- @click="openBatchTransportDialog"
- >
- 应用到选中样本
- </el-button>
- </div>
- <p v-if="formData.samples.length" class="sample-transport-hint"
- >勾选样本后,点击「应用到选中样本」可批量填写运输信息。点击展开可编辑单个运输信息。</p
- >
- <!-- Sample Cards -->
- <div
- v-for="(row, $index) in formData.samples"
- :key="row._rowKey"
- class="sample-card"
- >
- <div class="sample-header">
- <el-checkbox
- :model-value="selectedSamples.includes(row)"
- @change="(val) => val ? (!selectedSamples.includes(row) && selectedSamples.push(row)) : selectedSamples.splice(selectedSamples.indexOf(row), 1)"
- />
- <div class="sample-title">
- <span class="sample-num">样本 #{{ $index + 1 }} ·</span>
- {{ sampleRowLabel(row, $index) }}
- </div>
- <div class="sample-actions">
- <el-button
- link
- type="danger"
- size="small"
- @click="removeSample($index)"
- >
- <el-icon><Delete /></el-icon>
- </el-button>
- </div>
- </div>
- <div class="form-grid form-grid--col4">
- <el-form-item label="样本类型">
- <el-select
- v-model="row.sampleTypeId"
- placeholder="选择类型"
- style="width: 100%"
- @change="onSampleTypeChange(row)"
- >
- <el-option
- v-for="st in sampleTypeOptions"
- :key="st.id"
- :label="st.typeName + (st.typeCode ? ' (' + st.typeCode + ')' : '')"
- :value="st.id"
- />
- </el-select>
- </el-form-item>
- <el-form-item label="样本编码">
- <el-select
- v-model="row.sampleCodeId"
- placeholder="选择编码"
- filterable
- style="width: 100%"
- @change="onSampleCodeChange(row)"
- >
- <el-option
- v-for="sc in sampleCodeOptions"
- :key="sc.id"
- :label="sampleCodeOptionLabel(sc)"
- :value="sc.id"
- :disabled="isSampleCodeOptionDisabled(sc, row)"
- />
- </el-select>
- </el-form-item>
- <el-form-item label="采集量">
- <el-input
- v-model="row.collectAmount"
- type="number"
- step="any"
- :min="0"
- style="width: 100%"
- />
- </el-form-item>
- <el-form-item label="单位">
- <el-select
- v-model="row.unitId"
- placeholder="单位"
- filterable
- style="width: 100%"
- >
- <el-option
- v-for="u in unitOptions"
- :key="u.id"
- :label="u.unitName"
- :value="u.id"
- />
- </el-select>
- </el-form-item>
- <el-form-item label="价格(元)">
- <AmountInput
- v-model="row.price"
- :min="0"
- placeholder="请输入价格"
- @change="onSamplePriceChange"
- />
- </el-form-item>
- <el-form-item label="存储年限(年)">
- <el-input
- v-model="row.storageYears"
- type="number"
- step="any"
- :min="0"
- style="width: 100%"
- />
- </el-form-item>
- <el-form-item label="样本状态">
- <el-select
- v-model="row.sampleStatus"
- placeholder="状态"
- clearable
- style="width: 100%"
- @change="onSampleStatusChange(row)"
- >
- <el-option label="已采集" value="ycj" />
- <el-option label="检测中" value="jcz" />
- <el-option label="已入库" value="yrk" />
- <el-option label="已作废" value="yzf" />
- </el-select>
- </el-form-item>
- </div>
- <!-- Transport Toggle -->
- <div
- class="transport-toggle"
- :class="{ expanded: row._transportExpanded }"
- @click="row._transportExpanded = !row._transportExpanded"
- >
- <span class="chevron">▶</span>
- 运输信息
- <span class="summary">
- {{ row.transport?.transportType ? transportTypeLabel(String(row.transport.transportType)) : '' }}
- {{ row.transport?.destSiteId ? '→ ' + siteName(String(row.transport.destSiteId)) : '' }}
- </span>
- </div>
- <div
- class="transport-body"
- :class="{ open: row._transportExpanded }"
- >
- <div
- class="sample-transport-form-row"
- :class="{
- 'sample-transport-form-row--ryxd': row.transport?.transportType === 'ryxd'
- }"
- >
- <el-form-item label="运输类型">
- <el-select
- v-model="row.transport.transportType"
- placeholder="请选择"
- clearable
- style="width: 100%"
- >
- <el-option label="快递运输" value="kdys" />
- <el-option label="人员携带" value="ryxd" />
- </el-select>
- </el-form-item>
- <el-form-item label="目的站点">
- <el-select
- v-model="row.transport.destSiteId"
- placeholder="选择站点"
- filterable
- clearable
- style="width: 100%"
- >
- <el-option
- v-for="site in siteOptions"
- :key="site.id"
- :label="site.siteName"
- :value="site.id"
- />
- </el-select>
- </el-form-item>
- <el-form-item label="预计到达时间">
- <el-date-picker
- v-model="row.transport.etaTime"
- type="datetime"
- value-format="YYYY-MM-DD HH:mm:ss"
- format="YYYY-MM-DD HH:mm"
- placeholder="选择预计到达时间"
- clearable
- style="width: 100%"
- />
- </el-form-item>
- <template v-if="row.transport?.transportType === 'kdys'">
- <el-form-item label="快递公司">
- <el-select
- v-model="row.transport.expressCompanyId"
- placeholder="选择快递公司"
- filterable
- clearable
- style="width: 100%"
- >
- <el-option
- v-for="ec in expressCompanyOptions"
- :key="ec.id"
- :label="ec.expressName"
- :value="ec.id"
- />
- </el-select>
- </el-form-item>
- <el-form-item label="快递单号">
- <el-input
- v-model="row.transport.waybillNo"
- placeholder="请输入快递单号"
- />
- </el-form-item>
- <el-form-item label="快递费用(元)">
- <el-input
- v-model="row.transport.expressFee"
- type="number"
- step="any"
- :min="0"
- style="width: 100%"
- />
- </el-form-item>
- </template>
- <template v-if="row.transport?.transportType === 'ryxd'">
- <el-form-item label="携带人员姓名">
- <el-input
- v-model="row.transport.carrierName"
- placeholder="请输入姓名"
- />
- </el-form-item>
- <el-form-item label="携带人员电话">
- <el-input
- v-model="row.transport.carrierPhone"
- placeholder="请输入电话"
- />
- </el-form-item>
- </template>
- </div>
- </div>
- </div>
- <el-empty
- v-if="!formData.samples.length"
- description="暂无样本,点击上方按钮添加"
- :image-size="48"
- />
- </div>
- </div>
- <!-- Tab 5: 缴费信息 -->
- <div id="form-section-payment" class="form-tab-pane">
- <div class="section-card">
- <div class="section-title">
- <div class="section-title-left"><h3>缴费信息</h3></div>
- </div>
- <!-- 缴费主表 -->
- <div class="payment-main-card">
- <div class="section-subtitle">缴费主表</div>
- <div class="form-grid form-grid--col5">
- <el-form-item label="缴费模式">
- <el-radio-group v-model="formData.payMode" @change="onPayModeChange">
- <el-radio label="dj">趸交</el-radio>
- <el-radio label="fq">分期</el-radio>
- </el-radio-group>
- </el-form-item>
- <el-form-item label="合同金额(元)">
- <el-input
- v-model="formData.contractAmount"
- disabled
- placeholder="自动计算"
- style="width: 100%"
- />
- </el-form-item>
- <template v-if="formData.payMode === 'dj'">
- <el-form-item label="趸交截止时间">
- <el-date-picker
- v-model="formData.lumpSumDeadline"
- type="date"
- value-format="YYYYMMDD"
- format="YYYY-MM-DD"
- placeholder="选择截止时间"
- clearable
- style="width: 100%"
- />
- </el-form-item>
- </template>
- <template v-if="formData.payMode === 'fq'">
- <el-form-item label="分期期数">
- <el-input
- v-model="formData.installmentCount"
- type="number"
- step="any"
- :min="1"
- style="width: 100%"
- @change="tryGenerateInstallmentDetails"
- />
- </el-form-item>
- <el-form-item label="每期间隔(月)">
- <el-input
- v-model="formData.installmentIntervalMonths"
- type="number"
- step="any"
- :min="1"
- style="width: 100%"
- @change="tryGenerateInstallmentDetails"
- />
- </el-form-item>
- <el-form-item label="分期开始时间">
- <el-date-picker
- v-model="formData.installmentStartDate"
- type="date"
- value-format="YYYYMMDD"
- format="YYYY-MM-DD"
- placeholder="选择开始时间"
- clearable
- style="width: 100%"
- @change="tryGenerateInstallmentDetails"
- />
- </el-form-item>
- </template>
- </div>
- </div>
- <!-- 缴费明细 -->
- <div class="section-subtitle" style="margin-top: 20px">缴费明细</div>
- <div style="margin-bottom: 14px">
- <el-button type="primary" @click="addPaymentDetail">
- <el-icon><Plus /></el-icon>
- 添加明细
- </el-button>
- </div>
- <div class="payment-details">
- <div
- v-for="(detail, dIdx) in formData.paymentDetails"
- :key="dIdx"
- class="detail-card"
- :class="{ expanded: detail._expanded }"
- >
- <div
- class="detail-header"
- role="button"
- tabindex="0"
- @click="togglePaymentDetailExpand(dIdx)"
- @keydown.enter.prevent="togglePaymentDetailExpand(dIdx)"
- >
- <span class="detail-chevron">▶</span>
- <span class="detail-title">缴费明细 #{{ dIdx + 1 }}</span>
- <span
- v-if="detail.dueAmount != null && detail.dueAmount !== ''"
- class="detail-amount"
- :class="detail.payStatus === 'yjf' ? 'green' : 'amber'"
- >
- {{ fmtMoney(detail.dueAmount) }}
- </span>
- <span
- v-if="detail.payStatus"
- class="detail-badge"
- :class="{
- 'badge-green': detail.payStatus === 'yjf',
- 'badge-amber': detail.payStatus === 'dfj' || detail.payStatus === 'jfqrz',
- 'badge-gray': detail.payStatus === 'tfqrz' || detail.payStatus === 'ytf'
- }"
- >
- {{ payStatusLabel(String(detail.payStatus)) }}
- </span>
- <span v-if="!detail._expanded" class="detail-summary">
- {{ paymentDetailSummary(detail) }}
- </span>
- <el-button
- link
- type="danger"
- size="small"
- class="detail-delete"
- @click.stop="formData.paymentDetails.splice(dIdx, 1)"
- >
- <el-icon><Delete /></el-icon>
- </el-button>
- </div>
- <div v-show="detail._expanded" class="detail-body">
- <div class="detail-body-inner">
- <div class="form-grid form-grid--col4">
- <el-form-item label="应缴费金额(元)">
- <el-input
- v-model="detail.dueAmount"
- type="number"
- step="any"
- :min="0"
- disabled
- style="width: 100%"
- />
- </el-form-item>
- <el-form-item label="转账方式">
- <el-select
- v-model="detail.transferType"
- placeholder="请选择转账方式"
- style="width: 100%"
- >
- <el-option label="微信" value="wxzz" />
- <el-option label="支付宝" value="zzbzz" />
- <el-option label="银行卡" value="yhkzz" />
- <el-option label="现金" value="xj" />
- </el-select>
- </el-form-item>
- <el-form-item label="转账金额(元)">
- <AmountInput
- v-model="detail.transferAmount"
- :min="0"
- placeholder="请输入转账金额"
- />
- </el-form-item>
- <el-form-item label="转账时间">
- <el-date-picker
- v-model="detail.transferTime"
- type="datetime"
- value-format="YYYY-MM-DD HH:mm:ss"
- placeholder="选择时间"
- clearable
- style="width: 100%"
- />
- </el-form-item>
- <el-form-item label="缴费状态">
- <el-select
- v-model="detail.payStatus"
- placeholder="状态"
- style="width: 100%"
- >
- <el-option label="待缴费" value="dfj" />
- <el-option label="缴费确认中" value="jfqrz" />
- <el-option label="已缴费" value="yjf" />
- <el-option label="退费确认中" value="tfqrz" />
- <el-option label="已退费" value="ytf" />
- </el-select>
- </el-form-item>
- <el-form-item label="应缴费时间">
- <el-date-picker
- v-model="detail.installmentDueDate"
- type="date"
- value-format="YYYYMMDD"
- format="YYYY-MM-DD"
- placeholder="选择应缴费时间"
- clearable
- style="width: 100%"
- />
- </el-form-item>
- </div>
- <!-- 每条明细的附件上传区 -->
- <div class="payment-detail-card__attachments">
- <div class="attachment-upload">
- <div class="attachment-list">
- <el-upload
- :auto-upload="false"
- :show-file-list="false"
- :multiple="true"
- accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
- @change="(f: any) => handleDetailAttachmentChange(dIdx, f)"
- >
- <div class="upload-card upload-card--sm">
- <el-icon :size="22" color="#10b981"><Plus /></el-icon>
- <span class="upload-card-text">上传凭证</span>
- </div>
- </el-upload>
- <div
- v-for="(file, fIdx) in detail.attachments"
- :key="file.id || fIdx"
- class="attachment-item"
- :class="{ 'is-uploading': file._uploading }"
- >
- <div
- class="attachment-preview"
- @click="
- isImageFile(file) && file.fileUrl
- ? handlePreviewAttachment(file)
- : undefined
- "
- >
- <template v-if="isImageFile(file) && file.fileUrl">
- <img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
- </template>
- <template v-else-if="isImageFile(file) && !file.fileUrl">
- <el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
- </template>
- <template v-else-if="isVideoFile(file)">
- <div
- class="attachment-preview__icon attachment-preview__icon--video"
- >
- <el-icon :size="28"><VideoPlay /></el-icon>
- </div>
- </template>
- <template v-else>
- <div class="attachment-preview__icon attachment-preview__icon--doc">
- <el-icon :size="28"><Document /></el-icon>
- </div>
- </template>
- </div>
- <div class="attachment-footer">
- <span class="attachment-name" :title="file.fileName">{{
- file.fileName
- }}</span>
- <div class="attachment-actions">
- <el-icon
- class="action-btn"
- title="下载"
- @click="handleDownloadAttachment(file)"
- ><Download
- /></el-icon>
- <el-icon
- class="action-btn action-btn--danger"
- title="删除"
- @click="removeDetailAttachment(dIdx, fIdx)"
- ><Delete
- /></el-icon>
- </div>
- </div>
- </div>
- </div>
- <span class="upload-hint"
- >支持图片、视频、PDF、Word、Excel等文件</span
- >
- </div>
- </div>
- </div>
- </div>
- </div>
- <el-empty
- v-if="!formData.paymentDetails.length"
- description="暂无缴费明细,点击上方按钮添加"
- :image-size="48"
- />
- </div>
- </div>
- </div>
- </el-form>
- </DialogFormBody>
- <template #footer>
- <el-button class="btn-ghost" @click="emit('update:modelValue', false)">
- <el-icon><Close /></el-icon>
- 取消
- </el-button>
- <el-button class="btn-main" type="primary" :loading="submitLoading" @click="handleSubmit">
- <el-icon><Check /></el-icon>
- 确定
- </el-button>
- </template>
- </el-dialog>
- <!-- ========== 文件预览弹窗 ========== -->
- <el-dialog
- v-model="previewVisible"
- title="文件预览"
- width="800px"
- align-center
- destroy-on-close
- >
- <div v-if="previewType === 'image'" class="preview-container">
- <img
- :src="previewUrl"
- alt="预览"
- style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
- />
- </div>
- <div v-else class="preview-container">
- <video
- :src="previewUrl"
- controls
- style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
- />
- </div>
- </el-dialog>
- <!-- ========== 批量运输信息弹窗 ========== -->
- <el-dialog
- v-model="batchTransportVisible"
- title="批量运输信息"
- width="1200px"
- class="dialog-form-layout"
- align-center
- destroy-on-close
- :close-on-click-modal="false"
- >
- <p class="sample-transport-hint batch-transport-dialog-hint">
- 将以下运输信息应用到已选中的 {{ selectedSamples.length }} 个样本。
- </p>
- <el-form label-position="top" class="biz-dialog-form">
- <div
- class="sample-transport-form-row"
- :class="{
- 'sample-transport-form-row--ryxd': batchTransport.transportType === 'ryxd'
- }"
- >
- <el-form-item label="运输类型">
- <el-select
- v-model="batchTransport.transportType"
- placeholder="请选择"
- clearable
- style="width: 100%"
- >
- <el-option label="快递运输" value="kdys" />
- <el-option label="人员携带" value="ryxd" />
- </el-select>
- </el-form-item>
- <el-form-item label="目的站点">
- <el-select
- v-model="batchTransport.destSiteId"
- placeholder="选择站点"
- filterable
- clearable
- style="width: 100%"
- >
- <el-option
- v-for="site in siteOptions"
- :key="site.id"
- :label="site.siteName"
- :value="site.id"
- />
- </el-select>
- </el-form-item>
- <el-form-item label="预计到达时间">
- <el-date-picker
- v-model="batchTransport.etaTime"
- type="datetime"
- value-format="YYYY-MM-DD HH:mm:ss"
- format="YYYY-MM-DD HH:mm"
- placeholder="选择预计到达时间"
- clearable
- style="width: 100%"
- />
- </el-form-item>
- <template v-if="batchTransport.transportType === 'kdys'">
- <el-form-item label="快递公司">
- <el-select
- v-model="batchTransport.expressCompanyId"
- placeholder="选择快递公司"
- filterable
- clearable
- style="width: 100%"
- >
- <el-option
- v-for="ec in expressCompanyOptions"
- :key="ec.id"
- :label="ec.expressName"
- :value="ec.id"
- />
- </el-select>
- </el-form-item>
- <el-form-item label="快递单号">
- <el-input v-model="batchTransport.waybillNo" placeholder="请输入快递单号" />
- </el-form-item>
- <el-form-item label="快递费用(元)">
- <el-input
- v-model="batchTransport.expressFee"
- type="number"
- step="any"
- :min="0"
- style="width: 100%"
- />
- </el-form-item>
- </template>
- <template v-if="batchTransport.transportType === 'ryxd'">
- <el-form-item label="携带人员姓名">
- <el-input v-model="batchTransport.carrierName" placeholder="请输入姓名" />
- </el-form-item>
- <el-form-item label="携带人员电话">
- <el-input v-model="batchTransport.carrierPhone" placeholder="请输入电话" />
- </el-form-item>
- </template>
- </div>
- </el-form>
- <template #footer>
- <el-button class="btn-ghost" @click="batchTransportVisible = false">
- <el-icon><Close /></el-icon>
- 取消
- </el-button>
- <el-button class="btn-main" type="primary" @click="confirmBatchTransportApply">
- <el-icon><Check /></el-icon>
- 确定应用
- </el-button>
- </template>
- </el-dialog>
- </template>
- <script setup lang="ts">
- import DialogFormBody from '@/components/business/dialog-form-body/index.vue'
- import { ElMessage } from 'element-plus'
- import {
- Plus,
- Close,
- Check,
- Delete,
- Document,
- VideoPlay,
- Loading,
- Download,
- ArrowDown,
- ArrowRight
- } from '@element-plus/icons-vue'
- import {
- fetchCollectionProtocolDetail,
- fetchCreateCollectionProtocol,
- fetchUpdateCollectionProtocol,
- type CollectionProtocolRow
- } from '@/api/collection-protocol'
- import { fetchUploadAttachment } from '@/api/business'
- import { downloadAttachmentFile } from '@/utils/download-attachment'
- import {
- fetchCustomerList,
- fetchUserList,
- fetchUserDetail,
- fetchCellSampleTypeList,
- fetchItemUnitList,
- fetchCollectionRegionList,
- fetchHospitalList,
- fetchCollectionProtocolNoList,
- fetchSampleCodeList,
- fetchExpressCompanyList,
- fetchSiteList
- } from '@/api/admin'
- import { fetchEthnicList, type EthnicOption } from '@/api/ethnic'
- import { useUserStore } from '@/store/modules/user'
- import { syncFqPaymentDetailRowCount } from '@/utils/protocol-payment-installment'
- import AmountInput from '@/components/common/amount-input/index.vue'
- import { formatMoneyDisplay } from '@/utils/money-format'
- import {
- payModeLabel,
- newbornSexLabel,
- deliveryModeLabel,
- sampleStatusLabel,
- transportTypeLabel,
- transferTypeLabel,
- payStatusLabel
- } from '@/utils/status-tag-registry'
- /** 金额只读展示:千分位 + ¥ 前缀 */
- function fmtMoney(v: unknown): string {
- if (v == null || v === '') return '—'
- return '¥' + formatMoneyDisplay(v)
- }
- const userStore = useUserStore()
- const customerId = ref<string | null>(null)
- const customerOptions = ref<
- Array<{ id: string; customerName: string; phoneNo: string; idCardNo: string }>
- >([])
- const customerLoading = ref(false)
- const customerSnapshot = ref<{
- id: string
- customerName: string
- phoneNo: string
- idCardNo: string
- } | null>(null)
- const regionOptions = ref<Api.BizCollectionRegion.RegionItem[]>([])
- const regionLoading = ref(false)
- const hospitalOptions = ref<Api.BizCollectionHospital.HospitalItem[]>([])
- const hospitalLoading = ref(false)
- const collectorUserId = ref<string | null>(null)
- const collectorUserOptions = ref<Api.User.UserItem[]>([])
- const collectorLoading = ref(false)
- const collectorSnapshot = ref<Api.User.UserItem | null>(null)
- const salesUserId = ref<string | null>(null)
- const salesUserOptions = ref<Api.User.UserItem[]>([])
- const salesLoading = ref(false)
- const salesSnapshot = ref<Api.User.UserItem | null>(null)
- const sampleTypeOptions = ref<
- Array<{
- id: string
- typeCode: string
- typeName: string
- storageYears: number | null
- storagePrice: number | null
- }>
- >([])
- const unitOptions = ref<Array<{ id: string; unitName: string; unitCode: string }>>([])
- const protocolNoOptions = ref<Api.BizCollectionProtocolNo.ProtocolNoItem[]>([])
- const protocolNoLoading = ref(false)
- const sampleCodeOptions = ref<Api.BizSampleCode.SampleCodeItem[]>([])
- const sampleCodeLoading = ref(false)
- const expressCompanyOptions = ref<Api.ExpressCompany.ExpressCompanyItem[]>([])
- const siteOptions = ref<Api.Site.SiteItem[]>([])
- const selectedSamples = ref<Array<Record<string, any>>>([])
- let sampleRowSeq = 0
- type SampleTransportForm = {
- transportType: string
- expressCompanyId: string
- waybillNo: string
- destSiteId: string
- expressFee: number | null
- etaTime: string
- carrierName: string
- carrierPhone: string
- }
- function createEmptyTransport(): SampleTransportForm {
- return {
- transportType: '',
- expressCompanyId: '',
- waybillNo: '',
- destSiteId: '',
- expressFee: null,
- etaTime: '',
- carrierName: '',
- carrierPhone: ''
- }
- }
- const batchTransport = reactive<SampleTransportForm>(createEmptyTransport())
- const batchTransportVisible = ref(false)
- const ethnicOptions = ref<EthnicOption[]>([])
- const ethnicLoading = ref(false)
- const DEFAULT_MOTHER_ETHNIC = '汉族'
- const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
- function fullFileUrl(suffix: string) {
- if (!suffix) return ''
- if (suffix.startsWith('http://') || suffix.startsWith('https://') || suffix.startsWith('/'))
- return suffix
- return `${minioBaseUrl}/${suffix}`
- }
- async function loadEthnicOptions() {
- if (ethnicOptions.value.length) return
- ethnicLoading.value = true
- try {
- const res = await fetchEthnicList({ limit: 200 })
- ethnicOptions.value = res.list || []
- } finally {
- ethnicLoading.value = false
- }
- }
- function ensureEthnicOptionInList(name: string) {
- const n = (name || '').trim()
- if (!n) return
- if (ethnicOptions.value.some((e) => e.ethnicName === n)) return
- ethnicOptions.value = [
- { id: '', ethnicName: n, ethnicCode: '', orderNum: null },
- ...ethnicOptions.value
- ]
- }
- defineOptions({ name: 'ProtocolFormDialog' })
- const props = defineProps<{
- modelValue: boolean
- editId: string | null
- listRow?: CollectionProtocolRow | null
- }>()
- const emit = defineEmits<{
- (e: 'update:modelValue', val: boolean): void
- (e: 'saved'): void
- }>()
- function siteName(id: string) {
- if (!id) return '-'
- return siteOptions.value.find((x) => x.id === id)?.siteName || '-'
- }
- function expressName(id: string) {
- if (!id) return '-'
- return expressCompanyOptions.value.find((x) => x.id === id)?.expressName || '-'
- }
- function sampleTransportExtra(transport: Record<string, any> | undefined) {
- if (!transport?.transportType) return '-'
- if (transport.transportType === 'kdys') {
- const parts: string[] = []
- const ec = expressName(String(transport.expressCompanyId || ''))
- if (ec !== '-') parts.push(ec)
- if (transport.waybillNo) parts.push(String(transport.waybillNo))
- if (transport.expressFee != null && transport.expressFee !== '') {
- parts.push(`¥${transport.expressFee}`)
- }
- return parts.length ? parts.join(' · ') : '-'
- }
- if (transport.transportType === 'ryxd') {
- const parts: string[] = []
- if (transport.carrierName) parts.push(String(transport.carrierName))
- if (transport.carrierPhone) parts.push(String(transport.carrierPhone))
- return parts.length ? parts.join(' · ') : '-'
- }
- return '-'
- }
- function sampleRowLabel(row: Record<string, any>, index: number) {
- if (row.sampleCode) return row.sampleCode
- const st = sampleTypeOptions.value.find((x) => x.id === row.sampleTypeId)
- if (st?.typeName) return st.typeName
- return `样本 ${index + 1}`
- }
- function formatPaymentDateYmd(val: string) {
- const s = String(val || '').trim()
- if (s.length === 8 && /^\d{8}$/.test(s)) {
- return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`
- }
- return s || ''
- }
- function initPaymentDetailExpandFlags(details: Array<Record<string, any>>) {
- details.forEach((d, i) => {
- d._expanded = i === 0
- })
- }
- function paymentDetailSummary(detail: Record<string, any>) {
- const parts: string[] = []
- if (detail.dueAmount != null && detail.dueAmount !== '') {
- parts.push(`应缴 ¥${detail.dueAmount}`)
- }
- if (detail.transferType) {
- parts.push(transferTypeLabel(String(detail.transferType)))
- }
- if (detail.transferAmount != null && detail.transferAmount !== '') {
- parts.push(`转账 ¥${detail.transferAmount}`)
- }
- if (detail.transferTime) {
- parts.push(String(detail.transferTime))
- }
- if (detail.payStatus) {
- parts.push(payStatusLabel(String(detail.payStatus)))
- }
- if (detail.installmentDueDate) {
- parts.push(`应缴日 ${formatPaymentDateYmd(String(detail.installmentDueDate))}`)
- }
- const attCount = Array.isArray(detail.attachments) ? detail.attachments.length : 0
- if (attCount > 0) {
- parts.push(`凭证 ${attCount} 个`)
- }
- return parts.length ? parts.join(' · ') : '暂无填写'
- }
- function togglePaymentDetailExpand(dIdx: number) {
- const d = formData.paymentDetails[dIdx]
- if (d) {
- d._expanded = !d._expanded
- }
- }
- /* ====== Tab 常量 ====== */
- const formTabs = [
- { key: 'basic', label: '基本信息' },
- { key: 'party', label: '客户信息' },
- { key: 'collection', label: '采集信息' },
- { key: 'sample', label: '样本及运输信息' },
- { key: 'payment', label: '缴费信息' }
- ] as const
- /* ====== 新增/编辑弹窗 ====== */
- const dialogFormLoading = ref(false)
- const dialogTitle = ref('新增协议单')
- const isEdit = ref(false)
- const submitLoading = ref(false)
- const editingId = ref<string | null>(null)
- const formRef = ref()
- const activeFormTab = ref('basic')
- const formData = reactive({
- // Basic
- protocolNo: '',
- protocolNoId: '',
- protocolStatus: 'dsx',
- registerDate: '',
- collectorUserId: '',
- protocolAttachments: '',
- remark: '',
- attachments: [] as Array<{
- id?: string
- fileName: string
- fileUrl: string
- fileSize: number
- fileSizeDisplay?: string
- fileType?: string
- _uploading?: boolean
- }>,
- // Party Customer
- customerId: '' as string,
- motherName: '',
- motherPhone: '',
- motherIdCard: '',
- motherBirthYm: '',
- motherEthnic: '',
- motherAddress: '',
- fatherName: '',
- fatherPhone: '',
- fatherIdCard: '',
- newbornName: '',
- newbornIdCard: '',
- newbornDeliveryTime: '',
- newbornSex: '',
- deliveryMode: '',
- birthOrder: null as number | null,
- newbornWeightGram: null as number | null,
- apgar1min: null as number | null,
- apgar5min: null as number | null,
- // Collection Info
- regionId: '',
- hospitalId: '',
- medicalStaff: '',
- salesUserId: '',
- sampleCollectTime: '',
- maternalBloodCollectTime: '',
- // Samples
- samples: [] as Array<Record<string, any>>,
- // Payment
- payMode: 'dj',
- contractAmount: null as number | null,
- installmentCount: null as number | null,
- installmentIntervalMonths: null as number | null,
- lumpSumDeadline: '',
- installmentStartDate: '',
- // Payment Details
- paymentDetails: [] as Array<Record<string, any>>
- })
- const formRules = {
- protocolNo: [{ required: true, message: '请输入协议号', trigger: 'blur' }],
- protocolStatus: [{ required: true, message: '请选择协议状态', trigger: 'change' }]
- }
- function resetForm() {
- Object.assign(formData, {
- protocolNo: '',
- protocolNoId: '',
- protocolStatus: 'dsx',
- registerDate: '',
- collectorUserId: '',
- protocolAttachments: '',
- attachments: [],
- remark: '',
- customerId: '',
- motherName: '',
- motherPhone: '',
- motherIdCard: '',
- motherBirthYm: '',
- motherEthnic: '',
- motherAddress: '',
- fatherName: '',
- fatherPhone: '',
- fatherIdCard: '',
- newbornName: '',
- newbornIdCard: '',
- newbornDeliveryTime: '',
- newbornSex: '',
- deliveryMode: '',
- birthOrder: null,
- newbornWeightGram: null,
- apgar1min: null,
- apgar5min: null,
- regionId: '',
- hospitalId: '',
- medicalStaff: '',
- salesUserId: '',
- sampleCollectTime: '',
- maternalBloodCollectTime: '',
- samples: [],
- payMode: 'dj',
- contractAmount: null,
- installmentCount: null,
- installmentIntervalMonths: null,
- lumpSumDeadline: '',
- installmentStartDate: '',
- paymentDetails: []
- })
- customerId.value = null
- customerOptions.value = []
- customerSnapshot.value = null
- collectorUserId.value = null
- collectorUserOptions.value = []
- collectorSnapshot.value = null
- salesUserId.value = null
- salesUserOptions.value = []
- salesSnapshot.value = null
- regionOptions.value = []
- hospitalOptions.value = []
- protocolNoOptions.value = []
- selectedSamples.value = []
- batchTransportVisible.value = false
- Object.assign(batchTransport, createEmptyTransport())
- activeFormTab.value = 'basic'
- }
- function scrollToSection(key: string) {
- activeFormTab.value = key
- const el = document.getElementById(`form-section-${key}`)
- if (el) {
- el.scrollIntoView({ behavior: 'smooth', block: 'start' })
- }
- }
- function normalizeSampleRow(raw: Record<string, any>, index: number) {
- const transportRaw = raw.transport && typeof raw.transport === 'object' ? raw.transport : {}
- const statusRaw = raw.sampleStatus
- const sampleStatus =
- statusRaw != null && String(statusRaw).trim() !== '' ? String(statusRaw).trim() : ''
- return {
- ...raw,
- sampleStatus,
- _rowKey: raw.id || raw._rowKey || `new-${++sampleRowSeq}`,
- transport: {
- ...createEmptyTransport(),
- transportType: transportRaw.transportType || '',
- expressCompanyId: transportRaw.expressCompanyId || '',
- waybillNo: transportRaw.waybillNo || '',
- destSiteId: transportRaw.destSiteId || '',
- expressFee: transportRaw.expressFee ?? null,
- etaTime: transportRaw.etaTime || '',
- carrierName: transportRaw.carrierName || '',
- carrierPhone: transportRaw.carrierPhone || ''
- }
- }
- }
- function onSampleSelectionChange(rows: Array<Record<string, any>>) {
- selectedSamples.value = rows
- }
- function openBatchTransportDialog() {
- if (!selectedSamples.value.length) {
- ElMessage.warning('请先勾选需要应用运输信息的样本')
- return
- }
- batchTransportVisible.value = true
- }
- function confirmBatchTransportApply() {
- if (!batchTransport.transportType) {
- ElMessage.warning('请选择运输类型')
- return
- }
- const payload = { ...batchTransport }
- for (const row of selectedSamples.value) {
- if (!row.transport) row.transport = createEmptyTransport()
- Object.assign(row.transport, payload)
- }
- batchTransportVisible.value = false
- ElMessage.success(`已为 ${selectedSamples.value.length} 个样本应用运输信息`)
- }
- function addSample() {
- formData.samples.push(
- normalizeSampleRow(
- {
- sampleTypeId: '',
- sampleCode: '',
- sampleCodeId: '',
- collectAmount: null,
- unitId: '',
- price: null,
- storageYears: null,
- sampleStatus: 'ycj'
- },
- formData.samples.length
- )
- )
- }
- function removeSample(index: number) {
- formData.samples.splice(index, 1)
- syncContractAmount()
- }
- function addPaymentDetail() {
- const now = new Date()
- const pad = (n: number) => String(n).padStart(2, '0')
- const defaultTime = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
- const isFirst = formData.paymentDetails.length === 0
- // 趸交模式下应缴费时间默认等于趸交截止时间
- const defaultDueDate = formData.payMode === 'dj' ? formData.lumpSumDeadline : ''
- formData.paymentDetails.push({
- transferType: '',
- dueAmount: null,
- transferAmount: null,
- transferTime: defaultTime,
- payStatus: 'dfj',
- installmentDueDate: defaultDueDate,
- _expanded: isFirst,
- attachments: [] as Array<{
- id?: string
- fileName: string
- fileUrl: string
- fileSize: number
- fileSizeDisplay?: string
- fileType?: string
- _uploading?: boolean
- }>
- })
- syncPaymentDueAmounts()
- }
- /** 日期加 N 个月,返回 YYYYMMDD 格式(入参支持 YYYYMMDD 或 YYYY-MM-DD) */
- function addMonths(dateStr: string, months: number): string {
- const compact = dateStr.replace(/-/g, '')
- const y = parseInt(compact.slice(0, 4), 10)
- const m = parseInt(compact.slice(4, 6), 10) - 1
- const d = parseInt(compact.slice(6, 8), 10)
- const date = new Date(y, m + months, d)
- const yy = date.getFullYear()
- const mm = String(date.getMonth() + 1).padStart(2, '0')
- const dd = String(date.getDate()).padStart(2, '0')
- return `${yy}${mm}${dd}`
- }
- function toIntOrUndefined(val: unknown): number | undefined {
- if (val == null || val === '') return undefined
- const n = Number(val)
- return Number.isFinite(n) ? Math.trunc(n) : undefined
- }
- function fqPaymentContext() {
- return {
- payMode: formData.payMode,
- installmentCount: toIntOrUndefined(formData.installmentCount) ?? null,
- contractAmount: formData.contractAmount,
- installmentStartDate: formData.installmentStartDate,
- installmentIntervalMonths: toIntOrUndefined(formData.installmentIntervalMonths) ?? null
- }
- }
- /** 分期模式:三个参数都填好后生成/补齐缴费明细行(已有明细时仅补齐期次,不整表覆盖) */
- function tryGenerateInstallmentDetails() {
- if (formData.payMode !== 'fq') return
- const count = formData.installmentCount
- const interval = formData.installmentIntervalMonths
- const startDate = formData.installmentStartDate
- if (!count || !interval || !startDate) return
- if (formData.paymentDetails.length) {
- syncFqPaymentDetailRowCount(fqPaymentContext(), formData.paymentDetails)
- syncPaymentDueAmounts()
- formData.paymentDetails.forEach((d, i) => {
- d.installmentNo = i + 1
- d.installmentDueDate = addMonths(startDate, i * interval)
- })
- return
- }
- const dueAmounts = calcInstallmentDueAmounts(formData.contractAmount, count)
- const details = []
- for (let i = 0; i < count; i++) {
- details.push({
- transferType: '',
- dueAmount: dueAmounts[i] ?? null,
- transferAmount: null,
- transferTime: '',
- payStatus: 'dfj',
- installmentNo: i + 1,
- installmentDueDate: addMonths(startDate, i * interval),
- attachments: [] as Array<{
- id?: string
- fileName: string
- fileUrl: string
- fileSize: number
- fileSizeDisplay?: string
- fileType?: string
- _uploading?: boolean
- }>
- })
- }
- initPaymentDetailExpandFlags(details)
- formData.paymentDetails = details
- }
- /** 缴费模式切换回调 */
- function onPayModeChange(val: string) {
- if (val === 'fq') {
- tryGenerateInstallmentDetails()
- } else {
- // 趸交模式:将趸交截止时间同步到所有明细的应缴费时间
- syncPaymentDueAmounts()
- syncLumpSumDueDate()
- }
- }
- /** 趸交模式:将趸交截止时间同步到所有缴费明细的应缴费时间 */
- function syncLumpSumDueDate() {
- if (formData.payMode !== 'dj') return
- const deadline = formData.lumpSumDeadline || ''
- formData.paymentDetails.forEach((d) => {
- d.installmentDueDate = deadline
- })
- }
- function handleOpenForAdd() {
- isEdit.value = false
- dialogTitle.value = '新增协议单'
- resetForm()
- formData.motherEthnic = DEFAULT_MOTHER_ETHNIC
- formData.registerDate = new Date().toISOString().slice(0, 10).replace(/-/g, '')
- loadSampleOptions()
- void loadEthnicOptions()
- void remoteSearchCustomers('')
- // 默认采集专员、销售人员为当前登录人
- const currentUser = userStore.getUserInfo
- const uid = String(currentUser?.id || currentUser?.userId || '')
- if (uid) {
- const displayName = currentUser?.realName || currentUser?.username || ''
- const phone = currentUser?.phone || ''
- const userStub = {
- id: uid,
- nickName: displayName,
- phoneNo: phone,
- deptId: '',
- userNo: '',
- email: '',
- sex: '',
- avatar: '',
- status: '1',
- tenantNo: '',
- orderNum: 0,
- remark: '',
- createTime: '',
- updateTime: ''
- } as Api.User.UserItem
- // 采集专员默认当前用户
- collectorUserId.value = uid
- formData.collectorUserId = uid
- collectorSnapshot.value = { ...userStub }
- collectorUserOptions.value = [{ ...userStub }]
- // 销售人员默认当前用户
- salesUserId.value = uid
- formData.salesUserId = uid
- salesSnapshot.value = { ...userStub }
- salesUserOptions.value = [{ ...userStub }]
- }
- }
- function applyProtocolBundleToForm(bundle: Record<string, any>, row: CollectionProtocolRow) {
- const prot = bundle.protocol as Record<string, any> | null
- if (prot) {
- formData.collectorUserId = String(prot.collectorUserId || formData.collectorUserId || '')
- formData.protocolNo = String(prot.protocolNo || formData.protocolNo || '')
- formData.protocolNoId = String(prot.protocolNoId || formData.protocolNoId || '')
- formData.protocolStatus = String(prot.protocolStatus || formData.protocolStatus || 'dsx')
- formData.registerDate = String(prot.registerDate || formData.registerDate || '')
- formData.remark = String(prot.remark ?? formData.remark ?? '')
- if (formData.protocolNoId) {
- protocolNoOptions.value = [
- {
- id: formData.protocolNoId,
- protocolNo: formData.protocolNo,
- issued: 0,
- issuedUserId: '',
- issuedUserName: '',
- issuedTime: '',
- tenantNo: '',
- orderNum: 0,
- remark: '',
- createTime: '',
- updateTime: ''
- }
- ]
- }
- }
- const pc = bundle.partyCustomer as Record<string, any> | null
- if (pc) {
- formData.motherName = String(pc.motherName || '')
- formData.motherPhone = String(pc.motherPhone || '')
- formData.motherIdCard = String(pc.motherIdCard || '')
- formData.motherBirthYm = String(pc.motherBirthYm || '')
- formData.motherEthnic = String(pc.motherEthnic || '')
- ensureEthnicOptionInList(formData.motherEthnic)
- formData.motherAddress = String(pc.motherAddress || '')
- formData.fatherName = String(pc.fatherName || '')
- formData.fatherPhone = String(pc.fatherPhone || '')
- formData.fatherIdCard = String(pc.fatherIdCard || '')
- formData.newbornName = String(pc.newbornName || '')
- formData.newbornIdCard = String(pc.newbornIdCard || '')
- formData.newbornDeliveryTime = String(pc.newbornDeliveryTime || '')
- formData.newbornSex = String(pc.newbornSex ?? '')
- formData.deliveryMode = String(pc.deliveryMode || '')
- formData.birthOrder = pc.birthOrder ?? null
- formData.newbornWeightGram = pc.newbornWeightGram ?? null
- formData.apgar1min = pc.apgar1min ?? null
- formData.apgar5min = pc.apgar5min ?? null
- formData.customerId = String(pc.customerId || '')
- }
- const ci = bundle.collectionInfo as Record<string, any> | null
- if (ci) {
- formData.regionId = String(ci.regionId || '')
- formData.hospitalId = String(ci.hospitalId || '')
- formData.medicalStaff = String(ci.medicalStaff || '')
- formData.salesUserId = String(ci.salesUserId || '')
- formData.sampleCollectTime = String(ci.sampleCollectTime || '')
- formData.maternalBloodCollectTime = String(ci.maternalBloodCollectTime || '')
- }
- const samples = bundle.samples as Array<Record<string, any>> | null
- if (samples && samples.length) {
- formData.samples = samples.map((s, i) => normalizeSampleRow(s, i))
- }
- const pay = bundle.payment as Record<string, any> | null
- if (pay) {
- formData.payMode = String(pay.payMode || 'dj')
- formData.contractAmount = pay.contractAmount ?? null
- formData.lumpSumDeadline = String(pay.lumpSumDeadline || '')
- formData.installmentCount = pay.installmentCount ?? null
- formData.installmentIntervalMonths = pay.installmentIntervalMonths ?? null
- formData.installmentStartDate = String(pay.installmentStartDate || '')
- }
- const pd = bundle.paymentDetails as Array<Record<string, any>> | null
- if (pd && pd.length) {
- const details = pd.map((d) => ({
- ...d,
- attachments: Array.isArray(d.attachments) ? d.attachments : []
- }))
- initPaymentDetailExpandFlags(details)
- formData.paymentDetails = details
- }
- syncFqPaymentDetailRowCount(fqPaymentContext(), formData.paymentDetails)
- const pa =
- (row as any).protocolAttachments || (bundle.protocol as any)?.protocolAttachments || ''
- if (pa && typeof pa === 'string') {
- // legacy URL-only field
- }
- const att = (bundle as any).attachments as Array<Record<string, any>> | null
- if (att && att.length) {
- formData.attachments = att.map((a) => ({ ...a })) as any
- }
- syncContractAmount()
- }
- async function handleOpenForEdit() {
- const row = props.listRow
- if (!row || !props.editId) return
- isEdit.value = true
- dialogTitle.value = '编辑协议单'
- editingId.value = props.editId
- resetForm()
- formData.protocolNo = String(row.protocolNo || '')
- formData.protocolNoId = String((row as any).protocolNoId || '')
- formData.protocolStatus = String(row.protocolStatus || 'dsx')
- formData.registerDate = String(row.registerDate || '')
- formData.collectorUserId = String(row.collectorUserId || '')
- if (formData.protocolNoId) {
- protocolNoOptions.value = [
- {
- id: formData.protocolNoId,
- protocolNo: formData.protocolNo,
- issued: 0,
- issuedUserId: '',
- issuedUserName: '',
- issuedTime: '',
- tenantNo: '',
- orderNum: 0,
- remark: '',
- createTime: '',
- updateTime: ''
- }
- ]
- }
- formData.protocolAttachments = String((row as any).protocolAttachments || '')
- formData.remark = String(row.remark || '')
- dialogFormLoading.value = true
- try {
- const [, , bundle] = await Promise.all([
- loadSampleOptions(),
- loadEthnicOptions(),
- fetchCollectionProtocolDetail(String(row.id)).catch(() => null)
- ])
- if (bundle) {
- applyProtocolBundleToForm(bundle as Record<string, any>, row)
- }
- const hydrateTasks: Promise<void>[] = [
- hydrateCollectorUserForEdit(
- formData.collectorUserId,
- String((row as any).collectorUserName || '')
- ),
- hydrateSalesUserForEdit(formData.salesUserId, String((row as any).salesUserName || '')),
- hydrateCustomerForEdit(
- formData.customerId,
- formData.motherName,
- formData.motherPhone,
- formData.motherIdCard
- )
- ]
- if (formData.regionId) {
- hydrateTasks.push(
- ensureRegionOptionForEdit(formData.regionId, String((row as any).regionName || ''))
- )
- }
- if (formData.regionId && formData.hospitalId) {
- hydrateTasks.push(
- ensureHospitalOptionForEdit(
- formData.regionId,
- formData.hospitalId,
- String((row as any).hospitalName || '')
- )
- )
- }
- await Promise.all(hydrateTasks)
- if (formData.samples.length) {
- ensureSampleCodesInOptions(formData.samples)
- }
- } catch {
- /* 保留列表行已填基础字段 */
- } finally {
- dialogFormLoading.value = false
- }
- }
- async function handleSubmit() {
- if (!formRef.value) return
- await formRef.value.validate()
- if (!isEdit.value && formData.protocolNoId) {
- const picked = protocolNoOptions.value.find((x) => x.id === formData.protocolNoId)
- if (picked?.usedInProtocol) {
- ElMessage.warning(`协议号「${formData.protocolNo}」已被占用,请更换其他协议号`)
- return
- }
- }
- if (!isEdit.value) {
- for (const sampleRow of formData.samples) {
- if (!sampleRow.sampleCodeId) continue
- const sc = sampleCodeOptions.value.find((x) => x.id === sampleRow.sampleCodeId)
- if (sc?.usedInProtocol) {
- ElMessage.warning(
- `样本编码「${sampleRow.sampleCode || sc.sampleCode}」已被占用,请更换`
- )
- return
- }
- }
- }
- submitLoading.value = true
- try {
- syncFqPaymentDetailRowCount(fqPaymentContext(), formData.paymentDetails)
- syncPaymentDueAmounts()
- const protocolAttachmentUrls = formData.attachments
- .map((a) => a.fileUrl)
- .filter(Boolean)
- .join(',')
- const payload = {
- protocolNo: formData.protocolNo,
- protocolNoId: formData.protocolNoId || undefined,
- protocolStatus: formData.protocolStatus,
- registerDate: formData.registerDate || undefined,
- collectorUserId: formData.collectorUserId || undefined,
- protocolAttachments: protocolAttachmentUrls || formData.protocolAttachments || undefined,
- attachments: formData.attachments.map((a) => ({
- id: a.id,
- fileName: a.fileName,
- fileUrl: a.fileUrl,
- fileSize: a.fileSize,
- fileSizeDisplay: a.fileSizeDisplay,
- fileType: a.fileType
- })),
- remark: formData.remark || undefined,
- partyCustomer: {
- customerId: formData.customerId || undefined,
- motherName: formData.motherName || undefined,
- motherPhone: formData.motherPhone || undefined,
- motherIdCard: formData.motherIdCard || undefined,
- motherBirthYm: formData.motherBirthYm || undefined,
- motherEthnic: formData.motherEthnic || undefined,
- motherAddress: formData.motherAddress || undefined,
- fatherName: formData.fatherName || undefined,
- fatherPhone: formData.fatherPhone || undefined,
- fatherIdCard: formData.fatherIdCard || undefined,
- newbornName: formData.newbornName || undefined,
- newbornIdCard: formData.newbornIdCard || undefined,
- newbornDeliveryTime: formData.newbornDeliveryTime || undefined,
- newbornSex: formData.newbornSex || undefined,
- deliveryMode: formData.deliveryMode || undefined,
- birthOrder: formData.birthOrder ?? undefined,
- newbornWeightGram: formData.newbornWeightGram ?? undefined,
- apgar1min: formData.apgar1min ?? undefined,
- apgar5min: formData.apgar5min ?? undefined
- },
- collectionInfo: {
- regionId: formData.regionId || undefined,
- hospitalId: formData.hospitalId || undefined,
- medicalStaff: formData.medicalStaff || undefined,
- salesUserId: formData.salesUserId || undefined,
- sampleCollectTime: formData.sampleCollectTime || undefined,
- maternalBloodCollectTime: formData.maternalBloodCollectTime || undefined
- },
- samples: formData.samples.map(({ _rowKey, transport, ...s }) => ({
- ...s,
- sampleStatus:
- s.sampleStatus != null && String(s.sampleStatus).trim() !== ''
- ? String(s.sampleStatus).trim()
- : null,
- transport: transport?.transportType ? { ...transport } : undefined
- })),
- payment: {
- payMode: formData.payMode,
- contractAmount: formData.contractAmount ?? undefined,
- lumpSumDeadline: formData.lumpSumDeadline || undefined,
- installmentCount: toIntOrUndefined(formData.installmentCount),
- installmentIntervalMonths: toIntOrUndefined(formData.installmentIntervalMonths),
- installmentStartDate: formData.installmentStartDate || undefined
- },
- paymentDetails: formData.paymentDetails.map(({ _expanded, ...d }) => ({
- transferType: d.transferType,
- dueAmount: d.dueAmount,
- transferAmount: d.transferAmount,
- transferTime: d.transferTime,
- payStatus: d.payStatus,
- installmentNo: toIntOrUndefined(d.installmentNo),
- installmentDueDate: d.installmentDueDate,
- remark: d.remark,
- financeRemark: d.financeRemark,
- financeConfirmAttachments: d.financeConfirmAttachments,
- financeConfirmAttachmentFiles: d.financeConfirmAttachmentFiles,
- refundAmount: d.refundAmount,
- refundRemark: d.refundRemark,
- refundAttachments: d.refundAttachments,
- refundAttachmentFiles: d.refundAttachmentFiles,
- refundFinanceRemark: d.refundFinanceRemark,
- refundFinanceConfirmAttachments: d.refundFinanceConfirmAttachments,
- refundFinanceConfirmAttachmentFiles: d.refundFinanceConfirmAttachmentFiles,
- attachments: (d.attachments || []).map((a: any) => ({
- id: a.id,
- fileName: a.fileName,
- fileUrl: a.fileUrl,
- fileSize: a.fileSize,
- fileSizeDisplay: a.fileSizeDisplay,
- fileType: a.fileType
- }))
- }))
- }
- if (isEdit.value && editingId.value) {
- await fetchUpdateCollectionProtocol(editingId.value, payload)
- } else {
- await fetchCreateCollectionProtocol(payload)
- }
- emit('update:modelValue', false)
- emit('saved')
- } finally {
- submitLoading.value = false
- }
- }
- function createUserStub(id: string, nickName: string, phoneNo = ''): Api.User.UserItem {
- return {
- id,
- nickName: nickName || id,
- phoneNo,
- deptId: '',
- userNo: '',
- email: '',
- sex: '',
- avatar: '',
- status: '1',
- tenantNo: '',
- orderNum: 0,
- remark: '',
- createTime: '',
- updateTime: ''
- }
- }
- async function hydrateRemoteUserForEdit(
- userId: string,
- displayName: string | undefined,
- assign: (user: Api.User.UserItem) => void,
- clear: () => void
- ) {
- const uid = (userId || '').trim()
- if (!uid) {
- clear()
- return
- }
- try {
- const detail = await fetchUserDetail(uid)
- assign(detail)
- return
- } catch {
- /* 回退为列表/占位展示名 */
- }
- assign(createUserStub(uid, (displayName || '').trim()))
- }
- async function hydrateCollectorUserForEdit(userId: string, displayName?: string) {
- await hydrateRemoteUserForEdit(
- userId,
- displayName,
- (user) => {
- collectorSnapshot.value = { ...user }
- collectorUserId.value = user.id
- collectorUserOptions.value = [{ ...user }]
- formData.collectorUserId = user.id
- },
- () => {
- collectorUserId.value = null
- collectorSnapshot.value = null
- collectorUserOptions.value = []
- formData.collectorUserId = ''
- }
- )
- }
- async function hydrateSalesUserForEdit(userId: string, displayName?: string) {
- await hydrateRemoteUserForEdit(
- userId,
- displayName,
- (user) => {
- salesSnapshot.value = { ...user }
- salesUserId.value = user.id
- salesUserOptions.value = [{ ...user }]
- formData.salesUserId = user.id
- },
- () => {
- salesUserId.value = null
- salesSnapshot.value = null
- salesUserOptions.value = []
- formData.salesUserId = ''
- }
- )
- }
- async function hydrateCustomerForEdit(
- customerIdVal: string,
- motherName: string,
- motherPhone: string,
- motherIdCard: string
- ) {
- const cid = (customerIdVal || '').trim()
- if (!cid) {
- customerId.value = null
- customerSnapshot.value = null
- customerOptions.value = []
- formData.customerId = ''
- return
- }
- let snapshot = {
- id: cid,
- customerName: motherName,
- phoneNo: motherPhone,
- idCardNo: motherIdCard
- }
- const keyword = motherPhone || motherName || motherIdCard
- if (keyword) {
- try {
- const res = await fetchCustomerList({ page: 1, pageSize: 50, keyword })
- const match = (res.list || []).find((c) => c.id === cid)
- if (match) {
- snapshot = {
- id: match.id,
- customerName: match.customerName || motherName,
- phoneNo: match.phoneNo || motherPhone,
- idCardNo: match.idCardNo || motherIdCard
- }
- }
- } catch {
- /* 使用母亲信息占位 */
- }
- }
- customerSnapshot.value = snapshot
- customerId.value = cid
- customerOptions.value = [snapshot]
- formData.customerId = cid
- }
- async function ensureRegionOptionForEdit(regionId: string, regionName?: string) {
- const rid = (regionId || '').trim()
- if (!rid) return
- await remoteSearchRegions('')
- if (regionOptions.value.some((r) => r.id === rid)) return
- const stub: Api.BizCollectionRegion.RegionItem = {
- id: rid,
- regionName: (regionName || '').trim() || rid,
- hospitalNames: [],
- tenantNo: '',
- orderNum: 0,
- remark: '',
- createTime: '',
- updateTime: ''
- }
- regionOptions.value = [stub, ...regionOptions.value]
- }
- async function ensureHospitalOptionForEdit(
- regionId: string,
- hospitalId: string,
- hospitalName?: string
- ) {
- const hid = (hospitalId || '').trim()
- if (!hid || !(regionId || '').trim()) return
- await loadHospitalsByRegion(regionId, '')
- if (hospitalOptions.value.some((h) => h.id === hid)) return
- const stub: Api.BizCollectionHospital.HospitalItem = {
- id: hid,
- regionId,
- hospitalName: (hospitalName || '').trim() || hid,
- tenantNo: '',
- orderNum: 0,
- remark: '',
- createTime: '',
- updateTime: ''
- }
- hospitalOptions.value = [stub, ...hospitalOptions.value]
- }
- function ensureSampleCodesInOptions(samples: Array<Record<string, any>>) {
- const merged = [...sampleCodeOptions.value]
- for (const s of samples) {
- const id = String(s.sampleCodeId || '')
- if (!id || merged.some((x) => x.id === id)) continue
- merged.push({
- id,
- sampleCode: String(s.sampleCode || id),
- sampleCodeType: '',
- issued: 0,
- usedInProtocol: false,
- issuedUserId: '',
- issuedUserName: '',
- issuedTime: '',
- tenantNo: '',
- orderNum: 0,
- remark: '',
- createTime: '',
- updateTime: ''
- })
- }
- sampleCodeOptions.value = merged
- }
- /* ====== 客户检索(母亲信息) ====== */
- function formatCustomerLabel(c: { customerName: string; phoneNo: string; idCardNo: string }) {
- const parts = [c.customerName || '']
- if (c.phoneNo) parts.push(c.phoneNo)
- if (c.idCardNo) parts.push(c.idCardNo)
- return parts.filter(Boolean).join(' · ')
- }
- async function remoteSearchCustomers(query: string) {
- customerLoading.value = true
- try {
- const res = await fetchCustomerList({ page: 1, pageSize: 50, keyword: query || undefined })
- let list = [...(res.list || [])]
- // Keep current selection visible
- if (
- customerId.value &&
- customerSnapshot.value?.id === customerId.value &&
- !list.some((x) => x.id === customerId.value)
- ) {
- list = [customerSnapshot.value, ...list]
- }
- customerOptions.value = list
- } catch {
- customerOptions.value = []
- } finally {
- customerLoading.value = false
- }
- }
- function onCustomerChange(val: string | null | undefined) {
- if (!val) {
- customerId.value = null
- customerSnapshot.value = null
- // Clear auto-filled fields only
- formData.motherName = ''
- formData.motherPhone = ''
- formData.motherIdCard = ''
- formData.motherBirthYm = ''
- return
- }
- const c = customerOptions.value.find((x) => x.id === val) || customerSnapshot.value
- if (!c) return
- customerSnapshot.value = { ...c } as any
- customerId.value = c.id
- formData.customerId = c.id
- formData.motherName = c.customerName?.trim() || ''
- formData.motherPhone = c.phoneNo?.trim() || ''
- formData.motherIdCard = c.idCardNo?.trim() || ''
- calcBirthFromIdCard()
- }
- /* ====== 采集专员检索 ====== */
- async function remoteSearchCollectorUsers(query: string) {
- collectorLoading.value = true
- try {
- const res = await fetchUserList({
- page: 1,
- pageSize: 50,
- keyword: query || undefined,
- status: '1'
- })
- let list = [...(res.list || [])]
- if (
- collectorUserId.value &&
- collectorSnapshot.value?.id === collectorUserId.value &&
- !list.some((x) => x.id === collectorUserId.value)
- ) {
- list = [collectorSnapshot.value, ...list]
- }
- collectorUserOptions.value = list
- } catch {
- collectorUserOptions.value = []
- } finally {
- collectorLoading.value = false
- }
- }
- function onCollectorUserChange(val: string | null | undefined) {
- if (!val) {
- collectorUserId.value = null
- collectorSnapshot.value = null
- formData.collectorUserId = ''
- return
- }
- const u = collectorUserOptions.value.find((x) => x.id === val) || collectorSnapshot.value
- if (!u) return
- collectorSnapshot.value = { ...u }
- collectorUserId.value = u.id
- formData.collectorUserId = u.id
- }
- /* ====== 销售人员检索 ====== */
- async function remoteSearchSalesUsers(query: string) {
- salesLoading.value = true
- try {
- const res = await fetchUserList({
- page: 1,
- pageSize: 50,
- keyword: query || undefined,
- status: '1'
- })
- let list = [...(res.list || [])]
- if (
- salesUserId.value &&
- salesSnapshot.value?.id === salesUserId.value &&
- !list.some((x) => x.id === salesUserId.value)
- ) {
- list = [salesSnapshot.value, ...list]
- }
- salesUserOptions.value = list
- } catch {
- salesUserOptions.value = []
- } finally {
- salesLoading.value = false
- }
- }
- function onSalesUserChange(val: string | null | undefined) {
- if (!val) {
- salesUserId.value = null
- salesSnapshot.value = null
- formData.salesUserId = ''
- return
- }
- const u = salesUserOptions.value.find((x) => x.id === val) || salesSnapshot.value
- if (!u) return
- salesSnapshot.value = { ...u }
- salesUserId.value = u.id
- formData.salesUserId = u.id
- }
- /* ====== 样本类型/单位选项 ====== */
- async function loadSampleOptions() {
- try {
- const [stRes, uRes, scRes, ecRes, siteRes] = await Promise.all([
- fetchCellSampleTypeList({ page: 1, pageSize: 100 }),
- fetchItemUnitList({ page: 1, pageSize: 100 }),
- fetchSampleCodeList({ page: 1, pageSize: 100 }),
- fetchExpressCompanyList({ page: 1, pageSize: 100 }),
- fetchSiteList({ page: 1, pageSize: 100 })
- ])
- sampleTypeOptions.value = (stRes.list || []) as any
- unitOptions.value = (uRes.list || []) as any
- sampleCodeOptions.value = (scRes.list || []) as any
- expressCompanyOptions.value = ecRes.list || []
- siteOptions.value = siteRes.list || []
- } catch {
- /* ignore */
- }
- }
- /* ====== 采集地区下拉检索 ====== */
- async function remoteSearchRegions(query: string) {
- regionLoading.value = true
- try {
- const res = await fetchCollectionRegionList({
- page: 1,
- pageSize: 100,
- keyword: query || undefined
- })
- regionOptions.value = res.list || []
- } catch {
- regionOptions.value = []
- } finally {
- regionLoading.value = false
- }
- }
- function onRegionChange(val: string | null | undefined) {
- if (!val) {
- formData.regionId = ''
- hospitalOptions.value = []
- formData.hospitalId = ''
- return
- }
- formData.regionId = val
- formData.hospitalId = ''
- hospitalOptions.value = []
- // 联动加载该地区下的医院
- loadHospitalsByRegion(val)
- }
- function onRegionClear() {
- formData.regionId = ''
- formData.hospitalId = ''
- hospitalOptions.value = []
- }
- async function loadHospitalsByRegion(regionId: string, keyword?: string) {
- hospitalLoading.value = true
- try {
- const res = await fetchHospitalList(regionId, {
- page: 1,
- pageSize: 100,
- keyword: keyword || undefined
- })
- hospitalOptions.value = res.list || []
- } catch {
- hospitalOptions.value = []
- } finally {
- hospitalLoading.value = false
- }
- }
- /* ====== 采集医院下拉检索 ====== */
- async function remoteSearchHospitals(query: string) {
- if (!formData.regionId) {
- hospitalOptions.value = []
- return
- }
- await loadHospitalsByRegion(formData.regionId, query)
- }
- function onHospitalChange(val: string | null | undefined) {
- if (!val) {
- formData.hospitalId = ''
- return
- }
- formData.hospitalId = val
- }
- /** 分期应缴费金额分配:前 n-1 期均分(分),最后一期补齐差额 */
- function calcInstallmentDueAmounts(contractAmount: number | null, count: number): number[] {
- if (contractAmount == null || !count || count < 1) return []
- const totalCents = Math.round(Number(contractAmount) * 100)
- const baseCents = Math.floor(totalCents / count)
- const amounts: number[] = []
- let sumCents = 0
- for (let i = 0; i < count; i++) {
- if (i === count - 1) {
- amounts.push((totalCents - sumCents) / 100)
- } else {
- amounts.push(baseCents / 100)
- sumCents += baseCents
- }
- }
- return amounts
- }
- /** 按缴费模式同步各明细应缴费金额 */
- function syncPaymentDueAmounts() {
- const contract = formData.contractAmount
- if (contract == null) return
- if (formData.payMode === 'dj') {
- formData.paymentDetails.forEach((d) => {
- d.dueAmount = contract
- })
- return
- }
- if (formData.payMode === 'fq') {
- const count = formData.installmentCount || formData.paymentDetails.length
- if (!count) return
- const amounts = calcInstallmentDueAmounts(Number(contract), count)
- formData.paymentDetails.forEach((d, i) => {
- if (amounts[i] !== undefined) d.dueAmount = amounts[i]
- })
- }
- }
- /** 根据非作废样本价格之和同步合同金额,并联动应缴费金额 */
- function syncContractAmount() {
- const total = formData.samples.reduce((sum, s) => {
- if (s.sampleStatus === 'yzf') return sum
- return sum + (Number(s.price) || 0)
- }, 0)
- formData.contractAmount = Math.round(total * 100) / 100
- syncPaymentDueAmounts()
- }
- /** 样本价格或状态变更 */
- function onSamplePriceChange() {
- syncContractAmount()
- }
- function onSampleStatusChange(row: Record<string, any>) {
- if (row.sampleStatus == null) {
- row.sampleStatus = ''
- }
- onSamplePriceChange()
- }
- function onSampleTypeChange(row: Record<string, any>) {
- const st = sampleTypeOptions.value.find((x) => x.id === row.sampleTypeId)
- if (st) {
- row.price = st.storagePrice
- row.storageYears = st.storageYears
- }
- syncContractAmount()
- }
- /* ====== 协议号下拉检索 ====== */
- function protocolNoOptionLabel(p: Api.BizCollectionProtocolNo.ProtocolNoItem) {
- if (p.usedInProtocol) return `${p.protocolNo}(已占用)`
- if (p.issued) return `${p.protocolNo}(已下发)`
- return p.protocolNo
- }
- function isProtocolNoOptionDisabled(p: Api.BizCollectionProtocolNo.ProtocolNoItem) {
- if (!p.usedInProtocol) return false
- return !(isEdit.value && p.id === formData.protocolNoId)
- }
- async function remoteSearchProtocolNos(query: string) {
- protocolNoLoading.value = true
- try {
- const res = await fetchCollectionProtocolNoList({
- page: 1,
- pageSize: 100,
- keyword: query || undefined
- })
- protocolNoOptions.value = res.list || []
- } catch {
- protocolNoOptions.value = []
- } finally {
- protocolNoLoading.value = false
- }
- }
- function onProtocolNoChange(val: string | null | undefined) {
- if (!val) {
- formData.protocolNo = ''
- formData.protocolNoId = ''
- return
- }
- const p = protocolNoOptions.value.find((x) => x.id === val)
- if (p) {
- formData.protocolNo = p.protocolNo
- formData.protocolNoId = p.id
- }
- }
- function onProtocolNoClear() {
- formData.protocolNo = ''
- formData.protocolNoId = ''
- }
- /* ====== 样本编码选择 ====== */
- function sampleCodeOptionLabel(sc: Api.BizSampleCode.SampleCodeItem) {
- if (sc.usedInProtocol) return `${sc.sampleCode}(已占用)`
- if (sc.issued) return `${sc.sampleCode}(已下发)`
- return sc.sampleCode
- }
- function isSampleCodeOptionDisabled(sc: Api.BizSampleCode.SampleCodeItem, row: Record<string, any>) {
- if (!sc.usedInProtocol) return false
- return String(row.sampleCodeId || '') !== sc.id
- }
- function onSampleCodeChange(row: Record<string, any>) {
- const sc = sampleCodeOptions.value.find((x) => x.id === row.sampleCodeId)
- if (sc) {
- row.sampleCode = sc.sampleCode
- row.sampleCodeId = sc.id
- }
- }
- /* ====== 母亲身份证号 ↔ 出生年月联动 ====== */
- function calcBirthFromIdCard() {
- const idCard = (formData.motherIdCard || '').trim()
- if (/^\d{17}[\dXx]$/.test(idCard)) {
- formData.motherBirthYm = idCard.substring(6, 12)
- } else if (/^\d{15}$/.test(idCard)) {
- const yy = idCard.substring(6, 8)
- const mm = idCard.substring(8, 10)
- const prefix = parseInt(yy, 10) > 30 ? '19' : '20'
- formData.motherBirthYm = prefix + yy + mm
- } else if (!idCard) {
- formData.motherBirthYm = ''
- }
- }
- watch(
- () => formData.motherIdCard,
- () => calcBirthFromIdCard()
- )
- /* ====== 趸交截止时间联动 ====== */
- watch(
- () => formData.lumpSumDeadline,
- () => {
- if (formData.payMode === 'dj') {
- syncLumpSumDueDate()
- }
- }
- )
- /* ====== 附件上传 ====== */
- const attachmentUploadRef = ref()
- function isImageFile(file: any) {
- const url = (file.fileUrl || '').toLowerCase()
- const name = (file.fileName || '').toLowerCase()
- return (
- /\.(jpg|jpeg|png|gif|bmp|webp|svg)(\?|$)/.test(url) ||
- /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/.test(name) ||
- (file.fileType || '').startsWith('image/')
- )
- }
- function isVideoFile(file: any) {
- const url = (file.fileUrl || '').toLowerCase()
- const name = (file.fileName || '').toLowerCase()
- return (
- /\.(mp4|webm|ogg|mov|avi)(\?|$)/.test(url) ||
- /\.(mp4|webm|ogg|mov|avi)$/.test(name) ||
- (file.fileType || '').startsWith('video/')
- )
- }
- async function handleAttachmentChange(uploadFile: any) {
- const file = uploadFile.raw || uploadFile
- if (!file) return
- const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
- formData.attachments.push({
- id: tempId,
- fileName: file.name,
- fileUrl: '',
- fileSize: file.size,
- fileSizeDisplay: `${(file.size / 1024).toFixed(1)} KB`,
- fileType: file.type || '',
- _uploading: true
- })
- const idx = formData.attachments.length - 1
- try {
- const fd = new FormData()
- fd.append('folder', 'protocol')
- fd.append('file', file)
- const res = await fetchUploadAttachment(fd)
- formData.attachments[idx] = {
- ...formData.attachments[idx],
- id: res.id || tempId,
- fileName: file.name || res.fileName,
- fileUrl: res.fileUrl,
- fileSize: res.fileSize || file.size,
- fileSizeDisplay: res.fileSizeDisplay || `${(file.size / 1024).toFixed(1)} KB`,
- fileType: res.fileType || file.type || '',
- _uploading: false
- }
- } catch {
- ElMessage.error('文件上传失败')
- formData.attachments.splice(idx, 1)
- } finally {
- nextTick(() => attachmentUploadRef.value?.clearFiles())
- }
- }
- function removeAttachment(index: number) {
- formData.attachments.splice(index, 1)
- }
- /* ====== 缴费明细附件上传 ====== */
- async function handleDetailAttachmentChange(detailIndex: number, uploadFile: any) {
- const file = uploadFile.raw || uploadFile
- if (!file) return
- const detail = formData.paymentDetails[detailIndex]
- if (!detail) return
- const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
- detail.attachments.push({
- id: tempId,
- fileName: file.name,
- fileUrl: '',
- fileSize: file.size,
- fileSizeDisplay: `${(file.size / 1024).toFixed(1)} KB`,
- fileType: file.type || '',
- _uploading: true
- })
- const aIdx = detail.attachments.length - 1
- try {
- const fd = new FormData()
- fd.append('folder', 'protocol-payment')
- fd.append('file', file)
- const res = await fetchUploadAttachment(fd)
- detail.attachments[aIdx] = {
- ...detail.attachments[aIdx],
- id: res.id || tempId,
- fileName: file.name || res.fileName,
- fileUrl: res.fileUrl,
- fileSize: res.fileSize || file.size,
- fileSizeDisplay: res.fileSizeDisplay || `${(file.size / 1024).toFixed(1)} KB`,
- fileType: res.fileType || file.type || '',
- _uploading: false
- }
- } catch {
- ElMessage.error('文件上传失败')
- detail.attachments.splice(aIdx, 1)
- }
- }
- function removeDetailAttachment(detailIndex: number, attachmentIndex: number) {
- const detail = formData.paymentDetails[detailIndex]
- if (detail) {
- detail.attachments.splice(attachmentIndex, 1)
- }
- }
- /* 附件预览 */
- const previewVisible = ref(false)
- const previewUrl = ref('')
- const previewType = ref<'image' | 'video'>('image')
- function handlePreviewAttachment(file: any) {
- if (file._uploading) return
- previewUrl.value = fullFileUrl(file.fileUrl)
- previewType.value = isVideoFile(file) ? 'video' : 'image'
- previewVisible.value = true
- }
- function handleDownloadAttachment(file: any) {
- downloadAttachmentFile(file, { fullFileUrl })
- }
- /* ====== 弹窗打开时自动初始化 ====== */
- watch(
- () => props.modelValue,
- (visible) => {
- if (visible) {
- if (props.editId) {
- handleOpenForEdit()
- } else {
- handleOpenForAdd()
- }
- }
- }
- )
- </script>
- <style scoped lang="scss">
- /* ═══════════════════════════════════════════
- DIALOG BODY
- ═══════════════════════════════════════════ */
- .protocol-dialog-body {
- overflow: visible;
- background: #f5f7fa;
- }
- .protocol-form {
- :deep(.el-form-item) {
- margin-bottom: 4px;
- }
- }
- /* ---- Sticky Tab 导航(与查看弹窗 detail-tabs 一致) ---- */
- .detail-tabs {
- position: sticky;
- top: 0;
- z-index: 1000;
- display: flex;
- gap: 4px;
- background: #f0fdf4;
- padding: 4px;
- border-radius: 8px;
- margin: 0 28px 16px;
- width: fit-content;
- isolation: isolate;
- box-shadow: 0 1px 0 #f0fdf4;
- }
- .detail-tab {
- padding: 10px 20px;
- cursor: pointer;
- border-radius: 6px;
- font-size: 14px;
- color: #606266;
- transition: all 0.3s;
- &.active {
- background: #fff;
- color: #059669;
- font-weight: 500;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
- }
- &:hover:not(.active) {
- color: #059669;
- }
- }
- /* ═══════════════════════════════════════════
- SECTION CARDS
- ═══════════════════════════════════════════ */
- .form-tab-pane {
- padding: 0 28px 4px;
- & + .form-tab-pane {
- margin-top: 0;
- padding-top: 0;
- }
- }
- .section-card {
- border-radius: 12px;
- padding: 22px 24px;
- margin-bottom: 20px;
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
- transition: transform 0.2s, box-shadow 0.2s;
- position: relative;
- background: #fff;
- &:hover {
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
- }
- &--green {
- background: #f0fdf4;
- }
- }
- .section-title {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 18px;
- padding-bottom: 12px;
- border-bottom: 1px solid #e2e8f0;
- }
- .section-title-left {
- display: flex;
- align-items: center;
- &::before {
- content: '';
- width: 3px;
- height: 18px;
- background: #10b981;
- border-radius: 2px;
- margin-right: 10px;
- flex-shrink: 0;
- }
- }
- .section-title h3 {
- font-size: 15px;
- font-weight: 600;
- color: #1e293b;
- }
- .section-subtitle {
- display: flex;
- align-items: center;
- gap: 6px;
- font-size: 13px;
- font-weight: 600;
- color: #374151;
- margin: 18px 0 14px;
- &::before {
- content: '';
- width: 2px;
- height: 12px;
- background: #10b981;
- border-radius: 1px;
- }
- }
- /* ═══════════════════════════════════════════
- FORM GRID
- ═══════════════════════════════════════════ */
- .form-grid {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 16px 20px;
- &--col2 {
- grid-template-columns: repeat(2, 1fr);
- }
- &--col4 {
- grid-template-columns: repeat(4, minmax(0, 1fr));
- }
- &--col5 {
- grid-template-columns: repeat(5, minmax(0, 1fr));
- }
- :deep(.el-form-item) {
- margin-bottom: 0;
- }
- }
- .basic-remark-attach-row {
- margin-top: 16px;
- }
- .form-col-span-2 {
- grid-column: 1 / -1;
- }
- /* ═══════════════════════════════════════════
- CLIENT SUB CARDS
- ═══════════════════════════════════════════ */
- .client-sub-cards {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 16px;
- }
- .client-sub-card {
- background: #fff;
- border-radius: 10px;
- padding: 18px 20px;
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
- border: 1px solid rgba(0, 0, 0, 0.03);
- transition: transform 0.2s, box-shadow 0.2s;
- &:hover {
- transform: translateY(-1px);
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.07);
- }
- }
- .sub-card-header {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-bottom: 14px;
- padding-bottom: 10px;
- border-bottom: 1px solid #f1f5f9;
- .icon {
- font-size: 22px;
- line-height: 1;
- }
- h4 {
- font-size: 14px;
- font-weight: 600;
- color: #1e293b;
- }
- .search-link {
- margin-left: auto;
- font-size: 12px;
- color: #10b981;
- cursor: pointer;
- font-weight: 500;
- &:hover {
- text-decoration: underline;
- }
- }
- }
- /* ═══════════════════════════════════════════
- SAMPLE CARDS
- ═══════════════════════════════════════════ */
- .sample-toolbar {
- display: flex;
- align-items: center;
- gap: 12px;
- margin-bottom: 16px;
- }
- .sample-card {
- background: #fff;
- border: 1px solid #e2e8f0;
- border-radius: 10px;
- padding: 18px 20px;
- margin-bottom: 14px;
- transition: box-shadow 0.2s;
- &:hover {
- box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
- }
- }
- .sample-header {
- display: flex;
- align-items: center;
- gap: 10px;
- margin-bottom: 16px;
- padding-bottom: 12px;
- border-bottom: 1px solid #f1f5f9;
- }
- .sample-title {
- font-size: 14px;
- font-weight: 600;
- color: #1e293b;
- }
- .sample-num {
- color: #64748b;
- font-weight: 500;
- margin-right: 4px;
- }
- .sample-actions {
- margin-left: auto;
- display: flex;
- align-items: center;
- gap: 8px;
- }
- .sample-transport-hint {
- margin: 0 0 12px;
- font-size: 12px;
- color: var(--mint-text-muted, #64748b);
- line-height: 1.5;
- }
- /* ── Transport Panel (Collapsible) ── */
- .transport-toggle {
- display: flex;
- align-items: center;
- gap: 6px;
- cursor: pointer;
- font-size: 13px;
- font-weight: 500;
- color: #64748b;
- margin-top: 14px;
- padding: 10px 14px;
- background: #f8fafc;
- border-radius: 8px;
- border: 1px solid #e2e8f0;
- transition: all 0.2s;
- user-select: none;
- &:hover {
- background: #f1f5f9;
- color: #374151;
- }
- .chevron {
- display: inline-block;
- transition: transform 0.3s;
- font-size: 12px;
- }
- &.expanded .chevron {
- transform: rotate(90deg);
- }
- .summary {
- margin-left: auto;
- font-size: 12px;
- color: #94a3b8;
- font-weight: 400;
- }
- }
- .transport-body {
- max-height: 0;
- overflow: hidden;
- transition: max-height 0.35s ease, padding 0.35s ease;
- background: #f8fafc;
- border-radius: 0 0 8px 8px;
- margin-top: 0;
- padding: 0 16px;
- &.open {
- max-height: 600px;
- padding: 16px;
- margin-top: -1px;
- border: 1px solid #e2e8f0;
- border-top: none;
- }
- }
- .sample-transport-form-row {
- display: grid;
- grid-template-columns: repeat(6, minmax(0, 1fr));
- gap: 12px 16px;
- align-items: start;
- :deep(.el-form-item) {
- margin-bottom: 0;
- }
- &--ryxd {
- grid-template-columns: repeat(5, minmax(0, 1fr));
- }
- }
- /* ═══════════════════════════════════════════
- PAYMENT SECTION
- ═══════════════════════════════════════════ */
- .payment-main-card {
- background: #fff;
- border-radius: 10px;
- padding: 18px 20px;
- margin-bottom: 18px;
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
- border: 1px solid rgba(0, 0, 0, 0.03);
- }
- .payment-details {
- display: flex;
- flex-direction: column;
- gap: 12px;
- }
- /* Detail Card — Collapsible */
- .detail-card {
- background: #fff;
- border: 1px solid #e2e8f0;
- border-radius: 10px;
- overflow: hidden;
- transition: box-shadow 0.2s;
- &:hover {
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
- }
- &.expanded .detail-chevron {
- transform: rotate(90deg);
- }
- }
- .detail-header {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 14px 18px;
- cursor: pointer;
- user-select: none;
- transition: background 0.2s;
- &:hover {
- background: #f8fafc;
- }
- }
- .detail-chevron {
- font-size: 12px;
- color: #94a3b8;
- transition: transform 0.3s;
- flex-shrink: 0;
- }
- .detail-title {
- font-size: 14px;
- font-weight: 600;
- color: #1e293b;
- }
- .detail-amount {
- font-size: 14px;
- font-weight: 700;
- margin-left: 4px;
- &.green {
- color: #059669;
- }
- &.amber {
- color: #d97706;
- }
- }
- .detail-badge {
- display: inline-block;
- padding: 2px 10px;
- border-radius: 20px;
- font-size: 12px;
- font-weight: 500;
- white-space: nowrap;
- &.badge-green {
- background: #ecfdf5;
- color: #059669;
- }
- &.badge-amber {
- background: #fffbeb;
- color: #d97706;
- }
- &.badge-gray {
- background: #f1f5f9;
- color: #64748b;
- }
- }
- .detail-summary {
- margin-left: auto;
- font-size: 12px;
- color: #94a3b8;
- }
- .detail-delete {
- margin-left: 6px;
- }
- .detail-body-inner {
- padding: 0 18px 18px;
- border-top: 1px solid #f1f5f9;
- }
- /* ═══════════════════════════════════════════
- ATTACHMENTS
- ═══════════════════════════════════════════ */
- .attachment-upload {
- width: 100%;
- }
- .upload-card {
- width: 140px;
- height: 96px;
- border: 1px dashed #a7f3d0;
- border-radius: 8px;
- background: #f0fdf4;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 6px;
- cursor: pointer;
- transition:
- border-color 0.15s,
- background 0.15s;
- &:hover {
- border-color: #10b981;
- border-style: solid;
- background: #ecfdf5;
- }
- }
- .upload-card-text {
- font-size: 12px;
- color: #10b981;
- font-weight: 500;
- line-height: 1;
- }
- .upload-hint {
- display: block;
- font-size: 11px;
- color: #94a3b8;
- line-height: 1;
- margin-top: 8px;
- }
- .attachment-list {
- display: flex;
- flex-wrap: wrap;
- gap: 12px;
- }
- .attachment-item {
- width: 140px;
- display: flex;
- flex-direction: column;
- border: 1px solid #e2e8f0;
- border-radius: 8px;
- background: #fff;
- overflow: hidden;
- transition:
- border-color 0.15s,
- box-shadow 0.15s;
- &:hover {
- border-color: #10b981;
- box-shadow: 0 2px 8px rgba(16, 185, 129, 0.1);
- }
- &.is-uploading {
- opacity: 0.6;
- pointer-events: none;
- }
- }
- .attachment-preview {
- width: 100%;
- height: 96px;
- flex-shrink: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- background: #f8fafc;
- overflow: hidden;
- cursor: pointer;
- img {
- width: 100%;
- height: 100%;
- object-fit: cover;
- display: block;
- }
- }
- .attachment-preview__icon {
- width: 48px;
- height: 48px;
- border-radius: 10px;
- display: flex;
- align-items: center;
- justify-content: center;
- &--video {
- background: #ede9fe;
- color: #7c3aed;
- }
- &--doc {
- background: #e0f2fe;
- color: #0284c7;
- }
- }
- .attachment-footer {
- display: flex;
- flex-direction: column;
- flex: 1;
- padding: 6px 8px;
- }
- .attachment-name {
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
- text-overflow: ellipsis;
- font-size: 11px;
- color: #334155;
- line-height: 1.4;
- word-break: break-all;
- min-height: calc(11px * 1.4 * 2);
- }
- .attachment-actions {
- display: flex;
- justify-content: center;
- align-items: center;
- gap: 8px;
- margin-top: 4px;
- flex-shrink: 0;
- transition: opacity 0.15s;
- }
- .action-btn {
- width: 22px;
- height: 22px;
- border-radius: 4px;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 12px;
- color: #64748b;
- cursor: pointer;
- background: #f1f5f9;
- transition:
- background 0.15s,
- color 0.15s;
- &:hover {
- background: #10b981;
- color: #fff;
- }
- &--danger:hover {
- background: #ef4444;
- color: #fff;
- }
- }
- .upload-card--sm {
- width: 96px;
- height: 76px;
- .upload-card-text {
- font-size: 11px;
- }
- }
- .payment-detail-card__attachments {
- margin-top: 8px;
- padding-top: 8px;
- border-top: 1px dashed #e2e8f0;
- }
- .preview-container {
- text-align: center;
- }
- .batch-transport-dialog-hint {
- margin-top: 0;
- }
- /* ═══════════════════════════════════════════
- RESPONSIVE
- ═══════════════════════════════════════════ */
- @media (max-width: 900px) {
- .form-grid {
- grid-template-columns: repeat(2, 1fr);
- &--col2 {
- grid-template-columns: 1fr;
- }
- &--col4 {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
- &--col5 {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
- &--col2.basic-remark-attach-row {
- grid-template-columns: 1fr;
- }
- }
- .client-sub-cards {
- grid-template-columns: 1fr;
- }
- }
- </style>
|