Retain silent collectorUserId defaults and edit hydration. Co-authored-by: Cursor <cursoragent@cursor.com>
4369 lines
147 KiB
Vue
4369 lines
147 KiB
Vue
<template>
|
||
<!-- ========== 新增/编辑弹窗 ========== -->
|
||
<el-dialog
|
||
:model-value="modelValue"
|
||
:title="dialogTitle"
|
||
width="1600px"
|
||
class="dialog-form-layout"
|
||
align-center
|
||
append-to-body
|
||
modal-class="protocol-dialog-mask"
|
||
destroy-on-close
|
||
:close-on-click-modal="false"
|
||
@update:model-value="emit('update:modelValue', $event)"
|
||
@closed="resetForm"
|
||
>
|
||
<DialogFormBody :loading="dialogFormLoading" body-class="protocol-dialog-body min-h-[200px]">
|
||
<div class="detail-tabs">
|
||
<div
|
||
v-for="tab in formTabs"
|
||
:key="tab.key"
|
||
class="detail-tab"
|
||
:class="{ active: activeFormTab === tab.key }"
|
||
@click="scrollToSection(tab.key)"
|
||
>
|
||
{{ tab.label }}
|
||
</div>
|
||
</div>
|
||
|
||
<el-form
|
||
ref="formRef"
|
||
:model="formData"
|
||
:rules="formRules"
|
||
label-position="top"
|
||
scroll-to-error
|
||
class="protocol-form"
|
||
>
|
||
<!-- Tab 1: 基本信息 -->
|
||
<div id="form-section-basic" class="form-tab-pane">
|
||
<div class="section-card">
|
||
<div class="section-title">
|
||
<div class="section-title-left"><h3>基本信息</h3></div>
|
||
</div>
|
||
<div class="form-grid form-grid--col4">
|
||
<el-form-item label="协议状态" prop="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="sdfq" />
|
||
<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="备注">
|
||
<el-input v-model="formData.remark" type="textarea" :rows="2" placeholder="可选" />
|
||
</el-form-item>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab 2: 采集信息 -->
|
||
<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 form-grid--col6">
|
||
<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-select
|
||
v-model="formData.regionId"
|
||
class="w-full"
|
||
filterable
|
||
:remote="!linkedRegionMode"
|
||
clearable
|
||
reserve-keyword
|
||
:remote-method="linkedRegionMode ? undefined : remoteSearchRegions"
|
||
:loading="regionLoading"
|
||
:placeholder="linkedRegionMode ? '请选择关联地区' : '输入地区名称检索'"
|
||
@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-select
|
||
v-model="regionManagerUserId"
|
||
class="w-full"
|
||
filterable
|
||
remote
|
||
clearable
|
||
reserve-keyword
|
||
:remote-method="remoteSearchManagerUsers"
|
||
:loading="managerLoading"
|
||
placeholder="默认取地区负责人,可修改"
|
||
@change="onManagerUserChange"
|
||
>
|
||
<el-option
|
||
v-for="u in managerUserOptions"
|
||
:key="u.id"
|
||
:label="u.nickName + (u.phoneNo ? ' · ' + u.phoneNo : '')"
|
||
:value="u.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-date-picker
|
||
v-model="formData.sampleCollectTime"
|
||
type="datetime"
|
||
format="YYYY-MM-DD HH:mm"
|
||
value-format="YYYY-MM-DD HH:mm"
|
||
placeholder="选择采集时间"
|
||
clearable
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="母血采集时间">
|
||
<el-date-picker
|
||
v-model="formData.maternalBloodCollectTime"
|
||
type="datetime"
|
||
format="YYYY-MM-DD HH:mm"
|
||
value-format="YYYY-MM-DD HH:mm"
|
||
placeholder="选择采集时间"
|
||
clearable
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab 3: 样本及运输信息 -->
|
||
<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>
|
||
<SampleTransportFormSection
|
||
v-model:samples="formData.samples"
|
||
sample-source="cell_storage"
|
||
:protocol-id="formData.id || ''"
|
||
hide-dialog-collect-amount-unit
|
||
:sample-type-options="sampleTypeOptions"
|
||
:sample-type-options-loading="sampleTypeLoading"
|
||
:sample-code-options="sampleCodeOptions"
|
||
:sample-code-options-loading="sampleCodeLoading"
|
||
:unit-options="unitOptions"
|
||
:unit-options-loading="unitLoading"
|
||
:show-price="true"
|
||
@price-change="onSamplePriceChange"
|
||
@search-sample-code="remoteSearchSampleCodes"
|
||
@search-sample-type="remoteSearchSampleTypes"
|
||
@search-unit="remoteSearchUnits"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab 4: 客户信息 -->
|
||
<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>
|
||
</div>
|
||
<div class="form-grid form-grid--col2">
|
||
<el-form-item>
|
||
<template #label>
|
||
母亲姓名<span class="form-label-tip">可检索客户档案</span>
|
||
</template>
|
||
<el-autocomplete
|
||
v-model="formData.motherName"
|
||
class="w-full"
|
||
:fetch-suggestions="queryPartyCustomers"
|
||
:fit-input-width="false"
|
||
clearable
|
||
value-key="customerName"
|
||
placeholder="输入姓名检索或直接填写"
|
||
popper-class="mother-customer-suggest-popper"
|
||
@select="onMotherCustomerSelect"
|
||
@clear="onMotherCustomerClear"
|
||
@input="onMotherNameInput"
|
||
>
|
||
<template #default="{ item }">
|
||
<div class="mother-customer-suggest-item">
|
||
<span class="mother-customer-suggest-item__name">{{
|
||
item.customerName || '—'
|
||
}}</span>
|
||
<span class="mother-customer-suggest-item__phone">{{
|
||
item.phoneNo || '—'
|
||
}}</span>
|
||
<span class="mother-customer-suggest-item__idcard">{{
|
||
item.idCardNo || '—'
|
||
}}</span>
|
||
</div>
|
||
</template>
|
||
</el-autocomplete>
|
||
</el-form-item>
|
||
<el-form-item label="母亲联系电话">
|
||
<el-input v-model="formData.motherPhone" placeholder="请输入" />
|
||
</el-form-item>
|
||
<el-form-item label="母亲身份证号">
|
||
<el-input v-model="formData.motherIdCard" placeholder="请输入" />
|
||
</el-form-item>
|
||
<el-form-item label="母亲邮箱">
|
||
<el-input v-model="formData.motherEmail" 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>
|
||
<el-form-item label="母亲家庭住址" class="form-col-span-2">
|
||
<el-input v-model="formData.motherAddress" 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>
|
||
<template #label>
|
||
父亲姓名<span class="form-label-tip">可检索客户档案</span>
|
||
</template>
|
||
<el-autocomplete
|
||
v-model="formData.fatherName"
|
||
class="w-full"
|
||
:fetch-suggestions="queryPartyCustomers"
|
||
:fit-input-width="false"
|
||
clearable
|
||
value-key="customerName"
|
||
placeholder="输入姓名检索或直接填写"
|
||
popper-class="mother-customer-suggest-popper"
|
||
@select="onFatherCustomerSelect"
|
||
@clear="onFatherCustomerClear"
|
||
@input="onFatherNameInput"
|
||
>
|
||
<template #default="{ item }">
|
||
<div class="mother-customer-suggest-item">
|
||
<span class="mother-customer-suggest-item__name">{{
|
||
item.customerName || '—'
|
||
}}</span>
|
||
<span class="mother-customer-suggest-item__phone">{{
|
||
item.phoneNo || '—'
|
||
}}</span>
|
||
<span class="mother-customer-suggest-item__idcard">{{
|
||
item.idCardNo || '—'
|
||
}}</span>
|
||
</div>
|
||
</template>
|
||
</el-autocomplete>
|
||
</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="预产期">
|
||
<el-date-picker
|
||
v-model="formData.expectedDeliveryDate"
|
||
type="date"
|
||
value-format="YYYY-MM-DD"
|
||
placeholder="选择预产期"
|
||
clearable
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="新生儿分娩时间">
|
||
<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="新生儿评分">
|
||
<el-input
|
||
v-model="formData.apgar1min"
|
||
type="number"
|
||
step="any"
|
||
:min="0"
|
||
:max="10"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</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>
|
||
|
||
<!-- v2: 按样本缴费 -->
|
||
<template v-if="paymentModelVersion >= 2">
|
||
<el-alert
|
||
v-if="presignDepositHint"
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
class="presign-deposit-hint"
|
||
:title="presignDepositHintText"
|
||
/>
|
||
<div v-if="depositTransfers.length" class="payment-main-card" style="margin-bottom: 16px">
|
||
<div class="section-subtitle">意向金转账(关联预签约订单)</div>
|
||
<el-table
|
||
:data="depositTransfers"
|
||
class="list-table"
|
||
stripe
|
||
empty-text="暂无意向金转账"
|
||
>
|
||
<el-table-column type="index" label="#" min-width="50" align="center" />
|
||
<el-table-column label="转账方式" min-width="120" align="center">
|
||
<template #default="{ row }">
|
||
{{ transferTypeLabel(String(row.transferType || '')) || '—' }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="转账金额(元)" min-width="130" align="center">
|
||
<template #default="{ row }">{{ fmtMoney(row.transferAmount) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="转账时间" min-width="170" align="center">
|
||
<template #default="{ row }">{{ row.transferTime || '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="转账凭证" min-width="220" align="left">
|
||
<template #default="{ row }">
|
||
<ProtocolAttachmentReadonly
|
||
v-if="(row.attachments || []).length"
|
||
:files="row.attachments || []"
|
||
size="compact"
|
||
@preview="handlePreviewAttachment"
|
||
@download="handleDownloadAttachment"
|
||
/>
|
||
<span v-else class="muted-dash">—</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
label="备注"
|
||
min-width="140"
|
||
align="center"
|
||
show-overflow-tooltip
|
||
>
|
||
<template #default="{ row }">{{ row.remark || '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
label="财务备注"
|
||
min-width="140"
|
||
align="center"
|
||
show-overflow-tooltip
|
||
>
|
||
<template #default="{ row }">{{ row.financeRemark || '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" min-width="110" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag :type="payStatusTagType(String(row.payStatus || 'dfj'))" size="small">
|
||
{{ payStatusLabel(String(row.payStatus || 'dfj')) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
<div class="payment-main-card protocol-total-card" style="margin-bottom: 16px">
|
||
<div class="form-grid form-grid--col4 protocol-total-inline">
|
||
<el-form-item label="协议总金额(元)" label-position="left" class="protocol-total-item">
|
||
<AmountInput
|
||
v-model="formData.protocolTotalAmount"
|
||
:min="0"
|
||
placeholder="默认=样本合同合计"
|
||
style="width: 100%"
|
||
@change="onProtocolTotalUserEdit"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item
|
||
label="样本合同合计(元)"
|
||
label-position="left"
|
||
class="protocol-total-item"
|
||
>
|
||
<el-input :model-value="fmtMoney(sampleContractSum)" disabled />
|
||
</el-form-item>
|
||
</div>
|
||
<p v-if="protocolTotalHint" class="muted-hint warning-hint">{{ protocolTotalHint }}</p>
|
||
</div>
|
||
<SamplePaymentPanel
|
||
v-model="formData.samplePayments"
|
||
:samples="formData.samples"
|
||
:protocol-id="editingId || undefined"
|
||
/>
|
||
</template>
|
||
|
||
<!-- v1: 旧整单缴费主表(历史协议) -->
|
||
<template v-else>
|
||
<!-- 缴费主表 -->
|
||
<div class="payment-main-card">
|
||
<div class="section-subtitle">缴费主表</div>
|
||
<div class="form-grid form-grid--col6">
|
||
<el-form-item label="缴费模式">
|
||
<el-select
|
||
v-model="formData.payMode"
|
||
placeholder="请选择缴费模式"
|
||
style="width: 100%"
|
||
@change="onPayModeChange"
|
||
>
|
||
<el-option
|
||
v-for="opt in payModeOptions"
|
||
:key="opt.value"
|
||
:label="opt.label"
|
||
:value="opt.value"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="合同金额(元)">
|
||
<AmountInput
|
||
v-model="formData.contractAmount"
|
||
:min="0"
|
||
placeholder="请输入合同金额"
|
||
style="width: 100%"
|
||
@change="onContractAmountChange"
|
||
/>
|
||
</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="isInstallmentPayMode(formData.payMode)">
|
||
<el-form-item label="分期期数">
|
||
<el-input
|
||
v-model="formData.installmentCount"
|
||
type="number"
|
||
step="any"
|
||
:min="1"
|
||
style="width: 100%"
|
||
@change="onInstallmentCountChange"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="每期间隔(月)">
|
||
<el-input
|
||
v-model="formData.installmentIntervalMonths"
|
||
type="number"
|
||
step="any"
|
||
:min="0.1"
|
||
style="width: 100%"
|
||
@change="handleFqParamsChange"
|
||
/>
|
||
</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="handleFqParamsChange"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="提前催缴预警(天)">
|
||
<el-input
|
||
v-model="formData.installmentAdvanceDays"
|
||
type="number"
|
||
:min="0"
|
||
:max="365"
|
||
step="1"
|
||
style="width: 100%"
|
||
@change="handleFqParamsChange"
|
||
/>
|
||
</el-form-item>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 缴费明细 -->
|
||
<div class="section-subtitle" style="margin-top: 20px">缴费明细</div>
|
||
<div style="margin-bottom: 14px">
|
||
<el-button
|
||
type="primary"
|
||
:disabled="!canAddPaymentDetail(formData.payMode, formData.paymentDetails)"
|
||
@click="addPaymentDetail"
|
||
>
|
||
<el-icon><Plus /></el-icon>
|
||
添加明细
|
||
</el-button>
|
||
</div>
|
||
|
||
<div class="payment-details">
|
||
<div
|
||
v-for="(detail, dIdx) in formData.paymentDetails"
|
||
:key="dIdx"
|
||
class="detail-card"
|
||
:class="{ expanded: detail._expanded }"
|
||
>
|
||
<div
|
||
class="detail-header"
|
||
role="button"
|
||
tabindex="0"
|
||
@click="togglePaymentDetailExpand(dIdx)"
|
||
@keydown.enter.prevent="togglePaymentDetailExpand(dIdx)"
|
||
>
|
||
<span class="detail-chevron">▶</span>
|
||
<span class="detail-title">缴费明细 #{{ dIdx + 1 }}</span>
|
||
<span
|
||
v-if="detail.dueAmount != null && detail.dueAmount !== ''"
|
||
class="detail-amount"
|
||
:class="detail.payStatus === 'yjf' ? 'green' : 'amber'"
|
||
>
|
||
{{ fmtMoney(detail.dueAmount) }}
|
||
</span>
|
||
<span
|
||
v-if="detail.payStatus"
|
||
class="detail-badge"
|
||
:class="{
|
||
'badge-green': detail.payStatus === 'yjf',
|
||
'badge-primary': detail.payStatus === 'dfj',
|
||
'badge-amber': 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
|
||
v-if="!isPayDetailLocked(detail)"
|
||
link
|
||
type="danger"
|
||
size="small"
|
||
class="detail-delete"
|
||
@click.stop="removePaymentDetail(dIdx)"
|
||
>
|
||
<el-icon><Delete /></el-icon>
|
||
</el-button>
|
||
</div>
|
||
<div v-show="detail._expanded" class="detail-body">
|
||
<div class="detail-body-inner">
|
||
<div class="form-grid form-grid--col4">
|
||
<el-form-item label="应缴费金额(元)">
|
||
<AmountInput
|
||
v-model="detail.dueAmount"
|
||
:min="0"
|
||
:disabled="isPayDetailLocked(detail)"
|
||
placeholder="请输入应缴费金额"
|
||
@change="onPaymentDetailDueAmountChange(dIdx)"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="转账方式">
|
||
<el-select
|
||
v-model="detail.transferType"
|
||
placeholder="请选择转账方式"
|
||
style="width: 100%"
|
||
>
|
||
<el-option
|
||
v-for="opt in transferTypeOptions"
|
||
:key="opt.value"
|
||
:label="opt.label"
|
||
:value="opt.value"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="转账金额(元)">
|
||
<AmountInput
|
||
v-model="detail.transferAmount"
|
||
:min="0"
|
||
:disabled="isPayDetailLocked(detail)"
|
||
placeholder="请输入转账金额"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="转账时间">
|
||
<el-date-picker
|
||
v-model="detail.transferTime"
|
||
type="datetime"
|
||
format="YYYY-MM-DD HH:mm"
|
||
value-format="YYYY-MM-DD HH:mm:ss"
|
||
placeholder="选择时间"
|
||
clearable
|
||
:disabled="isPayDetailLocked(detail)"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="缴费状态">
|
||
<el-tag
|
||
:type="payStatusTagType(String(detail.payStatus || 'dfj'))"
|
||
size="small"
|
||
>
|
||
{{ payStatusLabel(String(detail.payStatus || 'dfj')) }}
|
||
</el-tag>
|
||
</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
|
||
:disabled="isPayDetailLocked(detail)"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
</div>
|
||
<!-- 每条明细的附件上传区 -->
|
||
<div v-if="!isPayDetailLocked(detail)" class="payment-detail-card__attachments">
|
||
<div
|
||
class="attachment-upload"
|
||
:class="{ 'is-dragover': isPaymentDetailDragover(dIdx) }"
|
||
v-bind="paymentDetailDropHandlers(dIdx)"
|
||
>
|
||
<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="#047857"><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>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab 6: 附件信息 -->
|
||
<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="form-grid form-grid--attachment">
|
||
<el-form-item label="协议附件">
|
||
<div
|
||
class="attachment-upload"
|
||
:class="{ 'is-dragover': isProtocolAttachDragover }"
|
||
v-bind="protocolAttachDropHandlers"
|
||
>
|
||
<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="#047857"><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>
|
||
</div>
|
||
<div class="form-grid form-grid--attachment">
|
||
<el-form-item label="知情同意书">
|
||
<div
|
||
class="attachment-upload"
|
||
:class="{ 'is-dragover': isConsentAttachDragover }"
|
||
v-bind="consentAttachDropHandlers"
|
||
>
|
||
<div class="attachment-list">
|
||
<el-upload
|
||
ref="consentAttachmentUploadRef"
|
||
:auto-upload="false"
|
||
:show-file-list="false"
|
||
:multiple="true"
|
||
accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
|
||
@change="handleConsentAttachmentChange"
|
||
>
|
||
<div class="upload-card">
|
||
<el-icon :size="28" color="#047857"><Plus /></el-icon>
|
||
<span class="upload-card-text">上传附件</span>
|
||
</div>
|
||
</el-upload>
|
||
<div
|
||
v-for="(file, index) in formData.consentAttachments"
|
||
: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="removeConsentAttachment(index)"
|
||
><Delete
|
||
/></el-icon>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<span class="upload-hint"
|
||
>支持拖拽或点击上传图片、视频、PDF、Word、Excel等文件,单文件不超过 20MB</span
|
||
>
|
||
</div>
|
||
</el-form-item>
|
||
</div>
|
||
<div class="form-grid form-grid--attachment">
|
||
<el-form-item label="孕妇健康调查表">
|
||
<div
|
||
class="attachment-upload"
|
||
:class="{ 'is-dragover': isHealthSurveyAttachDragover }"
|
||
v-bind="healthSurveyAttachDropHandlers"
|
||
>
|
||
<div class="attachment-list">
|
||
<el-upload
|
||
ref="healthSurveyAttachmentUploadRef"
|
||
:auto-upload="false"
|
||
:show-file-list="false"
|
||
:multiple="true"
|
||
accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
|
||
@change="handleHealthSurveyAttachmentChange"
|
||
>
|
||
<div class="upload-card">
|
||
<el-icon :size="28" color="#047857"><Plus /></el-icon>
|
||
<span class="upload-card-text">上传附件</span>
|
||
</div>
|
||
</el-upload>
|
||
<div
|
||
v-for="(file, index) in formData.healthSurveyAttachments"
|
||
: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="removeHealthSurveyAttachment(index)"
|
||
><Delete
|
||
/></el-icon>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<span class="upload-hint"
|
||
>支持拖拽或点击上传图片、视频、PDF、Word、Excel等文件,单文件不超过 20MB</span
|
||
>
|
||
</div>
|
||
</el-form-item>
|
||
</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
|
||
append-to-body
|
||
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>
|
||
</template>
|
||
<script setup lang="ts">
|
||
import DialogFormBody from '@/components/business/dialog-form-body/index.vue'
|
||
import SampleTransportFormSection from '@/components/business/sample-transport-form-section/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,
|
||
fetchSubmitReceiptFinance,
|
||
type CollectionProtocolRow
|
||
} from '@/api/collection-protocol'
|
||
import { fetchSubmitPaymentTransferFinance } from '@/api/protocol-sample-payment'
|
||
import {
|
||
flattenSamplePaymentTransfers,
|
||
formatAutoSubmitFinanceFailureMessage,
|
||
submitDfjTransfersOnSave
|
||
} from '@/utils/presign-save-auto-submit'
|
||
import { fetchUploadAttachment } from '@/api/business'
|
||
import { downloadAttachmentFile } from '@/utils/download-attachment'
|
||
import {
|
||
useAttachmentDropZone,
|
||
useIndexedAttachmentDropZone
|
||
} from '@/composables/useAttachmentDropZone'
|
||
import { formatDatetimeToMinute } from '@/utils/chinese-datetime-parse'
|
||
import {
|
||
fetchCustomerList,
|
||
fetchUserList,
|
||
fetchUserDetail,
|
||
fetchCellSampleTypeList,
|
||
fetchItemUnitList,
|
||
fetchCollectionRegionList,
|
||
fetchHospitalList,
|
||
fetchRegionStaffList,
|
||
fetchRegionsByStaffUser,
|
||
fetchSampleCodeList,
|
||
fetchExpressCompanyList,
|
||
fetchSiteList
|
||
} from '@/api/admin'
|
||
import { fetchEthnicList, type EthnicOption } from '@/api/ethnic'
|
||
import { useUserStore } from '@/store/modules/user'
|
||
import {
|
||
DEFAULT_INSTALLMENT_ADVANCE_DAYS,
|
||
buildProtocolPaymentDetailPayload,
|
||
normalizeInstallmentAmounts,
|
||
resizeInstallmentAmounts,
|
||
syncFqPaymentDetailRowCount
|
||
} from '@/utils/protocol-payment-installment'
|
||
import {
|
||
applyPayModeSwitch,
|
||
canAddPaymentDetail,
|
||
createPaymentDetailRow,
|
||
djDueAmountMismatchHint,
|
||
evaluatePayModeSwitch,
|
||
isPayDetailLocked,
|
||
markPayDetailLocks,
|
||
sumPaidAmount,
|
||
type PayMode
|
||
} from '@/utils/protocol-pay-mode-switch'
|
||
import {
|
||
availablePayModes,
|
||
getActiveSampleCount,
|
||
isInstallmentPayMode,
|
||
isPayModeValidForSampleCount,
|
||
type PayMode as PresetPayMode
|
||
} from '@/utils/protocol-payment-presets'
|
||
import {
|
||
applyFqParamsChange,
|
||
evaluateFqParamsChange,
|
||
fqParamsSnapshot,
|
||
generateInitialFqDetails,
|
||
isSameFqParams,
|
||
type FqParams
|
||
} from '@/utils/protocol-fq-params-change'
|
||
import AmountInput from '@/components/common/amount-input/index.vue'
|
||
import { formatMoneyDisplay } from '@/utils/money-format'
|
||
import {
|
||
payModeLabel,
|
||
newbornSexLabel,
|
||
deliveryModeLabel,
|
||
sampleStatusLabel,
|
||
loadTransferTypeDict,
|
||
getTransferTypeOptions,
|
||
transferTypeLabel,
|
||
payStatusLabel,
|
||
payStatusTagType
|
||
} from '@/utils/protocol-payment-labels'
|
||
import SamplePaymentPanel from './SamplePaymentPanel.vue'
|
||
import ProtocolAttachmentReadonly from './ProtocolAttachmentReadonly.vue'
|
||
import {
|
||
parseInstallmentRatios,
|
||
parsePreferentialPrice,
|
||
protocolTotalMismatchHint,
|
||
sampleScheduleDuesMismatchHint,
|
||
sampleScheduleRequiredFieldsHint,
|
||
sumSampleContractAmounts
|
||
} from '@/utils/protocol-sample-payment'
|
||
|
||
const transferTypeOptions = ref<Api.Dict.DictDataOption[]>([])
|
||
|
||
async function ensureTransferTypeOptions() {
|
||
await loadTransferTypeDict()
|
||
transferTypeOptions.value = getTransferTypeOptions()
|
||
}
|
||
|
||
/** 金额只读展示:千分位 + ¥ 前缀 */
|
||
function fmtMoney(v: unknown): string {
|
||
if (v == null || v === '') return '—'
|
||
return '¥' + formatMoneyDisplay(v)
|
||
}
|
||
|
||
const userStore = useUserStore()
|
||
|
||
/** 上次确认生效的缴费模式(用于切换取消/拦截时回滚) */
|
||
const lastConfirmedPayMode = ref<PayMode>('dj')
|
||
|
||
const activeSampleCount = computed(() => getActiveSampleCount(formData.samples))
|
||
|
||
const payModeOptions = computed(() => {
|
||
const modes = [...availablePayModes(activeSampleCount.value)]
|
||
const current = String(formData.payMode || '') as PresetPayMode
|
||
if (current && !modes.includes(current)) {
|
||
modes.push(current)
|
||
}
|
||
return modes.map((value) => ({ value, label: payModeLabel(value) }))
|
||
})
|
||
|
||
function hasLockedPaymentDetails() {
|
||
return formData.paymentDetails.some((d) => isPayDetailLocked(d))
|
||
}
|
||
|
||
/** 上次确认生效的分期参数(用于编辑时修改期数/间隔/开始时间回滚) */
|
||
const lastConfirmedFqParams = ref<FqParams>({
|
||
installmentCount: null,
|
||
installmentIntervalMonths: null,
|
||
installmentStartDate: '',
|
||
installmentAdvanceDays: DEFAULT_INSTALLMENT_ADVANCE_DAYS,
|
||
installmentRatios: [] as number[]
|
||
})
|
||
|
||
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 regionManagerUserId = ref<string | null>(null)
|
||
const managerUserOptions = ref<Api.User.UserItem[]>([])
|
||
const managerLoading = ref(false)
|
||
const managerSnapshot = ref<Api.User.UserItem | null>(null)
|
||
const linkedRegionMode = ref(false)
|
||
const linkedMotherCustomerId = ref('')
|
||
const linkedMotherCustomerName = ref('')
|
||
const linkedFatherCustomerId = ref('')
|
||
const linkedFatherCustomerName = ref('')
|
||
|
||
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 sampleCodeLoading = ref(false)
|
||
const sampleTypeLoading = ref(false)
|
||
const unitLoading = ref(false)
|
||
const siteLoading = ref(false)
|
||
const expressCompanyLoading = ref(false)
|
||
const expressCompanyOptions = ref<Api.ExpressCompany.ExpressCompanyItem[]>([])
|
||
const siteOptions = ref<Api.Site.SiteItem[]>([])
|
||
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 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 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: 'collection', label: '采集信息' },
|
||
{ key: 'sample', label: '样本及运输信息' },
|
||
{ key: 'party', label: '客户信息' },
|
||
{ key: 'payment', 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')
|
||
|
||
const formData = reactive({
|
||
// Basic
|
||
protocolNo: '',
|
||
protocolNoId: '',
|
||
protocolStatus: 'dsx',
|
||
registerDate: '',
|
||
collectorUserId: '',
|
||
protocolAttachments: '',
|
||
consentFormUrls: '',
|
||
healthSurveyFormUrls: '',
|
||
remark: '',
|
||
attachments: [] as Array<{
|
||
id?: string
|
||
fileName: string
|
||
fileUrl: string
|
||
fileSize: number
|
||
fileSizeDisplay?: string
|
||
fileType?: string
|
||
_uploading?: boolean
|
||
}>,
|
||
consentAttachments: [] as Array<{
|
||
id?: string
|
||
fileName: string
|
||
fileUrl: string
|
||
fileSize: number
|
||
fileSizeDisplay?: string
|
||
fileType?: string
|
||
_uploading?: boolean
|
||
}>,
|
||
healthSurveyAttachments: [] as Array<{
|
||
id?: string
|
||
fileName: string
|
||
fileUrl: string
|
||
fileSize: number
|
||
fileSizeDisplay?: string
|
||
fileType?: string
|
||
_uploading?: boolean
|
||
}>,
|
||
// Party Customer
|
||
customerId: '' as string,
|
||
fatherCustomerId: '' as string,
|
||
motherName: '',
|
||
motherPhone: '',
|
||
motherIdCard: '',
|
||
motherBirthYm: '',
|
||
motherEthnic: '',
|
||
motherEmail: '',
|
||
motherAddress: '',
|
||
fatherName: '',
|
||
fatherPhone: '',
|
||
fatherIdCard: '',
|
||
newbornName: '',
|
||
newbornIdCard: '',
|
||
expectedDeliveryDate: '',
|
||
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: '',
|
||
regionManagerUserId: '',
|
||
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: '',
|
||
installmentAdvanceDays: DEFAULT_INSTALLMENT_ADVANCE_DAYS as number | null,
|
||
installmentRatios: [] as number[] as number[],
|
||
// Payment Details
|
||
paymentDetails: [] as Array<Record<string, any>>,
|
||
// v2 per-sample payment
|
||
paymentModelVersion: 2,
|
||
protocolTotalAmount: null as number | null,
|
||
sourcePresignOrderId: '',
|
||
samplePayments: [] as Array<Record<string, any>>
|
||
})
|
||
|
||
const paymentModelVersion = computed(() => Number(formData.paymentModelVersion || 2))
|
||
const presignDepositHint = ref<Record<string, any> | null>(null)
|
||
const depositTransfers = ref<Array<Record<string, any>>>([])
|
||
const sampleContractSum = computed(() => sumSampleContractAmounts(formData.samplePayments))
|
||
const protocolTotalHint = computed(() =>
|
||
protocolTotalMismatchHint(formData.protocolTotalAmount, sampleContractSum.value)
|
||
)
|
||
/** 未手改时,协议总金额跟随样本合同合计 */
|
||
const protocolTotalFollowSamples = ref(true)
|
||
const presignDepositHintText = computed(() => {
|
||
const h = presignDepositHint.value
|
||
if (!h) return ''
|
||
const amt = Number(h.confirmedDepositAmount || 0)
|
||
const cnt = Number(h.confirmedTransferCount || 0)
|
||
return `来源预签约已确认意向金合计 ¥${formatMoneyDisplay(amt)}(${cnt} 笔),下方列表与预签同一数据,不自动写入样本缴费`
|
||
})
|
||
|
||
function roundMoney2(n: number) {
|
||
return Math.round(n * 100) / 100
|
||
}
|
||
|
||
function syncProtocolTotalFromSamples() {
|
||
if (paymentModelVersion.value < 2) return
|
||
if (!protocolTotalFollowSamples.value) return
|
||
const sum = sampleContractSum.value
|
||
formData.protocolTotalAmount = sum > 0 ? roundMoney2(sum) : null
|
||
}
|
||
|
||
function onProtocolTotalUserEdit(val: number | null) {
|
||
const sum = sampleContractSum.value
|
||
protocolTotalFollowSamples.value =
|
||
val != null && sum > 0 && Math.abs(Number(val) - sum) <= 0.015
|
||
}
|
||
|
||
watch(sampleContractSum, () => {
|
||
syncProtocolTotalFromSamples()
|
||
})
|
||
|
||
const formRules = {
|
||
protocolStatus: [{ required: true, message: '请选择协议状态', trigger: 'change' }]
|
||
}
|
||
|
||
function resetForm() {
|
||
Object.assign(formData, {
|
||
protocolNo: '',
|
||
protocolNoId: '',
|
||
protocolStatus: 'dsx',
|
||
registerDate: '',
|
||
collectorUserId: '',
|
||
protocolAttachments: '',
|
||
consentFormUrls: '',
|
||
healthSurveyFormUrls: '',
|
||
attachments: [],
|
||
consentAttachments: [],
|
||
healthSurveyAttachments: [],
|
||
remark: '',
|
||
customerId: '',
|
||
fatherCustomerId: '',
|
||
motherName: '',
|
||
motherPhone: '',
|
||
motherIdCard: '',
|
||
motherBirthYm: '',
|
||
motherEthnic: '',
|
||
motherEmail: '',
|
||
motherAddress: '',
|
||
fatherName: '',
|
||
fatherPhone: '',
|
||
fatherIdCard: '',
|
||
newbornName: '',
|
||
newbornIdCard: '',
|
||
expectedDeliveryDate: '',
|
||
newbornDeliveryTime: '',
|
||
newbornSex: '',
|
||
deliveryMode: '',
|
||
birthOrder: null,
|
||
newbornWeightGram: null,
|
||
apgar1min: null,
|
||
apgar5min: null,
|
||
regionId: '',
|
||
hospitalId: '',
|
||
medicalStaff: '',
|
||
salesUserId: '',
|
||
regionManagerUserId: '',
|
||
sampleCollectTime: '',
|
||
maternalBloodCollectTime: '',
|
||
samples: [],
|
||
payMode: 'dj',
|
||
contractAmount: null,
|
||
installmentCount: null,
|
||
installmentIntervalMonths: null,
|
||
lumpSumDeadline: '',
|
||
installmentStartDate: '',
|
||
installmentAdvanceDays: DEFAULT_INSTALLMENT_ADVANCE_DAYS,
|
||
installmentRatios: [] as number[],
|
||
paymentDetails: [],
|
||
paymentModelVersion: 2,
|
||
protocolTotalAmount: null,
|
||
sourcePresignOrderId: '',
|
||
samplePayments: []
|
||
})
|
||
protocolTotalFollowSamples.value = true
|
||
presignDepositHint.value = null
|
||
depositTransfers.value = []
|
||
collectorUserId.value = null
|
||
collectorUserOptions.value = []
|
||
collectorSnapshot.value = null
|
||
salesUserId.value = null
|
||
salesUserOptions.value = []
|
||
salesSnapshot.value = null
|
||
regionManagerUserId.value = null
|
||
managerUserOptions.value = []
|
||
managerSnapshot.value = null
|
||
linkedRegionMode.value = false
|
||
linkedMotherCustomerId.value = ''
|
||
linkedMotherCustomerName.value = ''
|
||
linkedFatherCustomerId.value = ''
|
||
linkedFatherCustomerName.value = ''
|
||
regionOptions.value = []
|
||
hospitalOptions.value = []
|
||
sampleTypeOptions.value = []
|
||
unitOptions.value = []
|
||
sampleCodeOptions.value = []
|
||
siteOptions.value = []
|
||
expressCompanyOptions.value = []
|
||
activeFormTab.value = 'basic'
|
||
lastConfirmedPayMode.value = 'dj'
|
||
lastConfirmedFqParams.value = {
|
||
installmentCount: null,
|
||
installmentIntervalMonths: null,
|
||
installmentStartDate: '',
|
||
installmentAdvanceDays: DEFAULT_INSTALLMENT_ADVANCE_DAYS,
|
||
installmentRatios: [] as number[]
|
||
}
|
||
}
|
||
|
||
function scrollToSection(key: string) {
|
||
activeFormTab.value = key
|
||
if (key === 'payment' && paymentModelVersion.value < 2) {
|
||
maybeApplyPreferentialContractOnTabEnter()
|
||
}
|
||
const el = document.getElementById(`form-section-${key}`)
|
||
if (el) {
|
||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||
}
|
||
}
|
||
|
||
/** 有效样本优惠价之和(v1 整单合同默认) */
|
||
function sumActiveSamplePreferentialPrices(): number | null {
|
||
let sum = 0
|
||
let any = false
|
||
for (const s of formData.samples || []) {
|
||
if (String(s.sampleStatus || '') === 'yzf') continue
|
||
const p = parsePreferentialPrice(s)
|
||
if (p == null) continue
|
||
sum += p
|
||
any = true
|
||
}
|
||
return any ? roundMoney2(sum) : null
|
||
}
|
||
|
||
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 removePaymentDetail(dIdx: number) {
|
||
const detail = formData.paymentDetails[dIdx]
|
||
if (detail && isPayDetailLocked(detail)) return
|
||
formData.paymentDetails.splice(dIdx, 1)
|
||
syncContractAmountFromPaymentDetails()
|
||
}
|
||
|
||
function addPaymentDetail() {
|
||
if (!canAddPaymentDetail(formData.payMode, formData.paymentDetails)) return
|
||
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()
|
||
}
|
||
|
||
/** 分期开始时间默认值:新生儿分娩时间 > 预产期 > 当前日期(YYYYMMDD) */
|
||
function defaultInstallmentStartDateYmd(): string {
|
||
const deliveryYmd = datetimeToYmd(formData.newbornDeliveryTime)
|
||
if (deliveryYmd) return deliveryYmd
|
||
const expectedYmd = datetimeToYmd(formData.expectedDeliveryDate)
|
||
if (expectedYmd) return expectedYmd
|
||
const now = new Date()
|
||
const pad = (n: number) => String(n).padStart(2, '0')
|
||
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
|
||
}
|
||
|
||
function datetimeToYmd(val: string): string {
|
||
const compact = String(val || '')
|
||
.trim()
|
||
.replace(/[-:\sT]/g, '')
|
||
.slice(0, 8)
|
||
return /^\d{8}$/.test(compact) ? compact : ''
|
||
}
|
||
|
||
function isFqNumericFieldEmpty(val: unknown): boolean {
|
||
if (val == null || val === '') return true
|
||
const n = Number(val)
|
||
return !Number.isFinite(n) || n < 1
|
||
}
|
||
|
||
/**
|
||
* 分期结构默认:期数/间隔可重置;合同取优惠价之和;各期金额不预填。
|
||
*/
|
||
function applyFqPaymentDefaults(options?: { resetCountInterval?: boolean }) {
|
||
const reset = options?.resetCountInterval ?? false
|
||
const prefSum = sumActiveSamplePreferentialPrices()
|
||
if (prefSum != null) {
|
||
formData.contractAmount = prefSum
|
||
}
|
||
if (reset || isFqNumericFieldEmpty(formData.installmentCount)) {
|
||
formData.installmentCount = 6
|
||
}
|
||
if (reset || isFqNumericFieldEmpty(formData.installmentIntervalMonths)) {
|
||
formData.installmentIntervalMonths = 6
|
||
}
|
||
if (!formData.installmentStartDate) {
|
||
formData.installmentStartDate = defaultInstallmentStartDateYmd()
|
||
}
|
||
if (reset || formData.installmentAdvanceDays == null) {
|
||
formData.installmentAdvanceDays = DEFAULT_INSTALLMENT_ADVANCE_DAYS
|
||
}
|
||
formData.installmentRatios = []
|
||
}
|
||
|
||
/** v1:按优惠价之和填合同;分期明细金额留空 */
|
||
function applyPreferentialContractForV1Mode(payMode: PresetPayMode) {
|
||
const prefSum = sumActiveSamplePreferentialPrices()
|
||
if (prefSum != null) {
|
||
formData.contractAmount = prefSum
|
||
}
|
||
if (isInstallmentPayMode(payMode)) {
|
||
if (isFqNumericFieldEmpty(formData.installmentCount)) {
|
||
formData.installmentCount = 6
|
||
}
|
||
if (isFqNumericFieldEmpty(formData.installmentIntervalMonths)) {
|
||
formData.installmentIntervalMonths = 6
|
||
}
|
||
if (!formData.installmentStartDate) {
|
||
formData.installmentStartDate = defaultInstallmentStartDateYmd()
|
||
}
|
||
if (formData.installmentAdvanceDays == null) {
|
||
formData.installmentAdvanceDays = DEFAULT_INSTALLMENT_ADVANCE_DAYS
|
||
}
|
||
formData.installmentRatios = []
|
||
const details = generateInitialFqDetails(getFqParamsFromForm(), formData.contractAmount)
|
||
formData.paymentDetails = details.map((d) => ({ ...d, dueAmount: null }))
|
||
initPaymentDetailExpandFlags(formData.paymentDetails)
|
||
markPayDetailLocks(formData.paymentDetails)
|
||
syncLastConfirmedFqParams()
|
||
} else {
|
||
formData.paymentDetails = [
|
||
createPaymentDetailRow({
|
||
dueAmount: formData.contractAmount,
|
||
installmentDueDate: formData.lumpSumDeadline || '',
|
||
expanded: true
|
||
})
|
||
]
|
||
initPaymentDetailExpandFlags(formData.paymentDetails)
|
||
markPayDetailLocks(formData.paymentDetails)
|
||
}
|
||
}
|
||
|
||
function maybeApplyPreferentialContractOnTabEnter() {
|
||
if (hasLockedPaymentDetails()) return
|
||
if (formData.paymentDetails.length > 0) return
|
||
applyPreferentialContractForV1Mode(formData.payMode as PresetPayMode)
|
||
}
|
||
|
||
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 toFloatOrUndefined(val: unknown): number | undefined {
|
||
if (val == null || val === '') return undefined
|
||
const n = Number(val)
|
||
return Number.isFinite(n) ? n : undefined
|
||
}
|
||
|
||
function fqPaymentContext() {
|
||
return {
|
||
payMode: formData.payMode,
|
||
installmentCount: toIntOrUndefined(formData.installmentCount) ?? null,
|
||
contractAmount: formData.contractAmount,
|
||
installmentStartDate: formData.installmentStartDate,
|
||
installmentIntervalMonths: toFloatOrUndefined(formData.installmentIntervalMonths) ?? null,
|
||
installmentAdvanceDays: toIntOrUndefined(formData.installmentAdvanceDays) ?? null,
|
||
installmentRatios: [...(formData.installmentRatios || [])]
|
||
}
|
||
}
|
||
|
||
function payModeSwitchContext() {
|
||
return {
|
||
contractAmount: formData.contractAmount,
|
||
lumpSumDeadline: formData.lumpSumDeadline,
|
||
installmentCount: toIntOrUndefined(formData.installmentCount) ?? null,
|
||
installmentIntervalMonths: toFloatOrUndefined(formData.installmentIntervalMonths) ?? null,
|
||
installmentStartDate: formData.installmentStartDate,
|
||
installmentAdvanceDays: toIntOrUndefined(formData.installmentAdvanceDays) ?? null,
|
||
installmentRatios: [...(formData.installmentRatios || [])]
|
||
}
|
||
}
|
||
|
||
function getFqParamsFromForm(): FqParams {
|
||
return {
|
||
installmentCount: toIntOrUndefined(formData.installmentCount) ?? null,
|
||
installmentIntervalMonths: toFloatOrUndefined(formData.installmentIntervalMonths) ?? null,
|
||
installmentStartDate: formData.installmentStartDate || '',
|
||
installmentAdvanceDays: toIntOrUndefined(formData.installmentAdvanceDays) ?? null,
|
||
installmentRatios: [...(formData.installmentRatios || [])].map((v) => Number(v || 0))
|
||
}
|
||
}
|
||
|
||
function restoreFqParams(params: FqParams) {
|
||
formData.installmentCount = params.installmentCount
|
||
formData.installmentIntervalMonths = params.installmentIntervalMonths
|
||
formData.installmentStartDate = params.installmentStartDate || ''
|
||
formData.installmentAdvanceDays = params.installmentAdvanceDays
|
||
formData.installmentRatios = [...(params.installmentRatios || [])]
|
||
}
|
||
|
||
function syncLastConfirmedFqParams() {
|
||
lastConfirmedFqParams.value = fqParamsSnapshot(getFqParamsFromForm())
|
||
}
|
||
|
||
/**
|
||
* 分期参数变更(期数 / 间隔 / 开始时间):校验 → 确认 → 重建未缴期次
|
||
* 与缴费模式切换独立;保留 yjf 已缴行,作废原 dfj 后按剩余金额重建。
|
||
*/
|
||
async function handleFqParamsChange() {
|
||
if (formData.payMode !== 'fq' && formData.payMode !== 'djfq') return
|
||
|
||
const newParams = getFqParamsFromForm()
|
||
const oldParams = lastConfirmedFqParams.value
|
||
|
||
if (isSameFqParams(oldParams, newParams)) return
|
||
|
||
if (
|
||
!newParams.installmentCount ||
|
||
!newParams.installmentIntervalMonths ||
|
||
!newParams.installmentStartDate
|
||
) {
|
||
return
|
||
}
|
||
|
||
if (formData.paymentDetails.length === 0) {
|
||
formData.paymentDetails = generateInitialFqDetails(newParams, formData.contractAmount)
|
||
initPaymentDetailExpandFlags(formData.paymentDetails)
|
||
markPayDetailLocks(formData.paymentDetails)
|
||
syncLastConfirmedFqParams()
|
||
return
|
||
}
|
||
|
||
const evaluation = evaluateFqParamsChange(
|
||
oldParams,
|
||
newParams,
|
||
formData.paymentDetails,
|
||
formData.contractAmount
|
||
)
|
||
|
||
if (evaluation.action === 'noop') {
|
||
syncLastConfirmedFqParams()
|
||
return
|
||
}
|
||
|
||
if (evaluation.action === 'blocked') {
|
||
restoreFqParams(oldParams)
|
||
await ElMessageBox.alert(evaluation.reason, '无法修改分期参数', { type: 'warning' })
|
||
return
|
||
}
|
||
|
||
if (evaluation.action === 'confirm') {
|
||
try {
|
||
await ElMessageBox.confirm(evaluation.summary, '修改分期参数', {
|
||
confirmButtonText: '确认修改',
|
||
cancelButtonText: '取消',
|
||
type: 'warning'
|
||
})
|
||
} catch {
|
||
restoreFqParams(oldParams)
|
||
return
|
||
}
|
||
}
|
||
|
||
formData.paymentDetails = applyFqParamsChange(
|
||
newParams,
|
||
formData.paymentDetails,
|
||
formData.contractAmount
|
||
)
|
||
initPaymentDetailExpandFlags(formData.paymentDetails)
|
||
markPayDetailLocks(formData.paymentDetails)
|
||
syncLastConfirmedFqParams()
|
||
}
|
||
|
||
/** 缴费模式切换:校验 → 确认 → 变换明细 */
|
||
async function onPayModeChange(val: string) {
|
||
const toMode = val as PayMode
|
||
const fromMode = lastConfirmedPayMode.value
|
||
if (toMode === fromMode) return
|
||
|
||
const evaluation = evaluatePayModeSwitch(
|
||
fromMode,
|
||
toMode,
|
||
formData.paymentDetails,
|
||
payModeSwitchContext()
|
||
)
|
||
|
||
if (evaluation.action === 'blocked') {
|
||
formData.payMode = fromMode
|
||
await ElMessageBox.alert(evaluation.reason, '无法切换缴费模式', { type: 'warning' })
|
||
return
|
||
}
|
||
|
||
if (evaluation.action === 'confirm') {
|
||
try {
|
||
await ElMessageBox.confirm(evaluation.summary, '切换缴费模式', {
|
||
confirmButtonText: '确认切换',
|
||
cancelButtonText: '取消',
|
||
type: 'warning'
|
||
})
|
||
} catch {
|
||
formData.payMode = fromMode
|
||
return
|
||
}
|
||
}
|
||
|
||
if (!hasLockedPaymentDetails()) {
|
||
applyPreferentialContractForV1Mode(toMode as PresetPayMode)
|
||
} else {
|
||
if (isInstallmentPayMode(toMode)) {
|
||
applyFqPaymentDefaults({ resetCountInterval: fromMode === 'dj' })
|
||
}
|
||
formData.paymentDetails = applyPayModeSwitch(
|
||
fromMode,
|
||
toMode,
|
||
formData.paymentDetails,
|
||
payModeSwitchContext()
|
||
)
|
||
initPaymentDetailExpandFlags(formData.paymentDetails)
|
||
markPayDetailLocks(formData.paymentDetails)
|
||
const prefSum = sumActiveSamplePreferentialPrices()
|
||
if (prefSum != null) {
|
||
formData.contractAmount = prefSum
|
||
}
|
||
}
|
||
|
||
if (toMode === 'dj') {
|
||
syncLumpSumDueDate()
|
||
}
|
||
|
||
lastConfirmedPayMode.value = toMode
|
||
if (isInstallmentPayMode(toMode)) {
|
||
syncLastConfirmedFqParams()
|
||
}
|
||
}
|
||
|
||
/** 趸交模式:将趸交截止时间同步到待缴明细的应缴费时间 */
|
||
function syncLumpSumDueDate() {
|
||
if (formData.payMode !== 'dj') return
|
||
const deadline = formData.lumpSumDeadline || ''
|
||
formData.paymentDetails.forEach((d) => {
|
||
if (isPayDetailLocked(d)) return
|
||
d.installmentDueDate = deadline
|
||
})
|
||
}
|
||
|
||
function handleOpenForAdd() {
|
||
isEdit.value = false
|
||
dialogTitle.value = '新增协议单'
|
||
resetForm()
|
||
formData.protocolStatus = 'sxz'
|
||
formData.motherEthnic = DEFAULT_MOTHER_ETHNIC
|
||
formData.registerDate = new Date().toISOString().slice(0, 10).replace(/-/g, '')
|
||
void loadEthnicOptions()
|
||
|
||
// 默认采集专员、市场专员为当前登录人
|
||
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 }]
|
||
void applyRegionsForSalesUser(uid)
|
||
}
|
||
}
|
||
|
||
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.protocolStatus = String(prot.protocolStatus || formData.protocolStatus || 'dsx')
|
||
formData.registerDate = String(prot.registerDate || formData.registerDate || '')
|
||
formData.remark = String(prot.remark ?? formData.remark ?? '')
|
||
formData.paymentModelVersion = Number(prot.paymentModelVersion || 1)
|
||
formData.protocolTotalAmount =
|
||
prot.protocolTotalAmount != null ? Number(prot.protocolTotalAmount) : null
|
||
formData.sourcePresignOrderId = String(prot.sourcePresignOrderId || '')
|
||
}
|
||
presignDepositHint.value = (bundle.presignDepositHint as Record<string, any>) || null
|
||
depositTransfers.value = Array.isArray(bundle.depositTransfers)
|
||
? (bundle.depositTransfers as Array<Record<string, any>>)
|
||
: []
|
||
const samplePays = bundle.samplePayments as Array<Record<string, any>> | null
|
||
if (samplePays && samplePays.length) {
|
||
formData.samplePayments = samplePays.map((p) => ({
|
||
...p,
|
||
installmentRatios: parseInstallmentRatios(p.installmentRatios),
|
||
schedules: (p.schedules || []).map((sch: Record<string, any>) => ({
|
||
...sch,
|
||
transfers: (sch.transfers || []).map((t: Record<string, any>) => ({
|
||
...t,
|
||
attachments: Array.isArray(t.attachments)
|
||
? t.attachments.map((a: Record<string, any>) => ({ ...a }))
|
||
: []
|
||
}))
|
||
}))
|
||
}))
|
||
}
|
||
{
|
||
const sum = sampleContractSum.value
|
||
const total = formData.protocolTotalAmount
|
||
protocolTotalFollowSamples.value =
|
||
total == null || (sum > 0 && Math.abs(Number(total) - sum) <= 0.015)
|
||
syncProtocolTotalFromSamples()
|
||
}
|
||
|
||
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.motherEmail = String(pc.motherEmail || '')
|
||
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.expectedDeliveryDate = String(pc.expectedDeliveryDate || '')
|
||
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 || '')
|
||
formData.fatherCustomerId = String(pc.fatherCustomerId || '')
|
||
linkedMotherCustomerId.value = formData.customerId
|
||
linkedMotherCustomerName.value = formData.motherName
|
||
linkedFatherCustomerId.value = formData.fatherCustomerId
|
||
linkedFatherCustomerName.value = formData.fatherName
|
||
}
|
||
|
||
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.regionManagerUserId = String(ci.regionManagerUserId || '')
|
||
formData.sampleCollectTime = formatDatetimeToMinute(String(ci.sampleCollectTime || ''))
|
||
formData.maternalBloodCollectTime = formatDatetimeToMinute(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 || '')
|
||
formData.installmentAdvanceDays =
|
||
pay.installmentAdvanceDays ?? DEFAULT_INSTALLMENT_ADVANCE_DAYS
|
||
const loadedAmounts = Array.isArray(pay.installmentRatios) ? pay.installmentRatios : []
|
||
if (loadedAmounts.length) {
|
||
formData.installmentRatios = loadedAmounts.map((v: unknown) => Number(v))
|
||
}
|
||
if (isInstallmentPayMode(formData.payMode)) {
|
||
applyFqPaymentDefaults()
|
||
const count = toIntOrUndefined(formData.installmentCount) || 6
|
||
formData.installmentRatios = normalizeInstallmentAmounts(
|
||
formData.installmentRatios,
|
||
formData.contractAmount,
|
||
count
|
||
)
|
||
syncLastConfirmedFqParams()
|
||
}
|
||
}
|
||
|
||
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
|
||
markPayDetailLocks(formData.paymentDetails)
|
||
}
|
||
syncFqPaymentDetailRowCount(fqPaymentContext(), formData.paymentDetails)
|
||
lastConfirmedPayMode.value = (
|
||
['fq', 'djfq'].includes(formData.payMode) ? formData.payMode : 'dj'
|
||
) as PayMode
|
||
|
||
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
|
||
}
|
||
const consentAtt = (bundle as any).consentAttachmentFiles as Array<Record<string, any>> | null
|
||
if (consentAtt && consentAtt.length) {
|
||
formData.consentAttachments = consentAtt.map((a) => ({ ...a })) as any
|
||
}
|
||
const healthSurveyAtt = (bundle as any).healthSurveyAttachmentFiles as
|
||
| Array<Record<string, any>>
|
||
| null
|
||
if (healthSurveyAtt && healthSurveyAtt.length) {
|
||
formData.healthSurveyAttachments = healthSurveyAtt.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.protocolNo = String(row.protocolNo || '')
|
||
formData.protocolStatus = String(row.protocolStatus || 'dsx')
|
||
formData.registerDate = String(row.registerDate || '')
|
||
formData.collectorUserId = String(row.collectorUserId || '')
|
||
formData.protocolAttachments = String((row as any).protocolAttachments || '')
|
||
formData.consentFormUrls = String((row as any).consentFormUrls || '')
|
||
formData.healthSurveyFormUrls = String((row as any).healthSurveyFormUrls || '')
|
||
formData.remark = String(row.remark || '')
|
||
|
||
dialogFormLoading.value = true
|
||
|
||
try {
|
||
const [, bundle] = await Promise.all([
|
||
loadEthnicOptions(),
|
||
fetchCollectionProtocolDetail(String(row.id)).catch(() => null)
|
||
])
|
||
if (bundle) {
|
||
applyProtocolBundleToForm(bundle as Record<string, any>, row)
|
||
}
|
||
|
||
const ciBundle = (bundle as any)?.collectionInfo as Record<string, any> | null
|
||
const hydrateTasks: Promise<void>[] = [
|
||
hydrateCollectorUserForEdit(
|
||
formData.collectorUserId,
|
||
String((row as any).collectorUserName || '')
|
||
),
|
||
hydrateSalesUserForEdit(formData.salesUserId, String((row as any).salesUserName || '')),
|
||
hydrateManagerUserForEdit(
|
||
formData.regionManagerUserId,
|
||
String(ciBundle?.regionManagerUserName || (row as any).regionManagerUserName || '')
|
||
)
|
||
]
|
||
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.salesUserId) {
|
||
await applyRegionsForSalesUser(formData.salesUserId, {
|
||
preferKeepRegion: true,
|
||
skipManagerDefault: true
|
||
})
|
||
}
|
||
if (formData.samples.length) {
|
||
await Promise.all([
|
||
remoteSearchSampleTypes(''),
|
||
remoteSearchSites(''),
|
||
remoteSearchUnits(''),
|
||
remoteSearchSampleCodes('')
|
||
])
|
||
hydrateSampleLookupOptions()
|
||
}
|
||
} catch {
|
||
/* 保留列表行已填基础字段 */
|
||
} finally {
|
||
dialogFormLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function handleSubmit() {
|
||
if (!formRef.value || submitLoading.value) return
|
||
submitLoading.value = true
|
||
try {
|
||
await formRef.value.validate()
|
||
} catch {
|
||
submitLoading.value = false
|
||
return
|
||
}
|
||
const sampleCount = getActiveSampleCount(formData.samples)
|
||
const isV2 = paymentModelVersion.value >= 2
|
||
if (!isV2) {
|
||
if (!isPayModeValidForSampleCount(formData.payMode as PresetPayMode, sampleCount)) {
|
||
submitLoading.value = false
|
||
await ElMessageBox.alert(
|
||
formData.payMode === 'djfq'
|
||
? '「趸交+分期」仅适用于 2 个样本,请切换缴费模式'
|
||
: '当前缴费模式与样本数量不匹配,请切换缴费模式',
|
||
'无法保存',
|
||
{ type: 'warning' }
|
||
)
|
||
return
|
||
}
|
||
} else {
|
||
const missingCode = formData.samples.some(
|
||
(s) => String(s.sampleStatus || '') !== 'yzf' && !String(s.sampleCode || '').trim()
|
||
)
|
||
if (missingCode && formData.samplePayments.length) {
|
||
submitLoading.value = false
|
||
ElMessage.warning('请先为样本分配样本编码,再保存按样本缴费方案')
|
||
return
|
||
}
|
||
for (const pay of formData.samplePayments) {
|
||
const requiredHint = sampleScheduleRequiredFieldsHint(pay)
|
||
if (requiredHint) {
|
||
submitLoading.value = false
|
||
await ElMessageBox.alert(requiredHint, '无法保存', { type: 'warning' })
|
||
return
|
||
}
|
||
const duesHint = sampleScheduleDuesMismatchHint(pay)
|
||
if (duesHint) {
|
||
submitLoading.value = false
|
||
await ElMessageBox.alert(duesHint, '无法保存', { type: 'warning' })
|
||
return
|
||
}
|
||
}
|
||
const mismatch = protocolTotalMismatchHint(
|
||
formData.protocolTotalAmount,
|
||
sumSampleContractAmounts(formData.samplePayments)
|
||
)
|
||
if (mismatch) ElMessage.warning(mismatch)
|
||
}
|
||
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) {
|
||
submitLoading.value = false
|
||
ElMessage.warning(`样本编码「${sampleRow.sampleCode || sc.sampleCode}」已被占用,请更换`)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
if (!isV2 && formData.payMode === 'dj') {
|
||
const dueHint = djDueAmountMismatchHint(formData.contractAmount, formData.paymentDetails)
|
||
if (dueHint) {
|
||
ElMessage.warning(dueHint)
|
||
}
|
||
}
|
||
try {
|
||
if (!isV2) {
|
||
syncFqPaymentDetailRowCount(fqPaymentContext(), formData.paymentDetails)
|
||
}
|
||
const protocolAttachmentUrls = formData.attachments
|
||
.map((a) => a.fileUrl)
|
||
.filter(Boolean)
|
||
.join(',')
|
||
const consentAttachmentUrls = formData.consentAttachments
|
||
.map((a) => a.fileUrl)
|
||
.filter(Boolean)
|
||
.join(',')
|
||
const healthSurveyAttachmentUrls = formData.healthSurveyAttachments
|
||
.map((a) => a.fileUrl)
|
||
.filter(Boolean)
|
||
.join(',')
|
||
const payload: Record<string, unknown> = {
|
||
protocolStatus: formData.protocolStatus,
|
||
registerDate: formData.registerDate || undefined,
|
||
collectorUserId: formData.collectorUserId || undefined,
|
||
protocolAttachments: protocolAttachmentUrls || formData.protocolAttachments || undefined,
|
||
consentFormUrls: consentAttachmentUrls || formData.consentFormUrls || undefined,
|
||
healthSurveyFormUrls:
|
||
healthSurveyAttachmentUrls || formData.healthSurveyFormUrls || undefined,
|
||
attachments: formData.attachments.map((a) => ({
|
||
id: a.id,
|
||
fileName: a.fileName,
|
||
fileUrl: a.fileUrl,
|
||
fileSize: a.fileSize,
|
||
fileSizeDisplay: a.fileSizeDisplay,
|
||
fileType: a.fileType
|
||
})),
|
||
consentAttachments: formData.consentAttachments.map((a) => ({
|
||
id: a.id,
|
||
fileName: a.fileName,
|
||
fileUrl: a.fileUrl,
|
||
fileSize: a.fileSize,
|
||
fileSizeDisplay: a.fileSizeDisplay,
|
||
fileType: a.fileType
|
||
})),
|
||
healthSurveyAttachments: formData.healthSurveyAttachments.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,
|
||
fatherCustomerId: formData.fatherCustomerId || undefined,
|
||
motherName: formData.motherName || undefined,
|
||
motherPhone: formData.motherPhone || undefined,
|
||
motherIdCard: formData.motherIdCard || undefined,
|
||
motherBirthYm: formData.motherBirthYm || undefined,
|
||
motherEthnic: formData.motherEthnic || undefined,
|
||
motherEmail: formData.motherEmail || 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,
|
||
expectedDeliveryDate: formData.expectedDeliveryDate || 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,
|
||
regionManagerUserId: formData.regionManagerUserId || undefined,
|
||
sampleCollectTime: formData.sampleCollectTime || undefined,
|
||
maternalBloodCollectTime: formData.maternalBloodCollectTime || undefined
|
||
},
|
||
samples: formData.samples.map(({ _rowKey, transport, freight, freightDraft, ...s }) => ({
|
||
...s,
|
||
sampleStatus:
|
||
s.sampleStatus != null && String(s.sampleStatus).trim() !== ''
|
||
? String(s.sampleStatus).trim()
|
||
: null,
|
||
transport: {
|
||
transportType: transport?.transportType || '',
|
||
expressCompanyId: transport?.expressCompanyId || '',
|
||
waybillNo: transport?.waybillNo || '',
|
||
destSiteId: transport?.destSiteId || '',
|
||
expressFee: transport?.expressFee ?? null,
|
||
etaTime: transport?.etaTime || '',
|
||
carrierName: transport?.carrierName || '',
|
||
carrierPhone: transport?.carrierPhone || ''
|
||
},
|
||
freightId: freight?.id || s.freightId || undefined,
|
||
freightDraft: freightDraft || undefined
|
||
}))
|
||
}
|
||
|
||
if (isV2) {
|
||
payload.paymentModelVersion = 2
|
||
payload.protocolTotalAmount = formData.protocolTotalAmount
|
||
payload.sourcePresignOrderId = formData.sourcePresignOrderId || undefined
|
||
payload.samplePayments = formData.samplePayments.map((p) => ({
|
||
id: p.id,
|
||
sampleCode: p.sampleCode,
|
||
protocolSampleId: p.protocolSampleId || undefined,
|
||
payMode: p.payMode,
|
||
contractAmount: p.contractAmount ?? undefined,
|
||
lumpSumDeadline: p.lumpSumDeadline || undefined,
|
||
installmentCount: toIntOrUndefined(p.installmentCount),
|
||
installmentIntervalMonths: toFloatOrUndefined(p.installmentIntervalMonths),
|
||
installmentStartDate: p.installmentStartDate || undefined,
|
||
installmentAdvanceDays: toIntOrUndefined(p.installmentAdvanceDays),
|
||
installmentRatios: Array.isArray(p.installmentRatios)
|
||
? p.installmentRatios.map((v: any) => Number(v || 0))
|
||
: parseInstallmentRatios(p.installmentRatios),
|
||
remark: p.remark || undefined,
|
||
schedules: (p.schedules || []).map((sch: any) => ({
|
||
scheduleNo: sch.scheduleNo,
|
||
dueAmount: sch.dueAmount,
|
||
dueDate: sch.dueDate || undefined,
|
||
scheduleStatus: sch.scheduleStatus || 'open',
|
||
remark: sch.remark,
|
||
transfers: (sch.transfers || []).map((t: any) => {
|
||
const fromFiles = Array.isArray(t.attachments)
|
||
? t.attachments
|
||
.filter((f: any) => f?.fileUrl && !f._uploading)
|
||
.map((f: any) => String(f.fileUrl))
|
||
.join(',')
|
||
: ''
|
||
return {
|
||
id: t.id,
|
||
transferType: t.transferType,
|
||
transferAmount: t.transferAmount,
|
||
transferTime: t.transferTime,
|
||
transferAttachments: fromFiles || t.transferAttachments || undefined,
|
||
payStatus: t.payStatus || 'dfj',
|
||
remark: t.remark
|
||
}
|
||
})
|
||
}))
|
||
}))
|
||
} else {
|
||
payload.payment = {
|
||
payMode: formData.payMode,
|
||
contractAmount: formData.contractAmount ?? undefined,
|
||
lumpSumDeadline: formData.lumpSumDeadline || undefined,
|
||
installmentCount: toIntOrUndefined(formData.installmentCount),
|
||
installmentIntervalMonths: toFloatOrUndefined(formData.installmentIntervalMonths),
|
||
installmentStartDate: formData.installmentStartDate || undefined,
|
||
installmentAdvanceDays: toIntOrUndefined(formData.installmentAdvanceDays),
|
||
installmentRatios: isInstallmentPayMode(formData.payMode)
|
||
? (formData.installmentRatios || []).map((v) => Number(v || 0))
|
||
: undefined
|
||
}
|
||
payload.paymentDetails = formData.paymentDetails.map(buildProtocolPaymentDetailPayload)
|
||
}
|
||
|
||
let protocolId = ''
|
||
if (isEdit.value && editingId.value) {
|
||
await fetchUpdateCollectionProtocol(editingId.value, payload)
|
||
protocolId = String(editingId.value)
|
||
} else {
|
||
const created: any = await fetchCreateCollectionProtocol(payload)
|
||
protocolId = String(created?.id || created?.protocol?.id || '')
|
||
}
|
||
|
||
if (protocolId) {
|
||
try {
|
||
const bundle: any = await fetchCollectionProtocolDetail(protocolId)
|
||
const rowsForSubmit = isV2
|
||
? flattenSamplePaymentTransfers(bundle?.samplePayments || [])
|
||
: Array.isArray(bundle?.paymentDetails)
|
||
? bundle.paymentDetails
|
||
: []
|
||
const failures = await submitDfjTransfersOnSave(rowsForSubmit, (transferId) =>
|
||
isV2
|
||
? fetchSubmitPaymentTransferFinance(transferId, { showSuccessMessage: false })
|
||
: fetchSubmitReceiptFinance(transferId, undefined, { showSuccessMessage: false })
|
||
)
|
||
if (failures.length) {
|
||
ElMessage.warning(formatAutoSubmitFinanceFailureMessage(failures, '协议'))
|
||
}
|
||
} catch {
|
||
ElMessage.warning('协议已保存,但自动提交财务确认失败,请在缴费信息中手动提交')
|
||
}
|
||
}
|
||
|
||
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 hydrateManagerUserForEdit(userId: string, displayName?: string) {
|
||
await hydrateRemoteUserForEdit(
|
||
userId,
|
||
displayName,
|
||
(user) => {
|
||
managerSnapshot.value = { ...user }
|
||
regionManagerUserId.value = user.id
|
||
managerUserOptions.value = [{ ...user }]
|
||
formData.regionManagerUserId = user.id
|
||
},
|
||
() => {
|
||
regionManagerUserId.value = null
|
||
managerSnapshot.value = null
|
||
managerUserOptions.value = []
|
||
formData.regionManagerUserId = ''
|
||
}
|
||
)
|
||
}
|
||
|
||
function makeManagerUserStub(id: string, name: string): Api.User.UserItem {
|
||
return {
|
||
id,
|
||
nickName: name || id,
|
||
phoneNo: '',
|
||
deptId: '',
|
||
userNo: '',
|
||
email: '',
|
||
sex: '',
|
||
avatar: '',
|
||
status: '1',
|
||
tenantNo: '',
|
||
orderNum: 0,
|
||
remark: '',
|
||
createTime: '',
|
||
updateTime: ''
|
||
} as Api.User.UserItem
|
||
}
|
||
|
||
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 ensureSampleTypesInOptions(samples: Array<Record<string, any>>) {
|
||
const merged = [...sampleTypeOptions.value]
|
||
for (const s of samples) {
|
||
const id = String(s.sampleTypeId || '')
|
||
if (!id) continue
|
||
const name = String(s.sampleTypeName || '').trim()
|
||
const existing = merged.find((x) => x.id === id)
|
||
if (existing) {
|
||
if (name && (!existing.typeName || existing.typeName === existing.id)) {
|
||
existing.typeName = name
|
||
}
|
||
continue
|
||
}
|
||
merged.push({
|
||
id,
|
||
typeCode: '',
|
||
typeName: name || id,
|
||
storageYears: s.storageYears ?? null,
|
||
storagePrice: s.originalPrice ?? null
|
||
})
|
||
}
|
||
sampleTypeOptions.value = merged
|
||
}
|
||
|
||
function ensureUnitsInOptions(samples: Array<Record<string, any>>) {
|
||
const merged = [...unitOptions.value]
|
||
for (const s of samples) {
|
||
const id = String(s.unitId || '')
|
||
if (!id) continue
|
||
const name = String(s.unitName || '').trim()
|
||
const existing = merged.find((x) => x.id === id)
|
||
if (existing) {
|
||
if (name && (!existing.unitName || existing.unitName === existing.id)) {
|
||
existing.unitName = name
|
||
}
|
||
continue
|
||
}
|
||
merged.push({ id, unitName: name || id, unitCode: String(s.unitCode || '') })
|
||
}
|
||
unitOptions.value = merged
|
||
}
|
||
|
||
function ensureSitesInOptions(samples: Array<Record<string, any>>) {
|
||
const merged = [...siteOptions.value]
|
||
for (const s of samples) {
|
||
const id = String(s.transport?.destSiteId || '')
|
||
if (!id) continue
|
||
const name = String(s.transport?.destSiteName || '').trim()
|
||
const existing = merged.find((x) => x.id === id)
|
||
if (existing) {
|
||
if (name && (!existing.siteName || existing.siteName === existing.id)) {
|
||
existing.siteName = name
|
||
}
|
||
continue
|
||
}
|
||
merged.push({
|
||
id,
|
||
siteCode: '',
|
||
siteName: name || id,
|
||
siteType: '',
|
||
status: '',
|
||
address: '',
|
||
contactName: '',
|
||
contactPhone: '',
|
||
businessScope: '',
|
||
siteImage: '',
|
||
tenantNo: '',
|
||
orderNum: 0,
|
||
remark: '',
|
||
createTime: '',
|
||
updateTime: ''
|
||
})
|
||
}
|
||
siteOptions.value = merged
|
||
}
|
||
|
||
function hydrateSampleLookupOptions() {
|
||
ensureSampleTypesInOptions(formData.samples)
|
||
ensureSampleCodesInOptions(formData.samples)
|
||
ensureUnitsInOptions(formData.samples)
|
||
ensureSitesInOptions(formData.samples)
|
||
}
|
||
|
||
/* ====== 采集专员检索 ====== */
|
||
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
|
||
}
|
||
}
|
||
|
||
async function remoteSearchManagerUsers(query: string) {
|
||
managerLoading.value = true
|
||
try {
|
||
const res = await fetchUserList({
|
||
page: 1,
|
||
pageSize: 50,
|
||
keyword: (query || '').trim() || undefined,
|
||
status: '1'
|
||
})
|
||
let list = [...(res.list || [])]
|
||
const cur = regionManagerUserId.value
|
||
if (cur && !list.some((x) => x.id === cur)) {
|
||
const keep =
|
||
managerSnapshot.value?.id === cur
|
||
? managerSnapshot.value
|
||
: managerUserOptions.value.find((x) => x.id === cur)
|
||
if (keep) list = [keep, ...list]
|
||
}
|
||
managerUserOptions.value = list
|
||
} catch {
|
||
managerUserOptions.value = []
|
||
} finally {
|
||
managerLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function onSalesUserChange(val: string | null | undefined) {
|
||
if (!val) {
|
||
salesUserId.value = null
|
||
salesSnapshot.value = null
|
||
formData.salesUserId = ''
|
||
await applyRegionsForSalesUser(null)
|
||
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
|
||
await applyRegionsForSalesUser(u.id)
|
||
}
|
||
|
||
function onManagerUserChange(val: string | null | undefined) {
|
||
if (!val) {
|
||
regionManagerUserId.value = null
|
||
managerSnapshot.value = null
|
||
formData.regionManagerUserId = ''
|
||
return
|
||
}
|
||
const u = managerUserOptions.value.find((x) => x.id === val) || managerSnapshot.value
|
||
if (!u) {
|
||
regionManagerUserId.value = val
|
||
formData.regionManagerUserId = val
|
||
return
|
||
}
|
||
managerSnapshot.value = { ...u }
|
||
regionManagerUserId.value = u.id
|
||
formData.regionManagerUserId = u.id
|
||
}
|
||
|
||
async function applyDefaultRegionManager(regionId: string) {
|
||
if (!regionId) {
|
||
regionManagerUserId.value = null
|
||
formData.regionManagerUserId = ''
|
||
managerUserOptions.value = []
|
||
managerSnapshot.value = null
|
||
return
|
||
}
|
||
try {
|
||
const res: any = await fetchRegionStaffList(regionId, { page: 1, pageSize: 100 })
|
||
const list = (res?.list || []) as Array<Record<string, any>>
|
||
const leader = list.find((x) => x.isLeader) || list[0]
|
||
if (!leader?.userId) {
|
||
regionManagerUserId.value = null
|
||
formData.regionManagerUserId = ''
|
||
return
|
||
}
|
||
const stub = makeManagerUserStub(
|
||
String(leader.userId),
|
||
String(leader.nickName || leader.userName || '')
|
||
)
|
||
regionManagerUserId.value = stub.id
|
||
formData.regionManagerUserId = stub.id
|
||
managerSnapshot.value = { ...stub }
|
||
if (!managerUserOptions.value.some((x) => x.id === stub.id)) {
|
||
managerUserOptions.value = [stub, ...managerUserOptions.value]
|
||
}
|
||
} catch {
|
||
/* 地区无人员配置时留空,可手工选择 */
|
||
}
|
||
}
|
||
|
||
async function applyRegionsForSalesUser(
|
||
userId: string | null,
|
||
opts?: { preferKeepRegion?: boolean; skipManagerDefault?: boolean }
|
||
) {
|
||
const uid = (userId || '').trim()
|
||
if (!uid) {
|
||
linkedRegionMode.value = false
|
||
formData.regionId = ''
|
||
formData.hospitalId = ''
|
||
regionManagerUserId.value = null
|
||
formData.regionManagerUserId = ''
|
||
managerUserOptions.value = []
|
||
managerSnapshot.value = null
|
||
regionOptions.value = []
|
||
hospitalOptions.value = []
|
||
return
|
||
}
|
||
try {
|
||
const res: any = await fetchRegionsByStaffUser(uid)
|
||
const list = (res?.list || []) as Array<{ id: string; regionName: string }>
|
||
if (list.length > 0) {
|
||
linkedRegionMode.value = true
|
||
// 编辑回填:以库中已存 regionId 为准,即使不在专员关联列表中也保留
|
||
const keep = Boolean(opts?.preferKeepRegion && formData.regionId)
|
||
const inLinked = Boolean(formData.regionId && list.some((x) => x.id === formData.regionId))
|
||
const prevRegionName =
|
||
regionOptions.value.find((r) => r.id === formData.regionId)?.regionName || ''
|
||
regionOptions.value = list as any
|
||
if (keep && !inLinked) {
|
||
const stub: Api.BizCollectionRegion.RegionItem = {
|
||
id: formData.regionId,
|
||
regionName: prevRegionName || formData.regionId,
|
||
hospitalNames: [],
|
||
tenantNo: '',
|
||
orderNum: 0,
|
||
remark: '',
|
||
createTime: '',
|
||
updateTime: ''
|
||
}
|
||
regionOptions.value = [stub, ...regionOptions.value]
|
||
}
|
||
const nextId = keep ? formData.regionId : inLinked ? formData.regionId : list[0].id
|
||
const regionChanged = nextId !== formData.regionId
|
||
formData.regionId = nextId
|
||
if (regionChanged) {
|
||
formData.hospitalId = ''
|
||
hospitalOptions.value = []
|
||
await loadHospitalsByRegion(nextId, '')
|
||
if (!opts?.skipManagerDefault) {
|
||
await applyDefaultRegionManager(nextId)
|
||
}
|
||
} else if (!opts?.skipManagerDefault && !formData.regionManagerUserId) {
|
||
await applyDefaultRegionManager(nextId)
|
||
}
|
||
} else {
|
||
linkedRegionMode.value = false
|
||
// 编辑回填:专员无关联地区时保留库中已存值,仅退出关联模式(恢复全量检索)
|
||
if (opts?.preferKeepRegion) {
|
||
return
|
||
}
|
||
formData.regionId = ''
|
||
formData.hospitalId = ''
|
||
hospitalOptions.value = []
|
||
regionOptions.value = []
|
||
regionManagerUserId.value = null
|
||
formData.regionManagerUserId = ''
|
||
managerUserOptions.value = []
|
||
managerSnapshot.value = null
|
||
}
|
||
} catch {
|
||
linkedRegionMode.value = false
|
||
}
|
||
}
|
||
|
||
async function remoteSearchSampleCodes(query: string, codeFilter?: string) {
|
||
sampleCodeLoading.value = true
|
||
try {
|
||
const res = await fetchSampleCodeList({
|
||
page: 1,
|
||
pageSize: 100,
|
||
keyword: query?.trim() || undefined,
|
||
codeFilter: codeFilter?.trim() || undefined
|
||
})
|
||
sampleCodeOptions.value = (res.list || []) as Api.BizSampleCode.SampleCodeItem[]
|
||
ensureSampleCodesInOptions(formData.samples)
|
||
} catch {
|
||
sampleCodeOptions.value = []
|
||
ensureSampleCodesInOptions(formData.samples)
|
||
} finally {
|
||
sampleCodeLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function remoteSearchSampleTypes(query: string) {
|
||
sampleTypeLoading.value = true
|
||
try {
|
||
const res = await fetchCellSampleTypeList({
|
||
page: 1,
|
||
pageSize: 100,
|
||
keyword: query?.trim() || undefined
|
||
})
|
||
sampleTypeOptions.value = (res.list || []) as any
|
||
ensureSampleTypesInOptions(formData.samples)
|
||
} catch {
|
||
sampleTypeOptions.value = []
|
||
ensureSampleTypesInOptions(formData.samples)
|
||
} finally {
|
||
sampleTypeLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function remoteSearchUnits(query: string) {
|
||
unitLoading.value = true
|
||
try {
|
||
const res = await fetchItemUnitList({
|
||
page: 1,
|
||
pageSize: 100,
|
||
keyword: query?.trim() || undefined
|
||
})
|
||
unitOptions.value = (res.list || []) as any
|
||
ensureUnitsInOptions(formData.samples)
|
||
} catch {
|
||
unitOptions.value = []
|
||
ensureUnitsInOptions(formData.samples)
|
||
} finally {
|
||
unitLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function remoteSearchSites(query: string) {
|
||
siteLoading.value = true
|
||
try {
|
||
const res = await fetchSiteList({
|
||
page: 1,
|
||
pageSize: 100,
|
||
keyword: query?.trim() || undefined
|
||
})
|
||
siteOptions.value = res.list || []
|
||
ensureSitesInOptions(formData.samples)
|
||
} catch {
|
||
siteOptions.value = []
|
||
ensureSitesInOptions(formData.samples)
|
||
} finally {
|
||
siteLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function remoteSearchExpressCompanies(query: string) {
|
||
expressCompanyLoading.value = true
|
||
try {
|
||
const res = await fetchExpressCompanyList({
|
||
page: 1,
|
||
pageSize: 100,
|
||
keyword: query?.trim() || undefined
|
||
})
|
||
expressCompanyOptions.value = res.list || []
|
||
} catch {
|
||
expressCompanyOptions.value = []
|
||
} finally {
|
||
expressCompanyLoading.value = false
|
||
}
|
||
}
|
||
|
||
/* ====== 采集地区下拉检索 ====== */
|
||
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
|
||
}
|
||
}
|
||
|
||
async function onRegionChange(val: string | null | undefined) {
|
||
if (!val) {
|
||
formData.regionId = ''
|
||
hospitalOptions.value = []
|
||
formData.hospitalId = ''
|
||
regionManagerUserId.value = null
|
||
formData.regionManagerUserId = ''
|
||
managerUserOptions.value = []
|
||
managerSnapshot.value = null
|
||
return
|
||
}
|
||
formData.regionId = val
|
||
formData.hospitalId = ''
|
||
hospitalOptions.value = []
|
||
await loadHospitalsByRegion(val)
|
||
await applyDefaultRegionManager(val)
|
||
}
|
||
|
||
function onRegionClear() {
|
||
formData.regionId = ''
|
||
formData.hospitalId = ''
|
||
hospitalOptions.value = []
|
||
regionManagerUserId.value = null
|
||
formData.regionManagerUserId = ''
|
||
managerUserOptions.value = []
|
||
managerSnapshot.value = null
|
||
}
|
||
|
||
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 onInstallmentCountChange() {
|
||
const count = toIntOrUndefined(formData.installmentCount) || 0
|
||
if (count > 0) {
|
||
formData.installmentRatios = resizeInstallmentAmounts(
|
||
formData.installmentRatios || [],
|
||
count,
|
||
formData.contractAmount
|
||
)
|
||
}
|
||
handleFqParamsChange()
|
||
}
|
||
|
||
/** 缴费明细应缴金额之和回写合同金额 */
|
||
function syncContractAmountFromPaymentDetails() {
|
||
if (!formData.paymentDetails.length) return
|
||
const total = formData.paymentDetails.reduce(
|
||
(sum, d) => sum + (Number(d.dueAmount) || 0),
|
||
0
|
||
)
|
||
formData.contractAmount = Math.round(total * 100) / 100
|
||
}
|
||
|
||
/** 缴费明细应缴金额变更:分期回写 installmentRatios,并同步合同金额 */
|
||
function onPaymentDetailDueAmountChange(dIdx: number) {
|
||
const detail = formData.paymentDetails[dIdx]
|
||
if (!detail || isPayDetailLocked(detail)) return
|
||
if (formData.payMode === 'fq' || formData.payMode === 'djfq') {
|
||
const installmentNo = Number(detail.installmentNo)
|
||
const ratioIdx = installmentNo >= 1 ? installmentNo - 1 : dIdx
|
||
if (!formData.installmentRatios) formData.installmentRatios = []
|
||
while (formData.installmentRatios.length <= ratioIdx) {
|
||
formData.installmentRatios.push(0)
|
||
}
|
||
formData.installmentRatios[ratioIdx] = Number(detail.dueAmount) || 0
|
||
}
|
||
syncContractAmountFromPaymentDetails()
|
||
}
|
||
|
||
/** 按缴费模式同步各明细应缴费金额(跳过已锁定明细) */
|
||
function syncPaymentDueAmounts() {
|
||
const contract = formData.contractAmount
|
||
if (contract == null) return
|
||
if (formData.payMode === 'dj') {
|
||
const paidSum = sumPaidAmount(formData.paymentDetails)
|
||
const remaining = Math.round((Number(contract) - paidSum) * 100) / 100
|
||
const dfjRows = formData.paymentDetails.filter(
|
||
(d) => !isPayDetailLocked(d) && String(d.payStatus || 'dfj') === 'dfj'
|
||
)
|
||
const targetAmount = paidSum > 0 ? remaining : contract
|
||
dfjRows.forEach((d, i) => {
|
||
if (i === 0) d.dueAmount = targetAmount
|
||
})
|
||
return
|
||
}
|
||
if (formData.payMode === 'fq' || formData.payMode === 'djfq') {
|
||
const count = Number(formData.installmentCount || formData.paymentDetails.length)
|
||
if (!count) return
|
||
const ratios = formData.installmentRatios || []
|
||
const amounts =
|
||
ratios.length === count
|
||
? ratios.map((v) => Number(v || 0))
|
||
: normalizeInstallmentAmounts(ratios, null, count)
|
||
const paidRows = formData.paymentDetails.filter((d) => String(d.payStatus) === 'yjf')
|
||
paidRows.forEach((d) => {
|
||
const no = Number(d.installmentNo)
|
||
if (no >= 1 && no <= Number(count)) amounts[no - 1] = Number(d.dueAmount) || 0
|
||
})
|
||
formData.paymentDetails.forEach((d, i) => {
|
||
if (isPayDetailLocked(d)) return
|
||
if (amounts[i] !== undefined) d.dueAmount = amounts[i]
|
||
})
|
||
}
|
||
}
|
||
|
||
/** 合同金额手动变更:趸交时同步待缴明细应缴金额 */
|
||
function onContractAmountChange() {
|
||
if (formData.payMode === 'dj') {
|
||
syncPaymentDueAmounts()
|
||
}
|
||
}
|
||
|
||
/** 样本优惠价变更:SamplePaymentPanel 按 sample.price 同步合同金额 */
|
||
function onSamplePriceChange() {
|
||
// 联动在 SamplePaymentPanel 的 samples watch 中完成
|
||
}
|
||
|
||
/* ====== 母亲/父亲姓名检索客户档案 ====== */
|
||
async function queryPartyCustomers(
|
||
query: string,
|
||
cb: (list: Api.BizCustomer.CustomerItem[]) => void
|
||
) {
|
||
const q = (query || '').trim()
|
||
if (!q) {
|
||
cb([])
|
||
return
|
||
}
|
||
try {
|
||
const res = await fetchCustomerList({ page: 1, pageSize: 20, keyword: q })
|
||
cb(res.list || [])
|
||
} catch {
|
||
cb([])
|
||
}
|
||
}
|
||
|
||
function onMotherCustomerSelect(item: Api.BizCustomer.CustomerItem) {
|
||
const name = String(item.customerName || '').trim()
|
||
formData.customerId = item.id
|
||
formData.motherName = name
|
||
formData.motherPhone = String(item.phoneNo || '').trim()
|
||
formData.motherIdCard = String(item.idCardNo || '').trim()
|
||
linkedMotherCustomerId.value = item.id
|
||
linkedMotherCustomerName.value = name
|
||
calcBirthFromIdCard()
|
||
const birthYm = String(item.birthYm || '').trim()
|
||
const ethnic = String(item.ethnic || '').trim()
|
||
const email = String(item.email || '').trim()
|
||
const address = String(item.address || '').trim()
|
||
if (!(formData.motherBirthYm || '').trim() && birthYm) formData.motherBirthYm = birthYm
|
||
if (!(formData.motherEthnic || '').trim() && ethnic) {
|
||
formData.motherEthnic = ethnic
|
||
ensureEthnicOptionInList(ethnic)
|
||
}
|
||
if (!(formData.motherEmail || '').trim() && email) formData.motherEmail = email
|
||
if (!(formData.motherAddress || '').trim() && address) formData.motherAddress = address
|
||
}
|
||
|
||
function onMotherCustomerClear() {
|
||
formData.customerId = ''
|
||
linkedMotherCustomerId.value = ''
|
||
linkedMotherCustomerName.value = ''
|
||
}
|
||
|
||
function onMotherNameInput(val: string) {
|
||
if (linkedMotherCustomerId.value && val !== linkedMotherCustomerName.value) {
|
||
formData.customerId = ''
|
||
linkedMotherCustomerId.value = ''
|
||
linkedMotherCustomerName.value = ''
|
||
}
|
||
}
|
||
|
||
function onFatherCustomerSelect(item: Api.BizCustomer.CustomerItem) {
|
||
const name = String(item.customerName || '').trim()
|
||
formData.fatherCustomerId = item.id
|
||
formData.fatherName = name
|
||
formData.fatherPhone = String(item.phoneNo || '').trim()
|
||
formData.fatherIdCard = String(item.idCardNo || '').trim()
|
||
linkedFatherCustomerId.value = item.id
|
||
linkedFatherCustomerName.value = name
|
||
}
|
||
|
||
function onFatherCustomerClear() {
|
||
formData.fatherCustomerId = ''
|
||
linkedFatherCustomerId.value = ''
|
||
linkedFatherCustomerName.value = ''
|
||
}
|
||
|
||
function onFatherNameInput(val: string) {
|
||
if (linkedFatherCustomerId.value && val !== linkedFatherCustomerName.value) {
|
||
formData.fatherCustomerId = ''
|
||
linkedFatherCustomerId.value = ''
|
||
linkedFatherCustomerName.value = ''
|
||
}
|
||
}
|
||
|
||
/* ====== 母亲身份证号 ↔ 出生年月联动 ====== */
|
||
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()
|
||
)
|
||
|
||
/** 样本数/优惠价变化:新增单 v1 整单合同跟随优惠价之和(无已缴锁定时) */
|
||
watch(activeSampleCount, () => {
|
||
if (isEdit.value) return
|
||
if (paymentModelVersion.value >= 2) return
|
||
if (hasLockedPaymentDetails()) {
|
||
const prefSum = sumActiveSamplePreferentialPrices()
|
||
if (prefSum != null) {
|
||
formData.contractAmount = prefSum
|
||
}
|
||
return
|
||
}
|
||
applyPreferentialContractForV1Mode(formData.payMode as PresetPayMode)
|
||
})
|
||
|
||
/* ====== 趸交截止时间联动 ====== */
|
||
watch(
|
||
() => formData.lumpSumDeadline,
|
||
() => {
|
||
if (formData.payMode === 'dj') {
|
||
syncLumpSumDueDate()
|
||
}
|
||
}
|
||
)
|
||
|
||
/* ====== 附件上传 ====== */
|
||
const attachmentUploadRef = ref()
|
||
const consentAttachmentUploadRef = ref()
|
||
const healthSurveyAttachmentUploadRef = ref()
|
||
|
||
type ProtocolAttachmentItem = {
|
||
id?: string
|
||
fileName: string
|
||
fileUrl: string
|
||
fileSize: number
|
||
fileSizeDisplay?: string
|
||
fileType?: string
|
||
_uploading?: boolean
|
||
}
|
||
|
||
async function uploadProtocolAttachmentFile(
|
||
uploadFile: any,
|
||
list: ProtocolAttachmentItem[],
|
||
folder: string,
|
||
uploadRef: { value?: { clearFiles?: () => void } | null }
|
||
) {
|
||
const file = uploadFile.raw || uploadFile
|
||
if (!file) return
|
||
|
||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||
list.push({
|
||
id: tempId,
|
||
fileName: file.name,
|
||
fileUrl: '',
|
||
fileSize: file.size,
|
||
fileSizeDisplay: `${(file.size / 1024).toFixed(1)} KB`,
|
||
fileType: file.type || '',
|
||
_uploading: true
|
||
})
|
||
const idx = list.length - 1
|
||
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('folder', folder)
|
||
fd.append('file', file)
|
||
const res = await fetchUploadAttachment(fd)
|
||
list[idx] = {
|
||
...list[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('文件上传失败')
|
||
list.splice(idx, 1)
|
||
} finally {
|
||
nextTick(() => uploadRef.value?.clearFiles())
|
||
}
|
||
}
|
||
|
||
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) {
|
||
await uploadProtocolAttachmentFile(
|
||
uploadFile,
|
||
formData.attachments,
|
||
'protocol',
|
||
attachmentUploadRef
|
||
)
|
||
}
|
||
|
||
function removeAttachment(index: number) {
|
||
formData.attachments.splice(index, 1)
|
||
}
|
||
|
||
async function handleConsentAttachmentChange(uploadFile: any) {
|
||
await uploadProtocolAttachmentFile(
|
||
uploadFile,
|
||
formData.consentAttachments,
|
||
'protocol-consent',
|
||
consentAttachmentUploadRef
|
||
)
|
||
}
|
||
|
||
function removeConsentAttachment(index: number) {
|
||
formData.consentAttachments.splice(index, 1)
|
||
}
|
||
|
||
async function handleHealthSurveyAttachmentChange(uploadFile: any) {
|
||
await uploadProtocolAttachmentFile(
|
||
uploadFile,
|
||
formData.healthSurveyAttachments,
|
||
'protocol-health-survey',
|
||
healthSurveyAttachmentUploadRef
|
||
)
|
||
}
|
||
|
||
function removeHealthSurveyAttachment(index: number) {
|
||
formData.healthSurveyAttachments.splice(index, 1)
|
||
}
|
||
|
||
const { isDragover: isProtocolAttachDragover, dropZoneHandlers: protocolAttachDropHandlers } =
|
||
useAttachmentDropZone({
|
||
onFiles: (files) => {
|
||
for (const file of files) {
|
||
void handleAttachmentChange({ raw: file })
|
||
}
|
||
}
|
||
})
|
||
|
||
const { isDragover: isConsentAttachDragover, dropZoneHandlers: consentAttachDropHandlers } =
|
||
useAttachmentDropZone({
|
||
onFiles: (files) => {
|
||
for (const file of files) {
|
||
void handleConsentAttachmentChange({ raw: file })
|
||
}
|
||
}
|
||
})
|
||
|
||
const {
|
||
isDragover: isHealthSurveyAttachDragover,
|
||
dropZoneHandlers: healthSurveyAttachDropHandlers
|
||
} = useAttachmentDropZone({
|
||
onFiles: (files) => {
|
||
for (const file of files) {
|
||
void handleHealthSurveyAttachmentChange({ raw: file })
|
||
}
|
||
}
|
||
})
|
||
|
||
const { isDragover: isPaymentDetailDragover, handlersFor: paymentDetailDropHandlers } =
|
||
useIndexedAttachmentDropZone({
|
||
onFiles: (detailIndex, files) => {
|
||
for (const file of files) {
|
||
void handleDetailAttachmentChange(detailIndex, { raw: file })
|
||
}
|
||
},
|
||
disabled: (detailIndex) => {
|
||
const detail = formData.paymentDetails[detailIndex]
|
||
return !detail || isPayDetailLocked(detail)
|
||
}
|
||
})
|
||
|
||
/* ====== 缴费明细附件上传 ====== */
|
||
async function handleDetailAttachmentChange(detailIndex: number, uploadFile: any) {
|
||
const file = uploadFile.raw || uploadFile
|
||
if (!file) return
|
||
|
||
const detail = formData.paymentDetails[detailIndex]
|
||
if (!detail) return
|
||
|
||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||
detail.attachments.push({
|
||
id: tempId,
|
||
fileName: file.name,
|
||
fileUrl: '',
|
||
fileSize: file.size,
|
||
fileSizeDisplay: `${(file.size / 1024).toFixed(1)} KB`,
|
||
fileType: file.type || '',
|
||
_uploading: true
|
||
})
|
||
const aIdx = detail.attachments.length - 1
|
||
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('folder', 'protocol-payment')
|
||
fd.append('file', file)
|
||
const res = await fetchUploadAttachment(fd)
|
||
detail.attachments[aIdx] = {
|
||
...detail.attachments[aIdx],
|
||
id: res.id || tempId,
|
||
fileName: file.name || res.fileName,
|
||
fileUrl: res.fileUrl,
|
||
fileSize: res.fileSize || file.size,
|
||
fileSizeDisplay: res.fileSizeDisplay || `${(file.size / 1024).toFixed(1)} KB`,
|
||
fileType: res.fileType || file.type || '',
|
||
_uploading: false
|
||
}
|
||
} catch {
|
||
ElMessage.error('文件上传失败')
|
||
detail.attachments.splice(aIdx, 1)
|
||
}
|
||
}
|
||
|
||
function removeDetailAttachment(detailIndex: number, attachmentIndex: number) {
|
||
const detail = formData.paymentDetails[detailIndex]
|
||
if (detail) {
|
||
detail.attachments.splice(attachmentIndex, 1)
|
||
}
|
||
}
|
||
|
||
/* 附件预览 */
|
||
const previewVisible = ref(false)
|
||
const previewUrl = ref('')
|
||
const previewType = ref<'image' | 'video'>('image')
|
||
|
||
function handlePreviewAttachment(file: any) {
|
||
if (file._uploading) return
|
||
previewUrl.value = fullFileUrl(file.fileUrl)
|
||
previewType.value = isVideoFile(file) ? 'video' : 'image'
|
||
previewVisible.value = true
|
||
}
|
||
|
||
function handleDownloadAttachment(file: any) {
|
||
downloadAttachmentFile(file, { fullFileUrl })
|
||
}
|
||
|
||
/* ====== 弹窗打开时自动初始化 ====== */
|
||
watch(
|
||
() => props.modelValue,
|
||
(visible) => {
|
||
if (visible) {
|
||
void ensureTransferTypeOptions()
|
||
if (props.editId) {
|
||
handleOpenForEdit()
|
||
} else {
|
||
handleOpenForAdd()
|
||
}
|
||
}
|
||
}
|
||
)
|
||
</script>
|
||
<style scoped lang="scss">
|
||
/* ═══════════════════════════════════════════
|
||
DIALOG BODY
|
||
═══════════════════════════════════════════ */
|
||
:deep(.protocol-dialog-body) {
|
||
overflow: visible;
|
||
background: #fff;
|
||
}
|
||
|
||
.detail-tabs {
|
||
margin: 0 20px 12px;
|
||
}
|
||
|
||
.protocol-form {
|
||
padding: 0 20px 16px;
|
||
|
||
:deep(.el-form-item) {
|
||
margin-bottom: 4px;
|
||
}
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════
|
||
SECTIONS(单区域连续排布,无分块圆角卡片)
|
||
═══════════════════════════════════════════ */
|
||
.form-tab-pane {
|
||
padding: 0;
|
||
|
||
& + .form-tab-pane {
|
||
margin-top: 14px;
|
||
}
|
||
}
|
||
|
||
.section-card {
|
||
border-radius: 0;
|
||
padding: 0;
|
||
margin-bottom: 0;
|
||
box-shadow: none;
|
||
position: relative;
|
||
background: transparent;
|
||
|
||
&--green {
|
||
background: transparent;
|
||
}
|
||
}
|
||
|
||
.section-title {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 10px;
|
||
padding-bottom: 0;
|
||
border-bottom: none;
|
||
}
|
||
|
||
.section-title-left {
|
||
display: flex;
|
||
align-items: center;
|
||
|
||
&::before {
|
||
content: '';
|
||
width: 3px;
|
||
height: 14px;
|
||
background: #047857;
|
||
border-radius: 2px;
|
||
margin-right: 8px;
|
||
flex-shrink: 0;
|
||
}
|
||
}
|
||
|
||
.section-title h3 {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #1e293b;
|
||
}
|
||
|
||
.section-subtitle {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #374151;
|
||
margin: 12px 0 10px;
|
||
|
||
&::before {
|
||
content: '';
|
||
width: 2px;
|
||
height: 12px;
|
||
background: #047857;
|
||
border-radius: 1px;
|
||
}
|
||
}
|
||
|
||
.presign-deposit-hint {
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.muted-hint {
|
||
margin: 4px 0 0;
|
||
font-size: 12px;
|
||
color: #94a3b8;
|
||
}
|
||
|
||
.muted-dash {
|
||
color: var(--mint-text-muted, #94a3b8);
|
||
}
|
||
|
||
.warning-hint {
|
||
color: #d97706;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════
|
||
FORM GRID
|
||
═══════════════════════════════════════════ */
|
||
.form-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 12px 16px;
|
||
|
||
&--col2 {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
}
|
||
|
||
&--col4 {
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
}
|
||
|
||
&--col5 {
|
||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||
}
|
||
|
||
&--col6 {
|
||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||
}
|
||
|
||
&--attachment {
|
||
margin-top: 10px;
|
||
grid-template-columns: 1fr;
|
||
|
||
&:first-of-type {
|
||
margin-top: 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;
|
||
}
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════
|
||
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);
|
||
}
|
||
|
||
.protocol-total-card {
|
||
padding-top: 14px;
|
||
padding-bottom: 14px;
|
||
}
|
||
|
||
.protocol-total-inline {
|
||
.protocol-total-item {
|
||
margin-bottom: 0;
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
gap: 12px;
|
||
|
||
:deep(.el-form-item__label) {
|
||
margin: 0 !important;
|
||
padding: 0 !important;
|
||
line-height: 32px;
|
||
height: auto;
|
||
width: auto !important;
|
||
justify-content: flex-start;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
:deep(.el-form-item__content) {
|
||
flex: 1;
|
||
min-width: 0;
|
||
margin-left: 0 !important;
|
||
}
|
||
}
|
||
}
|
||
|
||
.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: var(--mint-text-muted, #334155);
|
||
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: #065f46;
|
||
}
|
||
|
||
&.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: #e8f4ee;
|
||
color: #065f46;
|
||
}
|
||
|
||
&.badge-primary {
|
||
background: #ecfdf5;
|
||
color: #047857;
|
||
}
|
||
|
||
&.badge-amber {
|
||
background: #fffbeb;
|
||
color: #d97706;
|
||
}
|
||
|
||
&.badge-gray {
|
||
background: #f1f5f9;
|
||
color: var(--mint-text-muted, #334155);
|
||
}
|
||
}
|
||
|
||
.detail-summary {
|
||
margin-left: auto;
|
||
font-size: 12px;
|
||
color: var(--mint-text-muted, #334155);
|
||
}
|
||
|
||
.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 #a8d4c4;
|
||
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: #047857;
|
||
border-style: solid;
|
||
background: #e8f4ee;
|
||
}
|
||
}
|
||
|
||
.upload-card-text {
|
||
font-size: 12px;
|
||
color: #047857;
|
||
font-weight: 500;
|
||
line-height: 1;
|
||
}
|
||
|
||
.upload-hint {
|
||
display: block;
|
||
font-size: 11px;
|
||
color: var(--mint-text-muted, #334155);
|
||
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: #047857;
|
||
box-shadow: 0 2px 8px rgba(4, 120, 87, 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: var(--mint-text-muted, #334155);
|
||
cursor: pointer;
|
||
background: #f1f5f9;
|
||
transition:
|
||
background 0.15s,
|
||
color 0.15s;
|
||
|
||
&:hover {
|
||
background: #047857;
|
||
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;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════
|
||
RESPONSIVE
|
||
═══════════════════════════════════════════ */
|
||
@media (max-width: 900px) {
|
||
.form-grid {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
|
||
&--col2 {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
&--col4 {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
|
||
&--col5,
|
||
&--col6 {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
}
|
||
|
||
.client-sub-cards {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
.installment-amounts-row {
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.installment-amounts-field.el-form-item {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.installment-amounts-summary-row {
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.installment-amounts-cells {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px 16px;
|
||
}
|
||
|
||
.installment-amount-cell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
min-width: 120px;
|
||
flex: 1 1 120px;
|
||
max-width: 180px;
|
||
}
|
||
|
||
.installment-amount-cell-no {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--mint-text-muted, #334155);
|
||
}
|
||
|
||
.installment-amounts-sum {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #065f46;
|
||
|
||
&.is-error {
|
||
color: #dc2626;
|
||
}
|
||
}
|
||
.form-tip--error {
|
||
margin-top: 6px;
|
||
color: #dc2626;
|
||
font-size: 12px;
|
||
}
|
||
|
||
:global(.mother-customer-suggest-popper) {
|
||
min-width: 520px !important;
|
||
width: max-content !important;
|
||
}
|
||
|
||
:global(.mother-customer-suggest-popper .mother-customer-suggest-item) {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
white-space: nowrap;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
:global(.mother-customer-suggest-popper .mother-customer-suggest-item__name) {
|
||
flex: 0 0 72px;
|
||
font-weight: 500;
|
||
color: var(--mint-text, #0f172a);
|
||
}
|
||
|
||
:global(.mother-customer-suggest-popper .mother-customer-suggest-item__phone) {
|
||
flex: 0 0 120px;
|
||
color: var(--mint-text-muted, #64748b);
|
||
}
|
||
|
||
:global(.mother-customer-suggest-popper .mother-customer-suggest-item__idcard) {
|
||
flex: 0 0 auto;
|
||
font-variant-numeric: tabular-nums;
|
||
letter-spacing: 0.02em;
|
||
color: var(--mint-text, #0f172a);
|
||
}
|
||
</style>
|