Files
zkxw/web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue
T
2026-05-22 07:16:53 +08:00

3526 lines
117 KiB
Vue
Raw Blame History

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