Files
zkxw/web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue
T
zlwuandCursor b76a7d65be fix(web-admin): hide collector staff on collection protocol UI
Retain silent collectorUserId defaults and edit hydration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 17:45:30 +08:00

4369 lines
147 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<!-- ========== 新增/编辑弹窗 ========== -->
<el-dialog
:model-value="modelValue"
:title="dialogTitle"
width="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>