ProtocolFormDialog.vue 118 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545
  1. <template>
  2. <!-- ========== 新增/编辑弹窗 ========== -->
  3. <el-dialog
  4. :model-value="modelValue"
  5. :title="dialogTitle"
  6. width="1380px"
  7. class="dialog-form-layout"
  8. align-center
  9. destroy-on-close
  10. :close-on-click-modal="false"
  11. >
  12. <DialogFormBody
  13. :loading="dialogFormLoading"
  14. body-class="protocol-dialog-body min-h-[200px]"
  15. >
  16. <div class="detail-tabs">
  17. <div
  18. v-for="tab in formTabs"
  19. :key="tab.key"
  20. class="detail-tab"
  21. :class="{ active: activeFormTab === tab.key }"
  22. @click="scrollToSection(tab.key)"
  23. >
  24. {{ tab.label }}
  25. </div>
  26. </div>
  27. <el-form
  28. ref="formRef"
  29. :model="formData"
  30. :rules="formRules"
  31. label-position="top"
  32. scroll-to-error
  33. class="protocol-form"
  34. >
  35. <!-- Tab 1: 基本信息 -->
  36. <div id="form-section-basic" class="form-tab-pane">
  37. <div class="section-card">
  38. <div class="section-title">
  39. <div class="section-title-left"><h3>基本信息</h3></div>
  40. </div>
  41. <div class="form-grid form-grid--col4">
  42. <el-form-item label="协议号" prop="protocolNo">
  43. <el-select
  44. v-model="formData.protocolNoId"
  45. class="w-full"
  46. filterable
  47. remote
  48. clearable
  49. reserve-keyword
  50. :remote-method="remoteSearchProtocolNos"
  51. :loading="protocolNoLoading"
  52. placeholder="输入协议号检索"
  53. @change="onProtocolNoChange"
  54. @clear="onProtocolNoClear"
  55. >
  56. <el-option
  57. v-for="p in protocolNoOptions"
  58. :key="p.id"
  59. :label="protocolNoOptionLabel(p)"
  60. :value="p.id"
  61. :disabled="isProtocolNoOptionDisabled(p)"
  62. />
  63. </el-select>
  64. </el-form-item>
  65. <el-form-item label="协议状态" prop="protocolStatus">
  66. <el-select
  67. v-model="formData.protocolStatus"
  68. placeholder="请选择"
  69. style="width: 100%"
  70. >
  71. <el-option label="待生效" value="dsx" />
  72. <el-option label="生效中" value="sxz" />
  73. <el-option label="已过期" value="ygq" />
  74. <el-option label="已作废" value="yzf" />
  75. <el-option label="转科研" value="zky" />
  76. </el-select>
  77. </el-form-item>
  78. <el-form-item label="登记日期" prop="registerDate">
  79. <el-date-picker
  80. v-model="formData.registerDate"
  81. type="date"
  82. value-format="YYYYMMDD"
  83. format="YYYY-MM-DD"
  84. placeholder="选择登记日期"
  85. clearable
  86. style="width: 100%"
  87. />
  88. </el-form-item>
  89. <el-form-item label="采集专员" prop="collectorUserId">
  90. <el-select
  91. v-model="collectorUserId"
  92. class="w-full"
  93. filterable
  94. remote
  95. clearable
  96. reserve-keyword
  97. :remote-method="remoteSearchCollectorUsers"
  98. :loading="collectorLoading"
  99. placeholder="输入姓名检索用户"
  100. @change="onCollectorUserChange"
  101. >
  102. <el-option
  103. v-for="u in collectorUserOptions"
  104. :key="u.id"
  105. :label="u.nickName + (u.phoneNo ? ' · ' + u.phoneNo : '')"
  106. :value="u.id"
  107. />
  108. </el-select>
  109. </el-form-item>
  110. </div>
  111. <div class="form-grid form-grid--col2 basic-remark-attach-row">
  112. <el-form-item label="备注">
  113. <el-input
  114. v-model="formData.remark"
  115. type="textarea"
  116. :rows="3"
  117. placeholder="可选"
  118. />
  119. </el-form-item>
  120. <el-form-item label="协议附件">
  121. <div class="attachment-upload">
  122. <div class="attachment-list">
  123. <el-upload
  124. ref="attachmentUploadRef"
  125. :auto-upload="false"
  126. :show-file-list="false"
  127. :multiple="true"
  128. accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
  129. @change="handleAttachmentChange"
  130. >
  131. <div class="upload-card">
  132. <el-icon :size="28" color="#10b981"><Plus /></el-icon>
  133. <span class="upload-card-text">上传附件</span>
  134. </div>
  135. </el-upload>
  136. <div
  137. v-for="(file, index) in formData.attachments"
  138. :key="file.id || index"
  139. class="attachment-item"
  140. :class="{ 'is-uploading': file._uploading }"
  141. >
  142. <div
  143. class="attachment-preview"
  144. @click="
  145. isImageFile(file) && file.fileUrl
  146. ? handlePreviewAttachment(file)
  147. : undefined
  148. "
  149. >
  150. <template v-if="isImageFile(file) && file.fileUrl">
  151. <img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
  152. </template>
  153. <template v-else-if="isImageFile(file) && !file.fileUrl">
  154. <el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
  155. </template>
  156. <template v-else-if="isVideoFile(file)">
  157. <div class="attachment-preview__icon attachment-preview__icon--video">
  158. <el-icon :size="28"><VideoPlay /></el-icon>
  159. </div>
  160. </template>
  161. <template v-else>
  162. <div class="attachment-preview__icon attachment-preview__icon--doc">
  163. <el-icon :size="28"><Document /></el-icon>
  164. </div>
  165. </template>
  166. </div>
  167. <div class="attachment-footer">
  168. <span class="attachment-name" :title="file.fileName">{{
  169. file.fileName
  170. }}</span>
  171. <div class="attachment-actions">
  172. <el-icon
  173. class="action-btn"
  174. title="下载"
  175. @click="handleDownloadAttachment(file)"
  176. ><Download
  177. /></el-icon>
  178. <el-icon
  179. class="action-btn action-btn--danger"
  180. title="删除"
  181. @click="removeAttachment(index)"
  182. ><Delete
  183. /></el-icon>
  184. </div>
  185. </div>
  186. </div>
  187. </div>
  188. <span class="upload-hint"
  189. >支持图片、视频、PDF、Word、Excel等文件</span
  190. >
  191. </div>
  192. </el-form-item>
  193. </div>
  194. </div>
  195. </div>
  196. <!-- Tab 2: 客户信息 -->
  197. <div id="form-section-party" class="form-tab-pane">
  198. <div class="section-card section-card--green">
  199. <div class="section-title">
  200. <div class="section-title-left"><h3>客户信息</h3></div>
  201. </div>
  202. <div class="client-sub-cards">
  203. <!-- 母亲信息 -->
  204. <div class="client-sub-card">
  205. <div class="sub-card-header">
  206. <span class="icon">🤱</span>
  207. <h4>母亲信息</h4>
  208. <span class="search-link">关联客户 ▸</span>
  209. </div>
  210. <el-form-item label="关联客户" style="margin-bottom: 12px">
  211. <el-select
  212. v-model="customerId"
  213. class="w-full"
  214. filterable
  215. remote
  216. clearable
  217. reserve-keyword
  218. :remote-method="remoteSearchCustomers"
  219. :loading="customerLoading"
  220. placeholder="输入姓名/电话/身份证号检索客户"
  221. @change="onCustomerChange"
  222. >
  223. <el-option
  224. v-for="c in customerOptions"
  225. :key="c.id"
  226. :label="formatCustomerLabel(c)"
  227. :value="c.id"
  228. />
  229. </el-select>
  230. </el-form-item>
  231. <div class="form-grid form-grid--col2">
  232. <el-form-item label="母亲姓名">
  233. <el-input v-model="formData.motherName" placeholder="请输入" />
  234. </el-form-item>
  235. <el-form-item label="母亲联系电话">
  236. <el-input v-model="formData.motherPhone" placeholder="请输入" />
  237. </el-form-item>
  238. <el-form-item label="母亲身份证号" class="form-col-span-2">
  239. <el-input v-model="formData.motherIdCard" placeholder="请输入" />
  240. </el-form-item>
  241. <el-form-item label="母亲出生年月">
  242. <el-date-picker
  243. v-model="formData.motherBirthYm"
  244. type="month"
  245. value-format="YYYYMM"
  246. format="YYYY-MM"
  247. placeholder="选择月份"
  248. clearable
  249. style="width: 100%"
  250. />
  251. </el-form-item>
  252. <el-form-item label="母亲民族">
  253. <el-select
  254. v-model="formData.motherEthnic"
  255. class="w-full"
  256. filterable
  257. clearable
  258. :loading="ethnicLoading"
  259. placeholder="请选择或检索民族"
  260. >
  261. <el-option
  262. v-for="e in ethnicOptions"
  263. :key="e.ethnicCode"
  264. :label="e.ethnicName"
  265. :value="e.ethnicName"
  266. />
  267. </el-select>
  268. </el-form-item>
  269. </div>
  270. <el-form-item label="母亲家庭住址" style="margin-top: 12px">
  271. <el-input v-model="formData.motherAddress" placeholder="请输入" />
  272. </el-form-item>
  273. </div>
  274. <!-- 父亲信息 -->
  275. <div class="client-sub-card">
  276. <div class="sub-card-header">
  277. <span class="icon">👨</span>
  278. <h4>父亲信息</h4>
  279. </div>
  280. <div class="form-grid form-grid--col2">
  281. <el-form-item label="父亲姓名">
  282. <el-input v-model="formData.fatherName" placeholder="请输入" />
  283. </el-form-item>
  284. <el-form-item label="父亲电话">
  285. <el-input v-model="formData.fatherPhone" placeholder="请输入" />
  286. </el-form-item>
  287. <el-form-item label="父亲身份证号" class="form-col-span-2">
  288. <el-input v-model="formData.fatherIdCard" placeholder="请输入" />
  289. </el-form-item>
  290. </div>
  291. </div>
  292. <!-- 新生儿信息 -->
  293. <div class="client-sub-card">
  294. <div class="sub-card-header">
  295. <span class="icon">👶</span>
  296. <h4>新生儿信息</h4>
  297. </div>
  298. <div class="form-grid form-grid--col2">
  299. <el-form-item label="新生儿姓名">
  300. <el-input v-model="formData.newbornName" placeholder="请输入" />
  301. </el-form-item>
  302. <el-form-item label="新生儿性别">
  303. <el-radio-group v-model="formData.newbornSex">
  304. <el-radio label="1">男</el-radio>
  305. <el-radio label="0">女</el-radio>
  306. </el-radio-group>
  307. </el-form-item>
  308. <el-form-item label="新生儿分娩时间" class="form-col-span-2">
  309. <el-date-picker
  310. v-model="formData.newbornDeliveryTime"
  311. type="datetime"
  312. value-format="YYYY-MM-DD HH:mm:ss"
  313. placeholder="选择分娩时间"
  314. clearable
  315. style="width: 100%"
  316. />
  317. </el-form-item>
  318. <el-form-item label="新生儿身份证" class="form-col-span-2">
  319. <el-input v-model="formData.newbornIdCard" placeholder="请输入" />
  320. </el-form-item>
  321. <el-form-item label="分娩方式">
  322. <el-select
  323. v-model="formData.deliveryMode"
  324. placeholder="请选择"
  325. clearable
  326. style="width: 100%"
  327. >
  328. <el-option label="顺产" value="sc" />
  329. <el-option label="剖宫产" value="pgc" />
  330. </el-select>
  331. </el-form-item>
  332. <el-form-item label="胎次">
  333. <el-input
  334. v-model="formData.birthOrder"
  335. type="number"
  336. step="any"
  337. :min="1"
  338. style="width: 100%"
  339. />
  340. </el-form-item>
  341. <el-form-item label="新生儿体重(克)">
  342. <el-input
  343. v-model="formData.newbornWeightGram"
  344. type="number"
  345. step="any"
  346. :min="0"
  347. style="width: 100%"
  348. />
  349. </el-form-item>
  350. <el-form-item label="新生儿评分1分钟">
  351. <el-input
  352. v-model="formData.apgar1min"
  353. type="number"
  354. step="any"
  355. :min="0"
  356. :max="10"
  357. style="width: 100%"
  358. />
  359. </el-form-item>
  360. <el-form-item label="新生儿评分5分钟">
  361. <el-input
  362. v-model="formData.apgar5min"
  363. type="number"
  364. step="any"
  365. :min="0"
  366. :max="10"
  367. style="width: 100%"
  368. />
  369. </el-form-item>
  370. </div>
  371. </div>
  372. </div>
  373. </div>
  374. </div>
  375. <!-- Tab 3: 采集信息 -->
  376. <div id="form-section-collection" class="form-tab-pane">
  377. <div class="section-card">
  378. <div class="section-title">
  379. <div class="section-title-left"><h3>采集信息</h3></div>
  380. </div>
  381. <div class="form-grid">
  382. <el-form-item label="采集地区">
  383. <el-select
  384. v-model="formData.regionId"
  385. class="w-full"
  386. filterable
  387. remote
  388. clearable
  389. reserve-keyword
  390. :remote-method="remoteSearchRegions"
  391. :loading="regionLoading"
  392. placeholder="输入地区名称检索"
  393. @change="onRegionChange"
  394. @clear="onRegionClear"
  395. >
  396. <el-option
  397. v-for="r in regionOptions"
  398. :key="r.id"
  399. :label="r.regionName"
  400. :value="r.id"
  401. />
  402. </el-select>
  403. </el-form-item>
  404. <el-form-item label="采集医院">
  405. <el-select
  406. v-model="formData.hospitalId"
  407. class="w-full"
  408. filterable
  409. remote
  410. clearable
  411. reserve-keyword
  412. :remote-method="remoteSearchHospitals"
  413. :loading="hospitalLoading"
  414. :disabled="!formData.regionId"
  415. placeholder="请先选择采集地区"
  416. @change="onHospitalChange"
  417. >
  418. <el-option
  419. v-for="h in hospitalOptions"
  420. :key="h.id"
  421. :label="h.hospitalName"
  422. :value="h.id"
  423. />
  424. </el-select>
  425. </el-form-item>
  426. <el-form-item label="采集医护">
  427. <el-input v-model="formData.medicalStaff" placeholder="请输入" />
  428. </el-form-item>
  429. <el-form-item label="销售人员">
  430. <el-select
  431. v-model="salesUserId"
  432. class="w-full"
  433. filterable
  434. remote
  435. clearable
  436. reserve-keyword
  437. :remote-method="remoteSearchSalesUsers"
  438. :loading="salesLoading"
  439. placeholder="输入姓名检索用户"
  440. @change="onSalesUserChange"
  441. >
  442. <el-option
  443. v-for="u in salesUserOptions"
  444. :key="u.id"
  445. :label="u.nickName + (u.phoneNo ? ' · ' + u.phoneNo : '')"
  446. :value="u.id"
  447. />
  448. </el-select>
  449. </el-form-item>
  450. <el-form-item label="样本采集时间">
  451. <el-date-picker
  452. v-model="formData.sampleCollectTime"
  453. type="datetime"
  454. value-format="YYYY-MM-DD HH:mm:ss"
  455. placeholder="选择采集时间"
  456. clearable
  457. style="width: 100%"
  458. />
  459. </el-form-item>
  460. <el-form-item label="母血采集时间">
  461. <el-date-picker
  462. v-model="formData.maternalBloodCollectTime"
  463. type="datetime"
  464. value-format="YYYY-MM-DD HH:mm:ss"
  465. placeholder="选择采集时间"
  466. clearable
  467. style="width: 100%"
  468. />
  469. </el-form-item>
  470. </div>
  471. </div>
  472. </div>
  473. <!-- Tab 4: 样本及运输信息 -->
  474. <div id="form-section-sample" class="form-tab-pane">
  475. <div class="section-card">
  476. <div class="section-title">
  477. <div class="section-title-left"><h3>样本及运输信息</h3></div>
  478. </div>
  479. <div class="sample-toolbar">
  480. <el-button type="primary" @click="addSample">
  481. <el-icon><Plus /></el-icon>
  482. 添加样本
  483. </el-button>
  484. <el-button
  485. class="btn-ghost"
  486. :disabled="!selectedSamples.length"
  487. @click="openBatchTransportDialog"
  488. >
  489. 应用到选中样本
  490. </el-button>
  491. </div>
  492. <p v-if="formData.samples.length" class="sample-transport-hint"
  493. >勾选样本后,点击「应用到选中样本」可批量填写运输信息。点击展开可编辑单个运输信息。</p
  494. >
  495. <!-- Sample Cards -->
  496. <div
  497. v-for="(row, $index) in formData.samples"
  498. :key="row._rowKey"
  499. class="sample-card"
  500. >
  501. <div class="sample-header">
  502. <el-checkbox
  503. :model-value="selectedSamples.includes(row)"
  504. @change="(val) => val ? (!selectedSamples.includes(row) && selectedSamples.push(row)) : selectedSamples.splice(selectedSamples.indexOf(row), 1)"
  505. />
  506. <div class="sample-title">
  507. <span class="sample-num">样本 #{{ $index + 1 }} ·</span>
  508. {{ sampleRowLabel(row, $index) }}
  509. </div>
  510. <div class="sample-actions">
  511. <el-button
  512. link
  513. type="danger"
  514. size="small"
  515. @click="removeSample($index)"
  516. >
  517. <el-icon><Delete /></el-icon>
  518. </el-button>
  519. </div>
  520. </div>
  521. <div class="form-grid form-grid--col4">
  522. <el-form-item label="样本类型">
  523. <el-select
  524. v-model="row.sampleTypeId"
  525. placeholder="选择类型"
  526. style="width: 100%"
  527. @change="onSampleTypeChange(row)"
  528. >
  529. <el-option
  530. v-for="st in sampleTypeOptions"
  531. :key="st.id"
  532. :label="st.typeName + (st.typeCode ? ' (' + st.typeCode + ')' : '')"
  533. :value="st.id"
  534. />
  535. </el-select>
  536. </el-form-item>
  537. <el-form-item label="样本编码">
  538. <el-select
  539. v-model="row.sampleCodeId"
  540. placeholder="选择编码"
  541. filterable
  542. style="width: 100%"
  543. @change="onSampleCodeChange(row)"
  544. >
  545. <el-option
  546. v-for="sc in sampleCodeOptions"
  547. :key="sc.id"
  548. :label="sampleCodeOptionLabel(sc)"
  549. :value="sc.id"
  550. :disabled="isSampleCodeOptionDisabled(sc, row)"
  551. />
  552. </el-select>
  553. </el-form-item>
  554. <el-form-item label="采集量">
  555. <el-input
  556. v-model="row.collectAmount"
  557. type="number"
  558. step="any"
  559. :min="0"
  560. style="width: 100%"
  561. />
  562. </el-form-item>
  563. <el-form-item label="单位">
  564. <el-select
  565. v-model="row.unitId"
  566. placeholder="单位"
  567. filterable
  568. style="width: 100%"
  569. >
  570. <el-option
  571. v-for="u in unitOptions"
  572. :key="u.id"
  573. :label="u.unitName"
  574. :value="u.id"
  575. />
  576. </el-select>
  577. </el-form-item>
  578. <el-form-item label="价格(元)">
  579. <AmountInput
  580. v-model="row.price"
  581. :min="0"
  582. placeholder="请输入价格"
  583. @change="onSamplePriceChange"
  584. />
  585. </el-form-item>
  586. <el-form-item label="存储年限(年)">
  587. <el-input
  588. v-model="row.storageYears"
  589. type="number"
  590. step="any"
  591. :min="0"
  592. style="width: 100%"
  593. />
  594. </el-form-item>
  595. <el-form-item label="样本状态">
  596. <el-select
  597. v-model="row.sampleStatus"
  598. placeholder="状态"
  599. clearable
  600. style="width: 100%"
  601. @change="onSampleStatusChange(row)"
  602. >
  603. <el-option label="已采集" value="ycj" />
  604. <el-option label="检测中" value="jcz" />
  605. <el-option label="已入库" value="yrk" />
  606. <el-option label="已作废" value="yzf" />
  607. </el-select>
  608. </el-form-item>
  609. </div>
  610. <!-- Transport Toggle -->
  611. <div
  612. class="transport-toggle"
  613. :class="{ expanded: row._transportExpanded }"
  614. @click="row._transportExpanded = !row._transportExpanded"
  615. >
  616. <span class="chevron">▶</span>
  617. 运输信息
  618. <span class="summary">
  619. {{ row.transport?.transportType ? transportTypeLabel(String(row.transport.transportType)) : '' }}
  620. {{ row.transport?.destSiteId ? '→ ' + siteName(String(row.transport.destSiteId)) : '' }}
  621. </span>
  622. </div>
  623. <div
  624. class="transport-body"
  625. :class="{ open: row._transportExpanded }"
  626. >
  627. <div
  628. class="sample-transport-form-row"
  629. :class="{
  630. 'sample-transport-form-row--ryxd': row.transport?.transportType === 'ryxd'
  631. }"
  632. >
  633. <el-form-item label="运输类型">
  634. <el-select
  635. v-model="row.transport.transportType"
  636. placeholder="请选择"
  637. clearable
  638. style="width: 100%"
  639. >
  640. <el-option label="快递运输" value="kdys" />
  641. <el-option label="人员携带" value="ryxd" />
  642. </el-select>
  643. </el-form-item>
  644. <el-form-item label="目的站点">
  645. <el-select
  646. v-model="row.transport.destSiteId"
  647. placeholder="选择站点"
  648. filterable
  649. clearable
  650. style="width: 100%"
  651. >
  652. <el-option
  653. v-for="site in siteOptions"
  654. :key="site.id"
  655. :label="site.siteName"
  656. :value="site.id"
  657. />
  658. </el-select>
  659. </el-form-item>
  660. <el-form-item label="预计到达时间">
  661. <el-date-picker
  662. v-model="row.transport.etaTime"
  663. type="datetime"
  664. value-format="YYYY-MM-DD HH:mm:ss"
  665. format="YYYY-MM-DD HH:mm"
  666. placeholder="选择预计到达时间"
  667. clearable
  668. style="width: 100%"
  669. />
  670. </el-form-item>
  671. <template v-if="row.transport?.transportType === 'kdys'">
  672. <el-form-item label="快递公司">
  673. <el-select
  674. v-model="row.transport.expressCompanyId"
  675. placeholder="选择快递公司"
  676. filterable
  677. clearable
  678. style="width: 100%"
  679. >
  680. <el-option
  681. v-for="ec in expressCompanyOptions"
  682. :key="ec.id"
  683. :label="ec.expressName"
  684. :value="ec.id"
  685. />
  686. </el-select>
  687. </el-form-item>
  688. <el-form-item label="快递单号">
  689. <el-input
  690. v-model="row.transport.waybillNo"
  691. placeholder="请输入快递单号"
  692. />
  693. </el-form-item>
  694. <el-form-item label="快递费用(元)">
  695. <el-input
  696. v-model="row.transport.expressFee"
  697. type="number"
  698. step="any"
  699. :min="0"
  700. style="width: 100%"
  701. />
  702. </el-form-item>
  703. </template>
  704. <template v-if="row.transport?.transportType === 'ryxd'">
  705. <el-form-item label="携带人员姓名">
  706. <el-input
  707. v-model="row.transport.carrierName"
  708. placeholder="请输入姓名"
  709. />
  710. </el-form-item>
  711. <el-form-item label="携带人员电话">
  712. <el-input
  713. v-model="row.transport.carrierPhone"
  714. placeholder="请输入电话"
  715. />
  716. </el-form-item>
  717. </template>
  718. </div>
  719. </div>
  720. </div>
  721. <el-empty
  722. v-if="!formData.samples.length"
  723. description="暂无样本,点击上方按钮添加"
  724. :image-size="48"
  725. />
  726. </div>
  727. </div>
  728. <!-- Tab 5: 缴费信息 -->
  729. <div id="form-section-payment" class="form-tab-pane">
  730. <div class="section-card">
  731. <div class="section-title">
  732. <div class="section-title-left"><h3>缴费信息</h3></div>
  733. </div>
  734. <!-- 缴费主表 -->
  735. <div class="payment-main-card">
  736. <div class="section-subtitle">缴费主表</div>
  737. <div class="form-grid form-grid--col5">
  738. <el-form-item label="缴费模式">
  739. <el-radio-group v-model="formData.payMode" @change="onPayModeChange">
  740. <el-radio label="dj">趸交</el-radio>
  741. <el-radio label="fq">分期</el-radio>
  742. </el-radio-group>
  743. </el-form-item>
  744. <el-form-item label="合同金额(元)">
  745. <el-input
  746. v-model="formData.contractAmount"
  747. disabled
  748. placeholder="自动计算"
  749. style="width: 100%"
  750. />
  751. </el-form-item>
  752. <template v-if="formData.payMode === 'dj'">
  753. <el-form-item label="趸交截止时间">
  754. <el-date-picker
  755. v-model="formData.lumpSumDeadline"
  756. type="date"
  757. value-format="YYYYMMDD"
  758. format="YYYY-MM-DD"
  759. placeholder="选择截止时间"
  760. clearable
  761. style="width: 100%"
  762. />
  763. </el-form-item>
  764. </template>
  765. <template v-if="formData.payMode === 'fq'">
  766. <el-form-item label="分期期数">
  767. <el-input
  768. v-model="formData.installmentCount"
  769. type="number"
  770. step="any"
  771. :min="1"
  772. style="width: 100%"
  773. @change="tryGenerateInstallmentDetails"
  774. />
  775. </el-form-item>
  776. <el-form-item label="每期间隔(月)">
  777. <el-input
  778. v-model="formData.installmentIntervalMonths"
  779. type="number"
  780. step="any"
  781. :min="1"
  782. style="width: 100%"
  783. @change="tryGenerateInstallmentDetails"
  784. />
  785. </el-form-item>
  786. <el-form-item label="分期开始时间">
  787. <el-date-picker
  788. v-model="formData.installmentStartDate"
  789. type="date"
  790. value-format="YYYYMMDD"
  791. format="YYYY-MM-DD"
  792. placeholder="选择开始时间"
  793. clearable
  794. style="width: 100%"
  795. @change="tryGenerateInstallmentDetails"
  796. />
  797. </el-form-item>
  798. </template>
  799. </div>
  800. </div>
  801. <!-- 缴费明细 -->
  802. <div class="section-subtitle" style="margin-top: 20px">缴费明细</div>
  803. <div style="margin-bottom: 14px">
  804. <el-button type="primary" @click="addPaymentDetail">
  805. <el-icon><Plus /></el-icon>
  806. 添加明细
  807. </el-button>
  808. </div>
  809. <div class="payment-details">
  810. <div
  811. v-for="(detail, dIdx) in formData.paymentDetails"
  812. :key="dIdx"
  813. class="detail-card"
  814. :class="{ expanded: detail._expanded }"
  815. >
  816. <div
  817. class="detail-header"
  818. role="button"
  819. tabindex="0"
  820. @click="togglePaymentDetailExpand(dIdx)"
  821. @keydown.enter.prevent="togglePaymentDetailExpand(dIdx)"
  822. >
  823. <span class="detail-chevron">▶</span>
  824. <span class="detail-title">缴费明细 #{{ dIdx + 1 }}</span>
  825. <span
  826. v-if="detail.dueAmount != null && detail.dueAmount !== ''"
  827. class="detail-amount"
  828. :class="detail.payStatus === 'yjf' ? 'green' : 'amber'"
  829. >
  830. {{ fmtMoney(detail.dueAmount) }}
  831. </span>
  832. <span
  833. v-if="detail.payStatus"
  834. class="detail-badge"
  835. :class="{
  836. 'badge-green': detail.payStatus === 'yjf',
  837. 'badge-amber': detail.payStatus === 'dfj' || detail.payStatus === 'jfqrz',
  838. 'badge-gray': detail.payStatus === 'tfqrz' || detail.payStatus === 'ytf'
  839. }"
  840. >
  841. {{ payStatusLabel(String(detail.payStatus)) }}
  842. </span>
  843. <span v-if="!detail._expanded" class="detail-summary">
  844. {{ paymentDetailSummary(detail) }}
  845. </span>
  846. <el-button
  847. link
  848. type="danger"
  849. size="small"
  850. class="detail-delete"
  851. @click.stop="formData.paymentDetails.splice(dIdx, 1)"
  852. >
  853. <el-icon><Delete /></el-icon>
  854. </el-button>
  855. </div>
  856. <div v-show="detail._expanded" class="detail-body">
  857. <div class="detail-body-inner">
  858. <div class="form-grid form-grid--col4">
  859. <el-form-item label="应缴费金额(元)">
  860. <el-input
  861. v-model="detail.dueAmount"
  862. type="number"
  863. step="any"
  864. :min="0"
  865. disabled
  866. style="width: 100%"
  867. />
  868. </el-form-item>
  869. <el-form-item label="转账方式">
  870. <el-select
  871. v-model="detail.transferType"
  872. placeholder="请选择转账方式"
  873. style="width: 100%"
  874. >
  875. <el-option label="微信" value="wxzz" />
  876. <el-option label="支付宝" value="zzbzz" />
  877. <el-option label="银行卡" value="yhkzz" />
  878. <el-option label="现金" value="xj" />
  879. </el-select>
  880. </el-form-item>
  881. <el-form-item label="转账金额(元)">
  882. <AmountInput
  883. v-model="detail.transferAmount"
  884. :min="0"
  885. placeholder="请输入转账金额"
  886. />
  887. </el-form-item>
  888. <el-form-item label="转账时间">
  889. <el-date-picker
  890. v-model="detail.transferTime"
  891. type="datetime"
  892. value-format="YYYY-MM-DD HH:mm:ss"
  893. placeholder="选择时间"
  894. clearable
  895. style="width: 100%"
  896. />
  897. </el-form-item>
  898. <el-form-item label="缴费状态">
  899. <el-select
  900. v-model="detail.payStatus"
  901. placeholder="状态"
  902. style="width: 100%"
  903. >
  904. <el-option label="待缴费" value="dfj" />
  905. <el-option label="缴费确认中" value="jfqrz" />
  906. <el-option label="已缴费" value="yjf" />
  907. <el-option label="退费确认中" value="tfqrz" />
  908. <el-option label="已退费" value="ytf" />
  909. </el-select>
  910. </el-form-item>
  911. <el-form-item label="应缴费时间">
  912. <el-date-picker
  913. v-model="detail.installmentDueDate"
  914. type="date"
  915. value-format="YYYYMMDD"
  916. format="YYYY-MM-DD"
  917. placeholder="选择应缴费时间"
  918. clearable
  919. style="width: 100%"
  920. />
  921. </el-form-item>
  922. </div>
  923. <!-- 每条明细的附件上传区 -->
  924. <div class="payment-detail-card__attachments">
  925. <div class="attachment-upload">
  926. <div class="attachment-list">
  927. <el-upload
  928. :auto-upload="false"
  929. :show-file-list="false"
  930. :multiple="true"
  931. accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
  932. @change="(f: any) => handleDetailAttachmentChange(dIdx, f)"
  933. >
  934. <div class="upload-card upload-card--sm">
  935. <el-icon :size="22" color="#10b981"><Plus /></el-icon>
  936. <span class="upload-card-text">上传凭证</span>
  937. </div>
  938. </el-upload>
  939. <div
  940. v-for="(file, fIdx) in detail.attachments"
  941. :key="file.id || fIdx"
  942. class="attachment-item"
  943. :class="{ 'is-uploading': file._uploading }"
  944. >
  945. <div
  946. class="attachment-preview"
  947. @click="
  948. isImageFile(file) && file.fileUrl
  949. ? handlePreviewAttachment(file)
  950. : undefined
  951. "
  952. >
  953. <template v-if="isImageFile(file) && file.fileUrl">
  954. <img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
  955. </template>
  956. <template v-else-if="isImageFile(file) && !file.fileUrl">
  957. <el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
  958. </template>
  959. <template v-else-if="isVideoFile(file)">
  960. <div
  961. class="attachment-preview__icon attachment-preview__icon--video"
  962. >
  963. <el-icon :size="28"><VideoPlay /></el-icon>
  964. </div>
  965. </template>
  966. <template v-else>
  967. <div class="attachment-preview__icon attachment-preview__icon--doc">
  968. <el-icon :size="28"><Document /></el-icon>
  969. </div>
  970. </template>
  971. </div>
  972. <div class="attachment-footer">
  973. <span class="attachment-name" :title="file.fileName">{{
  974. file.fileName
  975. }}</span>
  976. <div class="attachment-actions">
  977. <el-icon
  978. class="action-btn"
  979. title="下载"
  980. @click="handleDownloadAttachment(file)"
  981. ><Download
  982. /></el-icon>
  983. <el-icon
  984. class="action-btn action-btn--danger"
  985. title="删除"
  986. @click="removeDetailAttachment(dIdx, fIdx)"
  987. ><Delete
  988. /></el-icon>
  989. </div>
  990. </div>
  991. </div>
  992. </div>
  993. <span class="upload-hint"
  994. >支持图片、视频、PDF、Word、Excel等文件</span
  995. >
  996. </div>
  997. </div>
  998. </div>
  999. </div>
  1000. </div>
  1001. <el-empty
  1002. v-if="!formData.paymentDetails.length"
  1003. description="暂无缴费明细,点击上方按钮添加"
  1004. :image-size="48"
  1005. />
  1006. </div>
  1007. </div>
  1008. </div>
  1009. </el-form>
  1010. </DialogFormBody>
  1011. <template #footer>
  1012. <el-button class="btn-ghost" @click="emit('update:modelValue', false)">
  1013. <el-icon><Close /></el-icon>
  1014. 取消
  1015. </el-button>
  1016. <el-button class="btn-main" type="primary" :loading="submitLoading" @click="handleSubmit">
  1017. <el-icon><Check /></el-icon>
  1018. 确定
  1019. </el-button>
  1020. </template>
  1021. </el-dialog>
  1022. <!-- ========== 文件预览弹窗 ========== -->
  1023. <el-dialog
  1024. v-model="previewVisible"
  1025. title="文件预览"
  1026. width="800px"
  1027. align-center
  1028. destroy-on-close
  1029. >
  1030. <div v-if="previewType === 'image'" class="preview-container">
  1031. <img
  1032. :src="previewUrl"
  1033. alt="预览"
  1034. style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
  1035. />
  1036. </div>
  1037. <div v-else class="preview-container">
  1038. <video
  1039. :src="previewUrl"
  1040. controls
  1041. style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
  1042. />
  1043. </div>
  1044. </el-dialog>
  1045. <!-- ========== 批量运输信息弹窗 ========== -->
  1046. <el-dialog
  1047. v-model="batchTransportVisible"
  1048. title="批量运输信息"
  1049. width="1200px"
  1050. class="dialog-form-layout"
  1051. align-center
  1052. destroy-on-close
  1053. :close-on-click-modal="false"
  1054. >
  1055. <p class="sample-transport-hint batch-transport-dialog-hint">
  1056. 将以下运输信息应用到已选中的 {{ selectedSamples.length }} 个样本。
  1057. </p>
  1058. <el-form label-position="top" class="biz-dialog-form">
  1059. <div
  1060. class="sample-transport-form-row"
  1061. :class="{
  1062. 'sample-transport-form-row--ryxd': batchTransport.transportType === 'ryxd'
  1063. }"
  1064. >
  1065. <el-form-item label="运输类型">
  1066. <el-select
  1067. v-model="batchTransport.transportType"
  1068. placeholder="请选择"
  1069. clearable
  1070. style="width: 100%"
  1071. >
  1072. <el-option label="快递运输" value="kdys" />
  1073. <el-option label="人员携带" value="ryxd" />
  1074. </el-select>
  1075. </el-form-item>
  1076. <el-form-item label="目的站点">
  1077. <el-select
  1078. v-model="batchTransport.destSiteId"
  1079. placeholder="选择站点"
  1080. filterable
  1081. clearable
  1082. style="width: 100%"
  1083. >
  1084. <el-option
  1085. v-for="site in siteOptions"
  1086. :key="site.id"
  1087. :label="site.siteName"
  1088. :value="site.id"
  1089. />
  1090. </el-select>
  1091. </el-form-item>
  1092. <el-form-item label="预计到达时间">
  1093. <el-date-picker
  1094. v-model="batchTransport.etaTime"
  1095. type="datetime"
  1096. value-format="YYYY-MM-DD HH:mm:ss"
  1097. format="YYYY-MM-DD HH:mm"
  1098. placeholder="选择预计到达时间"
  1099. clearable
  1100. style="width: 100%"
  1101. />
  1102. </el-form-item>
  1103. <template v-if="batchTransport.transportType === 'kdys'">
  1104. <el-form-item label="快递公司">
  1105. <el-select
  1106. v-model="batchTransport.expressCompanyId"
  1107. placeholder="选择快递公司"
  1108. filterable
  1109. clearable
  1110. style="width: 100%"
  1111. >
  1112. <el-option
  1113. v-for="ec in expressCompanyOptions"
  1114. :key="ec.id"
  1115. :label="ec.expressName"
  1116. :value="ec.id"
  1117. />
  1118. </el-select>
  1119. </el-form-item>
  1120. <el-form-item label="快递单号">
  1121. <el-input v-model="batchTransport.waybillNo" placeholder="请输入快递单号" />
  1122. </el-form-item>
  1123. <el-form-item label="快递费用(元)">
  1124. <el-input
  1125. v-model="batchTransport.expressFee"
  1126. type="number"
  1127. step="any"
  1128. :min="0"
  1129. style="width: 100%"
  1130. />
  1131. </el-form-item>
  1132. </template>
  1133. <template v-if="batchTransport.transportType === 'ryxd'">
  1134. <el-form-item label="携带人员姓名">
  1135. <el-input v-model="batchTransport.carrierName" placeholder="请输入姓名" />
  1136. </el-form-item>
  1137. <el-form-item label="携带人员电话">
  1138. <el-input v-model="batchTransport.carrierPhone" placeholder="请输入电话" />
  1139. </el-form-item>
  1140. </template>
  1141. </div>
  1142. </el-form>
  1143. <template #footer>
  1144. <el-button class="btn-ghost" @click="batchTransportVisible = false">
  1145. <el-icon><Close /></el-icon>
  1146. 取消
  1147. </el-button>
  1148. <el-button class="btn-main" type="primary" @click="confirmBatchTransportApply">
  1149. <el-icon><Check /></el-icon>
  1150. 确定应用
  1151. </el-button>
  1152. </template>
  1153. </el-dialog>
  1154. </template>
  1155. <script setup lang="ts">
  1156. import DialogFormBody from '@/components/business/dialog-form-body/index.vue'
  1157. import { ElMessage } from 'element-plus'
  1158. import {
  1159. Plus,
  1160. Close,
  1161. Check,
  1162. Delete,
  1163. Document,
  1164. VideoPlay,
  1165. Loading,
  1166. Download,
  1167. ArrowDown,
  1168. ArrowRight
  1169. } from '@element-plus/icons-vue'
  1170. import {
  1171. fetchCollectionProtocolDetail,
  1172. fetchCreateCollectionProtocol,
  1173. fetchUpdateCollectionProtocol,
  1174. type CollectionProtocolRow
  1175. } from '@/api/collection-protocol'
  1176. import { fetchUploadAttachment } from '@/api/business'
  1177. import { downloadAttachmentFile } from '@/utils/download-attachment'
  1178. import {
  1179. fetchCustomerList,
  1180. fetchUserList,
  1181. fetchUserDetail,
  1182. fetchCellSampleTypeList,
  1183. fetchItemUnitList,
  1184. fetchCollectionRegionList,
  1185. fetchHospitalList,
  1186. fetchCollectionProtocolNoList,
  1187. fetchSampleCodeList,
  1188. fetchExpressCompanyList,
  1189. fetchSiteList
  1190. } from '@/api/admin'
  1191. import { fetchEthnicList, type EthnicOption } from '@/api/ethnic'
  1192. import { useUserStore } from '@/store/modules/user'
  1193. import { syncFqPaymentDetailRowCount } from '@/utils/protocol-payment-installment'
  1194. import AmountInput from '@/components/common/amount-input/index.vue'
  1195. import { formatMoneyDisplay } from '@/utils/money-format'
  1196. import {
  1197. payModeLabel,
  1198. newbornSexLabel,
  1199. deliveryModeLabel,
  1200. sampleStatusLabel,
  1201. transportTypeLabel,
  1202. transferTypeLabel,
  1203. payStatusLabel
  1204. } from '@/utils/status-tag-registry'
  1205. /** 金额只读展示:千分位 + ¥ 前缀 */
  1206. function fmtMoney(v: unknown): string {
  1207. if (v == null || v === '') return '—'
  1208. return '¥' + formatMoneyDisplay(v)
  1209. }
  1210. const userStore = useUserStore()
  1211. const customerId = ref<string | null>(null)
  1212. const customerOptions = ref<
  1213. Array<{ id: string; customerName: string; phoneNo: string; idCardNo: string }>
  1214. >([])
  1215. const customerLoading = ref(false)
  1216. const customerSnapshot = ref<{
  1217. id: string
  1218. customerName: string
  1219. phoneNo: string
  1220. idCardNo: string
  1221. } | null>(null)
  1222. const regionOptions = ref<Api.BizCollectionRegion.RegionItem[]>([])
  1223. const regionLoading = ref(false)
  1224. const hospitalOptions = ref<Api.BizCollectionHospital.HospitalItem[]>([])
  1225. const hospitalLoading = ref(false)
  1226. const collectorUserId = ref<string | null>(null)
  1227. const collectorUserOptions = ref<Api.User.UserItem[]>([])
  1228. const collectorLoading = ref(false)
  1229. const collectorSnapshot = ref<Api.User.UserItem | null>(null)
  1230. const salesUserId = ref<string | null>(null)
  1231. const salesUserOptions = ref<Api.User.UserItem[]>([])
  1232. const salesLoading = ref(false)
  1233. const salesSnapshot = ref<Api.User.UserItem | null>(null)
  1234. const sampleTypeOptions = ref<
  1235. Array<{
  1236. id: string
  1237. typeCode: string
  1238. typeName: string
  1239. storageYears: number | null
  1240. storagePrice: number | null
  1241. }>
  1242. >([])
  1243. const unitOptions = ref<Array<{ id: string; unitName: string; unitCode: string }>>([])
  1244. const protocolNoOptions = ref<Api.BizCollectionProtocolNo.ProtocolNoItem[]>([])
  1245. const protocolNoLoading = ref(false)
  1246. const sampleCodeOptions = ref<Api.BizSampleCode.SampleCodeItem[]>([])
  1247. const sampleCodeLoading = ref(false)
  1248. const expressCompanyOptions = ref<Api.ExpressCompany.ExpressCompanyItem[]>([])
  1249. const siteOptions = ref<Api.Site.SiteItem[]>([])
  1250. const selectedSamples = ref<Array<Record<string, any>>>([])
  1251. let sampleRowSeq = 0
  1252. type SampleTransportForm = {
  1253. transportType: string
  1254. expressCompanyId: string
  1255. waybillNo: string
  1256. destSiteId: string
  1257. expressFee: number | null
  1258. etaTime: string
  1259. carrierName: string
  1260. carrierPhone: string
  1261. }
  1262. function createEmptyTransport(): SampleTransportForm {
  1263. return {
  1264. transportType: '',
  1265. expressCompanyId: '',
  1266. waybillNo: '',
  1267. destSiteId: '',
  1268. expressFee: null,
  1269. etaTime: '',
  1270. carrierName: '',
  1271. carrierPhone: ''
  1272. }
  1273. }
  1274. const batchTransport = reactive<SampleTransportForm>(createEmptyTransport())
  1275. const batchTransportVisible = ref(false)
  1276. const ethnicOptions = ref<EthnicOption[]>([])
  1277. const ethnicLoading = ref(false)
  1278. const DEFAULT_MOTHER_ETHNIC = '汉族'
  1279. const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
  1280. function fullFileUrl(suffix: string) {
  1281. if (!suffix) return ''
  1282. if (suffix.startsWith('http://') || suffix.startsWith('https://') || suffix.startsWith('/'))
  1283. return suffix
  1284. return `${minioBaseUrl}/${suffix}`
  1285. }
  1286. async function loadEthnicOptions() {
  1287. if (ethnicOptions.value.length) return
  1288. ethnicLoading.value = true
  1289. try {
  1290. const res = await fetchEthnicList({ limit: 200 })
  1291. ethnicOptions.value = res.list || []
  1292. } finally {
  1293. ethnicLoading.value = false
  1294. }
  1295. }
  1296. function ensureEthnicOptionInList(name: string) {
  1297. const n = (name || '').trim()
  1298. if (!n) return
  1299. if (ethnicOptions.value.some((e) => e.ethnicName === n)) return
  1300. ethnicOptions.value = [
  1301. { id: '', ethnicName: n, ethnicCode: '', orderNum: null },
  1302. ...ethnicOptions.value
  1303. ]
  1304. }
  1305. defineOptions({ name: 'ProtocolFormDialog' })
  1306. const props = defineProps<{
  1307. modelValue: boolean
  1308. editId: string | null
  1309. listRow?: CollectionProtocolRow | null
  1310. }>()
  1311. const emit = defineEmits<{
  1312. (e: 'update:modelValue', val: boolean): void
  1313. (e: 'saved'): void
  1314. }>()
  1315. function siteName(id: string) {
  1316. if (!id) return '-'
  1317. return siteOptions.value.find((x) => x.id === id)?.siteName || '-'
  1318. }
  1319. function expressName(id: string) {
  1320. if (!id) return '-'
  1321. return expressCompanyOptions.value.find((x) => x.id === id)?.expressName || '-'
  1322. }
  1323. function sampleTransportExtra(transport: Record<string, any> | undefined) {
  1324. if (!transport?.transportType) return '-'
  1325. if (transport.transportType === 'kdys') {
  1326. const parts: string[] = []
  1327. const ec = expressName(String(transport.expressCompanyId || ''))
  1328. if (ec !== '-') parts.push(ec)
  1329. if (transport.waybillNo) parts.push(String(transport.waybillNo))
  1330. if (transport.expressFee != null && transport.expressFee !== '') {
  1331. parts.push(`¥${transport.expressFee}`)
  1332. }
  1333. return parts.length ? parts.join(' · ') : '-'
  1334. }
  1335. if (transport.transportType === 'ryxd') {
  1336. const parts: string[] = []
  1337. if (transport.carrierName) parts.push(String(transport.carrierName))
  1338. if (transport.carrierPhone) parts.push(String(transport.carrierPhone))
  1339. return parts.length ? parts.join(' · ') : '-'
  1340. }
  1341. return '-'
  1342. }
  1343. function sampleRowLabel(row: Record<string, any>, index: number) {
  1344. if (row.sampleCode) return row.sampleCode
  1345. const st = sampleTypeOptions.value.find((x) => x.id === row.sampleTypeId)
  1346. if (st?.typeName) return st.typeName
  1347. return `样本 ${index + 1}`
  1348. }
  1349. function formatPaymentDateYmd(val: string) {
  1350. const s = String(val || '').trim()
  1351. if (s.length === 8 && /^\d{8}$/.test(s)) {
  1352. return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`
  1353. }
  1354. return s || ''
  1355. }
  1356. function initPaymentDetailExpandFlags(details: Array<Record<string, any>>) {
  1357. details.forEach((d, i) => {
  1358. d._expanded = i === 0
  1359. })
  1360. }
  1361. function paymentDetailSummary(detail: Record<string, any>) {
  1362. const parts: string[] = []
  1363. if (detail.dueAmount != null && detail.dueAmount !== '') {
  1364. parts.push(`应缴 ¥${detail.dueAmount}`)
  1365. }
  1366. if (detail.transferType) {
  1367. parts.push(transferTypeLabel(String(detail.transferType)))
  1368. }
  1369. if (detail.transferAmount != null && detail.transferAmount !== '') {
  1370. parts.push(`转账 ¥${detail.transferAmount}`)
  1371. }
  1372. if (detail.transferTime) {
  1373. parts.push(String(detail.transferTime))
  1374. }
  1375. if (detail.payStatus) {
  1376. parts.push(payStatusLabel(String(detail.payStatus)))
  1377. }
  1378. if (detail.installmentDueDate) {
  1379. parts.push(`应缴日 ${formatPaymentDateYmd(String(detail.installmentDueDate))}`)
  1380. }
  1381. const attCount = Array.isArray(detail.attachments) ? detail.attachments.length : 0
  1382. if (attCount > 0) {
  1383. parts.push(`凭证 ${attCount} 个`)
  1384. }
  1385. return parts.length ? parts.join(' · ') : '暂无填写'
  1386. }
  1387. function togglePaymentDetailExpand(dIdx: number) {
  1388. const d = formData.paymentDetails[dIdx]
  1389. if (d) {
  1390. d._expanded = !d._expanded
  1391. }
  1392. }
  1393. /* ====== Tab 常量 ====== */
  1394. const formTabs = [
  1395. { key: 'basic', label: '基本信息' },
  1396. { key: 'party', label: '客户信息' },
  1397. { key: 'collection', label: '采集信息' },
  1398. { key: 'sample', label: '样本及运输信息' },
  1399. { key: 'payment', label: '缴费信息' }
  1400. ] as const
  1401. /* ====== 新增/编辑弹窗 ====== */
  1402. const dialogFormLoading = ref(false)
  1403. const dialogTitle = ref('新增协议单')
  1404. const isEdit = ref(false)
  1405. const submitLoading = ref(false)
  1406. const editingId = ref<string | null>(null)
  1407. const formRef = ref()
  1408. const activeFormTab = ref('basic')
  1409. const formData = reactive({
  1410. // Basic
  1411. protocolNo: '',
  1412. protocolNoId: '',
  1413. protocolStatus: 'dsx',
  1414. registerDate: '',
  1415. collectorUserId: '',
  1416. protocolAttachments: '',
  1417. remark: '',
  1418. attachments: [] as Array<{
  1419. id?: string
  1420. fileName: string
  1421. fileUrl: string
  1422. fileSize: number
  1423. fileSizeDisplay?: string
  1424. fileType?: string
  1425. _uploading?: boolean
  1426. }>,
  1427. // Party Customer
  1428. customerId: '' as string,
  1429. motherName: '',
  1430. motherPhone: '',
  1431. motherIdCard: '',
  1432. motherBirthYm: '',
  1433. motherEthnic: '',
  1434. motherAddress: '',
  1435. fatherName: '',
  1436. fatherPhone: '',
  1437. fatherIdCard: '',
  1438. newbornName: '',
  1439. newbornIdCard: '',
  1440. newbornDeliveryTime: '',
  1441. newbornSex: '',
  1442. deliveryMode: '',
  1443. birthOrder: null as number | null,
  1444. newbornWeightGram: null as number | null,
  1445. apgar1min: null as number | null,
  1446. apgar5min: null as number | null,
  1447. // Collection Info
  1448. regionId: '',
  1449. hospitalId: '',
  1450. medicalStaff: '',
  1451. salesUserId: '',
  1452. sampleCollectTime: '',
  1453. maternalBloodCollectTime: '',
  1454. // Samples
  1455. samples: [] as Array<Record<string, any>>,
  1456. // Payment
  1457. payMode: 'dj',
  1458. contractAmount: null as number | null,
  1459. installmentCount: null as number | null,
  1460. installmentIntervalMonths: null as number | null,
  1461. lumpSumDeadline: '',
  1462. installmentStartDate: '',
  1463. // Payment Details
  1464. paymentDetails: [] as Array<Record<string, any>>
  1465. })
  1466. const formRules = {
  1467. protocolNo: [{ required: true, message: '请输入协议号', trigger: 'blur' }],
  1468. protocolStatus: [{ required: true, message: '请选择协议状态', trigger: 'change' }]
  1469. }
  1470. function resetForm() {
  1471. Object.assign(formData, {
  1472. protocolNo: '',
  1473. protocolNoId: '',
  1474. protocolStatus: 'dsx',
  1475. registerDate: '',
  1476. collectorUserId: '',
  1477. protocolAttachments: '',
  1478. attachments: [],
  1479. remark: '',
  1480. customerId: '',
  1481. motherName: '',
  1482. motherPhone: '',
  1483. motherIdCard: '',
  1484. motherBirthYm: '',
  1485. motherEthnic: '',
  1486. motherAddress: '',
  1487. fatherName: '',
  1488. fatherPhone: '',
  1489. fatherIdCard: '',
  1490. newbornName: '',
  1491. newbornIdCard: '',
  1492. newbornDeliveryTime: '',
  1493. newbornSex: '',
  1494. deliveryMode: '',
  1495. birthOrder: null,
  1496. newbornWeightGram: null,
  1497. apgar1min: null,
  1498. apgar5min: null,
  1499. regionId: '',
  1500. hospitalId: '',
  1501. medicalStaff: '',
  1502. salesUserId: '',
  1503. sampleCollectTime: '',
  1504. maternalBloodCollectTime: '',
  1505. samples: [],
  1506. payMode: 'dj',
  1507. contractAmount: null,
  1508. installmentCount: null,
  1509. installmentIntervalMonths: null,
  1510. lumpSumDeadline: '',
  1511. installmentStartDate: '',
  1512. paymentDetails: []
  1513. })
  1514. customerId.value = null
  1515. customerOptions.value = []
  1516. customerSnapshot.value = null
  1517. collectorUserId.value = null
  1518. collectorUserOptions.value = []
  1519. collectorSnapshot.value = null
  1520. salesUserId.value = null
  1521. salesUserOptions.value = []
  1522. salesSnapshot.value = null
  1523. regionOptions.value = []
  1524. hospitalOptions.value = []
  1525. protocolNoOptions.value = []
  1526. selectedSamples.value = []
  1527. batchTransportVisible.value = false
  1528. Object.assign(batchTransport, createEmptyTransport())
  1529. activeFormTab.value = 'basic'
  1530. }
  1531. function scrollToSection(key: string) {
  1532. activeFormTab.value = key
  1533. const el = document.getElementById(`form-section-${key}`)
  1534. if (el) {
  1535. el.scrollIntoView({ behavior: 'smooth', block: 'start' })
  1536. }
  1537. }
  1538. function normalizeSampleRow(raw: Record<string, any>, index: number) {
  1539. const transportRaw = raw.transport && typeof raw.transport === 'object' ? raw.transport : {}
  1540. const statusRaw = raw.sampleStatus
  1541. const sampleStatus =
  1542. statusRaw != null && String(statusRaw).trim() !== '' ? String(statusRaw).trim() : ''
  1543. return {
  1544. ...raw,
  1545. sampleStatus,
  1546. _rowKey: raw.id || raw._rowKey || `new-${++sampleRowSeq}`,
  1547. transport: {
  1548. ...createEmptyTransport(),
  1549. transportType: transportRaw.transportType || '',
  1550. expressCompanyId: transportRaw.expressCompanyId || '',
  1551. waybillNo: transportRaw.waybillNo || '',
  1552. destSiteId: transportRaw.destSiteId || '',
  1553. expressFee: transportRaw.expressFee ?? null,
  1554. etaTime: transportRaw.etaTime || '',
  1555. carrierName: transportRaw.carrierName || '',
  1556. carrierPhone: transportRaw.carrierPhone || ''
  1557. }
  1558. }
  1559. }
  1560. function onSampleSelectionChange(rows: Array<Record<string, any>>) {
  1561. selectedSamples.value = rows
  1562. }
  1563. function openBatchTransportDialog() {
  1564. if (!selectedSamples.value.length) {
  1565. ElMessage.warning('请先勾选需要应用运输信息的样本')
  1566. return
  1567. }
  1568. batchTransportVisible.value = true
  1569. }
  1570. function confirmBatchTransportApply() {
  1571. if (!batchTransport.transportType) {
  1572. ElMessage.warning('请选择运输类型')
  1573. return
  1574. }
  1575. const payload = { ...batchTransport }
  1576. for (const row of selectedSamples.value) {
  1577. if (!row.transport) row.transport = createEmptyTransport()
  1578. Object.assign(row.transport, payload)
  1579. }
  1580. batchTransportVisible.value = false
  1581. ElMessage.success(`已为 ${selectedSamples.value.length} 个样本应用运输信息`)
  1582. }
  1583. function addSample() {
  1584. formData.samples.push(
  1585. normalizeSampleRow(
  1586. {
  1587. sampleTypeId: '',
  1588. sampleCode: '',
  1589. sampleCodeId: '',
  1590. collectAmount: null,
  1591. unitId: '',
  1592. price: null,
  1593. storageYears: null,
  1594. sampleStatus: 'ycj'
  1595. },
  1596. formData.samples.length
  1597. )
  1598. )
  1599. }
  1600. function removeSample(index: number) {
  1601. formData.samples.splice(index, 1)
  1602. syncContractAmount()
  1603. }
  1604. function addPaymentDetail() {
  1605. const now = new Date()
  1606. const pad = (n: number) => String(n).padStart(2, '0')
  1607. const defaultTime = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
  1608. const isFirst = formData.paymentDetails.length === 0
  1609. // 趸交模式下应缴费时间默认等于趸交截止时间
  1610. const defaultDueDate = formData.payMode === 'dj' ? formData.lumpSumDeadline : ''
  1611. formData.paymentDetails.push({
  1612. transferType: '',
  1613. dueAmount: null,
  1614. transferAmount: null,
  1615. transferTime: defaultTime,
  1616. payStatus: 'dfj',
  1617. installmentDueDate: defaultDueDate,
  1618. _expanded: isFirst,
  1619. attachments: [] as Array<{
  1620. id?: string
  1621. fileName: string
  1622. fileUrl: string
  1623. fileSize: number
  1624. fileSizeDisplay?: string
  1625. fileType?: string
  1626. _uploading?: boolean
  1627. }>
  1628. })
  1629. syncPaymentDueAmounts()
  1630. }
  1631. /** 日期加 N 个月,返回 YYYYMMDD 格式(入参支持 YYYYMMDD 或 YYYY-MM-DD) */
  1632. function addMonths(dateStr: string, months: number): string {
  1633. const compact = dateStr.replace(/-/g, '')
  1634. const y = parseInt(compact.slice(0, 4), 10)
  1635. const m = parseInt(compact.slice(4, 6), 10) - 1
  1636. const d = parseInt(compact.slice(6, 8), 10)
  1637. const date = new Date(y, m + months, d)
  1638. const yy = date.getFullYear()
  1639. const mm = String(date.getMonth() + 1).padStart(2, '0')
  1640. const dd = String(date.getDate()).padStart(2, '0')
  1641. return `${yy}${mm}${dd}`
  1642. }
  1643. function toIntOrUndefined(val: unknown): number | undefined {
  1644. if (val == null || val === '') return undefined
  1645. const n = Number(val)
  1646. return Number.isFinite(n) ? Math.trunc(n) : undefined
  1647. }
  1648. function fqPaymentContext() {
  1649. return {
  1650. payMode: formData.payMode,
  1651. installmentCount: toIntOrUndefined(formData.installmentCount) ?? null,
  1652. contractAmount: formData.contractAmount,
  1653. installmentStartDate: formData.installmentStartDate,
  1654. installmentIntervalMonths: toIntOrUndefined(formData.installmentIntervalMonths) ?? null
  1655. }
  1656. }
  1657. /** 分期模式:三个参数都填好后生成/补齐缴费明细行(已有明细时仅补齐期次,不整表覆盖) */
  1658. function tryGenerateInstallmentDetails() {
  1659. if (formData.payMode !== 'fq') return
  1660. const count = formData.installmentCount
  1661. const interval = formData.installmentIntervalMonths
  1662. const startDate = formData.installmentStartDate
  1663. if (!count || !interval || !startDate) return
  1664. if (formData.paymentDetails.length) {
  1665. syncFqPaymentDetailRowCount(fqPaymentContext(), formData.paymentDetails)
  1666. syncPaymentDueAmounts()
  1667. formData.paymentDetails.forEach((d, i) => {
  1668. d.installmentNo = i + 1
  1669. d.installmentDueDate = addMonths(startDate, i * interval)
  1670. })
  1671. return
  1672. }
  1673. const dueAmounts = calcInstallmentDueAmounts(formData.contractAmount, count)
  1674. const details = []
  1675. for (let i = 0; i < count; i++) {
  1676. details.push({
  1677. transferType: '',
  1678. dueAmount: dueAmounts[i] ?? null,
  1679. transferAmount: null,
  1680. transferTime: '',
  1681. payStatus: 'dfj',
  1682. installmentNo: i + 1,
  1683. installmentDueDate: addMonths(startDate, i * interval),
  1684. attachments: [] as Array<{
  1685. id?: string
  1686. fileName: string
  1687. fileUrl: string
  1688. fileSize: number
  1689. fileSizeDisplay?: string
  1690. fileType?: string
  1691. _uploading?: boolean
  1692. }>
  1693. })
  1694. }
  1695. initPaymentDetailExpandFlags(details)
  1696. formData.paymentDetails = details
  1697. }
  1698. /** 缴费模式切换回调 */
  1699. function onPayModeChange(val: string) {
  1700. if (val === 'fq') {
  1701. tryGenerateInstallmentDetails()
  1702. } else {
  1703. // 趸交模式:将趸交截止时间同步到所有明细的应缴费时间
  1704. syncPaymentDueAmounts()
  1705. syncLumpSumDueDate()
  1706. }
  1707. }
  1708. /** 趸交模式:将趸交截止时间同步到所有缴费明细的应缴费时间 */
  1709. function syncLumpSumDueDate() {
  1710. if (formData.payMode !== 'dj') return
  1711. const deadline = formData.lumpSumDeadline || ''
  1712. formData.paymentDetails.forEach((d) => {
  1713. d.installmentDueDate = deadline
  1714. })
  1715. }
  1716. function handleOpenForAdd() {
  1717. isEdit.value = false
  1718. dialogTitle.value = '新增协议单'
  1719. resetForm()
  1720. formData.motherEthnic = DEFAULT_MOTHER_ETHNIC
  1721. formData.registerDate = new Date().toISOString().slice(0, 10).replace(/-/g, '')
  1722. loadSampleOptions()
  1723. void loadEthnicOptions()
  1724. void remoteSearchCustomers('')
  1725. // 默认采集专员、销售人员为当前登录人
  1726. const currentUser = userStore.getUserInfo
  1727. const uid = String(currentUser?.id || currentUser?.userId || '')
  1728. if (uid) {
  1729. const displayName = currentUser?.realName || currentUser?.username || ''
  1730. const phone = currentUser?.phone || ''
  1731. const userStub = {
  1732. id: uid,
  1733. nickName: displayName,
  1734. phoneNo: phone,
  1735. deptId: '',
  1736. userNo: '',
  1737. email: '',
  1738. sex: '',
  1739. avatar: '',
  1740. status: '1',
  1741. tenantNo: '',
  1742. orderNum: 0,
  1743. remark: '',
  1744. createTime: '',
  1745. updateTime: ''
  1746. } as Api.User.UserItem
  1747. // 采集专员默认当前用户
  1748. collectorUserId.value = uid
  1749. formData.collectorUserId = uid
  1750. collectorSnapshot.value = { ...userStub }
  1751. collectorUserOptions.value = [{ ...userStub }]
  1752. // 销售人员默认当前用户
  1753. salesUserId.value = uid
  1754. formData.salesUserId = uid
  1755. salesSnapshot.value = { ...userStub }
  1756. salesUserOptions.value = [{ ...userStub }]
  1757. }
  1758. }
  1759. function applyProtocolBundleToForm(bundle: Record<string, any>, row: CollectionProtocolRow) {
  1760. const prot = bundle.protocol as Record<string, any> | null
  1761. if (prot) {
  1762. formData.collectorUserId = String(prot.collectorUserId || formData.collectorUserId || '')
  1763. formData.protocolNo = String(prot.protocolNo || formData.protocolNo || '')
  1764. formData.protocolNoId = String(prot.protocolNoId || formData.protocolNoId || '')
  1765. formData.protocolStatus = String(prot.protocolStatus || formData.protocolStatus || 'dsx')
  1766. formData.registerDate = String(prot.registerDate || formData.registerDate || '')
  1767. formData.remark = String(prot.remark ?? formData.remark ?? '')
  1768. if (formData.protocolNoId) {
  1769. protocolNoOptions.value = [
  1770. {
  1771. id: formData.protocolNoId,
  1772. protocolNo: formData.protocolNo,
  1773. issued: 0,
  1774. issuedUserId: '',
  1775. issuedUserName: '',
  1776. issuedTime: '',
  1777. tenantNo: '',
  1778. orderNum: 0,
  1779. remark: '',
  1780. createTime: '',
  1781. updateTime: ''
  1782. }
  1783. ]
  1784. }
  1785. }
  1786. const pc = bundle.partyCustomer as Record<string, any> | null
  1787. if (pc) {
  1788. formData.motherName = String(pc.motherName || '')
  1789. formData.motherPhone = String(pc.motherPhone || '')
  1790. formData.motherIdCard = String(pc.motherIdCard || '')
  1791. formData.motherBirthYm = String(pc.motherBirthYm || '')
  1792. formData.motherEthnic = String(pc.motherEthnic || '')
  1793. ensureEthnicOptionInList(formData.motherEthnic)
  1794. formData.motherAddress = String(pc.motherAddress || '')
  1795. formData.fatherName = String(pc.fatherName || '')
  1796. formData.fatherPhone = String(pc.fatherPhone || '')
  1797. formData.fatherIdCard = String(pc.fatherIdCard || '')
  1798. formData.newbornName = String(pc.newbornName || '')
  1799. formData.newbornIdCard = String(pc.newbornIdCard || '')
  1800. formData.newbornDeliveryTime = String(pc.newbornDeliveryTime || '')
  1801. formData.newbornSex = String(pc.newbornSex ?? '')
  1802. formData.deliveryMode = String(pc.deliveryMode || '')
  1803. formData.birthOrder = pc.birthOrder ?? null
  1804. formData.newbornWeightGram = pc.newbornWeightGram ?? null
  1805. formData.apgar1min = pc.apgar1min ?? null
  1806. formData.apgar5min = pc.apgar5min ?? null
  1807. formData.customerId = String(pc.customerId || '')
  1808. }
  1809. const ci = bundle.collectionInfo as Record<string, any> | null
  1810. if (ci) {
  1811. formData.regionId = String(ci.regionId || '')
  1812. formData.hospitalId = String(ci.hospitalId || '')
  1813. formData.medicalStaff = String(ci.medicalStaff || '')
  1814. formData.salesUserId = String(ci.salesUserId || '')
  1815. formData.sampleCollectTime = String(ci.sampleCollectTime || '')
  1816. formData.maternalBloodCollectTime = String(ci.maternalBloodCollectTime || '')
  1817. }
  1818. const samples = bundle.samples as Array<Record<string, any>> | null
  1819. if (samples && samples.length) {
  1820. formData.samples = samples.map((s, i) => normalizeSampleRow(s, i))
  1821. }
  1822. const pay = bundle.payment as Record<string, any> | null
  1823. if (pay) {
  1824. formData.payMode = String(pay.payMode || 'dj')
  1825. formData.contractAmount = pay.contractAmount ?? null
  1826. formData.lumpSumDeadline = String(pay.lumpSumDeadline || '')
  1827. formData.installmentCount = pay.installmentCount ?? null
  1828. formData.installmentIntervalMonths = pay.installmentIntervalMonths ?? null
  1829. formData.installmentStartDate = String(pay.installmentStartDate || '')
  1830. }
  1831. const pd = bundle.paymentDetails as Array<Record<string, any>> | null
  1832. if (pd && pd.length) {
  1833. const details = pd.map((d) => ({
  1834. ...d,
  1835. attachments: Array.isArray(d.attachments) ? d.attachments : []
  1836. }))
  1837. initPaymentDetailExpandFlags(details)
  1838. formData.paymentDetails = details
  1839. }
  1840. syncFqPaymentDetailRowCount(fqPaymentContext(), formData.paymentDetails)
  1841. const pa =
  1842. (row as any).protocolAttachments || (bundle.protocol as any)?.protocolAttachments || ''
  1843. if (pa && typeof pa === 'string') {
  1844. // legacy URL-only field
  1845. }
  1846. const att = (bundle as any).attachments as Array<Record<string, any>> | null
  1847. if (att && att.length) {
  1848. formData.attachments = att.map((a) => ({ ...a })) as any
  1849. }
  1850. syncContractAmount()
  1851. }
  1852. async function handleOpenForEdit() {
  1853. const row = props.listRow
  1854. if (!row || !props.editId) return
  1855. isEdit.value = true
  1856. dialogTitle.value = '编辑协议单'
  1857. editingId.value = props.editId
  1858. resetForm()
  1859. formData.protocolNo = String(row.protocolNo || '')
  1860. formData.protocolNoId = String((row as any).protocolNoId || '')
  1861. formData.protocolStatus = String(row.protocolStatus || 'dsx')
  1862. formData.registerDate = String(row.registerDate || '')
  1863. formData.collectorUserId = String(row.collectorUserId || '')
  1864. if (formData.protocolNoId) {
  1865. protocolNoOptions.value = [
  1866. {
  1867. id: formData.protocolNoId,
  1868. protocolNo: formData.protocolNo,
  1869. issued: 0,
  1870. issuedUserId: '',
  1871. issuedUserName: '',
  1872. issuedTime: '',
  1873. tenantNo: '',
  1874. orderNum: 0,
  1875. remark: '',
  1876. createTime: '',
  1877. updateTime: ''
  1878. }
  1879. ]
  1880. }
  1881. formData.protocolAttachments = String((row as any).protocolAttachments || '')
  1882. formData.remark = String(row.remark || '')
  1883. dialogFormLoading.value = true
  1884. try {
  1885. const [, , bundle] = await Promise.all([
  1886. loadSampleOptions(),
  1887. loadEthnicOptions(),
  1888. fetchCollectionProtocolDetail(String(row.id)).catch(() => null)
  1889. ])
  1890. if (bundle) {
  1891. applyProtocolBundleToForm(bundle as Record<string, any>, row)
  1892. }
  1893. const hydrateTasks: Promise<void>[] = [
  1894. hydrateCollectorUserForEdit(
  1895. formData.collectorUserId,
  1896. String((row as any).collectorUserName || '')
  1897. ),
  1898. hydrateSalesUserForEdit(formData.salesUserId, String((row as any).salesUserName || '')),
  1899. hydrateCustomerForEdit(
  1900. formData.customerId,
  1901. formData.motherName,
  1902. formData.motherPhone,
  1903. formData.motherIdCard
  1904. )
  1905. ]
  1906. if (formData.regionId) {
  1907. hydrateTasks.push(
  1908. ensureRegionOptionForEdit(formData.regionId, String((row as any).regionName || ''))
  1909. )
  1910. }
  1911. if (formData.regionId && formData.hospitalId) {
  1912. hydrateTasks.push(
  1913. ensureHospitalOptionForEdit(
  1914. formData.regionId,
  1915. formData.hospitalId,
  1916. String((row as any).hospitalName || '')
  1917. )
  1918. )
  1919. }
  1920. await Promise.all(hydrateTasks)
  1921. if (formData.samples.length) {
  1922. ensureSampleCodesInOptions(formData.samples)
  1923. }
  1924. } catch {
  1925. /* 保留列表行已填基础字段 */
  1926. } finally {
  1927. dialogFormLoading.value = false
  1928. }
  1929. }
  1930. async function handleSubmit() {
  1931. if (!formRef.value) return
  1932. await formRef.value.validate()
  1933. if (!isEdit.value && formData.protocolNoId) {
  1934. const picked = protocolNoOptions.value.find((x) => x.id === formData.protocolNoId)
  1935. if (picked?.usedInProtocol) {
  1936. ElMessage.warning(`协议号「${formData.protocolNo}」已被占用,请更换其他协议号`)
  1937. return
  1938. }
  1939. }
  1940. if (!isEdit.value) {
  1941. for (const sampleRow of formData.samples) {
  1942. if (!sampleRow.sampleCodeId) continue
  1943. const sc = sampleCodeOptions.value.find((x) => x.id === sampleRow.sampleCodeId)
  1944. if (sc?.usedInProtocol) {
  1945. ElMessage.warning(
  1946. `样本编码「${sampleRow.sampleCode || sc.sampleCode}」已被占用,请更换`
  1947. )
  1948. return
  1949. }
  1950. }
  1951. }
  1952. submitLoading.value = true
  1953. try {
  1954. syncFqPaymentDetailRowCount(fqPaymentContext(), formData.paymentDetails)
  1955. syncPaymentDueAmounts()
  1956. const protocolAttachmentUrls = formData.attachments
  1957. .map((a) => a.fileUrl)
  1958. .filter(Boolean)
  1959. .join(',')
  1960. const payload = {
  1961. protocolNo: formData.protocolNo,
  1962. protocolNoId: formData.protocolNoId || undefined,
  1963. protocolStatus: formData.protocolStatus,
  1964. registerDate: formData.registerDate || undefined,
  1965. collectorUserId: formData.collectorUserId || undefined,
  1966. protocolAttachments: protocolAttachmentUrls || formData.protocolAttachments || undefined,
  1967. attachments: formData.attachments.map((a) => ({
  1968. id: a.id,
  1969. fileName: a.fileName,
  1970. fileUrl: a.fileUrl,
  1971. fileSize: a.fileSize,
  1972. fileSizeDisplay: a.fileSizeDisplay,
  1973. fileType: a.fileType
  1974. })),
  1975. remark: formData.remark || undefined,
  1976. partyCustomer: {
  1977. customerId: formData.customerId || undefined,
  1978. motherName: formData.motherName || undefined,
  1979. motherPhone: formData.motherPhone || undefined,
  1980. motherIdCard: formData.motherIdCard || undefined,
  1981. motherBirthYm: formData.motherBirthYm || undefined,
  1982. motherEthnic: formData.motherEthnic || undefined,
  1983. motherAddress: formData.motherAddress || undefined,
  1984. fatherName: formData.fatherName || undefined,
  1985. fatherPhone: formData.fatherPhone || undefined,
  1986. fatherIdCard: formData.fatherIdCard || undefined,
  1987. newbornName: formData.newbornName || undefined,
  1988. newbornIdCard: formData.newbornIdCard || undefined,
  1989. newbornDeliveryTime: formData.newbornDeliveryTime || undefined,
  1990. newbornSex: formData.newbornSex || undefined,
  1991. deliveryMode: formData.deliveryMode || undefined,
  1992. birthOrder: formData.birthOrder ?? undefined,
  1993. newbornWeightGram: formData.newbornWeightGram ?? undefined,
  1994. apgar1min: formData.apgar1min ?? undefined,
  1995. apgar5min: formData.apgar5min ?? undefined
  1996. },
  1997. collectionInfo: {
  1998. regionId: formData.regionId || undefined,
  1999. hospitalId: formData.hospitalId || undefined,
  2000. medicalStaff: formData.medicalStaff || undefined,
  2001. salesUserId: formData.salesUserId || undefined,
  2002. sampleCollectTime: formData.sampleCollectTime || undefined,
  2003. maternalBloodCollectTime: formData.maternalBloodCollectTime || undefined
  2004. },
  2005. samples: formData.samples.map(({ _rowKey, transport, ...s }) => ({
  2006. ...s,
  2007. sampleStatus:
  2008. s.sampleStatus != null && String(s.sampleStatus).trim() !== ''
  2009. ? String(s.sampleStatus).trim()
  2010. : null,
  2011. transport: transport?.transportType ? { ...transport } : undefined
  2012. })),
  2013. payment: {
  2014. payMode: formData.payMode,
  2015. contractAmount: formData.contractAmount ?? undefined,
  2016. lumpSumDeadline: formData.lumpSumDeadline || undefined,
  2017. installmentCount: toIntOrUndefined(formData.installmentCount),
  2018. installmentIntervalMonths: toIntOrUndefined(formData.installmentIntervalMonths),
  2019. installmentStartDate: formData.installmentStartDate || undefined
  2020. },
  2021. paymentDetails: formData.paymentDetails.map(({ _expanded, ...d }) => ({
  2022. transferType: d.transferType,
  2023. dueAmount: d.dueAmount,
  2024. transferAmount: d.transferAmount,
  2025. transferTime: d.transferTime,
  2026. payStatus: d.payStatus,
  2027. installmentNo: toIntOrUndefined(d.installmentNo),
  2028. installmentDueDate: d.installmentDueDate,
  2029. remark: d.remark,
  2030. financeRemark: d.financeRemark,
  2031. financeConfirmAttachments: d.financeConfirmAttachments,
  2032. financeConfirmAttachmentFiles: d.financeConfirmAttachmentFiles,
  2033. refundAmount: d.refundAmount,
  2034. refundRemark: d.refundRemark,
  2035. refundAttachments: d.refundAttachments,
  2036. refundAttachmentFiles: d.refundAttachmentFiles,
  2037. refundFinanceRemark: d.refundFinanceRemark,
  2038. refundFinanceConfirmAttachments: d.refundFinanceConfirmAttachments,
  2039. refundFinanceConfirmAttachmentFiles: d.refundFinanceConfirmAttachmentFiles,
  2040. attachments: (d.attachments || []).map((a: any) => ({
  2041. id: a.id,
  2042. fileName: a.fileName,
  2043. fileUrl: a.fileUrl,
  2044. fileSize: a.fileSize,
  2045. fileSizeDisplay: a.fileSizeDisplay,
  2046. fileType: a.fileType
  2047. }))
  2048. }))
  2049. }
  2050. if (isEdit.value && editingId.value) {
  2051. await fetchUpdateCollectionProtocol(editingId.value, payload)
  2052. } else {
  2053. await fetchCreateCollectionProtocol(payload)
  2054. }
  2055. emit('update:modelValue', false)
  2056. emit('saved')
  2057. } finally {
  2058. submitLoading.value = false
  2059. }
  2060. }
  2061. function createUserStub(id: string, nickName: string, phoneNo = ''): Api.User.UserItem {
  2062. return {
  2063. id,
  2064. nickName: nickName || id,
  2065. phoneNo,
  2066. deptId: '',
  2067. userNo: '',
  2068. email: '',
  2069. sex: '',
  2070. avatar: '',
  2071. status: '1',
  2072. tenantNo: '',
  2073. orderNum: 0,
  2074. remark: '',
  2075. createTime: '',
  2076. updateTime: ''
  2077. }
  2078. }
  2079. async function hydrateRemoteUserForEdit(
  2080. userId: string,
  2081. displayName: string | undefined,
  2082. assign: (user: Api.User.UserItem) => void,
  2083. clear: () => void
  2084. ) {
  2085. const uid = (userId || '').trim()
  2086. if (!uid) {
  2087. clear()
  2088. return
  2089. }
  2090. try {
  2091. const detail = await fetchUserDetail(uid)
  2092. assign(detail)
  2093. return
  2094. } catch {
  2095. /* 回退为列表/占位展示名 */
  2096. }
  2097. assign(createUserStub(uid, (displayName || '').trim()))
  2098. }
  2099. async function hydrateCollectorUserForEdit(userId: string, displayName?: string) {
  2100. await hydrateRemoteUserForEdit(
  2101. userId,
  2102. displayName,
  2103. (user) => {
  2104. collectorSnapshot.value = { ...user }
  2105. collectorUserId.value = user.id
  2106. collectorUserOptions.value = [{ ...user }]
  2107. formData.collectorUserId = user.id
  2108. },
  2109. () => {
  2110. collectorUserId.value = null
  2111. collectorSnapshot.value = null
  2112. collectorUserOptions.value = []
  2113. formData.collectorUserId = ''
  2114. }
  2115. )
  2116. }
  2117. async function hydrateSalesUserForEdit(userId: string, displayName?: string) {
  2118. await hydrateRemoteUserForEdit(
  2119. userId,
  2120. displayName,
  2121. (user) => {
  2122. salesSnapshot.value = { ...user }
  2123. salesUserId.value = user.id
  2124. salesUserOptions.value = [{ ...user }]
  2125. formData.salesUserId = user.id
  2126. },
  2127. () => {
  2128. salesUserId.value = null
  2129. salesSnapshot.value = null
  2130. salesUserOptions.value = []
  2131. formData.salesUserId = ''
  2132. }
  2133. )
  2134. }
  2135. async function hydrateCustomerForEdit(
  2136. customerIdVal: string,
  2137. motherName: string,
  2138. motherPhone: string,
  2139. motherIdCard: string
  2140. ) {
  2141. const cid = (customerIdVal || '').trim()
  2142. if (!cid) {
  2143. customerId.value = null
  2144. customerSnapshot.value = null
  2145. customerOptions.value = []
  2146. formData.customerId = ''
  2147. return
  2148. }
  2149. let snapshot = {
  2150. id: cid,
  2151. customerName: motherName,
  2152. phoneNo: motherPhone,
  2153. idCardNo: motherIdCard
  2154. }
  2155. const keyword = motherPhone || motherName || motherIdCard
  2156. if (keyword) {
  2157. try {
  2158. const res = await fetchCustomerList({ page: 1, pageSize: 50, keyword })
  2159. const match = (res.list || []).find((c) => c.id === cid)
  2160. if (match) {
  2161. snapshot = {
  2162. id: match.id,
  2163. customerName: match.customerName || motherName,
  2164. phoneNo: match.phoneNo || motherPhone,
  2165. idCardNo: match.idCardNo || motherIdCard
  2166. }
  2167. }
  2168. } catch {
  2169. /* 使用母亲信息占位 */
  2170. }
  2171. }
  2172. customerSnapshot.value = snapshot
  2173. customerId.value = cid
  2174. customerOptions.value = [snapshot]
  2175. formData.customerId = cid
  2176. }
  2177. async function ensureRegionOptionForEdit(regionId: string, regionName?: string) {
  2178. const rid = (regionId || '').trim()
  2179. if (!rid) return
  2180. await remoteSearchRegions('')
  2181. if (regionOptions.value.some((r) => r.id === rid)) return
  2182. const stub: Api.BizCollectionRegion.RegionItem = {
  2183. id: rid,
  2184. regionName: (regionName || '').trim() || rid,
  2185. hospitalNames: [],
  2186. tenantNo: '',
  2187. orderNum: 0,
  2188. remark: '',
  2189. createTime: '',
  2190. updateTime: ''
  2191. }
  2192. regionOptions.value = [stub, ...regionOptions.value]
  2193. }
  2194. async function ensureHospitalOptionForEdit(
  2195. regionId: string,
  2196. hospitalId: string,
  2197. hospitalName?: string
  2198. ) {
  2199. const hid = (hospitalId || '').trim()
  2200. if (!hid || !(regionId || '').trim()) return
  2201. await loadHospitalsByRegion(regionId, '')
  2202. if (hospitalOptions.value.some((h) => h.id === hid)) return
  2203. const stub: Api.BizCollectionHospital.HospitalItem = {
  2204. id: hid,
  2205. regionId,
  2206. hospitalName: (hospitalName || '').trim() || hid,
  2207. tenantNo: '',
  2208. orderNum: 0,
  2209. remark: '',
  2210. createTime: '',
  2211. updateTime: ''
  2212. }
  2213. hospitalOptions.value = [stub, ...hospitalOptions.value]
  2214. }
  2215. function ensureSampleCodesInOptions(samples: Array<Record<string, any>>) {
  2216. const merged = [...sampleCodeOptions.value]
  2217. for (const s of samples) {
  2218. const id = String(s.sampleCodeId || '')
  2219. if (!id || merged.some((x) => x.id === id)) continue
  2220. merged.push({
  2221. id,
  2222. sampleCode: String(s.sampleCode || id),
  2223. sampleCodeType: '',
  2224. issued: 0,
  2225. usedInProtocol: false,
  2226. issuedUserId: '',
  2227. issuedUserName: '',
  2228. issuedTime: '',
  2229. tenantNo: '',
  2230. orderNum: 0,
  2231. remark: '',
  2232. createTime: '',
  2233. updateTime: ''
  2234. })
  2235. }
  2236. sampleCodeOptions.value = merged
  2237. }
  2238. /* ====== 客户检索(母亲信息) ====== */
  2239. function formatCustomerLabel(c: { customerName: string; phoneNo: string; idCardNo: string }) {
  2240. const parts = [c.customerName || '']
  2241. if (c.phoneNo) parts.push(c.phoneNo)
  2242. if (c.idCardNo) parts.push(c.idCardNo)
  2243. return parts.filter(Boolean).join(' · ')
  2244. }
  2245. async function remoteSearchCustomers(query: string) {
  2246. customerLoading.value = true
  2247. try {
  2248. const res = await fetchCustomerList({ page: 1, pageSize: 50, keyword: query || undefined })
  2249. let list = [...(res.list || [])]
  2250. // Keep current selection visible
  2251. if (
  2252. customerId.value &&
  2253. customerSnapshot.value?.id === customerId.value &&
  2254. !list.some((x) => x.id === customerId.value)
  2255. ) {
  2256. list = [customerSnapshot.value, ...list]
  2257. }
  2258. customerOptions.value = list
  2259. } catch {
  2260. customerOptions.value = []
  2261. } finally {
  2262. customerLoading.value = false
  2263. }
  2264. }
  2265. function onCustomerChange(val: string | null | undefined) {
  2266. if (!val) {
  2267. customerId.value = null
  2268. customerSnapshot.value = null
  2269. // Clear auto-filled fields only
  2270. formData.motherName = ''
  2271. formData.motherPhone = ''
  2272. formData.motherIdCard = ''
  2273. formData.motherBirthYm = ''
  2274. return
  2275. }
  2276. const c = customerOptions.value.find((x) => x.id === val) || customerSnapshot.value
  2277. if (!c) return
  2278. customerSnapshot.value = { ...c } as any
  2279. customerId.value = c.id
  2280. formData.customerId = c.id
  2281. formData.motherName = c.customerName?.trim() || ''
  2282. formData.motherPhone = c.phoneNo?.trim() || ''
  2283. formData.motherIdCard = c.idCardNo?.trim() || ''
  2284. calcBirthFromIdCard()
  2285. }
  2286. /* ====== 采集专员检索 ====== */
  2287. async function remoteSearchCollectorUsers(query: string) {
  2288. collectorLoading.value = true
  2289. try {
  2290. const res = await fetchUserList({
  2291. page: 1,
  2292. pageSize: 50,
  2293. keyword: query || undefined,
  2294. status: '1'
  2295. })
  2296. let list = [...(res.list || [])]
  2297. if (
  2298. collectorUserId.value &&
  2299. collectorSnapshot.value?.id === collectorUserId.value &&
  2300. !list.some((x) => x.id === collectorUserId.value)
  2301. ) {
  2302. list = [collectorSnapshot.value, ...list]
  2303. }
  2304. collectorUserOptions.value = list
  2305. } catch {
  2306. collectorUserOptions.value = []
  2307. } finally {
  2308. collectorLoading.value = false
  2309. }
  2310. }
  2311. function onCollectorUserChange(val: string | null | undefined) {
  2312. if (!val) {
  2313. collectorUserId.value = null
  2314. collectorSnapshot.value = null
  2315. formData.collectorUserId = ''
  2316. return
  2317. }
  2318. const u = collectorUserOptions.value.find((x) => x.id === val) || collectorSnapshot.value
  2319. if (!u) return
  2320. collectorSnapshot.value = { ...u }
  2321. collectorUserId.value = u.id
  2322. formData.collectorUserId = u.id
  2323. }
  2324. /* ====== 销售人员检索 ====== */
  2325. async function remoteSearchSalesUsers(query: string) {
  2326. salesLoading.value = true
  2327. try {
  2328. const res = await fetchUserList({
  2329. page: 1,
  2330. pageSize: 50,
  2331. keyword: query || undefined,
  2332. status: '1'
  2333. })
  2334. let list = [...(res.list || [])]
  2335. if (
  2336. salesUserId.value &&
  2337. salesSnapshot.value?.id === salesUserId.value &&
  2338. !list.some((x) => x.id === salesUserId.value)
  2339. ) {
  2340. list = [salesSnapshot.value, ...list]
  2341. }
  2342. salesUserOptions.value = list
  2343. } catch {
  2344. salesUserOptions.value = []
  2345. } finally {
  2346. salesLoading.value = false
  2347. }
  2348. }
  2349. function onSalesUserChange(val: string | null | undefined) {
  2350. if (!val) {
  2351. salesUserId.value = null
  2352. salesSnapshot.value = null
  2353. formData.salesUserId = ''
  2354. return
  2355. }
  2356. const u = salesUserOptions.value.find((x) => x.id === val) || salesSnapshot.value
  2357. if (!u) return
  2358. salesSnapshot.value = { ...u }
  2359. salesUserId.value = u.id
  2360. formData.salesUserId = u.id
  2361. }
  2362. /* ====== 样本类型/单位选项 ====== */
  2363. async function loadSampleOptions() {
  2364. try {
  2365. const [stRes, uRes, scRes, ecRes, siteRes] = await Promise.all([
  2366. fetchCellSampleTypeList({ page: 1, pageSize: 100 }),
  2367. fetchItemUnitList({ page: 1, pageSize: 100 }),
  2368. fetchSampleCodeList({ page: 1, pageSize: 100 }),
  2369. fetchExpressCompanyList({ page: 1, pageSize: 100 }),
  2370. fetchSiteList({ page: 1, pageSize: 100 })
  2371. ])
  2372. sampleTypeOptions.value = (stRes.list || []) as any
  2373. unitOptions.value = (uRes.list || []) as any
  2374. sampleCodeOptions.value = (scRes.list || []) as any
  2375. expressCompanyOptions.value = ecRes.list || []
  2376. siteOptions.value = siteRes.list || []
  2377. } catch {
  2378. /* ignore */
  2379. }
  2380. }
  2381. /* ====== 采集地区下拉检索 ====== */
  2382. async function remoteSearchRegions(query: string) {
  2383. regionLoading.value = true
  2384. try {
  2385. const res = await fetchCollectionRegionList({
  2386. page: 1,
  2387. pageSize: 100,
  2388. keyword: query || undefined
  2389. })
  2390. regionOptions.value = res.list || []
  2391. } catch {
  2392. regionOptions.value = []
  2393. } finally {
  2394. regionLoading.value = false
  2395. }
  2396. }
  2397. function onRegionChange(val: string | null | undefined) {
  2398. if (!val) {
  2399. formData.regionId = ''
  2400. hospitalOptions.value = []
  2401. formData.hospitalId = ''
  2402. return
  2403. }
  2404. formData.regionId = val
  2405. formData.hospitalId = ''
  2406. hospitalOptions.value = []
  2407. // 联动加载该地区下的医院
  2408. loadHospitalsByRegion(val)
  2409. }
  2410. function onRegionClear() {
  2411. formData.regionId = ''
  2412. formData.hospitalId = ''
  2413. hospitalOptions.value = []
  2414. }
  2415. async function loadHospitalsByRegion(regionId: string, keyword?: string) {
  2416. hospitalLoading.value = true
  2417. try {
  2418. const res = await fetchHospitalList(regionId, {
  2419. page: 1,
  2420. pageSize: 100,
  2421. keyword: keyword || undefined
  2422. })
  2423. hospitalOptions.value = res.list || []
  2424. } catch {
  2425. hospitalOptions.value = []
  2426. } finally {
  2427. hospitalLoading.value = false
  2428. }
  2429. }
  2430. /* ====== 采集医院下拉检索 ====== */
  2431. async function remoteSearchHospitals(query: string) {
  2432. if (!formData.regionId) {
  2433. hospitalOptions.value = []
  2434. return
  2435. }
  2436. await loadHospitalsByRegion(formData.regionId, query)
  2437. }
  2438. function onHospitalChange(val: string | null | undefined) {
  2439. if (!val) {
  2440. formData.hospitalId = ''
  2441. return
  2442. }
  2443. formData.hospitalId = val
  2444. }
  2445. /** 分期应缴费金额分配:前 n-1 期均分(分),最后一期补齐差额 */
  2446. function calcInstallmentDueAmounts(contractAmount: number | null, count: number): number[] {
  2447. if (contractAmount == null || !count || count < 1) return []
  2448. const totalCents = Math.round(Number(contractAmount) * 100)
  2449. const baseCents = Math.floor(totalCents / count)
  2450. const amounts: number[] = []
  2451. let sumCents = 0
  2452. for (let i = 0; i < count; i++) {
  2453. if (i === count - 1) {
  2454. amounts.push((totalCents - sumCents) / 100)
  2455. } else {
  2456. amounts.push(baseCents / 100)
  2457. sumCents += baseCents
  2458. }
  2459. }
  2460. return amounts
  2461. }
  2462. /** 按缴费模式同步各明细应缴费金额 */
  2463. function syncPaymentDueAmounts() {
  2464. const contract = formData.contractAmount
  2465. if (contract == null) return
  2466. if (formData.payMode === 'dj') {
  2467. formData.paymentDetails.forEach((d) => {
  2468. d.dueAmount = contract
  2469. })
  2470. return
  2471. }
  2472. if (formData.payMode === 'fq') {
  2473. const count = formData.installmentCount || formData.paymentDetails.length
  2474. if (!count) return
  2475. const amounts = calcInstallmentDueAmounts(Number(contract), count)
  2476. formData.paymentDetails.forEach((d, i) => {
  2477. if (amounts[i] !== undefined) d.dueAmount = amounts[i]
  2478. })
  2479. }
  2480. }
  2481. /** 根据非作废样本价格之和同步合同金额,并联动应缴费金额 */
  2482. function syncContractAmount() {
  2483. const total = formData.samples.reduce((sum, s) => {
  2484. if (s.sampleStatus === 'yzf') return sum
  2485. return sum + (Number(s.price) || 0)
  2486. }, 0)
  2487. formData.contractAmount = Math.round(total * 100) / 100
  2488. syncPaymentDueAmounts()
  2489. }
  2490. /** 样本价格或状态变更 */
  2491. function onSamplePriceChange() {
  2492. syncContractAmount()
  2493. }
  2494. function onSampleStatusChange(row: Record<string, any>) {
  2495. if (row.sampleStatus == null) {
  2496. row.sampleStatus = ''
  2497. }
  2498. onSamplePriceChange()
  2499. }
  2500. function onSampleTypeChange(row: Record<string, any>) {
  2501. const st = sampleTypeOptions.value.find((x) => x.id === row.sampleTypeId)
  2502. if (st) {
  2503. row.price = st.storagePrice
  2504. row.storageYears = st.storageYears
  2505. }
  2506. syncContractAmount()
  2507. }
  2508. /* ====== 协议号下拉检索 ====== */
  2509. function protocolNoOptionLabel(p: Api.BizCollectionProtocolNo.ProtocolNoItem) {
  2510. if (p.usedInProtocol) return `${p.protocolNo}(已占用)`
  2511. if (p.issued) return `${p.protocolNo}(已下发)`
  2512. return p.protocolNo
  2513. }
  2514. function isProtocolNoOptionDisabled(p: Api.BizCollectionProtocolNo.ProtocolNoItem) {
  2515. if (!p.usedInProtocol) return false
  2516. return !(isEdit.value && p.id === formData.protocolNoId)
  2517. }
  2518. async function remoteSearchProtocolNos(query: string) {
  2519. protocolNoLoading.value = true
  2520. try {
  2521. const res = await fetchCollectionProtocolNoList({
  2522. page: 1,
  2523. pageSize: 100,
  2524. keyword: query || undefined
  2525. })
  2526. protocolNoOptions.value = res.list || []
  2527. } catch {
  2528. protocolNoOptions.value = []
  2529. } finally {
  2530. protocolNoLoading.value = false
  2531. }
  2532. }
  2533. function onProtocolNoChange(val: string | null | undefined) {
  2534. if (!val) {
  2535. formData.protocolNo = ''
  2536. formData.protocolNoId = ''
  2537. return
  2538. }
  2539. const p = protocolNoOptions.value.find((x) => x.id === val)
  2540. if (p) {
  2541. formData.protocolNo = p.protocolNo
  2542. formData.protocolNoId = p.id
  2543. }
  2544. }
  2545. function onProtocolNoClear() {
  2546. formData.protocolNo = ''
  2547. formData.protocolNoId = ''
  2548. }
  2549. /* ====== 样本编码选择 ====== */
  2550. function sampleCodeOptionLabel(sc: Api.BizSampleCode.SampleCodeItem) {
  2551. if (sc.usedInProtocol) return `${sc.sampleCode}(已占用)`
  2552. if (sc.issued) return `${sc.sampleCode}(已下发)`
  2553. return sc.sampleCode
  2554. }
  2555. function isSampleCodeOptionDisabled(sc: Api.BizSampleCode.SampleCodeItem, row: Record<string, any>) {
  2556. if (!sc.usedInProtocol) return false
  2557. return String(row.sampleCodeId || '') !== sc.id
  2558. }
  2559. function onSampleCodeChange(row: Record<string, any>) {
  2560. const sc = sampleCodeOptions.value.find((x) => x.id === row.sampleCodeId)
  2561. if (sc) {
  2562. row.sampleCode = sc.sampleCode
  2563. row.sampleCodeId = sc.id
  2564. }
  2565. }
  2566. /* ====== 母亲身份证号 ↔ 出生年月联动 ====== */
  2567. function calcBirthFromIdCard() {
  2568. const idCard = (formData.motherIdCard || '').trim()
  2569. if (/^\d{17}[\dXx]$/.test(idCard)) {
  2570. formData.motherBirthYm = idCard.substring(6, 12)
  2571. } else if (/^\d{15}$/.test(idCard)) {
  2572. const yy = idCard.substring(6, 8)
  2573. const mm = idCard.substring(8, 10)
  2574. const prefix = parseInt(yy, 10) > 30 ? '19' : '20'
  2575. formData.motherBirthYm = prefix + yy + mm
  2576. } else if (!idCard) {
  2577. formData.motherBirthYm = ''
  2578. }
  2579. }
  2580. watch(
  2581. () => formData.motherIdCard,
  2582. () => calcBirthFromIdCard()
  2583. )
  2584. /* ====== 趸交截止时间联动 ====== */
  2585. watch(
  2586. () => formData.lumpSumDeadline,
  2587. () => {
  2588. if (formData.payMode === 'dj') {
  2589. syncLumpSumDueDate()
  2590. }
  2591. }
  2592. )
  2593. /* ====== 附件上传 ====== */
  2594. const attachmentUploadRef = ref()
  2595. function isImageFile(file: any) {
  2596. const url = (file.fileUrl || '').toLowerCase()
  2597. const name = (file.fileName || '').toLowerCase()
  2598. return (
  2599. /\.(jpg|jpeg|png|gif|bmp|webp|svg)(\?|$)/.test(url) ||
  2600. /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/.test(name) ||
  2601. (file.fileType || '').startsWith('image/')
  2602. )
  2603. }
  2604. function isVideoFile(file: any) {
  2605. const url = (file.fileUrl || '').toLowerCase()
  2606. const name = (file.fileName || '').toLowerCase()
  2607. return (
  2608. /\.(mp4|webm|ogg|mov|avi)(\?|$)/.test(url) ||
  2609. /\.(mp4|webm|ogg|mov|avi)$/.test(name) ||
  2610. (file.fileType || '').startsWith('video/')
  2611. )
  2612. }
  2613. async function handleAttachmentChange(uploadFile: any) {
  2614. const file = uploadFile.raw || uploadFile
  2615. if (!file) return
  2616. const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
  2617. formData.attachments.push({
  2618. id: tempId,
  2619. fileName: file.name,
  2620. fileUrl: '',
  2621. fileSize: file.size,
  2622. fileSizeDisplay: `${(file.size / 1024).toFixed(1)} KB`,
  2623. fileType: file.type || '',
  2624. _uploading: true
  2625. })
  2626. const idx = formData.attachments.length - 1
  2627. try {
  2628. const fd = new FormData()
  2629. fd.append('folder', 'protocol')
  2630. fd.append('file', file)
  2631. const res = await fetchUploadAttachment(fd)
  2632. formData.attachments[idx] = {
  2633. ...formData.attachments[idx],
  2634. id: res.id || tempId,
  2635. fileName: file.name || res.fileName,
  2636. fileUrl: res.fileUrl,
  2637. fileSize: res.fileSize || file.size,
  2638. fileSizeDisplay: res.fileSizeDisplay || `${(file.size / 1024).toFixed(1)} KB`,
  2639. fileType: res.fileType || file.type || '',
  2640. _uploading: false
  2641. }
  2642. } catch {
  2643. ElMessage.error('文件上传失败')
  2644. formData.attachments.splice(idx, 1)
  2645. } finally {
  2646. nextTick(() => attachmentUploadRef.value?.clearFiles())
  2647. }
  2648. }
  2649. function removeAttachment(index: number) {
  2650. formData.attachments.splice(index, 1)
  2651. }
  2652. /* ====== 缴费明细附件上传 ====== */
  2653. async function handleDetailAttachmentChange(detailIndex: number, uploadFile: any) {
  2654. const file = uploadFile.raw || uploadFile
  2655. if (!file) return
  2656. const detail = formData.paymentDetails[detailIndex]
  2657. if (!detail) return
  2658. const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
  2659. detail.attachments.push({
  2660. id: tempId,
  2661. fileName: file.name,
  2662. fileUrl: '',
  2663. fileSize: file.size,
  2664. fileSizeDisplay: `${(file.size / 1024).toFixed(1)} KB`,
  2665. fileType: file.type || '',
  2666. _uploading: true
  2667. })
  2668. const aIdx = detail.attachments.length - 1
  2669. try {
  2670. const fd = new FormData()
  2671. fd.append('folder', 'protocol-payment')
  2672. fd.append('file', file)
  2673. const res = await fetchUploadAttachment(fd)
  2674. detail.attachments[aIdx] = {
  2675. ...detail.attachments[aIdx],
  2676. id: res.id || tempId,
  2677. fileName: file.name || res.fileName,
  2678. fileUrl: res.fileUrl,
  2679. fileSize: res.fileSize || file.size,
  2680. fileSizeDisplay: res.fileSizeDisplay || `${(file.size / 1024).toFixed(1)} KB`,
  2681. fileType: res.fileType || file.type || '',
  2682. _uploading: false
  2683. }
  2684. } catch {
  2685. ElMessage.error('文件上传失败')
  2686. detail.attachments.splice(aIdx, 1)
  2687. }
  2688. }
  2689. function removeDetailAttachment(detailIndex: number, attachmentIndex: number) {
  2690. const detail = formData.paymentDetails[detailIndex]
  2691. if (detail) {
  2692. detail.attachments.splice(attachmentIndex, 1)
  2693. }
  2694. }
  2695. /* 附件预览 */
  2696. const previewVisible = ref(false)
  2697. const previewUrl = ref('')
  2698. const previewType = ref<'image' | 'video'>('image')
  2699. function handlePreviewAttachment(file: any) {
  2700. if (file._uploading) return
  2701. previewUrl.value = fullFileUrl(file.fileUrl)
  2702. previewType.value = isVideoFile(file) ? 'video' : 'image'
  2703. previewVisible.value = true
  2704. }
  2705. function handleDownloadAttachment(file: any) {
  2706. downloadAttachmentFile(file, { fullFileUrl })
  2707. }
  2708. /* ====== 弹窗打开时自动初始化 ====== */
  2709. watch(
  2710. () => props.modelValue,
  2711. (visible) => {
  2712. if (visible) {
  2713. if (props.editId) {
  2714. handleOpenForEdit()
  2715. } else {
  2716. handleOpenForAdd()
  2717. }
  2718. }
  2719. }
  2720. )
  2721. </script>
  2722. <style scoped lang="scss">
  2723. /* ═══════════════════════════════════════════
  2724. DIALOG BODY
  2725. ═══════════════════════════════════════════ */
  2726. .protocol-dialog-body {
  2727. overflow: visible;
  2728. background: #f5f7fa;
  2729. }
  2730. .protocol-form {
  2731. :deep(.el-form-item) {
  2732. margin-bottom: 4px;
  2733. }
  2734. }
  2735. /* ---- Sticky Tab 导航(与查看弹窗 detail-tabs 一致) ---- */
  2736. .detail-tabs {
  2737. position: sticky;
  2738. top: 0;
  2739. z-index: 1000;
  2740. display: flex;
  2741. gap: 4px;
  2742. background: #f0fdf4;
  2743. padding: 4px;
  2744. border-radius: 8px;
  2745. margin: 0 28px 16px;
  2746. width: fit-content;
  2747. isolation: isolate;
  2748. box-shadow: 0 1px 0 #f0fdf4;
  2749. }
  2750. .detail-tab {
  2751. padding: 10px 20px;
  2752. cursor: pointer;
  2753. border-radius: 6px;
  2754. font-size: 14px;
  2755. color: #606266;
  2756. transition: all 0.3s;
  2757. &.active {
  2758. background: #fff;
  2759. color: #059669;
  2760. font-weight: 500;
  2761. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
  2762. }
  2763. &:hover:not(.active) {
  2764. color: #059669;
  2765. }
  2766. }
  2767. /* ═══════════════════════════════════════════
  2768. SECTION CARDS
  2769. ═══════════════════════════════════════════ */
  2770. .form-tab-pane {
  2771. padding: 0 28px 4px;
  2772. & + .form-tab-pane {
  2773. margin-top: 0;
  2774. padding-top: 0;
  2775. }
  2776. }
  2777. .section-card {
  2778. border-radius: 12px;
  2779. padding: 22px 24px;
  2780. margin-bottom: 20px;
  2781. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
  2782. transition: transform 0.2s, box-shadow 0.2s;
  2783. position: relative;
  2784. background: #fff;
  2785. &:hover {
  2786. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
  2787. }
  2788. &--green {
  2789. background: #f0fdf4;
  2790. }
  2791. }
  2792. .section-title {
  2793. display: flex;
  2794. align-items: center;
  2795. justify-content: space-between;
  2796. margin-bottom: 18px;
  2797. padding-bottom: 12px;
  2798. border-bottom: 1px solid #e2e8f0;
  2799. }
  2800. .section-title-left {
  2801. display: flex;
  2802. align-items: center;
  2803. &::before {
  2804. content: '';
  2805. width: 3px;
  2806. height: 18px;
  2807. background: #10b981;
  2808. border-radius: 2px;
  2809. margin-right: 10px;
  2810. flex-shrink: 0;
  2811. }
  2812. }
  2813. .section-title h3 {
  2814. font-size: 15px;
  2815. font-weight: 600;
  2816. color: #1e293b;
  2817. }
  2818. .section-subtitle {
  2819. display: flex;
  2820. align-items: center;
  2821. gap: 6px;
  2822. font-size: 13px;
  2823. font-weight: 600;
  2824. color: #374151;
  2825. margin: 18px 0 14px;
  2826. &::before {
  2827. content: '';
  2828. width: 2px;
  2829. height: 12px;
  2830. background: #10b981;
  2831. border-radius: 1px;
  2832. }
  2833. }
  2834. /* ═══════════════════════════════════════════
  2835. FORM GRID
  2836. ═══════════════════════════════════════════ */
  2837. .form-grid {
  2838. display: grid;
  2839. grid-template-columns: repeat(3, 1fr);
  2840. gap: 16px 20px;
  2841. &--col2 {
  2842. grid-template-columns: repeat(2, 1fr);
  2843. }
  2844. &--col4 {
  2845. grid-template-columns: repeat(4, minmax(0, 1fr));
  2846. }
  2847. &--col5 {
  2848. grid-template-columns: repeat(5, minmax(0, 1fr));
  2849. }
  2850. :deep(.el-form-item) {
  2851. margin-bottom: 0;
  2852. }
  2853. }
  2854. .basic-remark-attach-row {
  2855. margin-top: 16px;
  2856. }
  2857. .form-col-span-2 {
  2858. grid-column: 1 / -1;
  2859. }
  2860. /* ═══════════════════════════════════════════
  2861. CLIENT SUB CARDS
  2862. ═══════════════════════════════════════════ */
  2863. .client-sub-cards {
  2864. display: grid;
  2865. grid-template-columns: repeat(3, 1fr);
  2866. gap: 16px;
  2867. }
  2868. .client-sub-card {
  2869. background: #fff;
  2870. border-radius: 10px;
  2871. padding: 18px 20px;
  2872. box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
  2873. border: 1px solid rgba(0, 0, 0, 0.03);
  2874. transition: transform 0.2s, box-shadow 0.2s;
  2875. &:hover {
  2876. transform: translateY(-1px);
  2877. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.07);
  2878. }
  2879. }
  2880. .sub-card-header {
  2881. display: flex;
  2882. align-items: center;
  2883. gap: 8px;
  2884. margin-bottom: 14px;
  2885. padding-bottom: 10px;
  2886. border-bottom: 1px solid #f1f5f9;
  2887. .icon {
  2888. font-size: 22px;
  2889. line-height: 1;
  2890. }
  2891. h4 {
  2892. font-size: 14px;
  2893. font-weight: 600;
  2894. color: #1e293b;
  2895. }
  2896. .search-link {
  2897. margin-left: auto;
  2898. font-size: 12px;
  2899. color: #10b981;
  2900. cursor: pointer;
  2901. font-weight: 500;
  2902. &:hover {
  2903. text-decoration: underline;
  2904. }
  2905. }
  2906. }
  2907. /* ═══════════════════════════════════════════
  2908. SAMPLE CARDS
  2909. ═══════════════════════════════════════════ */
  2910. .sample-toolbar {
  2911. display: flex;
  2912. align-items: center;
  2913. gap: 12px;
  2914. margin-bottom: 16px;
  2915. }
  2916. .sample-card {
  2917. background: #fff;
  2918. border: 1px solid #e2e8f0;
  2919. border-radius: 10px;
  2920. padding: 18px 20px;
  2921. margin-bottom: 14px;
  2922. transition: box-shadow 0.2s;
  2923. &:hover {
  2924. box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
  2925. }
  2926. }
  2927. .sample-header {
  2928. display: flex;
  2929. align-items: center;
  2930. gap: 10px;
  2931. margin-bottom: 16px;
  2932. padding-bottom: 12px;
  2933. border-bottom: 1px solid #f1f5f9;
  2934. }
  2935. .sample-title {
  2936. font-size: 14px;
  2937. font-weight: 600;
  2938. color: #1e293b;
  2939. }
  2940. .sample-num {
  2941. color: #64748b;
  2942. font-weight: 500;
  2943. margin-right: 4px;
  2944. }
  2945. .sample-actions {
  2946. margin-left: auto;
  2947. display: flex;
  2948. align-items: center;
  2949. gap: 8px;
  2950. }
  2951. .sample-transport-hint {
  2952. margin: 0 0 12px;
  2953. font-size: 12px;
  2954. color: var(--mint-text-muted, #64748b);
  2955. line-height: 1.5;
  2956. }
  2957. /* ── Transport Panel (Collapsible) ── */
  2958. .transport-toggle {
  2959. display: flex;
  2960. align-items: center;
  2961. gap: 6px;
  2962. cursor: pointer;
  2963. font-size: 13px;
  2964. font-weight: 500;
  2965. color: #64748b;
  2966. margin-top: 14px;
  2967. padding: 10px 14px;
  2968. background: #f8fafc;
  2969. border-radius: 8px;
  2970. border: 1px solid #e2e8f0;
  2971. transition: all 0.2s;
  2972. user-select: none;
  2973. &:hover {
  2974. background: #f1f5f9;
  2975. color: #374151;
  2976. }
  2977. .chevron {
  2978. display: inline-block;
  2979. transition: transform 0.3s;
  2980. font-size: 12px;
  2981. }
  2982. &.expanded .chevron {
  2983. transform: rotate(90deg);
  2984. }
  2985. .summary {
  2986. margin-left: auto;
  2987. font-size: 12px;
  2988. color: #94a3b8;
  2989. font-weight: 400;
  2990. }
  2991. }
  2992. .transport-body {
  2993. max-height: 0;
  2994. overflow: hidden;
  2995. transition: max-height 0.35s ease, padding 0.35s ease;
  2996. background: #f8fafc;
  2997. border-radius: 0 0 8px 8px;
  2998. margin-top: 0;
  2999. padding: 0 16px;
  3000. &.open {
  3001. max-height: 600px;
  3002. padding: 16px;
  3003. margin-top: -1px;
  3004. border: 1px solid #e2e8f0;
  3005. border-top: none;
  3006. }
  3007. }
  3008. .sample-transport-form-row {
  3009. display: grid;
  3010. grid-template-columns: repeat(6, minmax(0, 1fr));
  3011. gap: 12px 16px;
  3012. align-items: start;
  3013. :deep(.el-form-item) {
  3014. margin-bottom: 0;
  3015. }
  3016. &--ryxd {
  3017. grid-template-columns: repeat(5, minmax(0, 1fr));
  3018. }
  3019. }
  3020. /* ═══════════════════════════════════════════
  3021. PAYMENT SECTION
  3022. ═══════════════════════════════════════════ */
  3023. .payment-main-card {
  3024. background: #fff;
  3025. border-radius: 10px;
  3026. padding: 18px 20px;
  3027. margin-bottom: 18px;
  3028. box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
  3029. border: 1px solid rgba(0, 0, 0, 0.03);
  3030. }
  3031. .payment-details {
  3032. display: flex;
  3033. flex-direction: column;
  3034. gap: 12px;
  3035. }
  3036. /* Detail Card — Collapsible */
  3037. .detail-card {
  3038. background: #fff;
  3039. border: 1px solid #e2e8f0;
  3040. border-radius: 10px;
  3041. overflow: hidden;
  3042. transition: box-shadow 0.2s;
  3043. &:hover {
  3044. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
  3045. }
  3046. &.expanded .detail-chevron {
  3047. transform: rotate(90deg);
  3048. }
  3049. }
  3050. .detail-header {
  3051. display: flex;
  3052. align-items: center;
  3053. gap: 10px;
  3054. padding: 14px 18px;
  3055. cursor: pointer;
  3056. user-select: none;
  3057. transition: background 0.2s;
  3058. &:hover {
  3059. background: #f8fafc;
  3060. }
  3061. }
  3062. .detail-chevron {
  3063. font-size: 12px;
  3064. color: #94a3b8;
  3065. transition: transform 0.3s;
  3066. flex-shrink: 0;
  3067. }
  3068. .detail-title {
  3069. font-size: 14px;
  3070. font-weight: 600;
  3071. color: #1e293b;
  3072. }
  3073. .detail-amount {
  3074. font-size: 14px;
  3075. font-weight: 700;
  3076. margin-left: 4px;
  3077. &.green {
  3078. color: #059669;
  3079. }
  3080. &.amber {
  3081. color: #d97706;
  3082. }
  3083. }
  3084. .detail-badge {
  3085. display: inline-block;
  3086. padding: 2px 10px;
  3087. border-radius: 20px;
  3088. font-size: 12px;
  3089. font-weight: 500;
  3090. white-space: nowrap;
  3091. &.badge-green {
  3092. background: #ecfdf5;
  3093. color: #059669;
  3094. }
  3095. &.badge-amber {
  3096. background: #fffbeb;
  3097. color: #d97706;
  3098. }
  3099. &.badge-gray {
  3100. background: #f1f5f9;
  3101. color: #64748b;
  3102. }
  3103. }
  3104. .detail-summary {
  3105. margin-left: auto;
  3106. font-size: 12px;
  3107. color: #94a3b8;
  3108. }
  3109. .detail-delete {
  3110. margin-left: 6px;
  3111. }
  3112. .detail-body-inner {
  3113. padding: 0 18px 18px;
  3114. border-top: 1px solid #f1f5f9;
  3115. }
  3116. /* ═══════════════════════════════════════════
  3117. ATTACHMENTS
  3118. ═══════════════════════════════════════════ */
  3119. .attachment-upload {
  3120. width: 100%;
  3121. }
  3122. .upload-card {
  3123. width: 140px;
  3124. height: 96px;
  3125. border: 1px dashed #a7f3d0;
  3126. border-radius: 8px;
  3127. background: #f0fdf4;
  3128. display: flex;
  3129. flex-direction: column;
  3130. align-items: center;
  3131. justify-content: center;
  3132. gap: 6px;
  3133. cursor: pointer;
  3134. transition:
  3135. border-color 0.15s,
  3136. background 0.15s;
  3137. &:hover {
  3138. border-color: #10b981;
  3139. border-style: solid;
  3140. background: #ecfdf5;
  3141. }
  3142. }
  3143. .upload-card-text {
  3144. font-size: 12px;
  3145. color: #10b981;
  3146. font-weight: 500;
  3147. line-height: 1;
  3148. }
  3149. .upload-hint {
  3150. display: block;
  3151. font-size: 11px;
  3152. color: #94a3b8;
  3153. line-height: 1;
  3154. margin-top: 8px;
  3155. }
  3156. .attachment-list {
  3157. display: flex;
  3158. flex-wrap: wrap;
  3159. gap: 12px;
  3160. }
  3161. .attachment-item {
  3162. width: 140px;
  3163. display: flex;
  3164. flex-direction: column;
  3165. border: 1px solid #e2e8f0;
  3166. border-radius: 8px;
  3167. background: #fff;
  3168. overflow: hidden;
  3169. transition:
  3170. border-color 0.15s,
  3171. box-shadow 0.15s;
  3172. &:hover {
  3173. border-color: #10b981;
  3174. box-shadow: 0 2px 8px rgba(16, 185, 129, 0.1);
  3175. }
  3176. &.is-uploading {
  3177. opacity: 0.6;
  3178. pointer-events: none;
  3179. }
  3180. }
  3181. .attachment-preview {
  3182. width: 100%;
  3183. height: 96px;
  3184. flex-shrink: 0;
  3185. display: flex;
  3186. align-items: center;
  3187. justify-content: center;
  3188. background: #f8fafc;
  3189. overflow: hidden;
  3190. cursor: pointer;
  3191. img {
  3192. width: 100%;
  3193. height: 100%;
  3194. object-fit: cover;
  3195. display: block;
  3196. }
  3197. }
  3198. .attachment-preview__icon {
  3199. width: 48px;
  3200. height: 48px;
  3201. border-radius: 10px;
  3202. display: flex;
  3203. align-items: center;
  3204. justify-content: center;
  3205. &--video {
  3206. background: #ede9fe;
  3207. color: #7c3aed;
  3208. }
  3209. &--doc {
  3210. background: #e0f2fe;
  3211. color: #0284c7;
  3212. }
  3213. }
  3214. .attachment-footer {
  3215. display: flex;
  3216. flex-direction: column;
  3217. flex: 1;
  3218. padding: 6px 8px;
  3219. }
  3220. .attachment-name {
  3221. display: -webkit-box;
  3222. -webkit-line-clamp: 2;
  3223. -webkit-box-orient: vertical;
  3224. overflow: hidden;
  3225. text-overflow: ellipsis;
  3226. font-size: 11px;
  3227. color: #334155;
  3228. line-height: 1.4;
  3229. word-break: break-all;
  3230. min-height: calc(11px * 1.4 * 2);
  3231. }
  3232. .attachment-actions {
  3233. display: flex;
  3234. justify-content: center;
  3235. align-items: center;
  3236. gap: 8px;
  3237. margin-top: 4px;
  3238. flex-shrink: 0;
  3239. transition: opacity 0.15s;
  3240. }
  3241. .action-btn {
  3242. width: 22px;
  3243. height: 22px;
  3244. border-radius: 4px;
  3245. display: flex;
  3246. align-items: center;
  3247. justify-content: center;
  3248. font-size: 12px;
  3249. color: #64748b;
  3250. cursor: pointer;
  3251. background: #f1f5f9;
  3252. transition:
  3253. background 0.15s,
  3254. color 0.15s;
  3255. &:hover {
  3256. background: #10b981;
  3257. color: #fff;
  3258. }
  3259. &--danger:hover {
  3260. background: #ef4444;
  3261. color: #fff;
  3262. }
  3263. }
  3264. .upload-card--sm {
  3265. width: 96px;
  3266. height: 76px;
  3267. .upload-card-text {
  3268. font-size: 11px;
  3269. }
  3270. }
  3271. .payment-detail-card__attachments {
  3272. margin-top: 8px;
  3273. padding-top: 8px;
  3274. border-top: 1px dashed #e2e8f0;
  3275. }
  3276. .preview-container {
  3277. text-align: center;
  3278. }
  3279. .batch-transport-dialog-hint {
  3280. margin-top: 0;
  3281. }
  3282. /* ═══════════════════════════════════════════
  3283. RESPONSIVE
  3284. ═══════════════════════════════════════════ */
  3285. @media (max-width: 900px) {
  3286. .form-grid {
  3287. grid-template-columns: repeat(2, 1fr);
  3288. &--col2 {
  3289. grid-template-columns: 1fr;
  3290. }
  3291. &--col4 {
  3292. grid-template-columns: repeat(2, minmax(0, 1fr));
  3293. }
  3294. &--col5 {
  3295. grid-template-columns: repeat(2, minmax(0, 1fr));
  3296. }
  3297. &--col2.basic-remark-attach-row {
  3298. grid-template-columns: 1fr;
  3299. }
  3300. }
  3301. .client-sub-cards {
  3302. grid-template-columns: 1fr;
  3303. }
  3304. }
  3305. </style>