Persist payment before samples to avoid consuming sample codes on validation failure, soft-delete samples on create rollback, and surface DJ due-amount mismatch as a hint only. Co-authored-by: Cursor <cursoragent@cursor.com>
4372 lines
143 KiB
Vue
4372 lines
143 KiB
Vue
<template>
|
||
<!-- ========== 新增/编辑弹窗 ========== -->
|
||
<el-dialog
|
||
:model-value="modelValue"
|
||
:title="dialogTitle"
|
||
width="1600px"
|
||
class="dialog-form-layout"
|
||
align-center
|
||
append-to-body
|
||
modal-class="protocol-dialog-mask"
|
||
destroy-on-close
|
||
:close-on-click-modal="false"
|
||
@update:model-value="emit('update:modelValue', $event)"
|
||
@closed="resetForm"
|
||
>
|
||
<DialogFormBody :loading="dialogFormLoading" body-class="protocol-dialog-body min-h-[200px]">
|
||
<div class="detail-tabs">
|
||
<div
|
||
v-for="tab in formTabs"
|
||
:key="tab.key"
|
||
class="detail-tab"
|
||
:class="{ active: activeFormTab === tab.key }"
|
||
@click="scrollToSection(tab.key)"
|
||
>
|
||
{{ tab.label }}
|
||
</div>
|
||
</div>
|
||
|
||
<el-form
|
||
ref="formRef"
|
||
:model="formData"
|
||
:rules="formRules"
|
||
label-position="top"
|
||
scroll-to-error
|
||
class="protocol-form"
|
||
>
|
||
<!-- Tab 1: 基本信息(新增时左侧一键导入、右侧基本信息) -->
|
||
<div
|
||
id="form-section-basic"
|
||
class="form-tab-pane"
|
||
:class="{ 'form-tab-pane--import-basic': !isEdit }"
|
||
>
|
||
<div v-if="!isEdit" class="section-card section-card--import">
|
||
<div class="section-title">
|
||
<div class="section-title-left"><h3>一键导入</h3></div>
|
||
</div>
|
||
<el-form-item label="标准信息" class="import-textarea-item">
|
||
<el-input
|
||
v-model="standardImportText"
|
||
type="textarea"
|
||
:rows="28"
|
||
class="import-standard-textarea"
|
||
placeholder="请粘贴标准格式信息,点击「一键导入」填充各 Tab 字段"
|
||
/>
|
||
</el-form-item>
|
||
<div class="import-actions">
|
||
<el-button
|
||
class="btn-main"
|
||
type="primary"
|
||
:loading="standardImportLoading"
|
||
@click="handleStandardImport"
|
||
>
|
||
<el-icon><Upload /></el-icon>
|
||
一键导入
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
<div class="section-card">
|
||
<div class="section-title">
|
||
<div class="section-title-left"><h3>基本信息</h3></div>
|
||
</div>
|
||
<div class="form-grid" :class="isEdit ? 'form-grid--col4' : 'form-grid--col2'">
|
||
<el-form-item label="协议状态" prop="protocolStatus">
|
||
<el-select
|
||
v-model="formData.protocolStatus"
|
||
placeholder="请选择"
|
||
style="width: 100%"
|
||
>
|
||
<el-option label="待生效" value="dsx" />
|
||
<el-option label="生效中" value="sxz" />
|
||
<el-option label="已过期" value="ygq" />
|
||
<el-option label="已作废" value="yzf" />
|
||
<el-option label="属地废弃" value="sdfq" />
|
||
<el-option label="转科研" value="zky" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="登记日期" prop="registerDate">
|
||
<el-date-picker
|
||
v-model="formData.registerDate"
|
||
type="date"
|
||
value-format="YYYYMMDD"
|
||
format="YYYY-MM-DD"
|
||
placeholder="选择登记日期"
|
||
clearable
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="采集专员" 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>
|
||
<el-form-item label="备注">
|
||
<el-input v-model="formData.remark" type="textarea" :rows="2" placeholder="可选" />
|
||
</el-form-item>
|
||
</div>
|
||
<div class="form-grid form-grid--attachment">
|
||
<el-form-item label="协议附件">
|
||
<div
|
||
class="attachment-upload"
|
||
:class="{ 'is-dragover': isProtocolAttachDragover }"
|
||
v-bind="protocolAttachDropHandlers"
|
||
>
|
||
<div class="attachment-list">
|
||
<el-upload
|
||
ref="attachmentUploadRef"
|
||
:auto-upload="false"
|
||
:show-file-list="false"
|
||
:multiple="true"
|
||
accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
|
||
@change="handleAttachmentChange"
|
||
>
|
||
<div class="upload-card">
|
||
<el-icon :size="28" color="#047857"><Plus /></el-icon>
|
||
<span class="upload-card-text">上传附件</span>
|
||
</div>
|
||
</el-upload>
|
||
<div
|
||
v-for="(file, index) in formData.attachments"
|
||
:key="file.id || index"
|
||
class="attachment-item"
|
||
:class="{ 'is-uploading': file._uploading }"
|
||
>
|
||
<div
|
||
class="attachment-preview"
|
||
@click="
|
||
isImageFile(file) && file.fileUrl
|
||
? handlePreviewAttachment(file)
|
||
: undefined
|
||
"
|
||
>
|
||
<template v-if="isImageFile(file) && file.fileUrl">
|
||
<img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
|
||
</template>
|
||
<template v-else-if="isImageFile(file) && !file.fileUrl">
|
||
<el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
|
||
</template>
|
||
<template v-else-if="isVideoFile(file)">
|
||
<div class="attachment-preview__icon attachment-preview__icon--video">
|
||
<el-icon :size="28"><VideoPlay /></el-icon>
|
||
</div>
|
||
</template>
|
||
<template v-else>
|
||
<div class="attachment-preview__icon attachment-preview__icon--doc">
|
||
<el-icon :size="28"><Document /></el-icon>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<div class="attachment-footer">
|
||
<span class="attachment-name" :title="file.fileName">{{
|
||
file.fileName
|
||
}}</span>
|
||
<div class="attachment-actions">
|
||
<el-icon
|
||
class="action-btn"
|
||
title="下载"
|
||
@click="handleDownloadAttachment(file)"
|
||
><Download
|
||
/></el-icon>
|
||
<el-icon
|
||
class="action-btn action-btn--danger"
|
||
title="删除"
|
||
@click="removeAttachment(index)"
|
||
><Delete
|
||
/></el-icon>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<span class="upload-hint"
|
||
>支持拖拽或点击上传图片、视频、PDF、Word、Excel等文件,单文件不超过 20MB</span
|
||
>
|
||
</div>
|
||
</el-form-item>
|
||
</div>
|
||
<div class="form-grid form-grid--attachment">
|
||
<el-form-item label="知情同意书">
|
||
<div
|
||
class="attachment-upload"
|
||
:class="{ 'is-dragover': isConsentAttachDragover }"
|
||
v-bind="consentAttachDropHandlers"
|
||
>
|
||
<div class="attachment-list">
|
||
<el-upload
|
||
ref="consentAttachmentUploadRef"
|
||
:auto-upload="false"
|
||
:show-file-list="false"
|
||
:multiple="true"
|
||
accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
|
||
@change="handleConsentAttachmentChange"
|
||
>
|
||
<div class="upload-card">
|
||
<el-icon :size="28" color="#047857"><Plus /></el-icon>
|
||
<span class="upload-card-text">上传附件</span>
|
||
</div>
|
||
</el-upload>
|
||
<div
|
||
v-for="(file, index) in formData.consentAttachments"
|
||
:key="file.id || index"
|
||
class="attachment-item"
|
||
:class="{ 'is-uploading': file._uploading }"
|
||
>
|
||
<div
|
||
class="attachment-preview"
|
||
@click="
|
||
isImageFile(file) && file.fileUrl
|
||
? handlePreviewAttachment(file)
|
||
: undefined
|
||
"
|
||
>
|
||
<template v-if="isImageFile(file) && file.fileUrl">
|
||
<img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
|
||
</template>
|
||
<template v-else-if="isImageFile(file) && !file.fileUrl">
|
||
<el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
|
||
</template>
|
||
<template v-else-if="isVideoFile(file)">
|
||
<div class="attachment-preview__icon attachment-preview__icon--video">
|
||
<el-icon :size="28"><VideoPlay /></el-icon>
|
||
</div>
|
||
</template>
|
||
<template v-else>
|
||
<div class="attachment-preview__icon attachment-preview__icon--doc">
|
||
<el-icon :size="28"><Document /></el-icon>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<div class="attachment-footer">
|
||
<span class="attachment-name" :title="file.fileName">{{
|
||
file.fileName
|
||
}}</span>
|
||
<div class="attachment-actions">
|
||
<el-icon
|
||
class="action-btn"
|
||
title="下载"
|
||
@click="handleDownloadAttachment(file)"
|
||
><Download
|
||
/></el-icon>
|
||
<el-icon
|
||
class="action-btn action-btn--danger"
|
||
title="删除"
|
||
@click="removeConsentAttachment(index)"
|
||
><Delete
|
||
/></el-icon>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<span class="upload-hint"
|
||
>支持拖拽或点击上传图片、视频、PDF、Word、Excel等文件,单文件不超过 20MB</span
|
||
>
|
||
</div>
|
||
</el-form-item>
|
||
</div>
|
||
<div class="form-grid form-grid--attachment">
|
||
<el-form-item label="孕妇健康调查表">
|
||
<div
|
||
class="attachment-upload"
|
||
:class="{ 'is-dragover': isHealthSurveyAttachDragover }"
|
||
v-bind="healthSurveyAttachDropHandlers"
|
||
>
|
||
<div class="attachment-list">
|
||
<el-upload
|
||
ref="healthSurveyAttachmentUploadRef"
|
||
:auto-upload="false"
|
||
:show-file-list="false"
|
||
:multiple="true"
|
||
accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
|
||
@change="handleHealthSurveyAttachmentChange"
|
||
>
|
||
<div class="upload-card">
|
||
<el-icon :size="28" color="#047857"><Plus /></el-icon>
|
||
<span class="upload-card-text">上传附件</span>
|
||
</div>
|
||
</el-upload>
|
||
<div
|
||
v-for="(file, index) in formData.healthSurveyAttachments"
|
||
:key="file.id || index"
|
||
class="attachment-item"
|
||
:class="{ 'is-uploading': file._uploading }"
|
||
>
|
||
<div
|
||
class="attachment-preview"
|
||
@click="
|
||
isImageFile(file) && file.fileUrl
|
||
? handlePreviewAttachment(file)
|
||
: undefined
|
||
"
|
||
>
|
||
<template v-if="isImageFile(file) && file.fileUrl">
|
||
<img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
|
||
</template>
|
||
<template v-else-if="isImageFile(file) && !file.fileUrl">
|
||
<el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
|
||
</template>
|
||
<template v-else-if="isVideoFile(file)">
|
||
<div class="attachment-preview__icon attachment-preview__icon--video">
|
||
<el-icon :size="28"><VideoPlay /></el-icon>
|
||
</div>
|
||
</template>
|
||
<template v-else>
|
||
<div class="attachment-preview__icon attachment-preview__icon--doc">
|
||
<el-icon :size="28"><Document /></el-icon>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<div class="attachment-footer">
|
||
<span class="attachment-name" :title="file.fileName">{{
|
||
file.fileName
|
||
}}</span>
|
||
<div class="attachment-actions">
|
||
<el-icon
|
||
class="action-btn"
|
||
title="下载"
|
||
@click="handleDownloadAttachment(file)"
|
||
><Download
|
||
/></el-icon>
|
||
<el-icon
|
||
class="action-btn action-btn--danger"
|
||
title="删除"
|
||
@click="removeHealthSurveyAttachment(index)"
|
||
><Delete
|
||
/></el-icon>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<span class="upload-hint"
|
||
>支持拖拽或点击上传图片、视频、PDF、Word、Excel等文件,单文件不超过 20MB</span
|
||
>
|
||
</div>
|
||
</el-form-item>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab 2: 采集信息 -->
|
||
<div id="form-section-collection" class="form-tab-pane">
|
||
<div class="section-card">
|
||
<div class="section-title">
|
||
<div class="section-title-left"><h3>采集信息</h3></div>
|
||
</div>
|
||
<div class="form-grid form-grid--col6">
|
||
<el-form-item label="采集地区">
|
||
<el-select
|
||
v-model="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"
|
||
format="YYYY-MM-DD HH:mm"
|
||
value-format="YYYY-MM-DD HH:mm"
|
||
placeholder="选择采集时间"
|
||
clearable
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="母血采集时间">
|
||
<el-date-picker
|
||
v-model="formData.maternalBloodCollectTime"
|
||
type="datetime"
|
||
format="YYYY-MM-DD HH:mm"
|
||
value-format="YYYY-MM-DD HH:mm"
|
||
placeholder="选择采集时间"
|
||
clearable
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab 3: 样本及运输信息 -->
|
||
<div id="form-section-sample" class="form-tab-pane">
|
||
<div class="section-card">
|
||
<div class="section-title">
|
||
<div class="section-title-left"><h3>样本及运输信息</h3></div>
|
||
</div>
|
||
<SampleTransportFormSection
|
||
v-model:samples="formData.samples"
|
||
hide-dialog-collect-amount-unit
|
||
:sample-type-options="sampleTypeOptions"
|
||
:sample-type-options-loading="sampleTypeLoading"
|
||
:sample-code-options="sampleCodeOptions"
|
||
:sample-code-options-loading="sampleCodeLoading"
|
||
:unit-options="unitOptions"
|
||
:unit-options-loading="unitLoading"
|
||
:site-options="siteOptions"
|
||
:site-options-loading="siteLoading"
|
||
:express-company-options="expressCompanyOptions"
|
||
:express-company-options-loading="expressCompanyLoading"
|
||
:show-price="true"
|
||
@price-change="onSamplePriceChange"
|
||
@search-sample-code="remoteSearchSampleCodes"
|
||
@search-sample-type="remoteSearchSampleTypes"
|
||
@search-unit="remoteSearchUnits"
|
||
@search-site="remoteSearchSites"
|
||
@search-express-company="remoteSearchExpressCompanies"
|
||
/>
|
||
</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="新生儿评分">
|
||
<el-input
|
||
v-model="formData.apgar1min"
|
||
type="number"
|
||
step="any"
|
||
:min="0"
|
||
:max="10"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab 5: 缴费信息 -->
|
||
<div id="form-section-payment" class="form-tab-pane">
|
||
<div class="section-card">
|
||
<div class="section-title">
|
||
<div class="section-title-left"><h3>缴费信息</h3></div>
|
||
</div>
|
||
|
||
<!-- 缴费主表 -->
|
||
<div class="payment-main-card">
|
||
<div class="section-subtitle">缴费主表</div>
|
||
<div class="form-grid form-grid--col6">
|
||
<el-form-item label="缴费模式">
|
||
<el-select
|
||
v-model="formData.payMode"
|
||
placeholder="请选择缴费模式"
|
||
style="width: 100%"
|
||
@change="onPayModeChange"
|
||
>
|
||
<el-option
|
||
v-for="opt in payModeOptions"
|
||
:key="opt.value"
|
||
:label="opt.label"
|
||
:value="opt.value"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="合同金额(元)">
|
||
<AmountInput
|
||
v-model="formData.contractAmount"
|
||
:min="0"
|
||
placeholder="请输入合同金额"
|
||
style="width: 100%"
|
||
@change="onContractAmountChange"
|
||
/>
|
||
</el-form-item>
|
||
<template v-if="formData.payMode === 'dj'">
|
||
<el-form-item label="趸交截止时间">
|
||
<el-date-picker
|
||
v-model="formData.lumpSumDeadline"
|
||
type="date"
|
||
value-format="YYYYMMDD"
|
||
format="YYYY-MM-DD"
|
||
placeholder="选择截止时间"
|
||
clearable
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
</template>
|
||
<template v-if="isInstallmentPayMode(formData.payMode)">
|
||
<el-form-item label="分期期数">
|
||
<el-input
|
||
v-model="formData.installmentCount"
|
||
type="number"
|
||
step="any"
|
||
:min="1"
|
||
style="width: 100%"
|
||
@change="onInstallmentCountChange"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="每期间隔(月)">
|
||
<el-input
|
||
v-model="formData.installmentIntervalMonths"
|
||
type="number"
|
||
step="any"
|
||
:min="0.1"
|
||
style="width: 100%"
|
||
@change="handleFqParamsChange"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="分期开始时间">
|
||
<el-date-picker
|
||
v-model="formData.installmentStartDate"
|
||
type="date"
|
||
value-format="YYYYMMDD"
|
||
format="YYYY-MM-DD"
|
||
placeholder="选择开始时间"
|
||
clearable
|
||
style="width: 100%"
|
||
@change="handleFqParamsChange"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="应缴费提前(天)">
|
||
<el-input
|
||
v-model="formData.installmentAdvanceDays"
|
||
type="number"
|
||
:min="0"
|
||
:max="365"
|
||
step="1"
|
||
style="width: 100%"
|
||
@change="handleFqParamsChange"
|
||
/>
|
||
</el-form-item>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 缴费明细 -->
|
||
<div class="section-subtitle" style="margin-top: 20px">缴费明细</div>
|
||
<div style="margin-bottom: 14px">
|
||
<el-button
|
||
type="primary"
|
||
:disabled="!canAddPaymentDetail(formData.payMode, formData.paymentDetails)"
|
||
@click="addPaymentDetail"
|
||
>
|
||
<el-icon><Plus /></el-icon>
|
||
添加明细
|
||
</el-button>
|
||
</div>
|
||
|
||
<div class="payment-details">
|
||
<div
|
||
v-for="(detail, dIdx) in formData.paymentDetails"
|
||
:key="dIdx"
|
||
class="detail-card"
|
||
:class="{ expanded: detail._expanded }"
|
||
>
|
||
<div
|
||
class="detail-header"
|
||
role="button"
|
||
tabindex="0"
|
||
@click="togglePaymentDetailExpand(dIdx)"
|
||
@keydown.enter.prevent="togglePaymentDetailExpand(dIdx)"
|
||
>
|
||
<span class="detail-chevron">▶</span>
|
||
<span class="detail-title">缴费明细 #{{ dIdx + 1 }}</span>
|
||
<span
|
||
v-if="detail.dueAmount != null && detail.dueAmount !== ''"
|
||
class="detail-amount"
|
||
:class="detail.payStatus === 'yjf' ? 'green' : 'amber'"
|
||
>
|
||
{{ fmtMoney(detail.dueAmount) }}
|
||
</span>
|
||
<span
|
||
v-if="detail.payStatus"
|
||
class="detail-badge"
|
||
:class="{
|
||
'badge-green': detail.payStatus === 'yjf',
|
||
'badge-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="removePaymentDetail(dIdx)"
|
||
>
|
||
<el-icon><Delete /></el-icon>
|
||
</el-button>
|
||
</div>
|
||
<div v-show="detail._expanded" class="detail-body">
|
||
<div class="detail-body-inner">
|
||
<div class="form-grid form-grid--col4">
|
||
<el-form-item label="应缴费金额(元)">
|
||
<AmountInput
|
||
v-model="detail.dueAmount"
|
||
:min="0"
|
||
:disabled="isPayDetailLocked(detail)"
|
||
placeholder="请输入应缴费金额"
|
||
@change="onPaymentDetailDueAmountChange(dIdx)"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="转账方式">
|
||
<el-select
|
||
v-model="detail.transferType"
|
||
placeholder="请选择转账方式"
|
||
style="width: 100%"
|
||
>
|
||
<el-option
|
||
v-for="opt in transferTypeOptions"
|
||
:key="opt.value"
|
||
:label="opt.label"
|
||
:value="opt.value"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="转账金额(元)">
|
||
<AmountInput
|
||
v-model="detail.transferAmount"
|
||
:min="0"
|
||
:disabled="isPayDetailLocked(detail)"
|
||
placeholder="请输入转账金额"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="转账时间">
|
||
<el-date-picker
|
||
v-model="detail.transferTime"
|
||
type="datetime"
|
||
value-format="YYYY-MM-DD HH:mm:ss"
|
||
placeholder="选择时间"
|
||
clearable
|
||
:disabled="isPayDetailLocked(detail)"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="缴费状态">
|
||
<el-tag
|
||
v-if="isPayDetailLocked(detail)"
|
||
:type="payStatusTagType(String(detail.payStatus))"
|
||
size="small"
|
||
>
|
||
{{ payStatusLabel(String(detail.payStatus)) }}
|
||
</el-tag>
|
||
<el-select
|
||
v-else
|
||
v-model="detail.payStatus"
|
||
placeholder="状态"
|
||
style="width: 100%"
|
||
>
|
||
<el-option label="待缴费" value="dfj" />
|
||
</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"
|
||
:class="{ 'is-dragover': isPaymentDetailDragover(dIdx) }"
|
||
v-bind="paymentDetailDropHandlers(dIdx)"
|
||
>
|
||
<div class="attachment-list">
|
||
<el-upload
|
||
:auto-upload="false"
|
||
:show-file-list="false"
|
||
:multiple="true"
|
||
accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
|
||
@change="(f: any) => handleDetailAttachmentChange(dIdx, f)"
|
||
>
|
||
<div class="upload-card upload-card--sm">
|
||
<el-icon :size="22" color="#047857"><Plus /></el-icon>
|
||
<span class="upload-card-text">上传凭证</span>
|
||
</div>
|
||
</el-upload>
|
||
<div
|
||
v-for="(file, fIdx) in detail.attachments"
|
||
:key="file.id || fIdx"
|
||
class="attachment-item"
|
||
:class="{ 'is-uploading': file._uploading }"
|
||
>
|
||
<div
|
||
class="attachment-preview"
|
||
@click="
|
||
isImageFile(file) && file.fileUrl
|
||
? handlePreviewAttachment(file)
|
||
: undefined
|
||
"
|
||
>
|
||
<template v-if="isImageFile(file) && file.fileUrl">
|
||
<img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
|
||
</template>
|
||
<template v-else-if="isImageFile(file) && !file.fileUrl">
|
||
<el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
|
||
</template>
|
||
<template v-else-if="isVideoFile(file)">
|
||
<div
|
||
class="attachment-preview__icon attachment-preview__icon--video"
|
||
>
|
||
<el-icon :size="28"><VideoPlay /></el-icon>
|
||
</div>
|
||
</template>
|
||
<template v-else>
|
||
<div class="attachment-preview__icon attachment-preview__icon--doc">
|
||
<el-icon :size="28"><Document /></el-icon>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<div class="attachment-footer">
|
||
<span class="attachment-name" :title="file.fileName">{{
|
||
file.fileName
|
||
}}</span>
|
||
<div class="attachment-actions">
|
||
<el-icon
|
||
class="action-btn"
|
||
title="下载"
|
||
@click="handleDownloadAttachment(file)"
|
||
><Download
|
||
/></el-icon>
|
||
<el-icon
|
||
class="action-btn action-btn--danger"
|
||
title="删除"
|
||
@click="removeDetailAttachment(dIdx, fIdx)"
|
||
><Delete
|
||
/></el-icon>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<span class="upload-hint"
|
||
>支持拖拽或点击上传图片、视频、PDF、Word、Excel等文件,单文件不超过
|
||
20MB</span
|
||
>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<el-empty
|
||
v-if="!formData.paymentDetails.length"
|
||
description="暂无缴费明细,点击上方按钮添加"
|
||
:image-size="48"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-form>
|
||
</DialogFormBody>
|
||
|
||
<template #footer>
|
||
<el-button class="btn-ghost" @click="emit('update:modelValue', false)">
|
||
<el-icon><Close /></el-icon>
|
||
取消
|
||
</el-button>
|
||
<el-button class="btn-main" type="primary" :loading="submitLoading" @click="handleSubmit">
|
||
<el-icon><Check /></el-icon>
|
||
确定
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- ========== 文件预览弹窗 ========== -->
|
||
<el-dialog
|
||
v-model="previewVisible"
|
||
title="文件预览"
|
||
width="800px"
|
||
align-center
|
||
append-to-body
|
||
destroy-on-close
|
||
>
|
||
<div v-if="previewType === 'image'" class="preview-container">
|
||
<img
|
||
:src="previewUrl"
|
||
alt="预览"
|
||
style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
|
||
/>
|
||
</div>
|
||
<div v-else class="preview-container">
|
||
<video
|
||
:src="previewUrl"
|
||
controls
|
||
style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
|
||
/>
|
||
</div>
|
||
</el-dialog>
|
||
</template>
|
||
<script setup lang="ts">
|
||
import DialogFormBody from '@/components/business/dialog-form-body/index.vue'
|
||
import SampleTransportFormSection from '@/components/business/sample-transport-form-section/index.vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import {
|
||
Plus,
|
||
Close,
|
||
Check,
|
||
Delete,
|
||
Document,
|
||
VideoPlay,
|
||
Loading,
|
||
Download,
|
||
ArrowDown,
|
||
ArrowRight,
|
||
Upload
|
||
} 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 {
|
||
useAttachmentDropZone,
|
||
useIndexedAttachmentDropZone
|
||
} from '@/composables/useAttachmentDropZone'
|
||
import { formatDatetimeToMinute } from '@/utils/chinese-datetime-parse'
|
||
import {
|
||
matchExpressCompany,
|
||
matchSampleTypeByExactName
|
||
} from '@/utils/donation-standard-import-parse'
|
||
import {
|
||
parseProtocolStandardImportText,
|
||
resolveTransferTypeCode,
|
||
isBlankString,
|
||
matchCellSampleType,
|
||
type ProtocolStandardImportParsed
|
||
} from '@/utils/protocol-standard-import-parse'
|
||
import {
|
||
fetchCustomerList,
|
||
fetchUserList,
|
||
fetchUserDetail,
|
||
fetchCellSampleTypeList,
|
||
fetchItemUnitList,
|
||
fetchCollectionRegionList,
|
||
fetchHospitalList,
|
||
fetchSearchCollectionHospitals,
|
||
fetchSampleCodeList,
|
||
fetchExpressCompanyList,
|
||
fetchSiteList
|
||
} from '@/api/admin'
|
||
import { fetchEthnicList, type EthnicOption } from '@/api/ethnic'
|
||
import { useUserStore } from '@/store/modules/user'
|
||
import {
|
||
DEFAULT_INSTALLMENT_ADVANCE_DAYS,
|
||
normalizeInstallmentAmounts,
|
||
resizeInstallmentAmounts,
|
||
syncFqPaymentDetailRowCount
|
||
} from '@/utils/protocol-payment-installment'
|
||
import {
|
||
applyPayModeSwitch,
|
||
canAddPaymentDetail,
|
||
createPaymentDetailRow,
|
||
djDueAmountMismatchHint,
|
||
evaluatePayModeSwitch,
|
||
isPayDetailLocked,
|
||
markPayDetailLocks,
|
||
sumPaidAmount,
|
||
type PayMode
|
||
} from '@/utils/protocol-pay-mode-switch'
|
||
import {
|
||
availablePayModes,
|
||
getActiveSampleCount,
|
||
isInstallmentPayMode,
|
||
isPayModeValidForSampleCount,
|
||
resolvePaymentPreset,
|
||
type PayMode as PresetPayMode
|
||
} from '@/utils/protocol-payment-presets'
|
||
import {
|
||
applyFqParamsChange,
|
||
evaluateFqParamsChange,
|
||
fqParamsSnapshot,
|
||
generateInitialFqDetails,
|
||
isSameFqParams,
|
||
type FqParams
|
||
} from '@/utils/protocol-fq-params-change'
|
||
import AmountInput from '@/components/common/amount-input/index.vue'
|
||
import { formatMoneyDisplay } from '@/utils/money-format'
|
||
import {
|
||
payModeLabel,
|
||
newbornSexLabel,
|
||
deliveryModeLabel,
|
||
sampleStatusLabel,
|
||
loadTransferTypeDict,
|
||
getTransferTypeOptions,
|
||
transferTypeLabel,
|
||
payStatusLabel,
|
||
payStatusTagType
|
||
} from '@/utils/protocol-payment-labels'
|
||
|
||
const transferTypeOptions = ref<Api.Dict.DictDataOption[]>([])
|
||
|
||
async function ensureTransferTypeOptions() {
|
||
await loadTransferTypeDict()
|
||
transferTypeOptions.value = getTransferTypeOptions()
|
||
}
|
||
|
||
/** 金额只读展示:千分位 + ¥ 前缀 */
|
||
function fmtMoney(v: unknown): string {
|
||
if (v == null || v === '') return '—'
|
||
return '¥' + formatMoneyDisplay(v)
|
||
}
|
||
|
||
const userStore = useUserStore()
|
||
|
||
/** 上次确认生效的缴费模式(用于切换取消/拦截时回滚) */
|
||
const lastConfirmedPayMode = ref<PayMode>('dj')
|
||
|
||
const activeSampleCount = computed(() => getActiveSampleCount(formData.samples))
|
||
|
||
const payModeOptions = computed(() => {
|
||
const modes = [...availablePayModes(activeSampleCount.value)]
|
||
const current = String(formData.payMode || '') as PresetPayMode
|
||
if (current && !modes.includes(current)) {
|
||
modes.push(current)
|
||
}
|
||
return modes.map((value) => ({ value, label: payModeLabel(value) }))
|
||
})
|
||
|
||
function hasLockedPaymentDetails() {
|
||
return formData.paymentDetails.some((d) => isPayDetailLocked(d))
|
||
}
|
||
|
||
/** 上次确认生效的分期参数(用于编辑时修改期数/间隔/开始时间回滚) */
|
||
const lastConfirmedFqParams = ref<FqParams>({
|
||
installmentCount: null,
|
||
installmentIntervalMonths: null,
|
||
installmentStartDate: '',
|
||
installmentAdvanceDays: DEFAULT_INSTALLMENT_ADVANCE_DAYS,
|
||
installmentRatios: [] as number[]
|
||
})
|
||
|
||
const 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 sampleTypeLoading = ref(false)
|
||
const unitLoading = ref(false)
|
||
const siteLoading = ref(false)
|
||
const expressCompanyLoading = ref(false)
|
||
const expressCompanyOptions = ref<Api.ExpressCompany.ExpressCompanyItem[]>([])
|
||
const siteOptions = ref<Api.Site.SiteItem[]>([])
|
||
let sampleRowSeq = 0
|
||
|
||
type SampleTransportForm = {
|
||
transportType: string
|
||
expressCompanyId: string
|
||
waybillNo: string
|
||
destSiteId: string
|
||
expressFee: number | null
|
||
etaTime: string
|
||
carrierName: string
|
||
carrierPhone: string
|
||
}
|
||
|
||
function createEmptyTransport(): SampleTransportForm {
|
||
return {
|
||
transportType: '',
|
||
expressCompanyId: '',
|
||
waybillNo: '',
|
||
destSiteId: '',
|
||
expressFee: null,
|
||
etaTime: '',
|
||
carrierName: '',
|
||
carrierPhone: ''
|
||
}
|
||
}
|
||
|
||
const ethnicOptions = ref<EthnicOption[]>([])
|
||
const ethnicLoading = ref(false)
|
||
const DEFAULT_MOTHER_ETHNIC = '汉族'
|
||
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
|
||
|
||
function fullFileUrl(suffix: string) {
|
||
if (!suffix) return ''
|
||
if (suffix.startsWith('http://') || suffix.startsWith('https://') || suffix.startsWith('/'))
|
||
return suffix
|
||
return `${minioBaseUrl}/${suffix}`
|
||
}
|
||
|
||
async function loadEthnicOptions() {
|
||
if (ethnicOptions.value.length) return
|
||
ethnicLoading.value = true
|
||
try {
|
||
const res = await fetchEthnicList({ limit: 200 })
|
||
ethnicOptions.value = res.list || []
|
||
} finally {
|
||
ethnicLoading.value = false
|
||
}
|
||
}
|
||
|
||
function ensureEthnicOptionInList(name: string) {
|
||
const n = (name || '').trim()
|
||
if (!n) return
|
||
if (ethnicOptions.value.some((e) => e.ethnicName === n)) return
|
||
ethnicOptions.value = [
|
||
{ id: '', ethnicName: n, ethnicCode: '', orderNum: null },
|
||
...ethnicOptions.value
|
||
]
|
||
}
|
||
|
||
defineOptions({ name: 'ProtocolFormDialog' })
|
||
|
||
const props = defineProps<{
|
||
modelValue: boolean
|
||
editId: string | null
|
||
listRow?: CollectionProtocolRow | null
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'update:modelValue', val: boolean): void
|
||
(e: 'saved'): void
|
||
}>()
|
||
|
||
function formatPaymentDateYmd(val: string) {
|
||
const s = String(val || '').trim()
|
||
if (s.length === 8 && /^\d{8}$/.test(s)) {
|
||
return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`
|
||
}
|
||
return s || ''
|
||
}
|
||
|
||
function initPaymentDetailExpandFlags(details: Array<Record<string, any>>) {
|
||
details.forEach((d, i) => {
|
||
d._expanded = i === 0
|
||
})
|
||
}
|
||
|
||
function paymentDetailSummary(detail: Record<string, any>) {
|
||
const parts: string[] = []
|
||
if (detail.dueAmount != null && detail.dueAmount !== '') {
|
||
parts.push(`应缴 ¥${detail.dueAmount}`)
|
||
}
|
||
if (detail.transferType) {
|
||
parts.push(transferTypeLabel(String(detail.transferType)))
|
||
}
|
||
if (detail.transferAmount != null && detail.transferAmount !== '') {
|
||
parts.push(`转账 ¥${detail.transferAmount}`)
|
||
}
|
||
if (detail.transferTime) {
|
||
parts.push(String(detail.transferTime))
|
||
}
|
||
if (detail.payStatus) {
|
||
parts.push(payStatusLabel(String(detail.payStatus)))
|
||
}
|
||
if (detail.installmentDueDate) {
|
||
parts.push(`应缴日 ${formatPaymentDateYmd(String(detail.installmentDueDate))}`)
|
||
}
|
||
const attCount = Array.isArray(detail.attachments) ? detail.attachments.length : 0
|
||
if (attCount > 0) {
|
||
parts.push(`凭证 ${attCount} 个`)
|
||
}
|
||
return parts.length ? parts.join(' · ') : '暂无填写'
|
||
}
|
||
|
||
function togglePaymentDetailExpand(dIdx: number) {
|
||
const d = formData.paymentDetails[dIdx]
|
||
if (d) {
|
||
d._expanded = !d._expanded
|
||
}
|
||
}
|
||
|
||
/* ====== Tab 常量 ====== */
|
||
const formTabs = [
|
||
{ key: 'basic', label: '基本信息' },
|
||
{ key: 'collection', label: '采集信息' },
|
||
{ key: 'sample', label: '样本及运输信息' },
|
||
{ key: 'party', label: '客户信息' },
|
||
{ key: 'payment', label: '缴费信息' }
|
||
] as const
|
||
|
||
/* ====== 新增/编辑弹窗 ====== */
|
||
const dialogFormLoading = ref(false)
|
||
const dialogTitle = ref('新增协议单')
|
||
const isEdit = ref(false)
|
||
const standardImportText = ref('')
|
||
const standardImportLoading = ref(false)
|
||
const submitLoading = ref(false)
|
||
const editingId = ref<string | null>(null)
|
||
const formRef = ref()
|
||
const activeFormTab = ref('basic')
|
||
|
||
const formData = reactive({
|
||
// Basic
|
||
protocolNo: '',
|
||
protocolNoId: '',
|
||
protocolStatus: 'dsx',
|
||
registerDate: '',
|
||
collectorUserId: '',
|
||
protocolAttachments: '',
|
||
consentFormUrls: '',
|
||
healthSurveyFormUrls: '',
|
||
remark: '',
|
||
attachments: [] as Array<{
|
||
id?: string
|
||
fileName: string
|
||
fileUrl: string
|
||
fileSize: number
|
||
fileSizeDisplay?: string
|
||
fileType?: string
|
||
_uploading?: boolean
|
||
}>,
|
||
consentAttachments: [] as Array<{
|
||
id?: string
|
||
fileName: string
|
||
fileUrl: string
|
||
fileSize: number
|
||
fileSizeDisplay?: string
|
||
fileType?: string
|
||
_uploading?: boolean
|
||
}>,
|
||
healthSurveyAttachments: [] as Array<{
|
||
id?: string
|
||
fileName: string
|
||
fileUrl: string
|
||
fileSize: number
|
||
fileSizeDisplay?: string
|
||
fileType?: string
|
||
_uploading?: boolean
|
||
}>,
|
||
// Party Customer
|
||
customerId: '' as string,
|
||
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: [] as number[] 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: '',
|
||
consentFormUrls: '',
|
||
healthSurveyFormUrls: '',
|
||
attachments: [],
|
||
consentAttachments: [],
|
||
healthSurveyAttachments: [],
|
||
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: [] as number[],
|
||
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 = []
|
||
sampleTypeOptions.value = []
|
||
unitOptions.value = []
|
||
sampleCodeOptions.value = []
|
||
siteOptions.value = []
|
||
expressCompanyOptions.value = []
|
||
activeFormTab.value = 'basic'
|
||
lastConfirmedPayMode.value = 'dj'
|
||
standardImportText.value = ''
|
||
lastConfirmedFqParams.value = {
|
||
installmentCount: null,
|
||
installmentIntervalMonths: null,
|
||
installmentStartDate: '',
|
||
installmentAdvanceDays: DEFAULT_INSTALLMENT_ADVANCE_DAYS,
|
||
installmentRatios: [] as number[]
|
||
}
|
||
}
|
||
|
||
function scrollToSection(key: string) {
|
||
activeFormTab.value = key
|
||
if (key === 'payment') {
|
||
maybeApplyPaymentPresetOnTabEnter()
|
||
}
|
||
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 removePaymentDetail(dIdx: number) {
|
||
const detail = formData.paymentDetails[dIdx]
|
||
if (detail && isPayDetailLocked(detail)) return
|
||
formData.paymentDetails.splice(dIdx, 1)
|
||
syncContractAmountFromPaymentDetails()
|
||
}
|
||
|
||
function addPaymentDetail() {
|
||
if (!canAddPaymentDetail(formData.payMode, formData.paymentDetails)) return
|
||
const now = new Date()
|
||
const pad = (n: number) => String(n).padStart(2, '0')
|
||
const defaultTime = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
|
||
const isFirst = formData.paymentDetails.length === 0
|
||
// 趸交模式下应缴费时间默认等于趸交截止时间
|
||
const defaultDueDate = formData.payMode === 'dj' ? formData.lumpSumDeadline : ''
|
||
formData.paymentDetails.push({
|
||
transferType: '',
|
||
dueAmount: null,
|
||
transferAmount: null,
|
||
transferTime: defaultTime,
|
||
payStatus: 'dfj',
|
||
installmentDueDate: defaultDueDate,
|
||
_expanded: isFirst,
|
||
attachments: [] as Array<{
|
||
id?: string
|
||
fileName: string
|
||
fileUrl: string
|
||
fileSize: number
|
||
fileSizeDisplay?: string
|
||
fileType?: string
|
||
_uploading?: boolean
|
||
}>
|
||
})
|
||
syncPaymentDueAmounts()
|
||
}
|
||
|
||
/** 分期开始时间默认值:新生儿分娩时间 > 预产期 > 当前日期(YYYYMMDD) */
|
||
function defaultInstallmentStartDateYmd(): string {
|
||
const deliveryYmd = datetimeToYmd(formData.newbornDeliveryTime)
|
||
if (deliveryYmd) return deliveryYmd
|
||
const expectedYmd = datetimeToYmd(formData.expectedDeliveryDate)
|
||
if (expectedYmd) return expectedYmd
|
||
const now = new Date()
|
||
const pad = (n: number) => String(n).padStart(2, '0')
|
||
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
|
||
}
|
||
|
||
function datetimeToYmd(val: string): string {
|
||
const compact = String(val || '')
|
||
.trim()
|
||
.replace(/[-:\sT]/g, '')
|
||
.slice(0, 8)
|
||
return /^\d{8}$/.test(compact) ? compact : ''
|
||
}
|
||
|
||
function isFqNumericFieldEmpty(val: unknown): boolean {
|
||
if (val == null || val === '') return true
|
||
const n = Number(val)
|
||
return !Number.isFinite(n) || n < 1
|
||
}
|
||
|
||
/**
|
||
* 分期默认:期数、每期间隔均为 6;开始时间为空时按分娩/预产期/今日填充。
|
||
* resetCountInterval:切换到分期时强制重置期数与间隔(与新增一致)。
|
||
*/
|
||
function applyFqPaymentDefaults(options?: { resetCountInterval?: boolean }) {
|
||
const reset = options?.resetCountInterval ?? false
|
||
const sampleCount = getActiveSampleCount(formData.samples)
|
||
const preset = resolvePaymentPreset(sampleCount, formData.payMode as PresetPayMode)
|
||
if (preset?.contractAmount != null) {
|
||
formData.contractAmount = preset.contractAmount
|
||
}
|
||
if (reset || isFqNumericFieldEmpty(formData.installmentCount)) {
|
||
formData.installmentCount = preset?.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 (preset?.installmentAmounts?.length) {
|
||
formData.installmentRatios = [...preset.installmentAmounts]
|
||
} else {
|
||
formData.installmentRatios = normalizeInstallmentAmounts(
|
||
formData.installmentRatios || [],
|
||
formData.contractAmount,
|
||
count
|
||
)
|
||
}
|
||
}
|
||
|
||
function applyPaymentPresetForMode(payMode: PresetPayMode) {
|
||
const sampleCount = getActiveSampleCount(formData.samples)
|
||
const preset = resolvePaymentPreset(sampleCount, payMode)
|
||
if (!preset) return
|
||
formData.contractAmount = preset.contractAmount
|
||
if (isInstallmentPayMode(payMode) && preset.installmentCount) {
|
||
formData.installmentCount = preset.installmentCount
|
||
if (isFqNumericFieldEmpty(formData.installmentIntervalMonths)) {
|
||
formData.installmentIntervalMonths = 6
|
||
}
|
||
if (!formData.installmentStartDate) {
|
||
formData.installmentStartDate = defaultInstallmentStartDateYmd()
|
||
}
|
||
if (formData.installmentAdvanceDays == null) {
|
||
formData.installmentAdvanceDays = DEFAULT_INSTALLMENT_ADVANCE_DAYS
|
||
}
|
||
formData.installmentRatios = [...(preset.installmentAmounts || [])]
|
||
formData.paymentDetails = generateInitialFqDetails(
|
||
getFqParamsFromForm(),
|
||
preset.contractAmount
|
||
)
|
||
initPaymentDetailExpandFlags(formData.paymentDetails)
|
||
markPayDetailLocks(formData.paymentDetails)
|
||
syncLastConfirmedFqParams()
|
||
} else {
|
||
formData.paymentDetails = [
|
||
createPaymentDetailRow({
|
||
dueAmount: preset.contractAmount,
|
||
installmentDueDate: formData.lumpSumDeadline || '',
|
||
expanded: true
|
||
})
|
||
]
|
||
initPaymentDetailExpandFlags(formData.paymentDetails)
|
||
markPayDetailLocks(formData.paymentDetails)
|
||
}
|
||
}
|
||
|
||
function maybeApplyPaymentPresetOnTabEnter() {
|
||
if (hasLockedPaymentDetails()) return
|
||
if (formData.paymentDetails.length > 0) return
|
||
applyPaymentPresetForMode(formData.payMode as PresetPayMode)
|
||
}
|
||
|
||
function toIntOrUndefined(val: unknown): number | undefined {
|
||
if (val == null || val === '') return undefined
|
||
const n = Number(val)
|
||
return Number.isFinite(n) ? Math.trunc(n) : undefined
|
||
}
|
||
|
||
function toFloatOrUndefined(val: unknown): number | undefined {
|
||
if (val == null || val === '') return undefined
|
||
const n = Number(val)
|
||
return Number.isFinite(n) ? n : undefined
|
||
}
|
||
|
||
function fqPaymentContext() {
|
||
return {
|
||
payMode: formData.payMode,
|
||
installmentCount: toIntOrUndefined(formData.installmentCount) ?? null,
|
||
contractAmount: formData.contractAmount,
|
||
installmentStartDate: formData.installmentStartDate,
|
||
installmentIntervalMonths: toFloatOrUndefined(formData.installmentIntervalMonths) ?? null,
|
||
installmentAdvanceDays: toIntOrUndefined(formData.installmentAdvanceDays) ?? null,
|
||
installmentRatios: [...(formData.installmentRatios || [])]
|
||
}
|
||
}
|
||
|
||
function payModeSwitchContext() {
|
||
return {
|
||
contractAmount: formData.contractAmount,
|
||
lumpSumDeadline: formData.lumpSumDeadline,
|
||
installmentCount: toIntOrUndefined(formData.installmentCount) ?? null,
|
||
installmentIntervalMonths: toFloatOrUndefined(formData.installmentIntervalMonths) ?? null,
|
||
installmentStartDate: formData.installmentStartDate,
|
||
installmentAdvanceDays: toIntOrUndefined(formData.installmentAdvanceDays) ?? null,
|
||
installmentRatios: [...(formData.installmentRatios || [])]
|
||
}
|
||
}
|
||
|
||
function getFqParamsFromForm(): FqParams {
|
||
return {
|
||
installmentCount: toIntOrUndefined(formData.installmentCount) ?? null,
|
||
installmentIntervalMonths: toFloatOrUndefined(formData.installmentIntervalMonths) ?? null,
|
||
installmentStartDate: formData.installmentStartDate || '',
|
||
installmentAdvanceDays: toIntOrUndefined(formData.installmentAdvanceDays) ?? null,
|
||
installmentRatios: [...(formData.installmentRatios || [])].map((v) => Number(v || 0))
|
||
}
|
||
}
|
||
|
||
function restoreFqParams(params: FqParams) {
|
||
formData.installmentCount = params.installmentCount
|
||
formData.installmentIntervalMonths = params.installmentIntervalMonths
|
||
formData.installmentStartDate = params.installmentStartDate || ''
|
||
formData.installmentAdvanceDays = params.installmentAdvanceDays
|
||
formData.installmentRatios = [...(params.installmentRatios || [])]
|
||
}
|
||
|
||
function syncLastConfirmedFqParams() {
|
||
lastConfirmedFqParams.value = fqParamsSnapshot(getFqParamsFromForm())
|
||
}
|
||
|
||
/**
|
||
* 分期参数变更(期数 / 间隔 / 开始时间):校验 → 确认 → 重建未缴期次
|
||
* 与缴费模式切换独立;保留 yjf 已缴行,作废原 dfj 后按剩余金额重建。
|
||
*/
|
||
async function handleFqParamsChange() {
|
||
if (formData.payMode !== 'fq' && formData.payMode !== 'djfq') return
|
||
|
||
const newParams = getFqParamsFromForm()
|
||
const oldParams = lastConfirmedFqParams.value
|
||
|
||
if (isSameFqParams(oldParams, newParams)) return
|
||
|
||
if (
|
||
!newParams.installmentCount ||
|
||
!newParams.installmentIntervalMonths ||
|
||
!newParams.installmentStartDate
|
||
) {
|
||
return
|
||
}
|
||
|
||
if (formData.paymentDetails.length === 0) {
|
||
formData.paymentDetails = generateInitialFqDetails(newParams, formData.contractAmount)
|
||
initPaymentDetailExpandFlags(formData.paymentDetails)
|
||
markPayDetailLocks(formData.paymentDetails)
|
||
syncLastConfirmedFqParams()
|
||
return
|
||
}
|
||
|
||
const evaluation = evaluateFqParamsChange(
|
||
oldParams,
|
||
newParams,
|
||
formData.paymentDetails,
|
||
formData.contractAmount
|
||
)
|
||
|
||
if (evaluation.action === 'noop') {
|
||
syncLastConfirmedFqParams()
|
||
return
|
||
}
|
||
|
||
if (evaluation.action === 'blocked') {
|
||
restoreFqParams(oldParams)
|
||
await ElMessageBox.alert(evaluation.reason, '无法修改分期参数', { type: 'warning' })
|
||
return
|
||
}
|
||
|
||
if (evaluation.action === 'confirm') {
|
||
try {
|
||
await ElMessageBox.confirm(evaluation.summary, '修改分期参数', {
|
||
confirmButtonText: '确认修改',
|
||
cancelButtonText: '取消',
|
||
type: 'warning'
|
||
})
|
||
} catch {
|
||
restoreFqParams(oldParams)
|
||
return
|
||
}
|
||
}
|
||
|
||
formData.paymentDetails = applyFqParamsChange(
|
||
newParams,
|
||
formData.paymentDetails,
|
||
formData.contractAmount
|
||
)
|
||
initPaymentDetailExpandFlags(formData.paymentDetails)
|
||
markPayDetailLocks(formData.paymentDetails)
|
||
syncLastConfirmedFqParams()
|
||
}
|
||
|
||
/** 缴费模式切换:校验 → 确认 → 变换明细 */
|
||
async function onPayModeChange(val: string) {
|
||
const toMode = val as PayMode
|
||
const fromMode = lastConfirmedPayMode.value
|
||
if (toMode === fromMode) return
|
||
|
||
const evaluation = evaluatePayModeSwitch(
|
||
fromMode,
|
||
toMode,
|
||
formData.paymentDetails,
|
||
payModeSwitchContext()
|
||
)
|
||
|
||
if (evaluation.action === 'blocked') {
|
||
formData.payMode = fromMode
|
||
await ElMessageBox.alert(evaluation.reason, '无法切换缴费模式', { type: 'warning' })
|
||
return
|
||
}
|
||
|
||
if (evaluation.action === 'confirm') {
|
||
try {
|
||
await ElMessageBox.confirm(evaluation.summary, '切换缴费模式', {
|
||
confirmButtonText: '确认切换',
|
||
cancelButtonText: '取消',
|
||
type: 'warning'
|
||
})
|
||
} catch {
|
||
formData.payMode = fromMode
|
||
return
|
||
}
|
||
}
|
||
|
||
if (!hasLockedPaymentDetails()) {
|
||
applyPaymentPresetForMode(toMode as PresetPayMode)
|
||
} else {
|
||
if (isInstallmentPayMode(toMode)) {
|
||
applyFqPaymentDefaults({ resetCountInterval: fromMode === 'dj' })
|
||
}
|
||
formData.paymentDetails = applyPayModeSwitch(
|
||
fromMode,
|
||
toMode,
|
||
formData.paymentDetails,
|
||
payModeSwitchContext()
|
||
)
|
||
initPaymentDetailExpandFlags(formData.paymentDetails)
|
||
markPayDetailLocks(formData.paymentDetails)
|
||
const preset = resolvePaymentPreset(
|
||
getActiveSampleCount(formData.samples),
|
||
toMode as PresetPayMode
|
||
)
|
||
if (preset?.contractAmount != null) {
|
||
formData.contractAmount = preset.contractAmount
|
||
}
|
||
}
|
||
|
||
if (toMode === 'dj') {
|
||
syncLumpSumDueDate()
|
||
}
|
||
|
||
lastConfirmedPayMode.value = toMode
|
||
if (isInstallmentPayMode(toMode)) {
|
||
syncLastConfirmedFqParams()
|
||
}
|
||
}
|
||
|
||
/** 趸交模式:将趸交截止时间同步到待缴明细的应缴费时间 */
|
||
function syncLumpSumDueDate() {
|
||
if (formData.payMode !== 'dj') return
|
||
const deadline = formData.lumpSumDeadline || ''
|
||
formData.paymentDetails.forEach((d) => {
|
||
if (isPayDetailLocked(d)) return
|
||
d.installmentDueDate = deadline
|
||
})
|
||
}
|
||
|
||
function handleOpenForAdd() {
|
||
isEdit.value = false
|
||
dialogTitle.value = '新增协议单'
|
||
resetForm()
|
||
formData.protocolStatus = 'sxz'
|
||
formData.motherEthnic = DEFAULT_MOTHER_ETHNIC
|
||
formData.registerDate = new Date().toISOString().slice(0, 10).replace(/-/g, '')
|
||
void loadEthnicOptions()
|
||
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 = formatDatetimeToMinute(String(ci.sampleCollectTime || ''))
|
||
formData.maternalBloodCollectTime = formatDatetimeToMinute(String(ci.maternalBloodCollectTime || ''))
|
||
}
|
||
|
||
const samples = bundle.samples as Array<Record<string, any>> | null
|
||
if (samples && samples.length) {
|
||
formData.samples = samples.map((s, i) => normalizeSampleRow(s, i))
|
||
}
|
||
|
||
const pay = bundle.payment as Record<string, any> | null
|
||
if (pay) {
|
||
formData.payMode = String(pay.payMode || 'dj')
|
||
formData.contractAmount = pay.contractAmount ?? null
|
||
formData.lumpSumDeadline = String(pay.lumpSumDeadline || '')
|
||
formData.installmentCount = pay.installmentCount ?? null
|
||
formData.installmentIntervalMonths = pay.installmentIntervalMonths ?? null
|
||
formData.installmentStartDate = String(pay.installmentStartDate || '')
|
||
formData.installmentAdvanceDays =
|
||
pay.installmentAdvanceDays ?? DEFAULT_INSTALLMENT_ADVANCE_DAYS
|
||
const loadedAmounts = Array.isArray(pay.installmentRatios) ? pay.installmentRatios : []
|
||
if (loadedAmounts.length) {
|
||
formData.installmentRatios = loadedAmounts.map((v: unknown) => Number(v))
|
||
}
|
||
if (isInstallmentPayMode(formData.payMode)) {
|
||
applyFqPaymentDefaults()
|
||
const count = toIntOrUndefined(formData.installmentCount) || 6
|
||
formData.installmentRatios = normalizeInstallmentAmounts(
|
||
formData.installmentRatios,
|
||
formData.contractAmount,
|
||
count
|
||
)
|
||
syncLastConfirmedFqParams()
|
||
}
|
||
}
|
||
|
||
const pd = bundle.paymentDetails as Array<Record<string, any>> | null
|
||
if (pd && pd.length) {
|
||
const details = pd.map((d) => ({
|
||
...d,
|
||
attachments: Array.isArray(d.attachments) ? d.attachments : []
|
||
}))
|
||
initPaymentDetailExpandFlags(details)
|
||
formData.paymentDetails = details
|
||
markPayDetailLocks(formData.paymentDetails)
|
||
}
|
||
syncFqPaymentDetailRowCount(fqPaymentContext(), formData.paymentDetails)
|
||
lastConfirmedPayMode.value = (
|
||
['fq', 'djfq'].includes(formData.payMode) ? formData.payMode : 'dj'
|
||
) as PayMode
|
||
|
||
const pa =
|
||
(row as any).protocolAttachments || (bundle.protocol as any)?.protocolAttachments || ''
|
||
if (pa && typeof pa === 'string') {
|
||
// legacy URL-only field
|
||
}
|
||
const att = (bundle as any).attachments as Array<Record<string, any>> | null
|
||
if (att && att.length) {
|
||
formData.attachments = att.map((a) => ({ ...a })) as any
|
||
}
|
||
const consentAtt = (bundle as any).consentAttachmentFiles as Array<Record<string, any>> | null
|
||
if (consentAtt && consentAtt.length) {
|
||
formData.consentAttachments = consentAtt.map((a) => ({ ...a })) as any
|
||
}
|
||
const healthSurveyAtt = (bundle as any).healthSurveyAttachmentFiles as
|
||
| Array<Record<string, any>>
|
||
| null
|
||
if (healthSurveyAtt && healthSurveyAtt.length) {
|
||
formData.healthSurveyAttachments = healthSurveyAtt.map((a) => ({ ...a })) as any
|
||
}
|
||
}
|
||
|
||
async function handleOpenForEdit() {
|
||
const row = props.listRow
|
||
if (!row || !props.editId) return
|
||
|
||
isEdit.value = true
|
||
dialogTitle.value = '编辑协议单'
|
||
editingId.value = props.editId
|
||
resetForm()
|
||
|
||
formData.protocolNo = String(row.protocolNo || '')
|
||
formData.protocolStatus = String(row.protocolStatus || 'dsx')
|
||
formData.registerDate = String(row.registerDate || '')
|
||
formData.collectorUserId = String(row.collectorUserId || '')
|
||
formData.protocolAttachments = String((row as any).protocolAttachments || '')
|
||
formData.consentFormUrls = String((row as any).consentFormUrls || '')
|
||
formData.healthSurveyFormUrls = String((row as any).healthSurveyFormUrls || '')
|
||
formData.remark = String(row.remark || '')
|
||
|
||
dialogFormLoading.value = true
|
||
|
||
try {
|
||
const [, bundle] = await Promise.all([
|
||
loadEthnicOptions(),
|
||
fetchCollectionProtocolDetail(String(row.id)).catch(() => null)
|
||
])
|
||
if (bundle) {
|
||
applyProtocolBundleToForm(bundle as Record<string, any>, row)
|
||
}
|
||
|
||
const 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) {
|
||
await Promise.all([
|
||
remoteSearchSampleTypes(''),
|
||
remoteSearchSites(''),
|
||
remoteSearchUnits(''),
|
||
remoteSearchSampleCodes('')
|
||
])
|
||
hydrateSampleLookupOptions()
|
||
}
|
||
} catch {
|
||
/* 保留列表行已填基础字段 */
|
||
} finally {
|
||
dialogFormLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function handleSubmit() {
|
||
if (!formRef.value || submitLoading.value) return
|
||
submitLoading.value = true
|
||
try {
|
||
await formRef.value.validate()
|
||
} catch {
|
||
submitLoading.value = false
|
||
return
|
||
}
|
||
const sampleCount = getActiveSampleCount(formData.samples)
|
||
if (!isPayModeValidForSampleCount(formData.payMode as PresetPayMode, sampleCount)) {
|
||
submitLoading.value = false
|
||
await ElMessageBox.alert(
|
||
formData.payMode === 'djfq'
|
||
? '「趸交+分期」仅适用于 2 个样本,请切换缴费模式'
|
||
: '当前缴费模式与样本数量不匹配,请切换缴费模式',
|
||
'无法保存',
|
||
{ 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) {
|
||
submitLoading.value = false
|
||
ElMessage.warning(`样本编码「${sampleRow.sampleCode || sc.sampleCode}」已被占用,请更换`)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
if (formData.payMode === 'dj') {
|
||
const dueHint = djDueAmountMismatchHint(formData.contractAmount, formData.paymentDetails)
|
||
if (dueHint) {
|
||
ElMessage.warning(dueHint)
|
||
}
|
||
}
|
||
try {
|
||
syncFqPaymentDetailRowCount(fqPaymentContext(), formData.paymentDetails)
|
||
const protocolAttachmentUrls = formData.attachments
|
||
.map((a) => a.fileUrl)
|
||
.filter(Boolean)
|
||
.join(',')
|
||
const consentAttachmentUrls = formData.consentAttachments
|
||
.map((a) => a.fileUrl)
|
||
.filter(Boolean)
|
||
.join(',')
|
||
const healthSurveyAttachmentUrls = formData.healthSurveyAttachments
|
||
.map((a) => a.fileUrl)
|
||
.filter(Boolean)
|
||
.join(',')
|
||
const payload: Record<string, unknown> = {
|
||
protocolStatus: formData.protocolStatus,
|
||
registerDate: formData.registerDate || undefined,
|
||
collectorUserId: formData.collectorUserId || undefined,
|
||
protocolAttachments: protocolAttachmentUrls || formData.protocolAttachments || undefined,
|
||
consentFormUrls: consentAttachmentUrls || formData.consentFormUrls || undefined,
|
||
healthSurveyFormUrls:
|
||
healthSurveyAttachmentUrls || formData.healthSurveyFormUrls || undefined,
|
||
attachments: formData.attachments.map((a) => ({
|
||
id: a.id,
|
||
fileName: a.fileName,
|
||
fileUrl: a.fileUrl,
|
||
fileSize: a.fileSize,
|
||
fileSizeDisplay: a.fileSizeDisplay,
|
||
fileType: a.fileType
|
||
})),
|
||
consentAttachments: formData.consentAttachments.map((a) => ({
|
||
id: a.id,
|
||
fileName: a.fileName,
|
||
fileUrl: a.fileUrl,
|
||
fileSize: a.fileSize,
|
||
fileSizeDisplay: a.fileSizeDisplay,
|
||
fileType: a.fileType
|
||
})),
|
||
healthSurveyAttachments: formData.healthSurveyAttachments.map((a) => ({
|
||
id: a.id,
|
||
fileName: a.fileName,
|
||
fileUrl: a.fileUrl,
|
||
fileSize: a.fileSize,
|
||
fileSizeDisplay: a.fileSizeDisplay,
|
||
fileType: a.fileType
|
||
})),
|
||
remark: formData.remark || undefined,
|
||
partyCustomer: {
|
||
customerId: formData.customerId || undefined,
|
||
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: isInstallmentPayMode(formData.payMode)
|
||
? (formData.installmentRatios || []).map((v) => Number(v || 0))
|
||
: 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 ensureSampleTypesInOptions(samples: Array<Record<string, any>>) {
|
||
const merged = [...sampleTypeOptions.value]
|
||
for (const s of samples) {
|
||
const id = String(s.sampleTypeId || '')
|
||
if (!id) continue
|
||
const name = String(s.sampleTypeName || '').trim()
|
||
const existing = merged.find((x) => x.id === id)
|
||
if (existing) {
|
||
if (name && (!existing.typeName || existing.typeName === existing.id)) {
|
||
existing.typeName = name
|
||
}
|
||
continue
|
||
}
|
||
merged.push({
|
||
id,
|
||
typeCode: '',
|
||
typeName: name || id,
|
||
storageYears: s.storageYears ?? null,
|
||
storagePrice: s.originalPrice ?? null
|
||
})
|
||
}
|
||
sampleTypeOptions.value = merged
|
||
}
|
||
|
||
function ensureUnitsInOptions(samples: Array<Record<string, any>>) {
|
||
const merged = [...unitOptions.value]
|
||
for (const s of samples) {
|
||
const id = String(s.unitId || '')
|
||
if (!id) continue
|
||
const name = String(s.unitName || '').trim()
|
||
const existing = merged.find((x) => x.id === id)
|
||
if (existing) {
|
||
if (name && (!existing.unitName || existing.unitName === existing.id)) {
|
||
existing.unitName = name
|
||
}
|
||
continue
|
||
}
|
||
merged.push({ id, unitName: name || id, unitCode: String(s.unitCode || '') })
|
||
}
|
||
unitOptions.value = merged
|
||
}
|
||
|
||
function ensureSitesInOptions(samples: Array<Record<string, any>>) {
|
||
const merged = [...siteOptions.value]
|
||
for (const s of samples) {
|
||
const id = String(s.transport?.destSiteId || '')
|
||
if (!id) continue
|
||
const name = String(s.transport?.destSiteName || '').trim()
|
||
const existing = merged.find((x) => x.id === id)
|
||
if (existing) {
|
||
if (name && (!existing.siteName || existing.siteName === existing.id)) {
|
||
existing.siteName = name
|
||
}
|
||
continue
|
||
}
|
||
merged.push({
|
||
id,
|
||
siteCode: '',
|
||
siteName: name || id,
|
||
siteType: '',
|
||
status: '',
|
||
address: '',
|
||
contactName: '',
|
||
contactPhone: '',
|
||
businessScope: '',
|
||
siteImage: '',
|
||
tenantNo: '',
|
||
orderNum: 0,
|
||
remark: '',
|
||
createTime: '',
|
||
updateTime: ''
|
||
})
|
||
}
|
||
siteOptions.value = merged
|
||
}
|
||
|
||
function hydrateSampleLookupOptions() {
|
||
ensureSampleTypesInOptions(formData.samples)
|
||
ensureSampleCodesInOptions(formData.samples)
|
||
ensureUnitsInOptions(formData.samples)
|
||
ensureSitesInOptions(formData.samples)
|
||
}
|
||
|
||
function formatNowDatetime(): string {
|
||
const now = new Date()
|
||
const pad = (n: number) => String(n).padStart(2, '0')
|
||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
|
||
}
|
||
|
||
/** 一键导入前清空上次导入/手填的可覆盖字段(保留协议状态、登记日期、附件等) */
|
||
function clearFormBeforeStandardImport() {
|
||
customerId.value = null
|
||
customerSnapshot.value = null
|
||
customerOptions.value = []
|
||
formData.customerId = ''
|
||
formData.motherName = ''
|
||
formData.motherPhone = ''
|
||
formData.motherIdCard = ''
|
||
formData.motherBirthYm = ''
|
||
formData.motherEmail = ''
|
||
formData.motherAddress = ''
|
||
|
||
formData.fatherName = ''
|
||
formData.fatherPhone = ''
|
||
formData.fatherIdCard = ''
|
||
formData.newbornName = ''
|
||
formData.newbornIdCard = ''
|
||
formData.expectedDeliveryDate = ''
|
||
|
||
formData.newbornSex = ''
|
||
formData.newbornWeightGram = null
|
||
formData.apgar1min = null
|
||
formData.apgar5min = null
|
||
formData.newbornDeliveryTime = ''
|
||
formData.deliveryMode = ''
|
||
formData.birthOrder = null
|
||
|
||
formData.regionId = ''
|
||
formData.hospitalId = ''
|
||
formData.medicalStaff = ''
|
||
formData.sampleCollectTime = ''
|
||
formData.maternalBloodCollectTime = ''
|
||
regionOptions.value = []
|
||
hospitalOptions.value = []
|
||
|
||
collectorUserId.value = null
|
||
collectorSnapshot.value = null
|
||
collectorUserOptions.value = []
|
||
formData.collectorUserId = ''
|
||
|
||
salesUserId.value = null
|
||
salesSnapshot.value = null
|
||
salesUserOptions.value = []
|
||
formData.salesUserId = ''
|
||
|
||
formData.samples = []
|
||
sampleCodeOptions.value = []
|
||
siteOptions.value = []
|
||
expressCompanyOptions.value = []
|
||
|
||
formData.payMode = 'dj'
|
||
formData.contractAmount = null
|
||
formData.lumpSumDeadline = ''
|
||
formData.installmentCount = null
|
||
formData.installmentIntervalMonths = null
|
||
formData.installmentStartDate = ''
|
||
formData.installmentAdvanceDays = DEFAULT_INSTALLMENT_ADVANCE_DAYS
|
||
formData.installmentRatios = []
|
||
formData.paymentDetails = []
|
||
lastConfirmedPayMode.value = 'dj'
|
||
lastConfirmedFqParams.value = {
|
||
installmentCount: null,
|
||
installmentIntervalMonths: null,
|
||
installmentStartDate: '',
|
||
installmentAdvanceDays: DEFAULT_INSTALLMENT_ADVANCE_DAYS,
|
||
installmentRatios: [] as number[]
|
||
}
|
||
|
||
formData.remark = ''
|
||
}
|
||
|
||
async function applyMotherByName(name: string): Promise<'linked' | 'name-only' | 'skipped'> {
|
||
const keyword = (name || '').trim()
|
||
if (!keyword) return 'skipped'
|
||
if (!isBlankString(formData.customerId)) return 'skipped'
|
||
if (!isBlankString(formData.motherName)) return 'skipped'
|
||
|
||
try {
|
||
const res = await fetchCustomerList({ page: 1, pageSize: 50, keyword })
|
||
const list = res.list || []
|
||
const match =
|
||
list.find((c) => c.customerName === keyword) ||
|
||
list.find((c) => (c.customerName || '').includes(keyword))
|
||
if (match) {
|
||
customerOptions.value = [match, ...list.filter((c) => c.id !== match.id)]
|
||
onCustomerChange(match.id)
|
||
return 'linked'
|
||
}
|
||
formData.motherName = keyword
|
||
return 'name-only'
|
||
} catch {
|
||
formData.motherName = keyword
|
||
return 'name-only'
|
||
}
|
||
}
|
||
|
||
async function applyHospitalByName(name: string): Promise<boolean> {
|
||
const keyword = (name || '').trim()
|
||
if (!keyword || !isBlankString(formData.hospitalId)) return false
|
||
try {
|
||
const res = await fetchSearchCollectionHospitals({ keyword, limit: 20 })
|
||
const list = res.list || []
|
||
const match =
|
||
list.find((h) => h.hospitalName === keyword) ||
|
||
list.find((h) => (h.hospitalName || '').includes(keyword)) ||
|
||
list[0]
|
||
if (!match) return false
|
||
|
||
const regionStub: Api.BizCollectionRegion.RegionItem = {
|
||
id: match.regionId,
|
||
regionName: match.regionName || match.regionId,
|
||
hospitalNames: [],
|
||
tenantNo: match.tenantNo || '',
|
||
orderNum: 0,
|
||
remark: '',
|
||
createTime: '',
|
||
updateTime: ''
|
||
}
|
||
if (!regionOptions.value.some((r) => r.id === match.regionId)) {
|
||
regionOptions.value = [regionStub, ...regionOptions.value]
|
||
}
|
||
formData.regionId = match.regionId
|
||
await loadHospitalsByRegion(match.regionId, keyword)
|
||
if (!hospitalOptions.value.some((h) => h.id === match.id)) {
|
||
hospitalOptions.value = [match, ...hospitalOptions.value]
|
||
}
|
||
formData.hospitalId = match.id
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
async function applySalesUserByName(name: string): Promise<boolean> {
|
||
const keyword = (name || '').trim()
|
||
if (!keyword) return false
|
||
try {
|
||
const res = await fetchUserList({
|
||
page: 1,
|
||
pageSize: 50,
|
||
keyword,
|
||
status: '1'
|
||
})
|
||
const list = res.list || []
|
||
const match =
|
||
list.find((u) => u.nickName === keyword) ||
|
||
list.find((u) => (u.nickName || '').includes(keyword))
|
||
if (!match) return false
|
||
applyUserToCollector(match)
|
||
applyUserToSales(match)
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
function applyUserToCollector(u: Api.User.UserItem) {
|
||
collectorSnapshot.value = { ...u }
|
||
collectorUserId.value = u.id
|
||
formData.collectorUserId = u.id
|
||
if (!collectorUserOptions.value.some((x) => x.id === u.id)) {
|
||
collectorUserOptions.value = [u, ...collectorUserOptions.value]
|
||
}
|
||
}
|
||
|
||
function applyUserToSales(u: Api.User.UserItem) {
|
||
salesSnapshot.value = { ...u }
|
||
salesUserId.value = u.id
|
||
formData.salesUserId = u.id
|
||
if (!salesUserOptions.value.some((x) => x.id === u.id)) {
|
||
salesUserOptions.value = [u, ...salesUserOptions.value]
|
||
}
|
||
}
|
||
|
||
async function resolveSampleCodeByCode(code: string) {
|
||
const keyword = (code || '').trim()
|
||
if (!keyword) return null
|
||
const res = await fetchSampleCodeList({ page: 1, pageSize: 50, keyword })
|
||
const list = (res.list || []) as Api.BizSampleCode.SampleCodeItem[]
|
||
return (
|
||
list.find((sc) => sc.sampleCode === keyword) ||
|
||
list.find((sc) => String(sc.sampleCode || '').includes(keyword)) ||
|
||
null
|
||
)
|
||
}
|
||
|
||
function applyParsedTransportToSample(
|
||
sample: Record<string, any>,
|
||
parsed: ProtocolStandardImportParsed,
|
||
warnings: string[],
|
||
filledLabels: string[]
|
||
) {
|
||
if (!parsed.transportType) return
|
||
|
||
const transport = { ...(sample.transport || createEmptyTransport()) }
|
||
let transportFilled = false
|
||
|
||
if (isBlankString(transport.transportType)) {
|
||
transport.transportType = parsed.transportType
|
||
transportFilled = true
|
||
}
|
||
if (isBlankString(transport.destSiteId) && siteOptions.value.length) {
|
||
const site = siteOptions.value[0]
|
||
transport.destSiteId = site.id
|
||
transportFilled = true
|
||
}
|
||
if (parsed.etaTime && isBlankString(transport.etaTime)) {
|
||
transport.etaTime = parsed.etaTime
|
||
transportFilled = true
|
||
} else if (isBlankString(transport.etaTime)) {
|
||
transport.etaTime = formatNowDatetime()
|
||
transportFilled = true
|
||
}
|
||
if (parsed.transportType === 'kdys' && isBlankString(transport.expressCompanyId)) {
|
||
const ec = matchExpressCompany(
|
||
parsed.transportModeText || '顺丰快递',
|
||
expressCompanyOptions.value
|
||
)
|
||
if (ec) {
|
||
transport.expressCompanyId = ec.id
|
||
if (!expressCompanyOptions.value.some((x) => x.id === ec.id)) {
|
||
expressCompanyOptions.value = [ec, ...expressCompanyOptions.value]
|
||
}
|
||
transportFilled = true
|
||
} else {
|
||
warnings.push(`未找到快递公司「${parsed.transportModeText || ''}」`)
|
||
}
|
||
}
|
||
if (parsed.transportType === 'ryxd' && parsed.carrierName && isBlankString(transport.carrierName)) {
|
||
transport.carrierName = parsed.carrierName
|
||
transportFilled = true
|
||
}
|
||
sample.transport = transport
|
||
if (transportFilled) filledLabels.push('运输信息')
|
||
}
|
||
|
||
async function applySampleHintsFromImport(
|
||
parsed: ProtocolStandardImportParsed,
|
||
warnings: string[],
|
||
filledLabels: string[]
|
||
) {
|
||
let hints = parsed.sampleHints
|
||
if (!hints.length) {
|
||
const matched = matchCellSampleType(parsed.sampleTypeHeader, sampleTypeOptions.value)
|
||
if (matched) {
|
||
hints = [{ typeKey: 'hsc', typeName: matched.typeName }]
|
||
}
|
||
}
|
||
|
||
for (const hint of hints) {
|
||
const matchedType = matchSampleTypeByExactName(hint.typeName, sampleTypeOptions.value)
|
||
const sample = normalizeSampleRow({ sampleTypeId: '', sampleStatus: 'ycj' }, formData.samples.length)
|
||
formData.samples.push(sample)
|
||
|
||
if (matchedType) {
|
||
sample.sampleTypeId = matchedType.id
|
||
sample.storageYears = matchedType.storageYears ?? null
|
||
sample.originalPrice = matchedType.storagePrice ?? null
|
||
sample.price = matchedType.storagePrice ?? null
|
||
if (!sampleTypeOptions.value.some((x) => x.id === matchedType.id)) {
|
||
sampleTypeOptions.value = [matchedType, ...sampleTypeOptions.value]
|
||
}
|
||
filledLabels.push(hint.typeName)
|
||
} else {
|
||
warnings.push(`无法匹配样本类型「${hint.typeName}」`)
|
||
}
|
||
|
||
if (hint.sampleCode) {
|
||
const sc = await resolveSampleCodeByCode(hint.sampleCode)
|
||
if (sc) {
|
||
sample.sampleCodeId = sc.id
|
||
sample.sampleCode = sc.sampleCode
|
||
if (!sampleCodeOptions.value.some((x) => x.id === sc.id)) {
|
||
sampleCodeOptions.value = [sc, ...sampleCodeOptions.value]
|
||
}
|
||
filledLabels.push('样本编码')
|
||
} else {
|
||
warnings.push(`未找到样本编码「${hint.sampleCode}」`)
|
||
}
|
||
}
|
||
|
||
applyParsedTransportToSample(sample, parsed, warnings, filledLabels)
|
||
}
|
||
}
|
||
|
||
function syncFormSamplesAfterImport() {
|
||
formData.samples = formData.samples.map((s, i) =>
|
||
normalizeSampleRow(
|
||
{
|
||
...s,
|
||
transport: s.transport ? { ...s.transport } : undefined
|
||
},
|
||
i
|
||
)
|
||
)
|
||
hydrateSampleLookupOptions()
|
||
}
|
||
|
||
function applyPaymentFromImport(parsed: ProtocolStandardImportParsed, warnings: string[]) {
|
||
formData.payMode = parsed.payMode || 'dj'
|
||
lastConfirmedPayMode.value = formData.payMode as PayMode
|
||
|
||
if (isInstallmentPayMode(parsed.payMode || 'dj')) {
|
||
applyFqPaymentDefaults()
|
||
}
|
||
|
||
if (parsed.contractAmount != null) {
|
||
formData.contractAmount = parsed.contractAmount
|
||
}
|
||
|
||
const transferTypeCode = resolveTransferTypeCode(
|
||
parsed.transferTypeLabel || '',
|
||
transferTypeOptions.value
|
||
)
|
||
if (parsed.transferTypeLabel && !transferTypeCode) {
|
||
warnings.push(`无法匹配转账方式「${parsed.transferTypeLabel}」`)
|
||
}
|
||
|
||
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())}`
|
||
|
||
formData.paymentDetails = []
|
||
const transfers = parsed.paymentTransfers.filter((t) => t.transferAmount > 0)
|
||
|
||
if (transfers.length) {
|
||
transfers.forEach((t, i) => {
|
||
formData.paymentDetails.push({
|
||
transferType: transferTypeCode,
|
||
dueAmount: t.transferAmount,
|
||
transferAmount: t.transferAmount,
|
||
transferTime: t.transferTime || defaultTime,
|
||
payStatus: 'dfj',
|
||
installmentDueDate: formData.payMode === 'dj' ? formData.lumpSumDeadline : '',
|
||
_expanded: i === 0,
|
||
attachments: []
|
||
})
|
||
})
|
||
} else if (formData.payMode === 'dj' && formData.contractAmount != null) {
|
||
addPaymentDetail()
|
||
const detail = formData.paymentDetails[0]
|
||
if (detail) {
|
||
detail.transferType = transferTypeCode
|
||
detail.dueAmount = formData.contractAmount
|
||
detail.transferAmount = formData.contractAmount
|
||
}
|
||
}
|
||
|
||
initPaymentDetailExpandFlags(formData.paymentDetails)
|
||
markPayDetailLocks(formData.paymentDetails)
|
||
if (!transfers.length && !(formData.payMode === 'dj' && formData.contractAmount != null)) {
|
||
syncPaymentDueAmounts()
|
||
}
|
||
|
||
if (parsed.contractAmount != null && transfers.length) {
|
||
const sum = transfers.reduce((acc, t) => acc + Number(t.transferAmount || 0), 0)
|
||
if (Math.abs(sum - Number(parsed.contractAmount)) > 0.01) {
|
||
warnings.push(
|
||
`转账合计 ${sum} 与合同金额 ${parsed.contractAmount} 不一致,请核对缴费明细`
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
async function handleStandardImport() {
|
||
const parsed = parseProtocolStandardImportText(standardImportText.value)
|
||
if (!parsed) {
|
||
ElMessage.warning('请粘贴有效的标准信息')
|
||
return
|
||
}
|
||
|
||
standardImportLoading.value = true
|
||
const warnings: string[] = []
|
||
const filledLabels: string[] = []
|
||
|
||
try {
|
||
clearFormBeforeStandardImport()
|
||
await ensureTransferTypeOptions()
|
||
await remoteSearchSampleTypes('')
|
||
|
||
const tasks: Promise<void>[] = []
|
||
|
||
if (parsed.motherName) {
|
||
tasks.push(
|
||
applyMotherByName(parsed.motherName).then((result) => {
|
||
if (result === 'linked') filledLabels.push('母亲信息(已关联客户)')
|
||
else if (result === 'name-only') filledLabels.push('母亲姓名')
|
||
})
|
||
)
|
||
}
|
||
|
||
if (parsed.hospitalName) {
|
||
tasks.push(
|
||
applyHospitalByName(parsed.hospitalName).then((ok) => {
|
||
if (ok) filledLabels.push('采集地区/采集医院')
|
||
else if (isBlankString(formData.hospitalId)) {
|
||
warnings.push(`未找到采集医院「${parsed.hospitalName}」`)
|
||
}
|
||
})
|
||
)
|
||
}
|
||
|
||
if (parsed.salesUserName) {
|
||
tasks.push(
|
||
applySalesUserByName(parsed.salesUserName).then((ok) => {
|
||
if (ok) filledLabels.push('采集专员/销售人员')
|
||
else warnings.push(`未找到销售人员「${parsed.salesUserName}」`)
|
||
})
|
||
)
|
||
}
|
||
|
||
const needsTransport = !!parsed.transportType
|
||
if (needsTransport) {
|
||
tasks.push(remoteSearchSites('').then(() => undefined))
|
||
if (parsed.transportType === 'kdys') {
|
||
tasks.push(
|
||
remoteSearchExpressCompanies(
|
||
(parsed.transportModeText || '').replace(/快递$/, '') || '顺丰'
|
||
).then(() => undefined)
|
||
)
|
||
}
|
||
}
|
||
|
||
await Promise.all(tasks)
|
||
|
||
if (parsed.motherPhone) {
|
||
formData.motherPhone = parsed.motherPhone
|
||
filledLabels.push('孕妇电话')
|
||
}
|
||
if (parsed.newbornSex) {
|
||
formData.newbornSex = parsed.newbornSex
|
||
filledLabels.push('新生儿性别')
|
||
}
|
||
if (parsed.newbornWeightGram != null) {
|
||
formData.newbornWeightGram = parsed.newbornWeightGram
|
||
filledLabels.push('新生儿体重')
|
||
}
|
||
if (parsed.apgarScore != null) {
|
||
formData.apgar1min = parsed.apgarScore
|
||
filledLabels.push('新生儿评分')
|
||
}
|
||
if (parsed.newbornDeliveryTime) {
|
||
formData.newbornDeliveryTime = parsed.newbornDeliveryTime
|
||
filledLabels.push('新生儿分娩时间')
|
||
}
|
||
if (parsed.sampleCollectTime) {
|
||
formData.sampleCollectTime = formatDatetimeToMinute(parsed.sampleCollectTime)
|
||
filledLabels.push('样本采集时间')
|
||
}
|
||
if (parsed.maternalBloodExplicitEmpty) {
|
||
formData.maternalBloodCollectTime = ''
|
||
filledLabels.push('母血采集时间')
|
||
} else if (parsed.maternalBloodCollectTime) {
|
||
formData.maternalBloodCollectTime = formatDatetimeToMinute(parsed.maternalBloodCollectTime)
|
||
filledLabels.push('母血采集时间')
|
||
}
|
||
if (parsed.deliveryMode) {
|
||
formData.deliveryMode = parsed.deliveryMode
|
||
filledLabels.push('分娩方式')
|
||
}
|
||
if (parsed.birthOrder != null) {
|
||
formData.birthOrder = parsed.birthOrder
|
||
filledLabels.push('胎次')
|
||
}
|
||
if (parsed.medicalStaff) {
|
||
formData.medicalStaff = parsed.medicalStaff
|
||
filledLabels.push('采集医护')
|
||
}
|
||
if (parsed.remark) {
|
||
formData.remark = parsed.remark
|
||
filledLabels.push('备注')
|
||
}
|
||
|
||
await applySampleHintsFromImport(parsed, warnings, filledLabels)
|
||
|
||
if (formData.samples.length) {
|
||
syncFormSamplesAfterImport()
|
||
}
|
||
|
||
applyPaymentFromImport(parsed, warnings)
|
||
|
||
if (parsed.contractAmount != null) {
|
||
filledLabels.push('合同金额')
|
||
}
|
||
|
||
if (parsed.transportModeText && !parsed.transportType) {
|
||
warnings.push(`无法识别运输方式「${parsed.transportModeText}」`)
|
||
}
|
||
|
||
const uniqueFilled = [...new Set(filledLabels)]
|
||
if (uniqueFilled.length) {
|
||
ElMessage.success(`已填充:${uniqueFilled.join('、')}`)
|
||
} else if (!warnings.length) {
|
||
ElMessage.info('没有可填充的字段')
|
||
}
|
||
if (warnings.length) {
|
||
ElMessage.warning(warnings.join(';'))
|
||
}
|
||
} catch {
|
||
ElMessage.error('导入失败,请检查标准信息格式')
|
||
} finally {
|
||
standardImportLoading.value = false
|
||
}
|
||
}
|
||
|
||
/* ====== 客户检索(母亲信息) ====== */
|
||
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 remoteSearchSampleCodes(query: string, codeFilter?: string) {
|
||
sampleCodeLoading.value = true
|
||
try {
|
||
const res = await fetchSampleCodeList({
|
||
page: 1,
|
||
pageSize: 100,
|
||
keyword: query?.trim() || undefined,
|
||
codeFilter: codeFilter?.trim() || undefined
|
||
})
|
||
sampleCodeOptions.value = (res.list || []) as Api.BizSampleCode.SampleCodeItem[]
|
||
ensureSampleCodesInOptions(formData.samples)
|
||
} catch {
|
||
sampleCodeOptions.value = []
|
||
ensureSampleCodesInOptions(formData.samples)
|
||
} finally {
|
||
sampleCodeLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function remoteSearchSampleTypes(query: string) {
|
||
sampleTypeLoading.value = true
|
||
try {
|
||
const res = await fetchCellSampleTypeList({
|
||
page: 1,
|
||
pageSize: 100,
|
||
keyword: query?.trim() || undefined
|
||
})
|
||
sampleTypeOptions.value = (res.list || []) as any
|
||
ensureSampleTypesInOptions(formData.samples)
|
||
} catch {
|
||
sampleTypeOptions.value = []
|
||
ensureSampleTypesInOptions(formData.samples)
|
||
} finally {
|
||
sampleTypeLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function remoteSearchUnits(query: string) {
|
||
unitLoading.value = true
|
||
try {
|
||
const res = await fetchItemUnitList({
|
||
page: 1,
|
||
pageSize: 100,
|
||
keyword: query?.trim() || undefined
|
||
})
|
||
unitOptions.value = (res.list || []) as any
|
||
ensureUnitsInOptions(formData.samples)
|
||
} catch {
|
||
unitOptions.value = []
|
||
ensureUnitsInOptions(formData.samples)
|
||
} finally {
|
||
unitLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function remoteSearchSites(query: string) {
|
||
siteLoading.value = true
|
||
try {
|
||
const res = await fetchSiteList({
|
||
page: 1,
|
||
pageSize: 100,
|
||
keyword: query?.trim() || undefined
|
||
})
|
||
siteOptions.value = res.list || []
|
||
ensureSitesInOptions(formData.samples)
|
||
} catch {
|
||
siteOptions.value = []
|
||
ensureSitesInOptions(formData.samples)
|
||
} finally {
|
||
siteLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function remoteSearchExpressCompanies(query: string) {
|
||
expressCompanyLoading.value = true
|
||
try {
|
||
const res = await fetchExpressCompanyList({
|
||
page: 1,
|
||
pageSize: 100,
|
||
keyword: query?.trim() || undefined
|
||
})
|
||
expressCompanyOptions.value = res.list || []
|
||
} catch {
|
||
expressCompanyOptions.value = []
|
||
} finally {
|
||
expressCompanyLoading.value = false
|
||
}
|
||
}
|
||
|
||
/* ====== 采集地区下拉检索 ====== */
|
||
async function remoteSearchRegions(query: string) {
|
||
regionLoading.value = true
|
||
try {
|
||
const res = await fetchCollectionRegionList({
|
||
page: 1,
|
||
pageSize: 100,
|
||
keyword: query || undefined
|
||
})
|
||
regionOptions.value = res.list || []
|
||
} catch {
|
||
regionOptions.value = []
|
||
} finally {
|
||
regionLoading.value = false
|
||
}
|
||
}
|
||
|
||
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 = resizeInstallmentAmounts(
|
||
formData.installmentRatios || [],
|
||
count,
|
||
formData.contractAmount
|
||
)
|
||
}
|
||
handleFqParamsChange()
|
||
}
|
||
|
||
/** 缴费明细应缴金额之和回写合同金额 */
|
||
function syncContractAmountFromPaymentDetails() {
|
||
if (!formData.paymentDetails.length) return
|
||
const total = formData.paymentDetails.reduce(
|
||
(sum, d) => sum + (Number(d.dueAmount) || 0),
|
||
0
|
||
)
|
||
formData.contractAmount = Math.round(total * 100) / 100
|
||
}
|
||
|
||
/** 缴费明细应缴金额变更:分期回写 installmentRatios,并同步合同金额 */
|
||
function onPaymentDetailDueAmountChange(dIdx: number) {
|
||
const detail = formData.paymentDetails[dIdx]
|
||
if (!detail || isPayDetailLocked(detail)) return
|
||
if (formData.payMode === 'fq' || formData.payMode === 'djfq') {
|
||
const installmentNo = Number(detail.installmentNo)
|
||
const ratioIdx = installmentNo >= 1 ? installmentNo - 1 : dIdx
|
||
if (!formData.installmentRatios) formData.installmentRatios = []
|
||
while (formData.installmentRatios.length <= ratioIdx) {
|
||
formData.installmentRatios.push(0)
|
||
}
|
||
formData.installmentRatios[ratioIdx] = Number(detail.dueAmount) || 0
|
||
}
|
||
syncContractAmountFromPaymentDetails()
|
||
}
|
||
|
||
/** 按缴费模式同步各明细应缴费金额(跳过已锁定明细) */
|
||
function syncPaymentDueAmounts() {
|
||
const contract = formData.contractAmount
|
||
if (contract == null) return
|
||
if (formData.payMode === 'dj') {
|
||
const paidSum = sumPaidAmount(formData.paymentDetails)
|
||
const remaining = Math.round((Number(contract) - paidSum) * 100) / 100
|
||
const dfjRows = formData.paymentDetails.filter(
|
||
(d) => !isPayDetailLocked(d) && String(d.payStatus || 'dfj') === 'dfj'
|
||
)
|
||
const targetAmount = paidSum > 0 ? remaining : contract
|
||
dfjRows.forEach((d, i) => {
|
||
if (i === 0) d.dueAmount = targetAmount
|
||
})
|
||
return
|
||
}
|
||
if (formData.payMode === 'fq' || formData.payMode === 'djfq') {
|
||
const count = Number(formData.installmentCount || formData.paymentDetails.length)
|
||
if (!count) return
|
||
const ratios = formData.installmentRatios || []
|
||
const amounts =
|
||
ratios.length === count
|
||
? ratios.map((v) => Number(v || 0))
|
||
: normalizeInstallmentAmounts(ratios, null, count)
|
||
const paidRows = formData.paymentDetails.filter((d) => String(d.payStatus) === 'yjf')
|
||
paidRows.forEach((d) => {
|
||
const no = Number(d.installmentNo)
|
||
if (no >= 1 && no <= Number(count)) amounts[no - 1] = Number(d.dueAmount) || 0
|
||
})
|
||
formData.paymentDetails.forEach((d, i) => {
|
||
if (isPayDetailLocked(d)) return
|
||
if (amounts[i] !== undefined) d.dueAmount = amounts[i]
|
||
})
|
||
}
|
||
}
|
||
|
||
/** 合同金额手动变更:趸交时同步待缴明细应缴金额 */
|
||
function onContractAmountChange() {
|
||
if (formData.payMode === 'dj') {
|
||
syncPaymentDueAmounts()
|
||
}
|
||
}
|
||
|
||
/** 样本价格或状态变更(不自动改合同金额) */
|
||
function onSamplePriceChange() {
|
||
// 保留样本价格字段,不与合同金额联动
|
||
}
|
||
|
||
/* ====== 母亲身份证号 ↔ 出生年月联动 ====== */
|
||
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(activeSampleCount, () => {
|
||
if (isEdit.value) return
|
||
if (hasLockedPaymentDetails()) {
|
||
const preset = resolvePaymentPreset(
|
||
getActiveSampleCount(formData.samples),
|
||
formData.payMode as PresetPayMode
|
||
)
|
||
if (preset?.contractAmount != null) {
|
||
formData.contractAmount = preset.contractAmount
|
||
}
|
||
return
|
||
}
|
||
applyPaymentPresetForMode(formData.payMode as PresetPayMode)
|
||
})
|
||
|
||
/* ====== 趸交截止时间联动 ====== */
|
||
watch(
|
||
() => formData.lumpSumDeadline,
|
||
() => {
|
||
if (formData.payMode === 'dj') {
|
||
syncLumpSumDueDate()
|
||
}
|
||
}
|
||
)
|
||
|
||
/* ====== 附件上传 ====== */
|
||
const attachmentUploadRef = ref()
|
||
const consentAttachmentUploadRef = ref()
|
||
const healthSurveyAttachmentUploadRef = ref()
|
||
|
||
type ProtocolAttachmentItem = {
|
||
id?: string
|
||
fileName: string
|
||
fileUrl: string
|
||
fileSize: number
|
||
fileSizeDisplay?: string
|
||
fileType?: string
|
||
_uploading?: boolean
|
||
}
|
||
|
||
async function uploadProtocolAttachmentFile(
|
||
uploadFile: any,
|
||
list: ProtocolAttachmentItem[],
|
||
folder: string,
|
||
uploadRef: { value?: { clearFiles?: () => void } | null }
|
||
) {
|
||
const file = uploadFile.raw || uploadFile
|
||
if (!file) return
|
||
|
||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||
list.push({
|
||
id: tempId,
|
||
fileName: file.name,
|
||
fileUrl: '',
|
||
fileSize: file.size,
|
||
fileSizeDisplay: `${(file.size / 1024).toFixed(1)} KB`,
|
||
fileType: file.type || '',
|
||
_uploading: true
|
||
})
|
||
const idx = list.length - 1
|
||
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('folder', folder)
|
||
fd.append('file', file)
|
||
const res = await fetchUploadAttachment(fd)
|
||
list[idx] = {
|
||
...list[idx],
|
||
id: res.id || tempId,
|
||
fileName: file.name || res.fileName,
|
||
fileUrl: res.fileUrl,
|
||
fileSize: res.fileSize || file.size,
|
||
fileSizeDisplay: res.fileSizeDisplay || `${(file.size / 1024).toFixed(1)} KB`,
|
||
fileType: res.fileType || file.type || '',
|
||
_uploading: false
|
||
}
|
||
} catch {
|
||
ElMessage.error('文件上传失败')
|
||
list.splice(idx, 1)
|
||
} finally {
|
||
nextTick(() => uploadRef.value?.clearFiles())
|
||
}
|
||
}
|
||
|
||
function isImageFile(file: any) {
|
||
const url = (file.fileUrl || '').toLowerCase()
|
||
const name = (file.fileName || '').toLowerCase()
|
||
return (
|
||
/\.(jpg|jpeg|png|gif|bmp|webp|svg)(\?|$)/.test(url) ||
|
||
/\.(jpg|jpeg|png|gif|bmp|webp|svg)$/.test(name) ||
|
||
(file.fileType || '').startsWith('image/')
|
||
)
|
||
}
|
||
|
||
function isVideoFile(file: any) {
|
||
const url = (file.fileUrl || '').toLowerCase()
|
||
const name = (file.fileName || '').toLowerCase()
|
||
return (
|
||
/\.(mp4|webm|ogg|mov|avi)(\?|$)/.test(url) ||
|
||
/\.(mp4|webm|ogg|mov|avi)$/.test(name) ||
|
||
(file.fileType || '').startsWith('video/')
|
||
)
|
||
}
|
||
|
||
async function handleAttachmentChange(uploadFile: any) {
|
||
await uploadProtocolAttachmentFile(
|
||
uploadFile,
|
||
formData.attachments,
|
||
'protocol',
|
||
attachmentUploadRef
|
||
)
|
||
}
|
||
|
||
function removeAttachment(index: number) {
|
||
formData.attachments.splice(index, 1)
|
||
}
|
||
|
||
async function handleConsentAttachmentChange(uploadFile: any) {
|
||
await uploadProtocolAttachmentFile(
|
||
uploadFile,
|
||
formData.consentAttachments,
|
||
'protocol-consent',
|
||
consentAttachmentUploadRef
|
||
)
|
||
}
|
||
|
||
function removeConsentAttachment(index: number) {
|
||
formData.consentAttachments.splice(index, 1)
|
||
}
|
||
|
||
async function handleHealthSurveyAttachmentChange(uploadFile: any) {
|
||
await uploadProtocolAttachmentFile(
|
||
uploadFile,
|
||
formData.healthSurveyAttachments,
|
||
'protocol-health-survey',
|
||
healthSurveyAttachmentUploadRef
|
||
)
|
||
}
|
||
|
||
function removeHealthSurveyAttachment(index: number) {
|
||
formData.healthSurveyAttachments.splice(index, 1)
|
||
}
|
||
|
||
const { isDragover: isProtocolAttachDragover, dropZoneHandlers: protocolAttachDropHandlers } =
|
||
useAttachmentDropZone({
|
||
onFiles: (files) => {
|
||
for (const file of files) {
|
||
void handleAttachmentChange({ raw: file })
|
||
}
|
||
}
|
||
})
|
||
|
||
const { isDragover: isConsentAttachDragover, dropZoneHandlers: consentAttachDropHandlers } =
|
||
useAttachmentDropZone({
|
||
onFiles: (files) => {
|
||
for (const file of files) {
|
||
void handleConsentAttachmentChange({ raw: file })
|
||
}
|
||
}
|
||
})
|
||
|
||
const {
|
||
isDragover: isHealthSurveyAttachDragover,
|
||
dropZoneHandlers: healthSurveyAttachDropHandlers
|
||
} = useAttachmentDropZone({
|
||
onFiles: (files) => {
|
||
for (const file of files) {
|
||
void handleHealthSurveyAttachmentChange({ raw: file })
|
||
}
|
||
}
|
||
})
|
||
|
||
const { isDragover: isPaymentDetailDragover, handlersFor: paymentDetailDropHandlers } =
|
||
useIndexedAttachmentDropZone({
|
||
onFiles: (detailIndex, files) => {
|
||
for (const file of files) {
|
||
void handleDetailAttachmentChange(detailIndex, { raw: file })
|
||
}
|
||
},
|
||
disabled: (detailIndex) => {
|
||
const detail = formData.paymentDetails[detailIndex]
|
||
return !detail || isPayDetailLocked(detail)
|
||
}
|
||
})
|
||
|
||
/* ====== 缴费明细附件上传 ====== */
|
||
async function handleDetailAttachmentChange(detailIndex: number, uploadFile: any) {
|
||
const file = uploadFile.raw || uploadFile
|
||
if (!file) return
|
||
|
||
const detail = formData.paymentDetails[detailIndex]
|
||
if (!detail) return
|
||
|
||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||
detail.attachments.push({
|
||
id: tempId,
|
||
fileName: file.name,
|
||
fileUrl: '',
|
||
fileSize: file.size,
|
||
fileSizeDisplay: `${(file.size / 1024).toFixed(1)} KB`,
|
||
fileType: file.type || '',
|
||
_uploading: true
|
||
})
|
||
const aIdx = detail.attachments.length - 1
|
||
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('folder', 'protocol-payment')
|
||
fd.append('file', file)
|
||
const res = await fetchUploadAttachment(fd)
|
||
detail.attachments[aIdx] = {
|
||
...detail.attachments[aIdx],
|
||
id: res.id || tempId,
|
||
fileName: file.name || res.fileName,
|
||
fileUrl: res.fileUrl,
|
||
fileSize: res.fileSize || file.size,
|
||
fileSizeDisplay: res.fileSizeDisplay || `${(file.size / 1024).toFixed(1)} KB`,
|
||
fileType: res.fileType || file.type || '',
|
||
_uploading: false
|
||
}
|
||
} catch {
|
||
ElMessage.error('文件上传失败')
|
||
detail.attachments.splice(aIdx, 1)
|
||
}
|
||
}
|
||
|
||
function removeDetailAttachment(detailIndex: number, attachmentIndex: number) {
|
||
const detail = formData.paymentDetails[detailIndex]
|
||
if (detail) {
|
||
detail.attachments.splice(attachmentIndex, 1)
|
||
}
|
||
}
|
||
|
||
/* 附件预览 */
|
||
const previewVisible = ref(false)
|
||
const previewUrl = ref('')
|
||
const previewType = ref<'image' | 'video'>('image')
|
||
|
||
function handlePreviewAttachment(file: any) {
|
||
if (file._uploading) return
|
||
previewUrl.value = fullFileUrl(file.fileUrl)
|
||
previewType.value = isVideoFile(file) ? 'video' : 'image'
|
||
previewVisible.value = true
|
||
}
|
||
|
||
function handleDownloadAttachment(file: any) {
|
||
downloadAttachmentFile(file, { fullFileUrl })
|
||
}
|
||
|
||
/* ====== 弹窗打开时自动初始化 ====== */
|
||
watch(
|
||
() => props.modelValue,
|
||
(visible) => {
|
||
if (visible) {
|
||
void ensureTransferTypeOptions()
|
||
if (props.editId) {
|
||
handleOpenForEdit()
|
||
} else {
|
||
handleOpenForAdd()
|
||
}
|
||
}
|
||
}
|
||
)
|
||
</script>
|
||
<style scoped lang="scss">
|
||
/* ═══════════════════════════════════════════
|
||
DIALOG BODY
|
||
═══════════════════════════════════════════ */
|
||
.protocol-dialog-body {
|
||
overflow: visible;
|
||
background: #f5f7fa;
|
||
}
|
||
|
||
.protocol-form {
|
||
:deep(.el-form-item) {
|
||
margin-bottom: 4px;
|
||
}
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════
|
||
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;
|
||
}
|
||
|
||
&--import {
|
||
margin-bottom: 0;
|
||
|
||
.import-textarea-item {
|
||
margin-bottom: 12px;
|
||
|
||
:deep(.el-textarea__inner) {
|
||
min-height: 560px;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.import-actions {
|
||
display: flex;
|
||
flex-shrink: 0;
|
||
justify-content: flex-end;
|
||
padding: 0 0 8px;
|
||
}
|
||
|
||
.form-tab-pane--import-basic {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||
gap: 16px;
|
||
align-items: stretch;
|
||
margin-bottom: 28px;
|
||
|
||
.section-card {
|
||
margin-bottom: 0;
|
||
height: 100%;
|
||
}
|
||
}
|
||
|
||
.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: #047857;
|
||
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: #047857;
|
||
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));
|
||
}
|
||
|
||
&--col6 {
|
||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||
}
|
||
|
||
&--attachment {
|
||
margin-top: 16px;
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
:deep(.el-form-item) {
|
||
margin-bottom: 0;
|
||
}
|
||
}
|
||
|
||
.form-col-span-2 {
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════
|
||
CLIENT SUB CARDS
|
||
═══════════════════════════════════════════ */
|
||
.client-sub-cards {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 16px;
|
||
}
|
||
|
||
.client-sub-card {
|
||
background: #fff;
|
||
border-radius: 10px;
|
||
padding: 18px 20px;
|
||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||
border: 1px solid rgba(0, 0, 0, 0.03);
|
||
transition:
|
||
transform 0.2s,
|
||
box-shadow 0.2s;
|
||
|
||
&:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.07);
|
||
}
|
||
}
|
||
|
||
.sub-card-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 14px;
|
||
padding-bottom: 10px;
|
||
border-bottom: 1px solid #f1f5f9;
|
||
|
||
.icon {
|
||
font-size: 22px;
|
||
line-height: 1;
|
||
}
|
||
|
||
h4 {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #1e293b;
|
||
}
|
||
|
||
.search-link {
|
||
margin-left: auto;
|
||
font-size: 12px;
|
||
color: #047857;
|
||
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: var(--mint-text-muted, #334155);
|
||
transition: transform 0.3s;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.detail-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #1e293b;
|
||
}
|
||
|
||
.detail-amount {
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
margin-left: 4px;
|
||
|
||
&.green {
|
||
color: #065f46;
|
||
}
|
||
|
||
&.amber {
|
||
color: #d97706;
|
||
}
|
||
}
|
||
|
||
.detail-badge {
|
||
display: inline-block;
|
||
padding: 2px 10px;
|
||
border-radius: 20px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
white-space: nowrap;
|
||
|
||
&.badge-green {
|
||
background: #e8f4ee;
|
||
color: #065f46;
|
||
}
|
||
|
||
&.badge-amber {
|
||
background: #fffbeb;
|
||
color: #d97706;
|
||
}
|
||
|
||
&.badge-gray {
|
||
background: #f1f5f9;
|
||
color: var(--mint-text-muted, #334155);
|
||
}
|
||
}
|
||
|
||
.detail-summary {
|
||
margin-left: auto;
|
||
font-size: 12px;
|
||
color: var(--mint-text-muted, #334155);
|
||
}
|
||
|
||
.detail-delete {
|
||
margin-left: 6px;
|
||
}
|
||
|
||
.detail-body-inner {
|
||
padding: 0 18px 18px;
|
||
border-top: 1px solid #f1f5f9;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════
|
||
ATTACHMENTS
|
||
═══════════════════════════════════════════ */
|
||
.attachment-upload {
|
||
width: 100%;
|
||
}
|
||
|
||
.upload-card {
|
||
width: 140px;
|
||
height: 96px;
|
||
border: 1px dashed #a8d4c4;
|
||
border-radius: 8px;
|
||
background: #f0fdf4;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
cursor: pointer;
|
||
transition:
|
||
border-color 0.15s,
|
||
background 0.15s;
|
||
|
||
&:hover {
|
||
border-color: #047857;
|
||
border-style: solid;
|
||
background: #e8f4ee;
|
||
}
|
||
}
|
||
|
||
.upload-card-text {
|
||
font-size: 12px;
|
||
color: #047857;
|
||
font-weight: 500;
|
||
line-height: 1;
|
||
}
|
||
|
||
.upload-hint {
|
||
display: block;
|
||
font-size: 11px;
|
||
color: var(--mint-text-muted, #334155);
|
||
line-height: 1;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.attachment-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
}
|
||
|
||
.attachment-item {
|
||
width: 140px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 8px;
|
||
background: #fff;
|
||
overflow: hidden;
|
||
transition:
|
||
border-color 0.15s,
|
||
box-shadow 0.15s;
|
||
|
||
&:hover {
|
||
border-color: #047857;
|
||
box-shadow: 0 2px 8px rgba(4, 120, 87, 0.1);
|
||
}
|
||
|
||
&.is-uploading {
|
||
opacity: 0.6;
|
||
pointer-events: none;
|
||
}
|
||
}
|
||
|
||
.attachment-preview {
|
||
width: 100%;
|
||
height: 96px;
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: #f8fafc;
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
|
||
img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
}
|
||
}
|
||
|
||
.attachment-preview__icon {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
|
||
&--video {
|
||
background: #ede9fe;
|
||
color: #7c3aed;
|
||
}
|
||
|
||
&--doc {
|
||
background: #e0f2fe;
|
||
color: #0284c7;
|
||
}
|
||
}
|
||
|
||
.attachment-footer {
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
padding: 6px 8px;
|
||
}
|
||
|
||
.attachment-name {
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
font-size: 11px;
|
||
color: #334155;
|
||
line-height: 1.4;
|
||
word-break: break-all;
|
||
min-height: calc(11px * 1.4 * 2);
|
||
}
|
||
|
||
.attachment-actions {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-top: 4px;
|
||
flex-shrink: 0;
|
||
transition: opacity 0.15s;
|
||
}
|
||
|
||
.action-btn {
|
||
width: 22px;
|
||
height: 22px;
|
||
border-radius: 4px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 12px;
|
||
color: var(--mint-text-muted, #334155);
|
||
cursor: pointer;
|
||
background: #f1f5f9;
|
||
transition:
|
||
background 0.15s,
|
||
color 0.15s;
|
||
|
||
&:hover {
|
||
background: #047857;
|
||
color: #fff;
|
||
}
|
||
|
||
&--danger:hover {
|
||
background: #ef4444;
|
||
color: #fff;
|
||
}
|
||
}
|
||
|
||
.upload-card--sm {
|
||
width: 96px;
|
||
height: 76px;
|
||
|
||
.upload-card-text {
|
||
font-size: 11px;
|
||
}
|
||
}
|
||
|
||
.payment-detail-card__attachments {
|
||
margin-top: 8px;
|
||
padding-top: 8px;
|
||
border-top: 1px dashed #e2e8f0;
|
||
}
|
||
|
||
.preview-container {
|
||
text-align: center;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════
|
||
RESPONSIVE
|
||
═══════════════════════════════════════════ */
|
||
@media (max-width: 900px) {
|
||
.form-tab-pane--import-basic {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.form-grid {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
|
||
&--col2 {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
&--col4 {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
|
||
&--col5,
|
||
&--col6 {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
}
|
||
|
||
.client-sub-cards {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
.installment-amounts-row {
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.installment-amounts-field.el-form-item {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.installment-amounts-summary-row {
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.installment-amounts-cells {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px 16px;
|
||
}
|
||
|
||
.installment-amount-cell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
min-width: 120px;
|
||
flex: 1 1 120px;
|
||
max-width: 180px;
|
||
}
|
||
|
||
.installment-amount-cell-no {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--mint-text-muted, #334155);
|
||
}
|
||
|
||
.installment-amounts-sum {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #065f46;
|
||
|
||
&.is-error {
|
||
color: #dc2626;
|
||
}
|
||
}
|
||
.form-tip--error {
|
||
margin-top: 6px;
|
||
color: #dc2626;
|
||
font-size: 12px;
|
||
}
|
||
</style>
|