ProtocolFormDialog.vue 117 KB

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