2693 lines
84 KiB
Vue
2693 lines
84 KiB
Vue
<template>
|
||
<!-- ========== 捐赠协议单 - 新增/编辑弹窗 ========== -->
|
||
<el-dialog
|
||
:model-value="modelValue"
|
||
:title="dialogTitle"
|
||
width="1380px"
|
||
class="dialog-form-layout"
|
||
align-center
|
||
destroy-on-close
|
||
:close-on-click-modal="false"
|
||
>
|
||
<DialogFormBody :loading="dialogFormLoading" body-class="protocol-dialog-body min-h-[200px]">
|
||
<div class="detail-tabs">
|
||
<div
|
||
v-for="tab in formTabs"
|
||
:key="tab.key"
|
||
class="detail-tab"
|
||
:class="{ active: activeFormTab === tab.key }"
|
||
@click="scrollToSection(tab.key)"
|
||
>
|
||
{{ tab.label }}
|
||
</div>
|
||
</div>
|
||
|
||
<el-form
|
||
ref="formRef"
|
||
:model="formData"
|
||
:rules="formRules"
|
||
label-position="top"
|
||
scroll-to-error
|
||
class="protocol-form"
|
||
>
|
||
<!-- Tab 1: 基本信息 -->
|
||
<div id="form-section-basic" class="form-tab-pane">
|
||
<div class="section-card">
|
||
<div class="section-title">
|
||
<div class="section-title-left"><h3>基本信息</h3></div>
|
||
</div>
|
||
<div class="form-grid form-grid--col4">
|
||
<el-form-item label="捐赠协议号">
|
||
<el-input
|
||
:model-value="isEdit ? formData.donationNo : ''"
|
||
class="w-full"
|
||
disabled
|
||
:placeholder="isEdit ? '' : '保存后自动生成'"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="捐赠状态" prop="donationStatus">
|
||
<el-select
|
||
v-model="formData.donationStatus"
|
||
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="zckc" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="捐赠日期" prop="donationDate">
|
||
<el-date-picker
|
||
v-model="formData.donationDate"
|
||
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="备注">
|
||
<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 form-grid--col4">
|
||
<el-form-item label="样本类型">
|
||
<el-select
|
||
v-model="row.sampleTypeId"
|
||
placeholder="选择类型"
|
||
style="width: 100%"
|
||
@change="onSampleTypeChange(row)"
|
||
>
|
||
<el-option
|
||
v-for="st in sampleTypeOptions"
|
||
:key="st.id"
|
||
:label="st.typeName + (st.typeCode ? ' (' + st.typeCode + ')' : '')"
|
||
:value="st.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="样本编码">
|
||
<el-select
|
||
v-model="row.sampleCodeId"
|
||
placeholder="选择编码"
|
||
filterable
|
||
style="width: 100%"
|
||
@change="onSampleCodeChange(row)"
|
||
>
|
||
<el-option
|
||
v-for="sc in sampleCodeOptions"
|
||
:key="sc.id"
|
||
:label="sampleCodeOptionLabel(sc)"
|
||
:value="sc.id"
|
||
:disabled="isSampleCodeOptionDisabled(sc, row)"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="采集量">
|
||
<el-input
|
||
v-model="row.collectAmount"
|
||
type="number"
|
||
step="any"
|
||
:min="0"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="单位">
|
||
<el-select v-model="row.unitId" placeholder="单位" filterable style="width: 100%">
|
||
<el-option
|
||
v-for="u in unitOptions"
|
||
:key="u.id"
|
||
:label="u.unitName"
|
||
:value="u.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="存储年限(年)">
|
||
<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-attachment" class="form-tab-pane">
|
||
<div class="section-card">
|
||
<div class="section-title">
|
||
<div class="section-title-left"><h3>附件信息</h3></div>
|
||
</div>
|
||
|
||
<!-- 知情同意书 -->
|
||
<div class="section-subtitle" style="margin-bottom: 12px">知情同意书</div>
|
||
<div class="attachment-upload">
|
||
<div class="attachment-list">
|
||
<el-upload
|
||
ref="consentUploadRef"
|
||
:auto-upload="false"
|
||
:show-file-list="false"
|
||
:multiple="true"
|
||
accept="image/*,.pdf"
|
||
@change="handleConsentAttachmentChange"
|
||
>
|
||
<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, fIdx) in consentAttachments"
|
||
: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="removeConsentAttachment(fIdx)"
|
||
><Delete
|
||
/></el-icon>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<span class="upload-hint">支持图片和 PDF 格式,可稍后补充</span>
|
||
</div>
|
||
|
||
<!-- 其他附件 -->
|
||
<div class="section-subtitle" style="margin-top: 24px; margin-bottom: 12px"
|
||
>其他附件</div
|
||
>
|
||
<div class="attachment-upload">
|
||
<div class="attachment-list">
|
||
<el-upload
|
||
ref="otherUploadRef"
|
||
:auto-upload="false"
|
||
:show-file-list="false"
|
||
:multiple="true"
|
||
accept="image/*,.pdf,.doc,.docx"
|
||
@change="handleOtherAttachmentChange"
|
||
>
|
||
<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, fIdx) in otherAttachments"
|
||
: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="removeOtherAttachment(fIdx)"
|
||
><Delete
|
||
/></el-icon>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<span class="upload-hint">支持图片、PDF、Word 格式</span>
|
||
</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
|
||
} from '@element-plus/icons-vue'
|
||
import {
|
||
fetchResearchDonationDetail,
|
||
fetchCreateResearchDonation,
|
||
fetchUpdateResearchDonation
|
||
} from '@/api/research-donation'
|
||
import { fetchUploadAttachment } from '@/api/business'
|
||
import { downloadAttachmentFile } from '@/utils/download-attachment'
|
||
import { transportTypeLabel } from '@/utils/status-tag-registry'
|
||
import {
|
||
fetchCustomerList,
|
||
fetchUserList,
|
||
fetchUserDetail,
|
||
fetchCellSampleTypeList,
|
||
fetchItemUnitList,
|
||
fetchCollectionRegionList,
|
||
fetchHospitalList,
|
||
fetchSampleCodeList,
|
||
fetchExpressCompanyList,
|
||
fetchSiteList
|
||
} from '@/api/admin'
|
||
import { fetchEthnicList, type EthnicOption } from '@/api/ethnic'
|
||
import { useUserStore } from '@/store/modules/user'
|
||
|
||
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 sampleCodeOptions = ref<Api.BizSampleCode.SampleCodeItem[]>([])
|
||
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: 'DonationFormDialog' })
|
||
|
||
const props = defineProps<{
|
||
modelValue: boolean
|
||
editId: string | null
|
||
listRow?: Record<string, any> | 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 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}`
|
||
}
|
||
|
||
/* ====== Tab 常量 ====== */
|
||
const formTabs = [
|
||
{ key: 'basic', label: '基本信息' },
|
||
{ key: 'party', label: '客户信息' },
|
||
{ key: 'collection', label: '采集信息' },
|
||
{ key: 'sample', label: '样本及运输信息' },
|
||
{ key: 'attachment', 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')
|
||
|
||
type AttachmentFileItem = {
|
||
id?: string
|
||
fileName: string
|
||
fileUrl: string
|
||
fileSize: number
|
||
fileSizeDisplay?: string
|
||
fileType?: string
|
||
_uploading?: boolean
|
||
}
|
||
|
||
const formData = reactive({
|
||
// Basic
|
||
donationNo: '',
|
||
donationNoId: '',
|
||
donationStatus: 'dsx',
|
||
donationDate: '',
|
||
collectorUserId: '',
|
||
remark: '',
|
||
// 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>>,
|
||
// Attachments
|
||
consentAttachments: [] as Array<AttachmentFileItem>,
|
||
otherAttachments: [] as Array<AttachmentFileItem>
|
||
})
|
||
|
||
const formRules = computed(() => ({
|
||
donationStatus: [{ required: true, message: '请选择捐赠状态', trigger: 'change' }]
|
||
}))
|
||
|
||
function resetForm() {
|
||
Object.assign(formData, {
|
||
donationNo: '',
|
||
donationNoId: '',
|
||
donationStatus: 'dsx',
|
||
donationDate: '',
|
||
collectorUserId: '',
|
||
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: [],
|
||
consentAttachments: [],
|
||
otherAttachments: []
|
||
})
|
||
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 = []
|
||
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>) {
|
||
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 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: '',
|
||
storageYears: null,
|
||
sampleStatus: 'ycj'
|
||
})
|
||
)
|
||
}
|
||
|
||
function removeSample(index: number) {
|
||
formData.samples.splice(index, 1)
|
||
}
|
||
|
||
function handleOpenForAdd() {
|
||
isEdit.value = false
|
||
editingId.value = null
|
||
dialogTitle.value = '新增捐赠协议单'
|
||
resetForm()
|
||
formData.motherEthnic = DEFAULT_MOTHER_ETHNIC
|
||
formData.donationDate = 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 applyDonationBundleToForm(bundle: Record<string, any>) {
|
||
const donation = (bundle.donation ?? bundle.protocol) as Record<string, any> | null
|
||
if (donation) {
|
||
formData.collectorUserId = String(donation.collectorUserId || formData.collectorUserId || '')
|
||
formData.donationNo = String(donation.donationNo || formData.donationNo || '')
|
||
formData.donationStatus = String(donation.donationStatus || formData.donationStatus || 'dsx')
|
||
formData.donationDate = String(donation.donationDate || formData.donationDate || '')
|
||
formData.remark = String(donation.remark ?? formData.remark ?? '')
|
||
}
|
||
|
||
const pc = (bundle.party ?? 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.collection ?? 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) => normalizeSampleRow(s))
|
||
}
|
||
|
||
// Attachments(在 donation 对象上,与查看弹窗一致)
|
||
const consentAtt = (donation?.consentAttachmentFiles ??
|
||
(bundle as any).consentAttachmentFiles) as Array<Record<string, any>> | null
|
||
if (consentAtt && consentAtt.length) {
|
||
formData.consentAttachments = consentAtt.map((a) => ({ ...a })) as any
|
||
}
|
||
const otherAtt = (donation?.attachmentFiles ?? (bundle as any).attachmentFiles) as Array<
|
||
Record<string, any>
|
||
> | null
|
||
if (otherAtt && otherAtt.length) {
|
||
formData.otherAttachments = otherAtt.map((a) => ({ ...a })) as any
|
||
}
|
||
}
|
||
|
||
async function handleOpenForEdit() {
|
||
const row = props.listRow
|
||
if (!row || !props.editId) return
|
||
|
||
isEdit.value = true
|
||
dialogTitle.value = '编辑捐赠协议单'
|
||
editingId.value = props.editId
|
||
resetForm()
|
||
|
||
formData.donationNo = String(row.donationNo || '')
|
||
formData.donationStatus = String(row.donationStatus || 'dsx')
|
||
formData.donationDate = String(row.donationDate || '')
|
||
formData.collectorUserId = String(row.collectorUserId || '')
|
||
formData.remark = String(row.remark || '')
|
||
|
||
dialogFormLoading.value = true
|
||
|
||
try {
|
||
const [, , bundle] = await Promise.all([
|
||
loadSampleOptions(),
|
||
loadEthnicOptions(),
|
||
fetchResearchDonationDetail(String(row.id)).catch(() => null)
|
||
])
|
||
if (bundle) {
|
||
applyDonationBundleToForm(bundle as Record<string, any>)
|
||
}
|
||
|
||
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) {
|
||
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 {
|
||
const payload: Record<string, unknown> = {
|
||
donationStatus: formData.donationStatus,
|
||
donationDate: formData.donationDate || undefined,
|
||
collectorUserId: formData.collectorUserId || undefined,
|
||
remark: formData.remark || undefined,
|
||
consentAttachments: formData.consentAttachments.map((a) => ({
|
||
id: a.id,
|
||
fileName: a.fileName,
|
||
fileUrl: a.fileUrl,
|
||
fileSize: a.fileSize,
|
||
fileSizeDisplay: a.fileSizeDisplay,
|
||
fileType: a.fileType
|
||
})),
|
||
otherAttachments: formData.otherAttachments.map((a) => ({
|
||
id: a.id,
|
||
fileName: a.fileName,
|
||
fileUrl: a.fileUrl,
|
||
fileSize: a.fileSize,
|
||
fileSizeDisplay: a.fileSizeDisplay,
|
||
fileType: a.fileType
|
||
})),
|
||
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
|
||
},
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
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
|
||
}))
|
||
}
|
||
|
||
if (isEdit.value && editingId.value) {
|
||
await fetchUpdateResearchDonation(editingId.value, payload)
|
||
} else {
|
||
await fetchCreateResearchDonation(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
|
||
}
|
||
|
||
function onSampleStatusChange(row: Record<string, any>) {
|
||
if (row.sampleStatus == null) {
|
||
row.sampleStatus = ''
|
||
}
|
||
}
|
||
|
||
function onSampleTypeChange(row: Record<string, any>) {
|
||
const st = sampleTypeOptions.value.find((x) => x.id === row.sampleTypeId)
|
||
if (st) {
|
||
row.storageYears = st.storageYears
|
||
}
|
||
}
|
||
|
||
/* ====== 样本编码选择 ====== */
|
||
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()
|
||
)
|
||
|
||
/* ====== 附件上传 ====== */
|
||
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/')
|
||
)
|
||
}
|
||
|
||
/* ====== 知情同意书附件上传 ====== */
|
||
const consentUploadRef = ref()
|
||
const consentAttachments = computed(() => formData.consentAttachments)
|
||
const otherAttachments = computed(() => formData.otherAttachments)
|
||
|
||
async function handleConsentAttachmentChange(uploadFile: any) {
|
||
const file = uploadFile.raw || uploadFile
|
||
if (!file) return
|
||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||
formData.consentAttachments.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.consentAttachments.length - 1
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('folder', 'donation-consent')
|
||
fd.append('file', file)
|
||
const res = await fetchUploadAttachment(fd)
|
||
formData.consentAttachments[idx] = {
|
||
...formData.consentAttachments[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.consentAttachments.splice(idx, 1)
|
||
} finally {
|
||
nextTick(() => consentUploadRef.value?.clearFiles())
|
||
}
|
||
}
|
||
|
||
function removeConsentAttachment(index: number) {
|
||
formData.consentAttachments.splice(index, 1)
|
||
}
|
||
|
||
/* ====== 其他附件上传 ====== */
|
||
const otherUploadRef = ref()
|
||
|
||
async function handleOtherAttachmentChange(uploadFile: any) {
|
||
const file = uploadFile.raw || uploadFile
|
||
if (!file) return
|
||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||
formData.otherAttachments.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.otherAttachments.length - 1
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('folder', 'donation-other')
|
||
fd.append('file', file)
|
||
const res = await fetchUploadAttachment(fd)
|
||
formData.otherAttachments[idx] = {
|
||
...formData.otherAttachments[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.otherAttachments.splice(idx, 1)
|
||
} finally {
|
||
nextTick(() => otherUploadRef.value?.clearFiles())
|
||
}
|
||
}
|
||
|
||
function removeOtherAttachment(index: number) {
|
||
formData.otherAttachments.splice(index, 1)
|
||
}
|
||
|
||
/* ====== 文件预览 ====== */
|
||
const previewVisible = ref(false)
|
||
const previewUrl = ref('')
|
||
const previewType = ref<'image' | 'video'>('image')
|
||
|
||
function handlePreviewAttachment(file: any) {
|
||
if (file._uploading) return
|
||
previewUrl.value = fullFileUrl(file.fileUrl)
|
||
previewType.value = isVideoFile(file) ? 'video' : 'image'
|
||
previewVisible.value = true
|
||
}
|
||
|
||
function handleDownloadAttachment(file: any) {
|
||
downloadAttachmentFile(file, { fullFileUrl })
|
||
}
|
||
|
||
/* ====== 弹窗打开时自动初始化 ====== */
|
||
watch(
|
||
() => props.modelValue,
|
||
(visible) => {
|
||
if (visible) {
|
||
if (props.editId) {
|
||
handleOpenForEdit()
|
||
} else {
|
||
handleOpenForAdd()
|
||
}
|
||
}
|
||
}
|
||
)
|
||
</script>
|
||
<style scoped lang="scss">
|
||
/* ═══════════════════════════════════════════
|
||
DIALOG BODY
|
||
═══════════════════════════════════════════ */
|
||
.protocol-dialog-body {
|
||
overflow: visible;
|
||
background: #f5f7fa;
|
||
}
|
||
|
||
.protocol-form {
|
||
:deep(.el-form-item) {
|
||
margin-bottom: 4px;
|
||
}
|
||
}
|
||
|
||
/* ---- Sticky Tab 导航(与查看弹窗 detail-tabs 一致) ---- */
|
||
.detail-tabs {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 1000;
|
||
display: flex;
|
||
gap: 4px;
|
||
background: #f0fdf4;
|
||
padding: 4px;
|
||
border-radius: 8px;
|
||
margin: 0 28px 16px;
|
||
width: fit-content;
|
||
isolation: isolate;
|
||
box-shadow: 0 1px 0 #f0fdf4;
|
||
}
|
||
|
||
.detail-tab {
|
||
padding: 10px 20px;
|
||
cursor: pointer;
|
||
border-radius: 6px;
|
||
font-size: 14px;
|
||
color: #606266;
|
||
transition: all 0.3s;
|
||
|
||
&.active {
|
||
background: #fff;
|
||
color: #059669;
|
||
font-weight: 500;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||
}
|
||
|
||
&:hover:not(.active) {
|
||
color: #059669;
|
||
}
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════
|
||
SECTION CARDS
|
||
═══════════════════════════════════════════ */
|
||
.form-tab-pane {
|
||
padding: 0 28px 4px;
|
||
|
||
& + .form-tab-pane {
|
||
margin-top: 0;
|
||
padding-top: 0;
|
||
}
|
||
}
|
||
|
||
.section-card {
|
||
border-radius: 12px;
|
||
padding: 22px 24px;
|
||
margin-bottom: 20px;
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||
transition:
|
||
transform 0.2s,
|
||
box-shadow 0.2s;
|
||
position: relative;
|
||
background: #fff;
|
||
|
||
&:hover {
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||
}
|
||
|
||
&--green {
|
||
background: #f0fdf4;
|
||
}
|
||
}
|
||
|
||
.section-title {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 18px;
|
||
padding-bottom: 12px;
|
||
border-bottom: 1px solid #e2e8f0;
|
||
}
|
||
|
||
.section-title-left {
|
||
display: flex;
|
||
align-items: center;
|
||
|
||
&::before {
|
||
content: '';
|
||
width: 3px;
|
||
height: 18px;
|
||
background: #10b981;
|
||
border-radius: 2px;
|
||
margin-right: 10px;
|
||
flex-shrink: 0;
|
||
}
|
||
}
|
||
|
||
.section-title h3 {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: #1e293b;
|
||
}
|
||
|
||
.section-subtitle {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #374151;
|
||
margin: 18px 0 14px;
|
||
|
||
&::before {
|
||
content: '';
|
||
width: 2px;
|
||
height: 12px;
|
||
background: #10b981;
|
||
border-radius: 1px;
|
||
}
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════
|
||
FORM GRID
|
||
═══════════════════════════════════════════ */
|
||
.form-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 16px 20px;
|
||
|
||
&--col2 {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
}
|
||
|
||
&--col4 {
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
}
|
||
|
||
:deep(.el-form-item) {
|
||
margin-bottom: 0;
|
||
}
|
||
}
|
||
|
||
.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));
|
||
}
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════
|
||
ATTACHMENTS (legacy)
|
||
═══════════════════════════════════════════ */
|
||
.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;
|
||
}
|
||
}
|
||
|
||
.preview-container {
|
||
text-align: center;
|
||
}
|
||
|
||
.batch-transport-dialog-hint {
|
||
margin-top: 0;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════
|
||
RESPONSIVE
|
||
═══════════════════════════════════════════ */
|
||
@media (max-width: 900px) {
|
||
.form-grid {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
|
||
&--col2 {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
&--col4 {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
}
|
||
|
||
.client-sub-cards {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|