3266 lines
106 KiB
Vue
3266 lines
106 KiB
Vue
<template>
|
||
<!-- ========== 新增/编辑弹窗 ========== -->
|
||
<el-dialog
|
||
:model-value="modelValue"
|
||
:title="dialogTitle"
|
||
width="1380px"
|
||
class="dialog-form-layout"
|
||
align-center
|
||
destroy-on-close
|
||
:close-on-click-modal="false"
|
||
>
|
||
<DialogFormBody :loading="dialogFormLoading" body-class="protocol-dialog-body min-h-[200px]">
|
||
<div class="detail-tabs">
|
||
<div
|
||
v-for="tab in formTabs"
|
||
:key="tab.key"
|
||
class="detail-tab"
|
||
:class="{ active: activeFormTab === tab.key }"
|
||
@click="scrollToSection(tab.key)"
|
||
>
|
||
{{ tab.label }}
|
||
</div>
|
||
</div>
|
||
|
||
<el-form
|
||
ref="formRef"
|
||
:model="formData"
|
||
:rules="formRules"
|
||
label-position="top"
|
||
scroll-to-error
|
||
class="protocol-form"
|
||
>
|
||
<!-- Tab 1: 基本信息 -->
|
||
<div id="form-section-basic" class="form-tab-pane">
|
||
<div class="section-card">
|
||
<div class="section-title">
|
||
<div class="section-title-left"><h3>基本信息</h3></div>
|
||
</div>
|
||
<div class="form-grid form-grid--col4">
|
||
<el-form-item label="协议状态" prop="protocolStatus">
|
||
<el-select
|
||
v-model="formData.protocolStatus"
|
||
placeholder="请选择"
|
||
style="width: 100%"
|
||
>
|
||
<el-option label="待生效" value="dsx" />
|
||
<el-option label="生效中" value="sxz" />
|
||
<el-option label="已过期" value="ygq" />
|
||
<el-option label="已作废" value="yzf" />
|
||
<el-option label="转科研" value="zky" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="登记日期" prop="registerDate">
|
||
<el-date-picker
|
||
v-model="formData.registerDate"
|
||
type="date"
|
||
value-format="YYYYMMDD"
|
||
format="YYYY-MM-DD"
|
||
placeholder="选择登记日期"
|
||
clearable
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="采集专员" prop="collectorUserId">
|
||
<el-select
|
||
v-model="collectorUserId"
|
||
class="w-full"
|
||
filterable
|
||
remote
|
||
clearable
|
||
reserve-keyword
|
||
:remote-method="remoteSearchCollectorUsers"
|
||
:loading="collectorLoading"
|
||
placeholder="输入姓名检索用户"
|
||
@change="onCollectorUserChange"
|
||
>
|
||
<el-option
|
||
v-for="u in collectorUserOptions"
|
||
:key="u.id"
|
||
:label="u.nickName + (u.phoneNo ? ' · ' + u.phoneNo : '')"
|
||
:value="u.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
</div>
|
||
|
||
<div class="form-grid form-grid--col2 basic-remark-attach-row">
|
||
<el-form-item label="备注">
|
||
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="可选" />
|
||
</el-form-item>
|
||
<el-form-item label="协议附件">
|
||
<div class="attachment-upload">
|
||
<div class="attachment-list">
|
||
<el-upload
|
||
ref="attachmentUploadRef"
|
||
:auto-upload="false"
|
||
:show-file-list="false"
|
||
:multiple="true"
|
||
accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
|
||
@change="handleAttachmentChange"
|
||
>
|
||
<div class="upload-card">
|
||
<el-icon :size="28" color="#10b981"><Plus /></el-icon>
|
||
<span class="upload-card-text">上传附件</span>
|
||
</div>
|
||
</el-upload>
|
||
<div
|
||
v-for="(file, index) in formData.attachments"
|
||
:key="file.id || index"
|
||
class="attachment-item"
|
||
:class="{ 'is-uploading': file._uploading }"
|
||
>
|
||
<div
|
||
class="attachment-preview"
|
||
@click="
|
||
isImageFile(file) && file.fileUrl
|
||
? handlePreviewAttachment(file)
|
||
: undefined
|
||
"
|
||
>
|
||
<template v-if="isImageFile(file) && file.fileUrl">
|
||
<img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
|
||
</template>
|
||
<template v-else-if="isImageFile(file) && !file.fileUrl">
|
||
<el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
|
||
</template>
|
||
<template v-else-if="isVideoFile(file)">
|
||
<div class="attachment-preview__icon attachment-preview__icon--video">
|
||
<el-icon :size="28"><VideoPlay /></el-icon>
|
||
</div>
|
||
</template>
|
||
<template v-else>
|
||
<div class="attachment-preview__icon attachment-preview__icon--doc">
|
||
<el-icon :size="28"><Document /></el-icon>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<div class="attachment-footer">
|
||
<span class="attachment-name" :title="file.fileName">{{
|
||
file.fileName
|
||
}}</span>
|
||
<div class="attachment-actions">
|
||
<el-icon
|
||
class="action-btn"
|
||
title="下载"
|
||
@click="handleDownloadAttachment(file)"
|
||
><Download
|
||
/></el-icon>
|
||
<el-icon
|
||
class="action-btn action-btn--danger"
|
||
title="删除"
|
||
@click="removeAttachment(index)"
|
||
><Delete
|
||
/></el-icon>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<span class="upload-hint">支持图片、视频、PDF、Word、Excel等文件</span>
|
||
</div>
|
||
</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">
|
||
<el-form-item label="采集地区">
|
||
<el-select
|
||
v-model="formData.regionId"
|
||
class="w-full"
|
||
filterable
|
||
remote
|
||
clearable
|
||
reserve-keyword
|
||
:remote-method="remoteSearchRegions"
|
||
:loading="regionLoading"
|
||
placeholder="输入地区名称检索"
|
||
@change="onRegionChange"
|
||
@clear="onRegionClear"
|
||
>
|
||
<el-option
|
||
v-for="r in regionOptions"
|
||
:key="r.id"
|
||
:label="r.regionName"
|
||
:value="r.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="采集医院">
|
||
<el-select
|
||
v-model="formData.hospitalId"
|
||
class="w-full"
|
||
filterable
|
||
remote
|
||
clearable
|
||
reserve-keyword
|
||
:remote-method="remoteSearchHospitals"
|
||
:loading="hospitalLoading"
|
||
:disabled="!formData.regionId"
|
||
placeholder="请先选择采集地区"
|
||
@change="onHospitalChange"
|
||
>
|
||
<el-option
|
||
v-for="h in hospitalOptions"
|
||
:key="h.id"
|
||
:label="h.hospitalName"
|
||
:value="h.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="采集医护">
|
||
<el-input v-model="formData.medicalStaff" placeholder="请输入" />
|
||
</el-form-item>
|
||
<el-form-item label="销售人员">
|
||
<el-select
|
||
v-model="salesUserId"
|
||
class="w-full"
|
||
filterable
|
||
remote
|
||
clearable
|
||
reserve-keyword
|
||
:remote-method="remoteSearchSalesUsers"
|
||
:loading="salesLoading"
|
||
placeholder="输入姓名检索用户"
|
||
@change="onSalesUserChange"
|
||
>
|
||
<el-option
|
||
v-for="u in salesUserOptions"
|
||
:key="u.id"
|
||
:label="u.nickName + (u.phoneNo ? ' · ' + u.phoneNo : '')"
|
||
:value="u.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="样本采集时间">
|
||
<el-date-picker
|
||
v-model="formData.sampleCollectTime"
|
||
type="datetime"
|
||
value-format="YYYY-MM-DD HH:mm:ss"
|
||
placeholder="选择采集时间"
|
||
clearable
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="母血采集时间">
|
||
<el-date-picker
|
||
v-model="formData.maternalBloodCollectTime"
|
||
type="datetime"
|
||
value-format="YYYY-MM-DD HH:mm:ss"
|
||
placeholder="选择采集时间"
|
||
clearable
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab 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-type-options="sampleTypeOptions"
|
||
:sample-code-options="sampleCodeOptions"
|
||
:unit-options="unitOptions"
|
||
:site-options="siteOptions"
|
||
:express-company-options="expressCompanyOptions"
|
||
:show-price="true"
|
||
@price-change="onSamplePriceChange"
|
||
/>
|
||
</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>
|
||
<span class="search-link">关联客户 ▸</span>
|
||
</div>
|
||
<el-form-item label="关联客户" style="margin-bottom: 12px">
|
||
<el-select
|
||
v-model="customerId"
|
||
class="w-full"
|
||
filterable
|
||
remote
|
||
clearable
|
||
reserve-keyword
|
||
:remote-method="remoteSearchCustomers"
|
||
:loading="customerLoading"
|
||
placeholder="输入姓名/电话/身份证号检索客户"
|
||
@change="onCustomerChange"
|
||
>
|
||
<el-option
|
||
v-for="c in customerOptions"
|
||
:key="c.id"
|
||
:label="formatCustomerLabel(c)"
|
||
:value="c.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<div class="form-grid form-grid--col2">
|
||
<el-form-item label="母亲姓名">
|
||
<el-input v-model="formData.motherName" placeholder="请输入" />
|
||
</el-form-item>
|
||
<el-form-item label="母亲联系电话">
|
||
<el-input v-model="formData.motherPhone" placeholder="请输入" />
|
||
</el-form-item>
|
||
<el-form-item label="母亲身份证号" class="form-col-span-2">
|
||
<el-input v-model="formData.motherIdCard" placeholder="请输入" />
|
||
</el-form-item>
|
||
<el-form-item label="母亲出生年月">
|
||
<el-date-picker
|
||
v-model="formData.motherBirthYm"
|
||
type="month"
|
||
value-format="YYYYMM"
|
||
format="YYYY-MM"
|
||
placeholder="选择月份"
|
||
clearable
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="母亲民族">
|
||
<el-select
|
||
v-model="formData.motherEthnic"
|
||
class="w-full"
|
||
filterable
|
||
clearable
|
||
:loading="ethnicLoading"
|
||
placeholder="请选择或检索民族"
|
||
>
|
||
<el-option
|
||
v-for="e in ethnicOptions"
|
||
:key="e.ethnicCode"
|
||
:label="e.ethnicName"
|
||
:value="e.ethnicName"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
</div>
|
||
<el-form-item label="母亲邮箱" style="margin-top: 12px">
|
||
<el-input v-model="formData.motherEmail" placeholder="请输入" />
|
||
</el-form-item>
|
||
<el-form-item label="母亲家庭住址">
|
||
<el-input v-model="formData.motherAddress" placeholder="请输入" />
|
||
</el-form-item>
|
||
</div>
|
||
|
||
<!-- 父亲信息 -->
|
||
<div class="client-sub-card">
|
||
<div class="sub-card-header">
|
||
<span class="icon">👨</span>
|
||
<h4>父亲信息</h4>
|
||
</div>
|
||
<div class="form-grid form-grid--col2">
|
||
<el-form-item label="父亲姓名">
|
||
<el-input v-model="formData.fatherName" placeholder="请输入" />
|
||
</el-form-item>
|
||
<el-form-item label="父亲电话">
|
||
<el-input v-model="formData.fatherPhone" placeholder="请输入" />
|
||
</el-form-item>
|
||
<el-form-item label="父亲身份证号" class="form-col-span-2">
|
||
<el-input v-model="formData.fatherIdCard" placeholder="请输入" />
|
||
</el-form-item>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 新生儿信息 -->
|
||
<div class="client-sub-card">
|
||
<div class="sub-card-header">
|
||
<span class="icon">👶</span>
|
||
<h4>新生儿信息</h4>
|
||
</div>
|
||
<div class="form-grid form-grid--col2">
|
||
<el-form-item label="新生儿姓名">
|
||
<el-input v-model="formData.newbornName" placeholder="请输入" />
|
||
</el-form-item>
|
||
<el-form-item label="新生儿性别">
|
||
<el-radio-group v-model="formData.newbornSex">
|
||
<el-radio label="1">男</el-radio>
|
||
<el-radio label="0">女</el-radio>
|
||
</el-radio-group>
|
||
</el-form-item>
|
||
<el-form-item label="预产期">
|
||
<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="新生儿评分1分钟">
|
||
<el-input
|
||
v-model="formData.apgar1min"
|
||
type="number"
|
||
step="any"
|
||
:min="0"
|
||
:max="10"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="新生儿评分5分钟">
|
||
<el-input
|
||
v-model="formData.apgar5min"
|
||
type="number"
|
||
step="any"
|
||
:min="0"
|
||
:max="10"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab 5: 缴费信息 -->
|
||
<div id="form-section-payment" class="form-tab-pane">
|
||
<div class="section-card">
|
||
<div class="section-title">
|
||
<div class="section-title-left"><h3>缴费信息</h3></div>
|
||
</div>
|
||
|
||
<!-- 缴费主表 -->
|
||
<div class="payment-main-card">
|
||
<div class="section-subtitle">缴费主表</div>
|
||
<div class="form-grid form-grid--col5">
|
||
<el-form-item label="缴费模式">
|
||
<el-radio-group v-model="formData.payMode" @change="onPayModeChange">
|
||
<el-radio label="dj">趸交</el-radio>
|
||
<el-radio label="fq">分期</el-radio>
|
||
</el-radio-group>
|
||
</el-form-item>
|
||
<el-form-item label="合同金额(元)">
|
||
<el-input
|
||
v-model="formData.contractAmount"
|
||
disabled
|
||
placeholder="自动计算"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<template v-if="formData.payMode === 'dj'">
|
||
<el-form-item label="趸交截止时间">
|
||
<el-date-picker
|
||
v-model="formData.lumpSumDeadline"
|
||
type="date"
|
||
value-format="YYYYMMDD"
|
||
format="YYYY-MM-DD"
|
||
placeholder="选择截止时间"
|
||
clearable
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
</template>
|
||
<template v-if="formData.payMode === 'fq'">
|
||
<el-form-item label="分期期数">
|
||
<el-input
|
||
v-model="formData.installmentCount"
|
||
type="number"
|
||
step="any"
|
||
:min="1"
|
||
style="width: 100%"
|
||
@change="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>
|
||
<div class="installment-advance-ratios-row form-col-span-2">
|
||
<el-form-item label="应缴费提前(天)" class="installment-advance-field">
|
||
<el-input
|
||
v-model="formData.installmentAdvanceDays"
|
||
type="number"
|
||
:min="0"
|
||
:max="365"
|
||
step="1"
|
||
style="width: 120px"
|
||
@change="handleFqParamsChange"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="缴费比例(%)" class="installment-ratios-field">
|
||
<div class="installment-ratios-panel">
|
||
<div class="installment-ratios-bar" aria-hidden="true">
|
||
<div
|
||
v-for="(ratio, rIdx) in formData.installmentRatios"
|
||
:key="'bar-' + rIdx"
|
||
class="installment-ratios-bar-seg"
|
||
:class="{ 'is-auto': rIdx === formData.installmentRatios.length - 1 }"
|
||
:style="{ flex: Math.max(Number(ratio) || 0, 0.001) }"
|
||
:title="`第${rIdx + 1}期 ${ratio}%`"
|
||
/>
|
||
</div>
|
||
<div class="installment-ratios-cells">
|
||
<div
|
||
v-for="(_ratio, rIdx) in formData.installmentRatios"
|
||
:key="rIdx"
|
||
class="installment-ratio-cell"
|
||
:class="{ 'is-auto': rIdx === formData.installmentRatios.length - 1 }"
|
||
>
|
||
<span class="installment-ratio-cell-no">{{ rIdx + 1 }}期</span>
|
||
<el-input
|
||
v-model="formData.installmentRatios[rIdx]"
|
||
type="number"
|
||
:min="0"
|
||
:max="100"
|
||
step="any"
|
||
:disabled="rIdx === formData.installmentRatios.length - 1"
|
||
@change="onInstallmentRatioChange(rIdx)"
|
||
>
|
||
<template #suffix>%</template>
|
||
</el-input>
|
||
</div>
|
||
</div>
|
||
<div class="installment-ratios-foot">
|
||
<span class="form-label-tip">末期为自动补齐</span>
|
||
<span
|
||
class="installment-ratios-sum"
|
||
:class="{ 'is-error': !!installmentRatiosError }"
|
||
>
|
||
合计 {{ installmentRatiosSum }}%
|
||
</span>
|
||
</div>
|
||
<div v-if="installmentRatiosError" class="form-tip form-tip--error">
|
||
{{ installmentRatiosError }}
|
||
</div>
|
||
</div>
|
||
</el-form-item>
|
||
</div>
|
||
</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-amber': detail.payStatus === 'dfj' || detail.payStatus === 'jfqrz',
|
||
'badge-gray': detail.payStatus === 'tfqrz' || detail.payStatus === 'ytf'
|
||
}"
|
||
>
|
||
{{ payStatusLabel(String(detail.payStatus)) }}
|
||
</span>
|
||
<span v-if="!detail._expanded" class="detail-summary">
|
||
{{ paymentDetailSummary(detail) }}
|
||
</span>
|
||
<el-button
|
||
v-if="!isPayDetailLocked(detail)"
|
||
link
|
||
type="danger"
|
||
size="small"
|
||
class="detail-delete"
|
||
@click.stop="formData.paymentDetails.splice(dIdx, 1)"
|
||
>
|
||
<el-icon><Delete /></el-icon>
|
||
</el-button>
|
||
</div>
|
||
<div v-show="detail._expanded" class="detail-body">
|
||
<div class="detail-body-inner">
|
||
<div class="form-grid form-grid--col4">
|
||
<el-form-item label="应缴费金额(元)">
|
||
<el-input
|
||
v-model="detail.dueAmount"
|
||
type="number"
|
||
step="any"
|
||
:min="0"
|
||
disabled
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="转账方式">
|
||
<el-select
|
||
v-model="detail.transferType"
|
||
placeholder="请选择转账方式"
|
||
style="width: 100%"
|
||
>
|
||
<el-option label="微信" value="wxzz" />
|
||
<el-option label="支付宝" value="zzbzz" />
|
||
<el-option label="银行卡" value="yhkzz" />
|
||
<el-option label="现金" value="xj" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="转账金额(元)">
|
||
<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"
|
||
value-format="YYYY-MM-DD HH:mm:ss"
|
||
placeholder="选择时间"
|
||
clearable
|
||
:disabled="isPayDetailLocked(detail)"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="缴费状态">
|
||
<el-select
|
||
v-model="detail.payStatus"
|
||
placeholder="状态"
|
||
:disabled="isPayDetailLocked(detail)"
|
||
style="width: 100%"
|
||
>
|
||
<el-option label="待缴费" value="dfj" />
|
||
<el-option label="缴费确认中" value="jfqrz" />
|
||
<el-option label="已缴费" value="yjf" />
|
||
<el-option label="退费确认中" value="tfqrz" />
|
||
<el-option label="已退费" value="ytf" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="应缴费时间">
|
||
<el-date-picker
|
||
v-model="detail.installmentDueDate"
|
||
type="date"
|
||
value-format="YYYYMMDD"
|
||
format="YYYY-MM-DD"
|
||
placeholder="选择应缴费时间"
|
||
clearable
|
||
:disabled="isPayDetailLocked(detail)"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
</div>
|
||
<!-- 每条明细的附件上传区 -->
|
||
<div v-if="!isPayDetailLocked(detail)" class="payment-detail-card__attachments">
|
||
<div class="attachment-upload">
|
||
<div class="attachment-list">
|
||
<el-upload
|
||
:auto-upload="false"
|
||
:show-file-list="false"
|
||
:multiple="true"
|
||
accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
|
||
@change="(f: any) => handleDetailAttachmentChange(dIdx, f)"
|
||
>
|
||
<div class="upload-card upload-card--sm">
|
||
<el-icon :size="22" color="#10b981"><Plus /></el-icon>
|
||
<span class="upload-card-text">上传凭证</span>
|
||
</div>
|
||
</el-upload>
|
||
<div
|
||
v-for="(file, fIdx) in detail.attachments"
|
||
:key="file.id || fIdx"
|
||
class="attachment-item"
|
||
:class="{ 'is-uploading': file._uploading }"
|
||
>
|
||
<div
|
||
class="attachment-preview"
|
||
@click="
|
||
isImageFile(file) && file.fileUrl
|
||
? handlePreviewAttachment(file)
|
||
: undefined
|
||
"
|
||
>
|
||
<template v-if="isImageFile(file) && file.fileUrl">
|
||
<img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
|
||
</template>
|
||
<template v-else-if="isImageFile(file) && !file.fileUrl">
|
||
<el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
|
||
</template>
|
||
<template v-else-if="isVideoFile(file)">
|
||
<div
|
||
class="attachment-preview__icon attachment-preview__icon--video"
|
||
>
|
||
<el-icon :size="28"><VideoPlay /></el-icon>
|
||
</div>
|
||
</template>
|
||
<template v-else>
|
||
<div class="attachment-preview__icon attachment-preview__icon--doc">
|
||
<el-icon :size="28"><Document /></el-icon>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<div class="attachment-footer">
|
||
<span class="attachment-name" :title="file.fileName">{{
|
||
file.fileName
|
||
}}</span>
|
||
<div class="attachment-actions">
|
||
<el-icon
|
||
class="action-btn"
|
||
title="下载"
|
||
@click="handleDownloadAttachment(file)"
|
||
><Download
|
||
/></el-icon>
|
||
<el-icon
|
||
class="action-btn action-btn--danger"
|
||
title="删除"
|
||
@click="removeDetailAttachment(dIdx, fIdx)"
|
||
><Delete
|
||
/></el-icon>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<span class="upload-hint">支持图片、视频、PDF、Word、Excel等文件</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<el-empty
|
||
v-if="!formData.paymentDetails.length"
|
||
description="暂无缴费明细,点击上方按钮添加"
|
||
:image-size="48"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-form>
|
||
</DialogFormBody>
|
||
|
||
<template #footer>
|
||
<el-button class="btn-ghost" @click="emit('update:modelValue', false)">
|
||
<el-icon><Close /></el-icon>
|
||
取消
|
||
</el-button>
|
||
<el-button class="btn-main" type="primary" :loading="submitLoading" @click="handleSubmit">
|
||
<el-icon><Check /></el-icon>
|
||
确定
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- ========== 文件预览弹窗 ========== -->
|
||
<el-dialog v-model="previewVisible" title="文件预览" width="800px" align-center destroy-on-close>
|
||
<div v-if="previewType === 'image'" class="preview-container">
|
||
<img
|
||
:src="previewUrl"
|
||
alt="预览"
|
||
style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
|
||
/>
|
||
</div>
|
||
<div v-else class="preview-container">
|
||
<video
|
||
:src="previewUrl"
|
||
controls
|
||
style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
|
||
/>
|
||
</div>
|
||
</el-dialog>
|
||
</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,
|
||
type CollectionProtocolRow
|
||
} from '@/api/collection-protocol'
|
||
import { fetchUploadAttachment } from '@/api/business'
|
||
import { downloadAttachmentFile } from '@/utils/download-attachment'
|
||
import {
|
||
fetchCustomerList,
|
||
fetchUserList,
|
||
fetchUserDetail,
|
||
fetchCellSampleTypeList,
|
||
fetchItemUnitList,
|
||
fetchCollectionRegionList,
|
||
fetchHospitalList,
|
||
fetchSampleCodeList,
|
||
fetchExpressCompanyList,
|
||
fetchSiteList
|
||
} from '@/api/admin'
|
||
import { fetchEthnicList, type EthnicOption } from '@/api/ethnic'
|
||
import { useUserStore } from '@/store/modules/user'
|
||
import {
|
||
calcInstallmentDueAmountsByRatios,
|
||
calcUnpaidAmountsByRatios,
|
||
DEFAULT_INSTALLMENT_ADVANCE_DAYS,
|
||
DEFAULT_INSTALLMENT_RATIOS_6,
|
||
defaultInstallmentRatios,
|
||
finalizeInstallmentRatios,
|
||
isInstallmentRatiosValid,
|
||
resizeInstallmentRatios,
|
||
syncFqPaymentDetailRowCount
|
||
} from '@/utils/protocol-payment-installment'
|
||
import {
|
||
applyPayModeSwitch,
|
||
canAddPaymentDetail,
|
||
evaluatePayModeSwitch,
|
||
isPayDetailLocked,
|
||
markPayDetailLocks,
|
||
sumPaidAmount,
|
||
type PayMode
|
||
} from '@/utils/protocol-pay-mode-switch'
|
||
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,
|
||
transferTypeLabel,
|
||
payStatusLabel
|
||
} from '@/utils/status-tag-registry'
|
||
|
||
/** 金额只读展示:千分位 + ¥ 前缀 */
|
||
function fmtMoney(v: unknown): string {
|
||
if (v == null || v === '') return '—'
|
||
return '¥' + formatMoneyDisplay(v)
|
||
}
|
||
|
||
const userStore = useUserStore()
|
||
|
||
/** 上次确认生效的缴费模式(用于切换取消/拦截时回滚) */
|
||
const lastConfirmedPayMode = ref<PayMode>('dj')
|
||
|
||
/** 上次确认生效的分期参数(用于编辑时修改期数/间隔/开始时间回滚) */
|
||
const lastConfirmedFqParams = ref<FqParams>({
|
||
installmentCount: null,
|
||
installmentIntervalMonths: null,
|
||
installmentStartDate: '',
|
||
installmentAdvanceDays: DEFAULT_INSTALLMENT_ADVANCE_DAYS,
|
||
installmentRatios: [...DEFAULT_INSTALLMENT_RATIOS_6]
|
||
})
|
||
|
||
const customerId = ref<string | null>(null)
|
||
const customerOptions = ref<
|
||
Array<{ id: string; customerName: string; phoneNo: string; idCardNo: string }>
|
||
>([])
|
||
const customerLoading = ref(false)
|
||
const customerSnapshot = ref<{
|
||
id: string
|
||
customerName: string
|
||
phoneNo: string
|
||
idCardNo: string
|
||
} | null>(null)
|
||
|
||
const regionOptions = ref<Api.BizCollectionRegion.RegionItem[]>([])
|
||
const regionLoading = ref(false)
|
||
const hospitalOptions = ref<Api.BizCollectionHospital.HospitalItem[]>([])
|
||
const hospitalLoading = ref(false)
|
||
|
||
const collectorUserId = ref<string | null>(null)
|
||
const collectorUserOptions = ref<Api.User.UserItem[]>([])
|
||
const collectorLoading = ref(false)
|
||
const collectorSnapshot = ref<Api.User.UserItem | null>(null)
|
||
|
||
const salesUserId = ref<string | null>(null)
|
||
const salesUserOptions = ref<Api.User.UserItem[]>([])
|
||
const salesLoading = ref(false)
|
||
const salesSnapshot = ref<Api.User.UserItem | null>(null)
|
||
|
||
const sampleTypeOptions = ref<
|
||
Array<{
|
||
id: string
|
||
typeCode: string
|
||
typeName: string
|
||
storageYears: number | null
|
||
storagePrice: number | null
|
||
}>
|
||
>([])
|
||
const unitOptions = ref<Array<{ id: string; unitName: string; unitCode: string }>>([])
|
||
const sampleCodeOptions = ref<Api.BizSampleCode.SampleCodeItem[]>([])
|
||
const sampleCodeLoading = 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: '缴费信息' }
|
||
] as const
|
||
|
||
/* ====== 新增/编辑弹窗 ====== */
|
||
const dialogFormLoading = ref(false)
|
||
const dialogTitle = ref('新增协议单')
|
||
const isEdit = ref(false)
|
||
const submitLoading = ref(false)
|
||
const editingId = ref<string | null>(null)
|
||
const formRef = ref()
|
||
const activeFormTab = ref('basic')
|
||
|
||
const formData = reactive({
|
||
// Basic
|
||
protocolNo: '',
|
||
protocolNoId: '',
|
||
protocolStatus: 'dsx',
|
||
registerDate: '',
|
||
collectorUserId: '',
|
||
protocolAttachments: '',
|
||
remark: '',
|
||
attachments: [] as Array<{
|
||
id?: string
|
||
fileName: string
|
||
fileUrl: string
|
||
fileSize: number
|
||
fileSizeDisplay?: string
|
||
fileType?: string
|
||
_uploading?: boolean
|
||
}>,
|
||
// Party Customer
|
||
customerId: '' as string,
|
||
motherName: '',
|
||
motherPhone: '',
|
||
motherIdCard: '',
|
||
motherBirthYm: '',
|
||
motherEthnic: '',
|
||
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: '',
|
||
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: [...DEFAULT_INSTALLMENT_RATIOS_6] as number[],
|
||
// Payment Details
|
||
paymentDetails: [] as Array<Record<string, any>>
|
||
})
|
||
|
||
const formRules = {
|
||
protocolStatus: [{ required: true, message: '请选择协议状态', trigger: 'change' }]
|
||
}
|
||
|
||
function resetForm() {
|
||
Object.assign(formData, {
|
||
protocolNo: '',
|
||
protocolNoId: '',
|
||
protocolStatus: 'dsx',
|
||
registerDate: '',
|
||
collectorUserId: '',
|
||
protocolAttachments: '',
|
||
attachments: [],
|
||
remark: '',
|
||
customerId: '',
|
||
motherName: '',
|
||
motherPhone: '',
|
||
motherIdCard: '',
|
||
motherBirthYm: '',
|
||
motherEthnic: '',
|
||
motherEmail: '',
|
||
motherAddress: '',
|
||
fatherName: '',
|
||
fatherPhone: '',
|
||
fatherIdCard: '',
|
||
newbornName: '',
|
||
newbornIdCard: '',
|
||
expectedDeliveryDate: '',
|
||
newbornDeliveryTime: '',
|
||
newbornSex: '',
|
||
deliveryMode: '',
|
||
birthOrder: null,
|
||
newbornWeightGram: null,
|
||
apgar1min: null,
|
||
apgar5min: null,
|
||
regionId: '',
|
||
hospitalId: '',
|
||
medicalStaff: '',
|
||
salesUserId: '',
|
||
sampleCollectTime: '',
|
||
maternalBloodCollectTime: '',
|
||
samples: [],
|
||
payMode: 'dj',
|
||
contractAmount: null,
|
||
installmentCount: null,
|
||
installmentIntervalMonths: null,
|
||
lumpSumDeadline: '',
|
||
installmentStartDate: '',
|
||
installmentAdvanceDays: DEFAULT_INSTALLMENT_ADVANCE_DAYS,
|
||
installmentRatios: [...DEFAULT_INSTALLMENT_RATIOS_6],
|
||
paymentDetails: []
|
||
})
|
||
customerId.value = null
|
||
customerOptions.value = []
|
||
customerSnapshot.value = null
|
||
collectorUserId.value = null
|
||
collectorUserOptions.value = []
|
||
collectorSnapshot.value = null
|
||
salesUserId.value = null
|
||
salesUserOptions.value = []
|
||
salesSnapshot.value = null
|
||
regionOptions.value = []
|
||
hospitalOptions.value = []
|
||
activeFormTab.value = 'basic'
|
||
lastConfirmedPayMode.value = 'dj'
|
||
lastConfirmedFqParams.value = {
|
||
installmentCount: null,
|
||
installmentIntervalMonths: null,
|
||
installmentStartDate: '',
|
||
installmentAdvanceDays: DEFAULT_INSTALLMENT_ADVANCE_DAYS,
|
||
installmentRatios: [...DEFAULT_INSTALLMENT_RATIOS_6]
|
||
}
|
||
}
|
||
|
||
function scrollToSection(key: string) {
|
||
activeFormTab.value = key
|
||
const el = document.getElementById(`form-section-${key}`)
|
||
if (el) {
|
||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||
}
|
||
}
|
||
|
||
function normalizeSampleRow(raw: Record<string, any>, _index: number) {
|
||
const transportRaw = raw.transport && typeof raw.transport === 'object' ? raw.transport : {}
|
||
const statusRaw = raw.sampleStatus
|
||
const sampleStatus =
|
||
statusRaw != null && String(statusRaw).trim() !== '' ? String(statusRaw).trim() : ''
|
||
return {
|
||
...raw,
|
||
sampleStatus,
|
||
_rowKey: raw.id || raw._rowKey || `new-${++sampleRowSeq}`,
|
||
transport: {
|
||
...createEmptyTransport(),
|
||
transportType: transportRaw.transportType || '',
|
||
expressCompanyId: transportRaw.expressCompanyId || '',
|
||
waybillNo: transportRaw.waybillNo || '',
|
||
destSiteId: transportRaw.destSiteId || '',
|
||
expressFee: transportRaw.expressFee ?? null,
|
||
etaTime: transportRaw.etaTime || '',
|
||
carrierName: transportRaw.carrierName || '',
|
||
carrierPhone: transportRaw.carrierPhone || ''
|
||
}
|
||
}
|
||
}
|
||
|
||
function 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
|
||
}
|
||
|
||
/**
|
||
* 分期默认:期数、每期间隔均为 6;开始时间为空时按分娩/预产期/今日填充。
|
||
* resetCountInterval:切换到分期时强制重置期数与间隔(与新增一致)。
|
||
*/
|
||
function applyFqPaymentDefaults(options?: { resetCountInterval?: boolean }) {
|
||
const reset = options?.resetCountInterval ?? false
|
||
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
|
||
}
|
||
const count = toIntOrUndefined(formData.installmentCount) || 6
|
||
if (reset || !formData.installmentRatios?.length) {
|
||
formData.installmentRatios = defaultInstallmentRatios(count)
|
||
} else {
|
||
formData.installmentRatios = finalizeInstallmentRatios(
|
||
resizeInstallmentRatios(formData.installmentRatios, count)
|
||
)
|
||
}
|
||
}
|
||
|
||
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: finalizeInstallmentRatios([...(formData.installmentRatios || [])])
|
||
}
|
||
}
|
||
|
||
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') 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 (toMode === 'fq') {
|
||
applyFqPaymentDefaults({ resetCountInterval: false })
|
||
}
|
||
|
||
formData.paymentDetails = applyPayModeSwitch(
|
||
fromMode,
|
||
toMode,
|
||
formData.paymentDetails,
|
||
payModeSwitchContext()
|
||
)
|
||
initPaymentDetailExpandFlags(formData.paymentDetails)
|
||
markPayDetailLocks(formData.paymentDetails)
|
||
|
||
if (toMode === 'dj') {
|
||
syncLumpSumDueDate()
|
||
}
|
||
|
||
lastConfirmedPayMode.value = toMode
|
||
if (toMode === 'fq') {
|
||
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.motherEthnic = DEFAULT_MOTHER_ETHNIC
|
||
formData.registerDate = new Date().toISOString().slice(0, 10).replace(/-/g, '')
|
||
loadSampleOptions()
|
||
void loadEthnicOptions()
|
||
void remoteSearchCustomers('')
|
||
|
||
// 默认采集专员、销售人员为当前登录人
|
||
const currentUser = userStore.getUserInfo
|
||
const uid = String(currentUser?.id || currentUser?.userId || '')
|
||
if (uid) {
|
||
const displayName = currentUser?.realName || currentUser?.username || ''
|
||
const phone = currentUser?.phone || ''
|
||
const userStub = {
|
||
id: uid,
|
||
nickName: displayName,
|
||
phoneNo: phone,
|
||
deptId: '',
|
||
userNo: '',
|
||
email: '',
|
||
sex: '',
|
||
avatar: '',
|
||
status: '1',
|
||
tenantNo: '',
|
||
orderNum: 0,
|
||
remark: '',
|
||
createTime: '',
|
||
updateTime: ''
|
||
} as Api.User.UserItem
|
||
|
||
// 采集专员默认当前用户
|
||
collectorUserId.value = uid
|
||
formData.collectorUserId = uid
|
||
collectorSnapshot.value = { ...userStub }
|
||
collectorUserOptions.value = [{ ...userStub }]
|
||
|
||
// 销售人员默认当前用户
|
||
salesUserId.value = uid
|
||
formData.salesUserId = uid
|
||
salesSnapshot.value = { ...userStub }
|
||
salesUserOptions.value = [{ ...userStub }]
|
||
}
|
||
}
|
||
|
||
function applyProtocolBundleToForm(bundle: Record<string, any>, row: CollectionProtocolRow) {
|
||
const prot = bundle.protocol as Record<string, any> | null
|
||
if (prot) {
|
||
formData.collectorUserId = String(prot.collectorUserId || formData.collectorUserId || '')
|
||
formData.protocolNo = String(prot.protocolNo || formData.protocolNo || '')
|
||
formData.protocolStatus = String(prot.protocolStatus || formData.protocolStatus || 'dsx')
|
||
formData.registerDate = String(prot.registerDate || formData.registerDate || '')
|
||
formData.remark = String(prot.remark ?? formData.remark ?? '')
|
||
}
|
||
|
||
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 || '')
|
||
}
|
||
|
||
const ci = bundle.collectionInfo as Record<string, any> | null
|
||
if (ci) {
|
||
formData.regionId = String(ci.regionId || '')
|
||
formData.hospitalId = String(ci.hospitalId || '')
|
||
formData.medicalStaff = String(ci.medicalStaff || '')
|
||
formData.salesUserId = String(ci.salesUserId || '')
|
||
formData.sampleCollectTime = String(ci.sampleCollectTime || '')
|
||
formData.maternalBloodCollectTime = String(ci.maternalBloodCollectTime || '')
|
||
}
|
||
|
||
const samples = bundle.samples as Array<Record<string, any>> | null
|
||
if (samples && samples.length) {
|
||
formData.samples = samples.map((s, i) => normalizeSampleRow(s, i))
|
||
}
|
||
|
||
const pay = bundle.payment as Record<string, any> | null
|
||
if (pay) {
|
||
formData.payMode = String(pay.payMode || 'dj')
|
||
formData.contractAmount = pay.contractAmount ?? null
|
||
formData.lumpSumDeadline = String(pay.lumpSumDeadline || '')
|
||
formData.installmentCount = pay.installmentCount ?? null
|
||
formData.installmentIntervalMonths = pay.installmentIntervalMonths ?? null
|
||
formData.installmentStartDate = String(pay.installmentStartDate || '')
|
||
formData.installmentAdvanceDays =
|
||
pay.installmentAdvanceDays ?? DEFAULT_INSTALLMENT_ADVANCE_DAYS
|
||
const loadedRatios = Array.isArray(pay.installmentRatios) ? pay.installmentRatios : []
|
||
if (loadedRatios.length) {
|
||
formData.installmentRatios = loadedRatios.map((v: unknown) => Number(v))
|
||
}
|
||
if (formData.payMode === 'fq') {
|
||
applyFqPaymentDefaults()
|
||
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 = (formData.payMode === 'fq' ? 'fq' : '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
|
||
}
|
||
|
||
syncContractAmount()
|
||
}
|
||
|
||
async function handleOpenForEdit() {
|
||
const row = props.listRow
|
||
if (!row || !props.editId) return
|
||
|
||
isEdit.value = true
|
||
dialogTitle.value = '编辑协议单'
|
||
editingId.value = props.editId
|
||
resetForm()
|
||
|
||
formData.protocolNo = String(row.protocolNo || '')
|
||
formData.protocolStatus = String(row.protocolStatus || 'dsx')
|
||
formData.registerDate = String(row.registerDate || '')
|
||
formData.collectorUserId = String(row.collectorUserId || '')
|
||
formData.protocolAttachments = String((row as any).protocolAttachments || '')
|
||
formData.remark = String(row.remark || '')
|
||
|
||
dialogFormLoading.value = true
|
||
|
||
try {
|
||
const [, , bundle] = await Promise.all([
|
||
loadSampleOptions(),
|
||
loadEthnicOptions(),
|
||
fetchCollectionProtocolDetail(String(row.id)).catch(() => null)
|
||
])
|
||
if (bundle) {
|
||
applyProtocolBundleToForm(bundle as Record<string, any>, row)
|
||
}
|
||
|
||
const hydrateTasks: Promise<void>[] = [
|
||
hydrateCollectorUserForEdit(
|
||
formData.collectorUserId,
|
||
String((row as any).collectorUserName || '')
|
||
),
|
||
hydrateSalesUserForEdit(formData.salesUserId, String((row as any).salesUserName || '')),
|
||
hydrateCustomerForEdit(
|
||
formData.customerId,
|
||
formData.motherName,
|
||
formData.motherPhone,
|
||
formData.motherIdCard
|
||
)
|
||
]
|
||
if (formData.regionId) {
|
||
hydrateTasks.push(
|
||
ensureRegionOptionForEdit(formData.regionId, String((row as any).regionName || ''))
|
||
)
|
||
}
|
||
if (formData.regionId && formData.hospitalId) {
|
||
hydrateTasks.push(
|
||
ensureHospitalOptionForEdit(
|
||
formData.regionId,
|
||
formData.hospitalId,
|
||
String((row as any).hospitalName || '')
|
||
)
|
||
)
|
||
}
|
||
await Promise.all(hydrateTasks)
|
||
if (formData.samples.length) {
|
||
ensureSampleCodesInOptions(formData.samples)
|
||
}
|
||
} catch {
|
||
/* 保留列表行已填基础字段 */
|
||
} finally {
|
||
dialogFormLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function handleSubmit() {
|
||
if (!formRef.value) return
|
||
await formRef.value.validate()
|
||
if (formData.payMode === 'fq') {
|
||
if (installmentRatiosError.value) {
|
||
await ElMessageBox.alert(installmentRatiosError.value, '缴费比例无效', { type: 'warning' })
|
||
return
|
||
}
|
||
const ratios = finalizeInstallmentRatios([...(formData.installmentRatios || [])])
|
||
if (!isInstallmentRatiosValid(ratios)) {
|
||
await ElMessageBox.alert('缴费比例之和须等于 100%', '缴费比例无效', { type: 'warning' })
|
||
return
|
||
}
|
||
}
|
||
if (!isEdit.value) {
|
||
for (const sampleRow of formData.samples) {
|
||
if (!sampleRow.sampleCodeId) continue
|
||
const sc = sampleCodeOptions.value.find((x) => x.id === sampleRow.sampleCodeId)
|
||
if (sc?.usedInProtocol) {
|
||
ElMessage.warning(`样本编码「${sampleRow.sampleCode || sc.sampleCode}」已被占用,请更换`)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
submitLoading.value = true
|
||
try {
|
||
syncFqPaymentDetailRowCount(fqPaymentContext(), formData.paymentDetails)
|
||
syncPaymentDueAmounts()
|
||
const protocolAttachmentUrls = formData.attachments
|
||
.map((a) => a.fileUrl)
|
||
.filter(Boolean)
|
||
.join(',')
|
||
const payload: Record<string, unknown> = {
|
||
protocolStatus: formData.protocolStatus,
|
||
registerDate: formData.registerDate || undefined,
|
||
collectorUserId: formData.collectorUserId || undefined,
|
||
protocolAttachments: protocolAttachmentUrls || formData.protocolAttachments || undefined,
|
||
attachments: formData.attachments.map((a) => ({
|
||
id: a.id,
|
||
fileName: a.fileName,
|
||
fileUrl: a.fileUrl,
|
||
fileSize: a.fileSize,
|
||
fileSizeDisplay: a.fileSizeDisplay,
|
||
fileType: a.fileType
|
||
})),
|
||
remark: formData.remark || undefined,
|
||
partyCustomer: {
|
||
customerId: formData.customerId || undefined,
|
||
motherName: formData.motherName || undefined,
|
||
motherPhone: formData.motherPhone || undefined,
|
||
motherIdCard: formData.motherIdCard || undefined,
|
||
motherBirthYm: formData.motherBirthYm || undefined,
|
||
motherEthnic: formData.motherEthnic || undefined,
|
||
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,
|
||
sampleCollectTime: formData.sampleCollectTime || undefined,
|
||
maternalBloodCollectTime: formData.maternalBloodCollectTime || undefined
|
||
},
|
||
samples: formData.samples.map(({ _rowKey, transport, ...s }) => ({
|
||
...s,
|
||
sampleStatus:
|
||
s.sampleStatus != null && String(s.sampleStatus).trim() !== ''
|
||
? String(s.sampleStatus).trim()
|
||
: null,
|
||
transport: transport?.transportType ? { ...transport } : undefined
|
||
})),
|
||
payment: {
|
||
payMode: formData.payMode,
|
||
contractAmount: formData.contractAmount ?? undefined,
|
||
lumpSumDeadline: formData.lumpSumDeadline || undefined,
|
||
installmentCount: toIntOrUndefined(formData.installmentCount),
|
||
installmentIntervalMonths: toFloatOrUndefined(formData.installmentIntervalMonths),
|
||
installmentStartDate: formData.installmentStartDate || undefined,
|
||
installmentAdvanceDays: toIntOrUndefined(formData.installmentAdvanceDays),
|
||
installmentRatios:
|
||
formData.payMode === 'fq'
|
||
? finalizeInstallmentRatios([...(formData.installmentRatios || [])])
|
||
: undefined
|
||
},
|
||
paymentDetails: formData.paymentDetails.map(
|
||
({ _expanded, _payModeLocked, _voided, ...d }) => ({
|
||
id: d.id,
|
||
transferType: d.transferType,
|
||
dueAmount: d.dueAmount,
|
||
transferAmount: d.transferAmount,
|
||
transferTime: d.transferTime,
|
||
payStatus: d.payStatus,
|
||
installmentNo: toIntOrUndefined(d.installmentNo),
|
||
installmentDueDate: d.installmentDueDate,
|
||
remark: d.remark,
|
||
financeRemark: d.financeRemark,
|
||
financeConfirmAttachments: d.financeConfirmAttachments,
|
||
financeConfirmAttachmentFiles: d.financeConfirmAttachmentFiles,
|
||
refundAmount: d.refundAmount,
|
||
refundRemark: d.refundRemark,
|
||
refundAttachments: d.refundAttachments,
|
||
refundAttachmentFiles: d.refundAttachmentFiles,
|
||
refundFinanceRemark: d.refundFinanceRemark,
|
||
refundFinanceConfirmAttachments: d.refundFinanceConfirmAttachments,
|
||
refundFinanceConfirmAttachmentFiles: d.refundFinanceConfirmAttachmentFiles,
|
||
attachments: (d.attachments || []).map((a: any) => ({
|
||
id: a.id,
|
||
fileName: a.fileName,
|
||
fileUrl: a.fileUrl,
|
||
fileSize: a.fileSize,
|
||
fileSizeDisplay: a.fileSizeDisplay,
|
||
fileType: a.fileType
|
||
}))
|
||
})
|
||
)
|
||
}
|
||
|
||
if (isEdit.value && editingId.value) {
|
||
await fetchUpdateCollectionProtocol(editingId.value, payload)
|
||
} else {
|
||
await fetchCreateCollectionProtocol(payload)
|
||
}
|
||
emit('update:modelValue', false)
|
||
emit('saved')
|
||
} finally {
|
||
submitLoading.value = false
|
||
}
|
||
}
|
||
|
||
function createUserStub(id: string, nickName: string, phoneNo = ''): Api.User.UserItem {
|
||
return {
|
||
id,
|
||
nickName: nickName || id,
|
||
phoneNo,
|
||
deptId: '',
|
||
userNo: '',
|
||
email: '',
|
||
sex: '',
|
||
avatar: '',
|
||
status: '1',
|
||
tenantNo: '',
|
||
orderNum: 0,
|
||
remark: '',
|
||
createTime: '',
|
||
updateTime: ''
|
||
}
|
||
}
|
||
|
||
async function hydrateRemoteUserForEdit(
|
||
userId: string,
|
||
displayName: string | undefined,
|
||
assign: (user: Api.User.UserItem) => void,
|
||
clear: () => void
|
||
) {
|
||
const uid = (userId || '').trim()
|
||
if (!uid) {
|
||
clear()
|
||
return
|
||
}
|
||
try {
|
||
const detail = await fetchUserDetail(uid)
|
||
assign(detail)
|
||
return
|
||
} catch {
|
||
/* 回退为列表/占位展示名 */
|
||
}
|
||
assign(createUserStub(uid, (displayName || '').trim()))
|
||
}
|
||
|
||
async function hydrateCollectorUserForEdit(userId: string, displayName?: string) {
|
||
await hydrateRemoteUserForEdit(
|
||
userId,
|
||
displayName,
|
||
(user) => {
|
||
collectorSnapshot.value = { ...user }
|
||
collectorUserId.value = user.id
|
||
collectorUserOptions.value = [{ ...user }]
|
||
formData.collectorUserId = user.id
|
||
},
|
||
() => {
|
||
collectorUserId.value = null
|
||
collectorSnapshot.value = null
|
||
collectorUserOptions.value = []
|
||
formData.collectorUserId = ''
|
||
}
|
||
)
|
||
}
|
||
|
||
async function hydrateSalesUserForEdit(userId: string, displayName?: string) {
|
||
await hydrateRemoteUserForEdit(
|
||
userId,
|
||
displayName,
|
||
(user) => {
|
||
salesSnapshot.value = { ...user }
|
||
salesUserId.value = user.id
|
||
salesUserOptions.value = [{ ...user }]
|
||
formData.salesUserId = user.id
|
||
},
|
||
() => {
|
||
salesUserId.value = null
|
||
salesSnapshot.value = null
|
||
salesUserOptions.value = []
|
||
formData.salesUserId = ''
|
||
}
|
||
)
|
||
}
|
||
|
||
async function hydrateCustomerForEdit(
|
||
customerIdVal: string,
|
||
motherName: string,
|
||
motherPhone: string,
|
||
motherIdCard: string
|
||
) {
|
||
const cid = (customerIdVal || '').trim()
|
||
if (!cid) {
|
||
customerId.value = null
|
||
customerSnapshot.value = null
|
||
customerOptions.value = []
|
||
formData.customerId = ''
|
||
return
|
||
}
|
||
let snapshot = {
|
||
id: cid,
|
||
customerName: motherName,
|
||
phoneNo: motherPhone,
|
||
idCardNo: motherIdCard
|
||
}
|
||
const keyword = motherPhone || motherName || motherIdCard
|
||
if (keyword) {
|
||
try {
|
||
const res = await fetchCustomerList({ page: 1, pageSize: 50, keyword })
|
||
const match = (res.list || []).find((c) => c.id === cid)
|
||
if (match) {
|
||
snapshot = {
|
||
id: match.id,
|
||
customerName: match.customerName || motherName,
|
||
phoneNo: match.phoneNo || motherPhone,
|
||
idCardNo: match.idCardNo || motherIdCard
|
||
}
|
||
}
|
||
} catch {
|
||
/* 使用母亲信息占位 */
|
||
}
|
||
}
|
||
customerSnapshot.value = snapshot
|
||
customerId.value = cid
|
||
customerOptions.value = [snapshot]
|
||
formData.customerId = cid
|
||
}
|
||
|
||
async function ensureRegionOptionForEdit(regionId: string, regionName?: string) {
|
||
const rid = (regionId || '').trim()
|
||
if (!rid) return
|
||
await remoteSearchRegions('')
|
||
if (regionOptions.value.some((r) => r.id === rid)) return
|
||
const stub: Api.BizCollectionRegion.RegionItem = {
|
||
id: rid,
|
||
regionName: (regionName || '').trim() || rid,
|
||
hospitalNames: [],
|
||
tenantNo: '',
|
||
orderNum: 0,
|
||
remark: '',
|
||
createTime: '',
|
||
updateTime: ''
|
||
}
|
||
regionOptions.value = [stub, ...regionOptions.value]
|
||
}
|
||
|
||
async function ensureHospitalOptionForEdit(
|
||
regionId: string,
|
||
hospitalId: string,
|
||
hospitalName?: string
|
||
) {
|
||
const hid = (hospitalId || '').trim()
|
||
if (!hid || !(regionId || '').trim()) return
|
||
await loadHospitalsByRegion(regionId, '')
|
||
if (hospitalOptions.value.some((h) => h.id === hid)) return
|
||
const stub: Api.BizCollectionHospital.HospitalItem = {
|
||
id: hid,
|
||
regionId,
|
||
hospitalName: (hospitalName || '').trim() || hid,
|
||
tenantNo: '',
|
||
orderNum: 0,
|
||
remark: '',
|
||
createTime: '',
|
||
updateTime: ''
|
||
}
|
||
hospitalOptions.value = [stub, ...hospitalOptions.value]
|
||
}
|
||
|
||
function ensureSampleCodesInOptions(samples: Array<Record<string, any>>) {
|
||
const merged = [...sampleCodeOptions.value]
|
||
for (const s of samples) {
|
||
const id = String(s.sampleCodeId || '')
|
||
if (!id || merged.some((x) => x.id === id)) continue
|
||
merged.push({
|
||
id,
|
||
sampleCode: String(s.sampleCode || id),
|
||
sampleCodeType: '',
|
||
issued: 0,
|
||
usedInProtocol: false,
|
||
issuedUserId: '',
|
||
issuedUserName: '',
|
||
issuedTime: '',
|
||
tenantNo: '',
|
||
orderNum: 0,
|
||
remark: '',
|
||
createTime: '',
|
||
updateTime: ''
|
||
})
|
||
}
|
||
sampleCodeOptions.value = merged
|
||
}
|
||
|
||
/* ====== 客户检索(母亲信息) ====== */
|
||
function formatCustomerLabel(c: { customerName: string; phoneNo: string; idCardNo: string }) {
|
||
const parts = [c.customerName || '']
|
||
if (c.phoneNo) parts.push(c.phoneNo)
|
||
if (c.idCardNo) parts.push(c.idCardNo)
|
||
return parts.filter(Boolean).join(' · ')
|
||
}
|
||
|
||
async function remoteSearchCustomers(query: string) {
|
||
customerLoading.value = true
|
||
try {
|
||
const res = await fetchCustomerList({ page: 1, pageSize: 50, keyword: query || undefined })
|
||
let list = [...(res.list || [])]
|
||
// Keep current selection visible
|
||
if (
|
||
customerId.value &&
|
||
customerSnapshot.value?.id === customerId.value &&
|
||
!list.some((x) => x.id === customerId.value)
|
||
) {
|
||
list = [customerSnapshot.value, ...list]
|
||
}
|
||
customerOptions.value = list
|
||
} catch {
|
||
customerOptions.value = []
|
||
} finally {
|
||
customerLoading.value = false
|
||
}
|
||
}
|
||
|
||
function onCustomerChange(val: string | null | undefined) {
|
||
if (!val) {
|
||
customerId.value = null
|
||
customerSnapshot.value = null
|
||
// Clear auto-filled fields only
|
||
formData.motherName = ''
|
||
formData.motherPhone = ''
|
||
formData.motherIdCard = ''
|
||
formData.motherBirthYm = ''
|
||
formData.motherEmail = ''
|
||
return
|
||
}
|
||
const c = customerOptions.value.find((x) => x.id === val) || customerSnapshot.value
|
||
if (!c) return
|
||
customerSnapshot.value = { ...c } as any
|
||
customerId.value = c.id
|
||
formData.customerId = c.id
|
||
formData.motherName = c.customerName?.trim() || ''
|
||
formData.motherPhone = c.phoneNo?.trim() || ''
|
||
formData.motherIdCard = c.idCardNo?.trim() || ''
|
||
calcBirthFromIdCard()
|
||
}
|
||
|
||
/* ====== 采集专员检索 ====== */
|
||
async function remoteSearchCollectorUsers(query: string) {
|
||
collectorLoading.value = true
|
||
try {
|
||
const res = await fetchUserList({
|
||
page: 1,
|
||
pageSize: 50,
|
||
keyword: query || undefined,
|
||
status: '1'
|
||
})
|
||
let list = [...(res.list || [])]
|
||
if (
|
||
collectorUserId.value &&
|
||
collectorSnapshot.value?.id === collectorUserId.value &&
|
||
!list.some((x) => x.id === collectorUserId.value)
|
||
) {
|
||
list = [collectorSnapshot.value, ...list]
|
||
}
|
||
collectorUserOptions.value = list
|
||
} catch {
|
||
collectorUserOptions.value = []
|
||
} finally {
|
||
collectorLoading.value = false
|
||
}
|
||
}
|
||
|
||
function onCollectorUserChange(val: string | null | undefined) {
|
||
if (!val) {
|
||
collectorUserId.value = null
|
||
collectorSnapshot.value = null
|
||
formData.collectorUserId = ''
|
||
return
|
||
}
|
||
const u = collectorUserOptions.value.find((x) => x.id === val) || collectorSnapshot.value
|
||
if (!u) return
|
||
collectorSnapshot.value = { ...u }
|
||
collectorUserId.value = u.id
|
||
formData.collectorUserId = u.id
|
||
}
|
||
|
||
/* ====== 销售人员检索 ====== */
|
||
async function remoteSearchSalesUsers(query: string) {
|
||
salesLoading.value = true
|
||
try {
|
||
const res = await fetchUserList({
|
||
page: 1,
|
||
pageSize: 50,
|
||
keyword: query || undefined,
|
||
status: '1'
|
||
})
|
||
let list = [...(res.list || [])]
|
||
if (
|
||
salesUserId.value &&
|
||
salesSnapshot.value?.id === salesUserId.value &&
|
||
!list.some((x) => x.id === salesUserId.value)
|
||
) {
|
||
list = [salesSnapshot.value, ...list]
|
||
}
|
||
salesUserOptions.value = list
|
||
} catch {
|
||
salesUserOptions.value = []
|
||
} finally {
|
||
salesLoading.value = false
|
||
}
|
||
}
|
||
|
||
function onSalesUserChange(val: string | null | undefined) {
|
||
if (!val) {
|
||
salesUserId.value = null
|
||
salesSnapshot.value = null
|
||
formData.salesUserId = ''
|
||
return
|
||
}
|
||
const u = salesUserOptions.value.find((x) => x.id === val) || salesSnapshot.value
|
||
if (!u) return
|
||
salesSnapshot.value = { ...u }
|
||
salesUserId.value = u.id
|
||
formData.salesUserId = u.id
|
||
}
|
||
|
||
/* ====== 样本类型/单位选项 ====== */
|
||
async function loadSampleOptions() {
|
||
try {
|
||
const [stRes, uRes, scRes, ecRes, siteRes] = await Promise.all([
|
||
fetchCellSampleTypeList({ page: 1, pageSize: 100 }),
|
||
fetchItemUnitList({ page: 1, pageSize: 100 }),
|
||
fetchSampleCodeList({ page: 1, pageSize: 100 }),
|
||
fetchExpressCompanyList({ page: 1, pageSize: 100 }),
|
||
fetchSiteList({ page: 1, pageSize: 100 })
|
||
])
|
||
sampleTypeOptions.value = (stRes.list || []) as any
|
||
unitOptions.value = (uRes.list || []) as any
|
||
sampleCodeOptions.value = (scRes.list || []) as any
|
||
expressCompanyOptions.value = ecRes.list || []
|
||
siteOptions.value = siteRes.list || []
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
/* ====== 采集地区下拉检索 ====== */
|
||
async function remoteSearchRegions(query: string) {
|
||
regionLoading.value = true
|
||
try {
|
||
const res = await fetchCollectionRegionList({
|
||
page: 1,
|
||
pageSize: 100,
|
||
keyword: query || undefined
|
||
})
|
||
regionOptions.value = res.list || []
|
||
} catch {
|
||
regionOptions.value = []
|
||
} finally {
|
||
regionLoading.value = false
|
||
}
|
||
}
|
||
|
||
function onRegionChange(val: string | null | undefined) {
|
||
if (!val) {
|
||
formData.regionId = ''
|
||
hospitalOptions.value = []
|
||
formData.hospitalId = ''
|
||
return
|
||
}
|
||
formData.regionId = val
|
||
formData.hospitalId = ''
|
||
hospitalOptions.value = []
|
||
// 联动加载该地区下的医院
|
||
loadHospitalsByRegion(val)
|
||
}
|
||
|
||
function onRegionClear() {
|
||
formData.regionId = ''
|
||
formData.hospitalId = ''
|
||
hospitalOptions.value = []
|
||
}
|
||
|
||
async function loadHospitalsByRegion(regionId: string, keyword?: string) {
|
||
hospitalLoading.value = true
|
||
try {
|
||
const res = await fetchHospitalList(regionId, {
|
||
page: 1,
|
||
pageSize: 100,
|
||
keyword: keyword || undefined
|
||
})
|
||
hospitalOptions.value = res.list || []
|
||
} catch {
|
||
hospitalOptions.value = []
|
||
} finally {
|
||
hospitalLoading.value = false
|
||
}
|
||
}
|
||
|
||
/* ====== 采集医院下拉检索 ====== */
|
||
async function remoteSearchHospitals(query: string) {
|
||
if (!formData.regionId) {
|
||
hospitalOptions.value = []
|
||
return
|
||
}
|
||
await loadHospitalsByRegion(formData.regionId, query)
|
||
}
|
||
|
||
function onHospitalChange(val: string | null | undefined) {
|
||
if (!val) {
|
||
formData.hospitalId = ''
|
||
return
|
||
}
|
||
formData.hospitalId = val
|
||
}
|
||
|
||
function onInstallmentCountChange() {
|
||
const count = toIntOrUndefined(formData.installmentCount) || 0
|
||
if (count > 0) {
|
||
formData.installmentRatios = finalizeInstallmentRatios(
|
||
resizeInstallmentRatios(formData.installmentRatios || [], count)
|
||
)
|
||
}
|
||
handleFqParamsChange()
|
||
}
|
||
|
||
function onInstallmentRatioChange(changedIdx: number) {
|
||
if (changedIdx === formData.installmentRatios.length - 1) return
|
||
formData.installmentRatios = finalizeInstallmentRatios([...formData.installmentRatios])
|
||
syncPaymentDueAmounts()
|
||
handleFqParamsChange()
|
||
}
|
||
|
||
const installmentRatiosError = computed(() => {
|
||
const ratios = formData.installmentRatios || []
|
||
if (!ratios.length) return ''
|
||
const head = ratios.slice(0, -1)
|
||
const sum = head.reduce((s, v) => s + Number(v || 0), 0)
|
||
if (sum > 100 + 0.001) return '前 N−1 期比例之和不能超过 100%'
|
||
return ''
|
||
})
|
||
|
||
const installmentRatiosSum = computed(() => {
|
||
const total = (formData.installmentRatios || []).reduce((s, v) => s + Number(v || 0), 0)
|
||
return Math.round(total * 100) / 100
|
||
})
|
||
|
||
/** 按缴费模式同步各明细应缴费金额(跳过已锁定明细) */
|
||
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') {
|
||
const count = formData.installmentCount || formData.paymentDetails.length
|
||
if (!count) return
|
||
const ratios = finalizeInstallmentRatios(formData.installmentRatios || [])
|
||
const paidRows = formData.paymentDetails.filter((d) => String(d.payStatus) === 'yjf')
|
||
let amounts: number[]
|
||
if (paidRows.length > 0 && formData.paymentDetails.some((d) => isPayDetailLocked(d))) {
|
||
const paidSum = sumPaidAmount(formData.paymentDetails)
|
||
const remaining = Math.round((Number(contract) - paidSum) * 100) / 100
|
||
const unpaidIndices = Array.from({ length: Number(count) }, (_, i) => i).filter(
|
||
(i) => !paidRows.some((d) => Number(d.installmentNo) === i + 1)
|
||
)
|
||
const unpaidAmounts = calcUnpaidAmountsByRatios(remaining, ratios, unpaidIndices)
|
||
amounts = Array(Number(count)).fill(0)
|
||
unpaidIndices.forEach((idx, j) => {
|
||
amounts[idx] = unpaidAmounts[j] ?? 0
|
||
})
|
||
paidRows.forEach((d) => {
|
||
const no = Number(d.installmentNo)
|
||
if (no >= 1 && no <= Number(count)) amounts[no - 1] = Number(d.dueAmount) || 0
|
||
})
|
||
} else {
|
||
amounts = calcInstallmentDueAmountsByRatios(Number(contract), ratios)
|
||
}
|
||
formData.paymentDetails.forEach((d, i) => {
|
||
if (isPayDetailLocked(d)) return
|
||
if (amounts[i] !== undefined) d.dueAmount = amounts[i]
|
||
})
|
||
}
|
||
}
|
||
|
||
/** 根据非作废样本价格之和同步合同金额,并联动应缴费金额 */
|
||
function syncContractAmount() {
|
||
const total = formData.samples.reduce((sum, s) => {
|
||
if (s.sampleStatus === 'yzf') return sum
|
||
return sum + (Number(s.price) || 0)
|
||
}, 0)
|
||
formData.contractAmount = Math.round(total * 100) / 100
|
||
syncPaymentDueAmounts()
|
||
}
|
||
|
||
/** 样本价格或状态变更 */
|
||
function onSamplePriceChange() {
|
||
syncContractAmount()
|
||
}
|
||
|
||
/* ====== 母亲身份证号 ↔ 出生年月联动 ====== */
|
||
function calcBirthFromIdCard() {
|
||
const idCard = (formData.motherIdCard || '').trim()
|
||
if (/^\d{17}[\dXx]$/.test(idCard)) {
|
||
formData.motherBirthYm = idCard.substring(6, 12)
|
||
} else if (/^\d{15}$/.test(idCard)) {
|
||
const yy = idCard.substring(6, 8)
|
||
const mm = idCard.substring(8, 10)
|
||
const prefix = parseInt(yy, 10) > 30 ? '19' : '20'
|
||
formData.motherBirthYm = prefix + yy + mm
|
||
} else if (!idCard) {
|
||
formData.motherBirthYm = ''
|
||
}
|
||
}
|
||
|
||
watch(
|
||
() => formData.motherIdCard,
|
||
() => calcBirthFromIdCard()
|
||
)
|
||
|
||
/* ====== 趸交截止时间联动 ====== */
|
||
watch(
|
||
() => formData.lumpSumDeadline,
|
||
() => {
|
||
if (formData.payMode === 'dj') {
|
||
syncLumpSumDueDate()
|
||
}
|
||
}
|
||
)
|
||
|
||
/* ====== 附件上传 ====== */
|
||
const attachmentUploadRef = ref()
|
||
|
||
function isImageFile(file: any) {
|
||
const url = (file.fileUrl || '').toLowerCase()
|
||
const name = (file.fileName || '').toLowerCase()
|
||
return (
|
||
/\.(jpg|jpeg|png|gif|bmp|webp|svg)(\?|$)/.test(url) ||
|
||
/\.(jpg|jpeg|png|gif|bmp|webp|svg)$/.test(name) ||
|
||
(file.fileType || '').startsWith('image/')
|
||
)
|
||
}
|
||
|
||
function isVideoFile(file: any) {
|
||
const url = (file.fileUrl || '').toLowerCase()
|
||
const name = (file.fileName || '').toLowerCase()
|
||
return (
|
||
/\.(mp4|webm|ogg|mov|avi)(\?|$)/.test(url) ||
|
||
/\.(mp4|webm|ogg|mov|avi)$/.test(name) ||
|
||
(file.fileType || '').startsWith('video/')
|
||
)
|
||
}
|
||
|
||
async function handleAttachmentChange(uploadFile: any) {
|
||
const file = uploadFile.raw || uploadFile
|
||
if (!file) return
|
||
|
||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||
formData.attachments.push({
|
||
id: tempId,
|
||
fileName: file.name,
|
||
fileUrl: '',
|
||
fileSize: file.size,
|
||
fileSizeDisplay: `${(file.size / 1024).toFixed(1)} KB`,
|
||
fileType: file.type || '',
|
||
_uploading: true
|
||
})
|
||
const idx = formData.attachments.length - 1
|
||
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('folder', 'protocol')
|
||
fd.append('file', file)
|
||
const res = await fetchUploadAttachment(fd)
|
||
formData.attachments[idx] = {
|
||
...formData.attachments[idx],
|
||
id: res.id || tempId,
|
||
fileName: file.name || res.fileName,
|
||
fileUrl: res.fileUrl,
|
||
fileSize: res.fileSize || file.size,
|
||
fileSizeDisplay: res.fileSizeDisplay || `${(file.size / 1024).toFixed(1)} KB`,
|
||
fileType: res.fileType || file.type || '',
|
||
_uploading: false
|
||
}
|
||
} catch {
|
||
ElMessage.error('文件上传失败')
|
||
formData.attachments.splice(idx, 1)
|
||
} finally {
|
||
nextTick(() => attachmentUploadRef.value?.clearFiles())
|
||
}
|
||
}
|
||
|
||
function removeAttachment(index: number) {
|
||
formData.attachments.splice(index, 1)
|
||
}
|
||
|
||
/* ====== 缴费明细附件上传 ====== */
|
||
async function handleDetailAttachmentChange(detailIndex: number, uploadFile: any) {
|
||
const file = uploadFile.raw || uploadFile
|
||
if (!file) return
|
||
|
||
const detail = formData.paymentDetails[detailIndex]
|
||
if (!detail) return
|
||
|
||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||
detail.attachments.push({
|
||
id: tempId,
|
||
fileName: file.name,
|
||
fileUrl: '',
|
||
fileSize: file.size,
|
||
fileSizeDisplay: `${(file.size / 1024).toFixed(1)} KB`,
|
||
fileType: file.type || '',
|
||
_uploading: true
|
||
})
|
||
const aIdx = detail.attachments.length - 1
|
||
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('folder', 'protocol-payment')
|
||
fd.append('file', file)
|
||
const res = await fetchUploadAttachment(fd)
|
||
detail.attachments[aIdx] = {
|
||
...detail.attachments[aIdx],
|
||
id: res.id || tempId,
|
||
fileName: file.name || res.fileName,
|
||
fileUrl: res.fileUrl,
|
||
fileSize: res.fileSize || file.size,
|
||
fileSizeDisplay: res.fileSizeDisplay || `${(file.size / 1024).toFixed(1)} KB`,
|
||
fileType: res.fileType || file.type || '',
|
||
_uploading: false
|
||
}
|
||
} catch {
|
||
ElMessage.error('文件上传失败')
|
||
detail.attachments.splice(aIdx, 1)
|
||
}
|
||
}
|
||
|
||
function removeDetailAttachment(detailIndex: number, attachmentIndex: number) {
|
||
const detail = formData.paymentDetails[detailIndex]
|
||
if (detail) {
|
||
detail.attachments.splice(attachmentIndex, 1)
|
||
}
|
||
}
|
||
|
||
/* 附件预览 */
|
||
const previewVisible = ref(false)
|
||
const previewUrl = ref('')
|
||
const previewType = ref<'image' | 'video'>('image')
|
||
|
||
function handlePreviewAttachment(file: any) {
|
||
if (file._uploading) return
|
||
previewUrl.value = fullFileUrl(file.fileUrl)
|
||
previewType.value = isVideoFile(file) ? 'video' : 'image'
|
||
previewVisible.value = true
|
||
}
|
||
|
||
function handleDownloadAttachment(file: any) {
|
||
downloadAttachmentFile(file, { fullFileUrl })
|
||
}
|
||
|
||
/* ====== 弹窗打开时自动初始化 ====== */
|
||
watch(
|
||
() => props.modelValue,
|
||
(visible) => {
|
||
if (visible) {
|
||
if (props.editId) {
|
||
handleOpenForEdit()
|
||
} else {
|
||
handleOpenForAdd()
|
||
}
|
||
}
|
||
}
|
||
)
|
||
</script>
|
||
<style scoped lang="scss">
|
||
/* ═══════════════════════════════════════════
|
||
DIALOG BODY
|
||
═══════════════════════════════════════════ */
|
||
.protocol-dialog-body {
|
||
overflow: visible;
|
||
background: #f5f7fa;
|
||
}
|
||
|
||
.protocol-form {
|
||
:deep(.el-form-item) {
|
||
margin-bottom: 4px;
|
||
}
|
||
}
|
||
|
||
/* ---- Sticky Tab 导航(与查看弹窗 detail-tabs 一致) ---- */
|
||
.detail-tabs {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 1000;
|
||
display: flex;
|
||
gap: 4px;
|
||
background: #f0fdf4;
|
||
padding: 4px;
|
||
border-radius: 8px;
|
||
margin: 0 28px 16px;
|
||
width: fit-content;
|
||
isolation: isolate;
|
||
box-shadow: 0 1px 0 #f0fdf4;
|
||
}
|
||
|
||
.detail-tab {
|
||
padding: 10px 20px;
|
||
cursor: pointer;
|
||
border-radius: 6px;
|
||
font-size: 14px;
|
||
color: #606266;
|
||
transition: all 0.3s;
|
||
|
||
&.active {
|
||
background: #fff;
|
||
color: #059669;
|
||
font-weight: 500;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||
}
|
||
|
||
&:hover:not(.active) {
|
||
color: #059669;
|
||
}
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════
|
||
SECTION CARDS
|
||
═══════════════════════════════════════════ */
|
||
.form-tab-pane {
|
||
padding: 0 28px 4px;
|
||
|
||
& + .form-tab-pane {
|
||
margin-top: 0;
|
||
padding-top: 0;
|
||
}
|
||
}
|
||
|
||
.section-card {
|
||
border-radius: 12px;
|
||
padding: 22px 24px;
|
||
margin-bottom: 20px;
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||
transition:
|
||
transform 0.2s,
|
||
box-shadow 0.2s;
|
||
position: relative;
|
||
background: #fff;
|
||
|
||
&:hover {
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||
}
|
||
|
||
&--green {
|
||
background: #f0fdf4;
|
||
}
|
||
}
|
||
|
||
.section-title {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 18px;
|
||
padding-bottom: 12px;
|
||
border-bottom: 1px solid #e2e8f0;
|
||
}
|
||
|
||
.section-title-left {
|
||
display: flex;
|
||
align-items: center;
|
||
|
||
&::before {
|
||
content: '';
|
||
width: 3px;
|
||
height: 18px;
|
||
background: #10b981;
|
||
border-radius: 2px;
|
||
margin-right: 10px;
|
||
flex-shrink: 0;
|
||
}
|
||
}
|
||
|
||
.section-title h3 {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: #1e293b;
|
||
}
|
||
|
||
.section-subtitle {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #374151;
|
||
margin: 18px 0 14px;
|
||
|
||
&::before {
|
||
content: '';
|
||
width: 2px;
|
||
height: 12px;
|
||
background: #10b981;
|
||
border-radius: 1px;
|
||
}
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════
|
||
FORM GRID
|
||
═══════════════════════════════════════════ */
|
||
.form-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 16px 20px;
|
||
|
||
&--col2 {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
}
|
||
|
||
&--col4 {
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
}
|
||
|
||
&--col5 {
|
||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||
}
|
||
|
||
:deep(.el-form-item) {
|
||
margin-bottom: 0;
|
||
}
|
||
}
|
||
|
||
.basic-remark-attach-row {
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.form-col-span-2 {
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════
|
||
CLIENT SUB CARDS
|
||
═══════════════════════════════════════════ */
|
||
.client-sub-cards {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 16px;
|
||
}
|
||
|
||
.client-sub-card {
|
||
background: #fff;
|
||
border-radius: 10px;
|
||
padding: 18px 20px;
|
||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||
border: 1px solid rgba(0, 0, 0, 0.03);
|
||
transition:
|
||
transform 0.2s,
|
||
box-shadow 0.2s;
|
||
|
||
&:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.07);
|
||
}
|
||
}
|
||
|
||
.sub-card-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 14px;
|
||
padding-bottom: 10px;
|
||
border-bottom: 1px solid #f1f5f9;
|
||
|
||
.icon {
|
||
font-size: 22px;
|
||
line-height: 1;
|
||
}
|
||
|
||
h4 {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #1e293b;
|
||
}
|
||
|
||
.search-link {
|
||
margin-left: auto;
|
||
font-size: 12px;
|
||
color: #10b981;
|
||
cursor: pointer;
|
||
font-weight: 500;
|
||
|
||
&:hover {
|
||
text-decoration: underline;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════
|
||
PAYMENT SECTION
|
||
═══════════════════════════════════════════ */
|
||
.payment-main-card {
|
||
background: #fff;
|
||
border-radius: 10px;
|
||
padding: 18px 20px;
|
||
margin-bottom: 18px;
|
||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||
border: 1px solid rgba(0, 0, 0, 0.03);
|
||
}
|
||
|
||
.payment-details {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
/* Detail Card — Collapsible */
|
||
.detail-card {
|
||
background: #fff;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
transition: box-shadow 0.2s;
|
||
|
||
&:hover {
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||
}
|
||
|
||
&.expanded .detail-chevron {
|
||
transform: rotate(90deg);
|
||
}
|
||
}
|
||
|
||
.detail-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 14px 18px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
transition: background 0.2s;
|
||
|
||
&:hover {
|
||
background: #f8fafc;
|
||
}
|
||
}
|
||
|
||
.detail-chevron {
|
||
font-size: 12px;
|
||
color: #94a3b8;
|
||
transition: transform 0.3s;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.detail-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #1e293b;
|
||
}
|
||
|
||
.detail-amount {
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
margin-left: 4px;
|
||
|
||
&.green {
|
||
color: #059669;
|
||
}
|
||
|
||
&.amber {
|
||
color: #d97706;
|
||
}
|
||
}
|
||
|
||
.detail-badge {
|
||
display: inline-block;
|
||
padding: 2px 10px;
|
||
border-radius: 20px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
white-space: nowrap;
|
||
|
||
&.badge-green {
|
||
background: #ecfdf5;
|
||
color: #059669;
|
||
}
|
||
|
||
&.badge-amber {
|
||
background: #fffbeb;
|
||
color: #d97706;
|
||
}
|
||
|
||
&.badge-gray {
|
||
background: #f1f5f9;
|
||
color: #64748b;
|
||
}
|
||
}
|
||
|
||
.detail-summary {
|
||
margin-left: auto;
|
||
font-size: 12px;
|
||
color: #94a3b8;
|
||
}
|
||
|
||
.detail-delete {
|
||
margin-left: 6px;
|
||
}
|
||
|
||
.detail-body-inner {
|
||
padding: 0 18px 18px;
|
||
border-top: 1px solid #f1f5f9;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════
|
||
ATTACHMENTS
|
||
═══════════════════════════════════════════ */
|
||
.attachment-upload {
|
||
width: 100%;
|
||
}
|
||
|
||
.upload-card {
|
||
width: 140px;
|
||
height: 96px;
|
||
border: 1px dashed #a7f3d0;
|
||
border-radius: 8px;
|
||
background: #f0fdf4;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
cursor: pointer;
|
||
transition:
|
||
border-color 0.15s,
|
||
background 0.15s;
|
||
|
||
&:hover {
|
||
border-color: #10b981;
|
||
border-style: solid;
|
||
background: #ecfdf5;
|
||
}
|
||
}
|
||
|
||
.upload-card-text {
|
||
font-size: 12px;
|
||
color: #10b981;
|
||
font-weight: 500;
|
||
line-height: 1;
|
||
}
|
||
|
||
.upload-hint {
|
||
display: block;
|
||
font-size: 11px;
|
||
color: #94a3b8;
|
||
line-height: 1;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.attachment-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
}
|
||
|
||
.attachment-item {
|
||
width: 140px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 8px;
|
||
background: #fff;
|
||
overflow: hidden;
|
||
transition:
|
||
border-color 0.15s,
|
||
box-shadow 0.15s;
|
||
|
||
&:hover {
|
||
border-color: #10b981;
|
||
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.1);
|
||
}
|
||
|
||
&.is-uploading {
|
||
opacity: 0.6;
|
||
pointer-events: none;
|
||
}
|
||
}
|
||
|
||
.attachment-preview {
|
||
width: 100%;
|
||
height: 96px;
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: #f8fafc;
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
|
||
img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
}
|
||
}
|
||
|
||
.attachment-preview__icon {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
|
||
&--video {
|
||
background: #ede9fe;
|
||
color: #7c3aed;
|
||
}
|
||
|
||
&--doc {
|
||
background: #e0f2fe;
|
||
color: #0284c7;
|
||
}
|
||
}
|
||
|
||
.attachment-footer {
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
padding: 6px 8px;
|
||
}
|
||
|
||
.attachment-name {
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
font-size: 11px;
|
||
color: #334155;
|
||
line-height: 1.4;
|
||
word-break: break-all;
|
||
min-height: calc(11px * 1.4 * 2);
|
||
}
|
||
|
||
.attachment-actions {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-top: 4px;
|
||
flex-shrink: 0;
|
||
transition: opacity 0.15s;
|
||
}
|
||
|
||
.action-btn {
|
||
width: 22px;
|
||
height: 22px;
|
||
border-radius: 4px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 12px;
|
||
color: #64748b;
|
||
cursor: pointer;
|
||
background: #f1f5f9;
|
||
transition:
|
||
background 0.15s,
|
||
color 0.15s;
|
||
|
||
&:hover {
|
||
background: #10b981;
|
||
color: #fff;
|
||
}
|
||
|
||
&--danger:hover {
|
||
background: #ef4444;
|
||
color: #fff;
|
||
}
|
||
}
|
||
|
||
.upload-card--sm {
|
||
width: 96px;
|
||
height: 76px;
|
||
|
||
.upload-card-text {
|
||
font-size: 11px;
|
||
}
|
||
}
|
||
|
||
.payment-detail-card__attachments {
|
||
margin-top: 8px;
|
||
padding-top: 8px;
|
||
border-top: 1px dashed #e2e8f0;
|
||
}
|
||
|
||
.preview-container {
|
||
text-align: center;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════
|
||
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 {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
|
||
&--col2.basic-remark-attach-row {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
.client-sub-cards {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
.installment-advance-ratios-row {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 24px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.installment-advance-field {
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
.installment-ratios-field {
|
||
flex: 1 1 360px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.installment-ratios-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
padding: 12px 14px;
|
||
border: 1px solid #d9e8e2;
|
||
border-radius: 8px;
|
||
background: #f8fafc;
|
||
}
|
||
|
||
.installment-ratios-bar {
|
||
display: flex;
|
||
height: 8px;
|
||
overflow: hidden;
|
||
border-radius: 4px;
|
||
background: #e2e8f0;
|
||
}
|
||
|
||
.installment-ratios-bar-seg {
|
||
background: #10b981;
|
||
min-width: 2px;
|
||
transition: flex 0.2s ease;
|
||
|
||
&.is-auto {
|
||
background: #6ee7b7;
|
||
}
|
||
|
||
& + & {
|
||
border-left: 1px solid rgba(255, 255, 255, 0.6);
|
||
}
|
||
}
|
||
|
||
.installment-ratios-cells {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.installment-ratio-cell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
flex: 1 1 72px;
|
||
min-width: 72px;
|
||
max-width: 96px;
|
||
|
||
:deep(.el-input__wrapper) {
|
||
padding-left: 8px;
|
||
padding-right: 4px;
|
||
}
|
||
|
||
:deep(.el-input__suffix) {
|
||
color: var(--mint-text-muted, #64748b);
|
||
font-size: 12px;
|
||
}
|
||
|
||
&.is-auto :deep(.el-input__wrapper) {
|
||
background: #ecfdf5;
|
||
}
|
||
}
|
||
|
||
.installment-ratio-cell-no {
|
||
font-size: 11px;
|
||
color: var(--mint-text-muted, #64748b);
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.installment-ratios-foot {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
|
||
.installment-ratios-sum {
|
||
font-size: 12px;
|
||
color: #059669;
|
||
font-weight: 500;
|
||
|
||
&.is-error {
|
||
color: #dc2626;
|
||
}
|
||
}
|
||
|
||
.form-tip--error {
|
||
margin-top: 6px;
|
||
color: #dc2626;
|
||
font-size: 12px;
|
||
}
|
||
</style>
|