3526 lines
117 KiB
Vue
3526 lines
117 KiB
Vue
<template>
|
||
<!-- ========== 新增/编辑弹窗 ========== -->
|
||
<el-dialog
|
||
:model-value="modelValue"
|
||
:title="dialogTitle"
|
||
width="1380px"
|
||
class="dialog-form-layout"
|
||
align-center
|
||
destroy-on-close
|
||
:close-on-click-modal="false"
|
||
>
|
||
<DialogFormBody
|
||
:loading="dialogFormLoading"
|
||
body-class="protocol-dialog-body min-h-[200px]"
|
||
>
|
||
<div class="detail-tabs">
|
||
<div
|
||
v-for="tab in formTabs"
|
||
:key="tab.key"
|
||
class="detail-tab"
|
||
:class="{ active: activeFormTab === tab.key }"
|
||
@click="scrollToSection(tab.key)"
|
||
>
|
||
{{ tab.label }}
|
||
</div>
|
||
</div>
|
||
|
||
<el-form
|
||
ref="formRef"
|
||
:model="formData"
|
||
:rules="formRules"
|
||
label-position="top"
|
||
scroll-to-error
|
||
class="protocol-form"
|
||
>
|
||
<!-- Tab 1: 基本信息 -->
|
||
<div id="form-section-basic" class="form-tab-pane">
|
||
<div class="section-card">
|
||
<div class="section-title">
|
||
<div class="section-title-left"><h3>基本信息</h3></div>
|
||
</div>
|
||
<div class="form-grid">
|
||
<el-form-item label="协议号" prop="protocolNo">
|
||
<el-select
|
||
v-model="formData.protocolNoId"
|
||
class="w-full"
|
||
filterable
|
||
remote
|
||
clearable
|
||
reserve-keyword
|
||
:remote-method="remoteSearchProtocolNos"
|
||
:loading="protocolNoLoading"
|
||
placeholder="输入协议号检索"
|
||
@change="onProtocolNoChange"
|
||
@clear="onProtocolNoClear"
|
||
>
|
||
<el-option
|
||
v-for="p in protocolNoOptions"
|
||
:key="p.id"
|
||
:label="protocolNoOptionLabel(p)"
|
||
:value="p.id"
|
||
:disabled="isProtocolNoOptionDisabled(p)"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="协议状态" prop="protocolStatus">
|
||
<el-select
|
||
v-model="formData.protocolStatus"
|
||
placeholder="请选择"
|
||
style="width: 100%"
|
||
>
|
||
<el-option label="待生效" value="dsx" />
|
||
<el-option label="生效中" value="sxz" />
|
||
<el-option label="已过期" value="ygq" />
|
||
<el-option label="已作废" value="yzf" />
|
||
<el-option label="转科研" value="zky" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="登记日期" prop="registerDate">
|
||
<el-date-picker
|
||
v-model="formData.registerDate"
|
||
type="date"
|
||
value-format="YYYYMMDD"
|
||
format="YYYY-MM-DD"
|
||
placeholder="选择登记日期"
|
||
clearable
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="采集专员" prop="collectorUserId">
|
||
<el-select
|
||
v-model="collectorUserId"
|
||
class="w-full"
|
||
filterable
|
||
remote
|
||
clearable
|
||
reserve-keyword
|
||
:remote-method="remoteSearchCollectorUsers"
|
||
:loading="collectorLoading"
|
||
placeholder="输入姓名检索用户"
|
||
@change="onCollectorUserChange"
|
||
>
|
||
<el-option
|
||
v-for="u in collectorUserOptions"
|
||
:key="u.id"
|
||
:label="u.nickName + (u.phoneNo ? ' · ' + u.phoneNo : '')"
|
||
:value="u.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
</div>
|
||
|
||
<!-- 协议附件 -->
|
||
<el-form-item label="协议附件" style="margin-top: 18px">
|
||
<div class="attachment-upload">
|
||
<div class="attachment-list">
|
||
<el-upload
|
||
ref="attachmentUploadRef"
|
||
:auto-upload="false"
|
||
:show-file-list="false"
|
||
:multiple="true"
|
||
accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
|
||
@change="handleAttachmentChange"
|
||
>
|
||
<div class="upload-card">
|
||
<el-icon :size="28" color="#10b981"><Plus /></el-icon>
|
||
<span class="upload-card-text">上传附件</span>
|
||
</div>
|
||
</el-upload>
|
||
<div
|
||
v-for="(file, index) in formData.attachments"
|
||
:key="file.id || index"
|
||
class="attachment-item"
|
||
:class="{ 'is-uploading': file._uploading }"
|
||
>
|
||
<div
|
||
class="attachment-preview"
|
||
@click="
|
||
isImageFile(file) && file.fileUrl
|
||
? handlePreviewAttachment(file)
|
||
: undefined
|
||
"
|
||
>
|
||
<template v-if="isImageFile(file) && file.fileUrl">
|
||
<img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
|
||
</template>
|
||
<template v-else-if="isImageFile(file) && !file.fileUrl">
|
||
<el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
|
||
</template>
|
||
<template v-else-if="isVideoFile(file)">
|
||
<div class="attachment-preview__icon attachment-preview__icon--video">
|
||
<el-icon :size="28"><VideoPlay /></el-icon>
|
||
</div>
|
||
</template>
|
||
<template v-else>
|
||
<div class="attachment-preview__icon attachment-preview__icon--doc">
|
||
<el-icon :size="28"><Document /></el-icon>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<div class="attachment-footer">
|
||
<span class="attachment-name" :title="file.fileName">{{
|
||
file.fileName
|
||
}}</span>
|
||
<div class="attachment-actions">
|
||
<el-icon
|
||
class="action-btn"
|
||
title="下载"
|
||
@click="handleDownloadAttachment(file)"
|
||
><Download
|
||
/></el-icon>
|
||
<el-icon
|
||
class="action-btn action-btn--danger"
|
||
title="删除"
|
||
@click="removeAttachment(index)"
|
||
><Delete
|
||
/></el-icon>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<span class="upload-hint"
|
||
>支持图片、视频、PDF、Word、Excel等文件,单文件不超过 20MB</span
|
||
>
|
||
</div>
|
||
</el-form-item>
|
||
|
||
<el-form-item label="备注">
|
||
<el-input
|
||
v-model="formData.remark"
|
||
type="textarea"
|
||
:rows="3"
|
||
placeholder="可选"
|
||
/>
|
||
</el-form-item>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab 2: 客户信息 -->
|
||
<div id="form-section-party" class="form-tab-pane">
|
||
<div class="section-card section-card--green">
|
||
<div class="section-title">
|
||
<div class="section-title-left"><h3>客户信息</h3></div>
|
||
</div>
|
||
<div class="client-sub-cards">
|
||
<!-- 母亲信息 -->
|
||
<div class="client-sub-card">
|
||
<div class="sub-card-header">
|
||
<span class="icon">🤱</span>
|
||
<h4>母亲信息</h4>
|
||
<span class="search-link">关联客户 ▸</span>
|
||
</div>
|
||
<el-form-item label="关联客户" style="margin-bottom: 12px">
|
||
<el-select
|
||
v-model="customerId"
|
||
class="w-full"
|
||
filterable
|
||
remote
|
||
clearable
|
||
reserve-keyword
|
||
:remote-method="remoteSearchCustomers"
|
||
:loading="customerLoading"
|
||
placeholder="输入姓名/电话/身份证号检索客户"
|
||
@change="onCustomerChange"
|
||
>
|
||
<el-option
|
||
v-for="c in customerOptions"
|
||
:key="c.id"
|
||
:label="formatCustomerLabel(c)"
|
||
:value="c.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<div class="form-grid form-grid--col2">
|
||
<el-form-item label="母亲姓名">
|
||
<el-input v-model="formData.motherName" placeholder="请输入" />
|
||
</el-form-item>
|
||
<el-form-item label="母亲联系电话">
|
||
<el-input v-model="formData.motherPhone" placeholder="请输入" />
|
||
</el-form-item>
|
||
<el-form-item label="母亲身份证号" class="form-col-span-2">
|
||
<el-input v-model="formData.motherIdCard" placeholder="请输入" />
|
||
</el-form-item>
|
||
<el-form-item label="母亲出生年月">
|
||
<el-date-picker
|
||
v-model="formData.motherBirthYm"
|
||
type="month"
|
||
value-format="YYYYMM"
|
||
format="YYYY-MM"
|
||
placeholder="选择月份"
|
||
clearable
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="母亲民族">
|
||
<el-select
|
||
v-model="formData.motherEthnic"
|
||
class="w-full"
|
||
filterable
|
||
clearable
|
||
:loading="ethnicLoading"
|
||
placeholder="请选择或检索民族"
|
||
>
|
||
<el-option
|
||
v-for="e in ethnicOptions"
|
||
:key="e.ethnicCode"
|
||
:label="e.ethnicName"
|
||
:value="e.ethnicName"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
</div>
|
||
<el-form-item label="母亲家庭住址" style="margin-top: 12px">
|
||
<el-input v-model="formData.motherAddress" placeholder="请输入" />
|
||
</el-form-item>
|
||
</div>
|
||
|
||
<!-- 父亲信息 -->
|
||
<div class="client-sub-card">
|
||
<div class="sub-card-header">
|
||
<span class="icon">👨</span>
|
||
<h4>父亲信息</h4>
|
||
</div>
|
||
<div class="form-grid form-grid--col2">
|
||
<el-form-item label="父亲姓名">
|
||
<el-input v-model="formData.fatherName" placeholder="请输入" />
|
||
</el-form-item>
|
||
<el-form-item label="父亲电话">
|
||
<el-input v-model="formData.fatherPhone" placeholder="请输入" />
|
||
</el-form-item>
|
||
<el-form-item label="父亲身份证号" class="form-col-span-2">
|
||
<el-input v-model="formData.fatherIdCard" placeholder="请输入" />
|
||
</el-form-item>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 新生儿信息 -->
|
||
<div class="client-sub-card">
|
||
<div class="sub-card-header">
|
||
<span class="icon">👶</span>
|
||
<h4>新生儿信息</h4>
|
||
</div>
|
||
<div class="form-grid form-grid--col2">
|
||
<el-form-item label="新生儿姓名">
|
||
<el-input v-model="formData.newbornName" placeholder="请输入" />
|
||
</el-form-item>
|
||
<el-form-item label="新生儿性别">
|
||
<el-radio-group v-model="formData.newbornSex">
|
||
<el-radio label="1">男</el-radio>
|
||
<el-radio label="0">女</el-radio>
|
||
</el-radio-group>
|
||
</el-form-item>
|
||
<el-form-item label="新生儿分娩时间" class="form-col-span-2">
|
||
<el-date-picker
|
||
v-model="formData.newbornDeliveryTime"
|
||
type="datetime"
|
||
value-format="YYYY-MM-DD HH:mm:ss"
|
||
placeholder="选择分娩时间"
|
||
clearable
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="新生儿身份证" class="form-col-span-2">
|
||
<el-input v-model="formData.newbornIdCard" placeholder="请输入" />
|
||
</el-form-item>
|
||
<el-form-item label="分娩方式">
|
||
<el-select
|
||
v-model="formData.deliveryMode"
|
||
placeholder="请选择"
|
||
clearable
|
||
style="width: 100%"
|
||
>
|
||
<el-option label="顺产" value="sc" />
|
||
<el-option label="剖宫产" value="pgc" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="胎次">
|
||
<el-input
|
||
v-model="formData.birthOrder"
|
||
type="number"
|
||
step="any"
|
||
:min="1"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="新生儿体重(克)">
|
||
<el-input
|
||
v-model="formData.newbornWeightGram"
|
||
type="number"
|
||
step="any"
|
||
:min="0"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="新生儿评分1分钟">
|
||
<el-input
|
||
v-model="formData.apgar1min"
|
||
type="number"
|
||
step="any"
|
||
:min="0"
|
||
:max="10"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="新生儿评分5分钟">
|
||
<el-input
|
||
v-model="formData.apgar5min"
|
||
type="number"
|
||
step="any"
|
||
:min="0"
|
||
:max="10"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab 3: 采集信息 -->
|
||
<div id="form-section-collection" class="form-tab-pane">
|
||
<div class="section-card">
|
||
<div class="section-title">
|
||
<div class="section-title-left"><h3>采集信息</h3></div>
|
||
</div>
|
||
<div class="form-grid">
|
||
<el-form-item label="采集地区">
|
||
<el-select
|
||
v-model="formData.regionId"
|
||
class="w-full"
|
||
filterable
|
||
remote
|
||
clearable
|
||
reserve-keyword
|
||
:remote-method="remoteSearchRegions"
|
||
:loading="regionLoading"
|
||
placeholder="输入地区名称检索"
|
||
@change="onRegionChange"
|
||
@clear="onRegionClear"
|
||
>
|
||
<el-option
|
||
v-for="r in regionOptions"
|
||
:key="r.id"
|
||
:label="r.regionName"
|
||
:value="r.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="采集医院">
|
||
<el-select
|
||
v-model="formData.hospitalId"
|
||
class="w-full"
|
||
filterable
|
||
remote
|
||
clearable
|
||
reserve-keyword
|
||
:remote-method="remoteSearchHospitals"
|
||
:loading="hospitalLoading"
|
||
:disabled="!formData.regionId"
|
||
placeholder="请先选择采集地区"
|
||
@change="onHospitalChange"
|
||
>
|
||
<el-option
|
||
v-for="h in hospitalOptions"
|
||
:key="h.id"
|
||
:label="h.hospitalName"
|
||
:value="h.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="采集医护">
|
||
<el-input v-model="formData.medicalStaff" placeholder="请输入" />
|
||
</el-form-item>
|
||
<el-form-item label="销售人员">
|
||
<el-select
|
||
v-model="salesUserId"
|
||
class="w-full"
|
||
filterable
|
||
remote
|
||
clearable
|
||
reserve-keyword
|
||
:remote-method="remoteSearchSalesUsers"
|
||
:loading="salesLoading"
|
||
placeholder="输入姓名检索用户"
|
||
@change="onSalesUserChange"
|
||
>
|
||
<el-option
|
||
v-for="u in salesUserOptions"
|
||
:key="u.id"
|
||
:label="u.nickName + (u.phoneNo ? ' · ' + u.phoneNo : '')"
|
||
:value="u.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="样本采集时间">
|
||
<el-date-picker
|
||
v-model="formData.sampleCollectTime"
|
||
type="datetime"
|
||
value-format="YYYY-MM-DD HH:mm:ss"
|
||
placeholder="选择采集时间"
|
||
clearable
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="母血采集时间">
|
||
<el-date-picker
|
||
v-model="formData.maternalBloodCollectTime"
|
||
type="datetime"
|
||
value-format="YYYY-MM-DD HH:mm:ss"
|
||
placeholder="选择采集时间"
|
||
clearable
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab 4: 样本及运输信息 -->
|
||
<div id="form-section-sample" class="form-tab-pane">
|
||
<div class="section-card">
|
||
<div class="section-title">
|
||
<div class="section-title-left"><h3>样本及运输信息</h3></div>
|
||
</div>
|
||
|
||
<div class="sample-toolbar">
|
||
<el-button type="primary" @click="addSample">
|
||
<el-icon><Plus /></el-icon>
|
||
添加样本
|
||
</el-button>
|
||
<el-button
|
||
class="btn-ghost"
|
||
:disabled="!selectedSamples.length"
|
||
@click="openBatchTransportDialog"
|
||
>
|
||
应用到选中样本
|
||
</el-button>
|
||
</div>
|
||
<p v-if="formData.samples.length" class="sample-transport-hint"
|
||
>勾选样本后,点击「应用到选中样本」可批量填写运输信息。点击展开可编辑单个运输信息。</p
|
||
>
|
||
|
||
<!-- Sample Cards -->
|
||
<div
|
||
v-for="(row, $index) in formData.samples"
|
||
:key="row._rowKey"
|
||
class="sample-card"
|
||
>
|
||
<div class="sample-header">
|
||
<el-checkbox
|
||
:model-value="selectedSamples.includes(row)"
|
||
@change="(val) => val ? (!selectedSamples.includes(row) && selectedSamples.push(row)) : selectedSamples.splice(selectedSamples.indexOf(row), 1)"
|
||
/>
|
||
<div class="sample-title">
|
||
<span class="sample-num">样本 #{{ $index + 1 }} ·</span>
|
||
{{ sampleRowLabel(row, $index) }}
|
||
</div>
|
||
<div class="sample-actions">
|
||
<el-button
|
||
link
|
||
type="danger"
|
||
size="small"
|
||
@click="removeSample($index)"
|
||
>
|
||
<el-icon><Delete /></el-icon>
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
<div class="form-grid">
|
||
<el-form-item label="样本类型">
|
||
<el-select
|
||
v-model="row.sampleTypeId"
|
||
placeholder="选择类型"
|
||
style="width: 100%"
|
||
@change="onSampleTypeChange(row)"
|
||
>
|
||
<el-option
|
||
v-for="st in sampleTypeOptions"
|
||
:key="st.id"
|
||
:label="st.typeName + (st.typeCode ? ' (' + st.typeCode + ')' : '')"
|
||
:value="st.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="样本编码">
|
||
<el-select
|
||
v-model="row.sampleCodeId"
|
||
placeholder="选择编码"
|
||
filterable
|
||
style="width: 100%"
|
||
@change="onSampleCodeChange(row)"
|
||
>
|
||
<el-option
|
||
v-for="sc in sampleCodeOptions"
|
||
:key="sc.id"
|
||
:label="sampleCodeOptionLabel(sc)"
|
||
:value="sc.id"
|
||
:disabled="isSampleCodeOptionDisabled(sc, row)"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="采集量">
|
||
<el-input
|
||
v-model="row.collectAmount"
|
||
type="number"
|
||
step="any"
|
||
:min="0"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="单位">
|
||
<el-select
|
||
v-model="row.unitId"
|
||
placeholder="单位"
|
||
filterable
|
||
style="width: 100%"
|
||
>
|
||
<el-option
|
||
v-for="u in unitOptions"
|
||
:key="u.id"
|
||
:label="u.unitName"
|
||
:value="u.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="价格(元)">
|
||
<el-input
|
||
v-model="row.price"
|
||
type="number"
|
||
step="any"
|
||
:min="0"
|
||
style="width: 100%"
|
||
@change="onSamplePriceChange"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="存储年限(年)">
|
||
<el-input
|
||
v-model="row.storageYears"
|
||
type="number"
|
||
step="any"
|
||
:min="0"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="样本状态">
|
||
<el-select
|
||
v-model="row.sampleStatus"
|
||
placeholder="状态"
|
||
clearable
|
||
style="width: 100%"
|
||
@change="onSampleStatusChange(row)"
|
||
>
|
||
<el-option label="已采集" value="ycj" />
|
||
<el-option label="检测中" value="jcz" />
|
||
<el-option label="已入库" value="yrk" />
|
||
<el-option label="已作废" value="yzf" />
|
||
</el-select>
|
||
</el-form-item>
|
||
</div>
|
||
|
||
<!-- Transport Toggle -->
|
||
<div
|
||
class="transport-toggle"
|
||
:class="{ expanded: row._transportExpanded }"
|
||
@click="row._transportExpanded = !row._transportExpanded"
|
||
>
|
||
<span class="chevron">▶</span>
|
||
运输信息
|
||
<span class="summary">
|
||
{{ row.transport?.transportType ? transportTypeLabel(String(row.transport.transportType)) : '' }}
|
||
{{ row.transport?.destSiteId ? '→ ' + siteName(String(row.transport.destSiteId)) : '' }}
|
||
</span>
|
||
</div>
|
||
<div
|
||
class="transport-body"
|
||
:class="{ open: row._transportExpanded }"
|
||
>
|
||
<div
|
||
class="sample-transport-form-row"
|
||
:class="{
|
||
'sample-transport-form-row--ryxd': row.transport?.transportType === 'ryxd'
|
||
}"
|
||
>
|
||
<el-form-item label="运输类型">
|
||
<el-select
|
||
v-model="row.transport.transportType"
|
||
placeholder="请选择"
|
||
clearable
|
||
style="width: 100%"
|
||
>
|
||
<el-option label="快递运输" value="kdys" />
|
||
<el-option label="人员携带" value="ryxd" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="目的站点">
|
||
<el-select
|
||
v-model="row.transport.destSiteId"
|
||
placeholder="选择站点"
|
||
filterable
|
||
clearable
|
||
style="width: 100%"
|
||
>
|
||
<el-option
|
||
v-for="site in siteOptions"
|
||
:key="site.id"
|
||
:label="site.siteName"
|
||
:value="site.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="预计到达时间">
|
||
<el-date-picker
|
||
v-model="row.transport.etaTime"
|
||
type="datetime"
|
||
value-format="YYYY-MM-DD HH:mm:ss"
|
||
format="YYYY-MM-DD HH:mm"
|
||
placeholder="选择预计到达时间"
|
||
clearable
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<template v-if="row.transport?.transportType === 'kdys'">
|
||
<el-form-item label="快递公司">
|
||
<el-select
|
||
v-model="row.transport.expressCompanyId"
|
||
placeholder="选择快递公司"
|
||
filterable
|
||
clearable
|
||
style="width: 100%"
|
||
>
|
||
<el-option
|
||
v-for="ec in expressCompanyOptions"
|
||
:key="ec.id"
|
||
:label="ec.expressName"
|
||
:value="ec.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="快递单号">
|
||
<el-input
|
||
v-model="row.transport.waybillNo"
|
||
placeholder="请输入快递单号"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="快递费用(元)">
|
||
<el-input
|
||
v-model="row.transport.expressFee"
|
||
type="number"
|
||
step="any"
|
||
:min="0"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
</template>
|
||
<template v-if="row.transport?.transportType === 'ryxd'">
|
||
<el-form-item label="携带人员姓名">
|
||
<el-input
|
||
v-model="row.transport.carrierName"
|
||
placeholder="请输入姓名"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="携带人员电话">
|
||
<el-input
|
||
v-model="row.transport.carrierPhone"
|
||
placeholder="请输入电话"
|
||
/>
|
||
</el-form-item>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<el-empty
|
||
v-if="!formData.samples.length"
|
||
description="暂无样本,点击上方按钮添加"
|
||
:image-size="48"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab 5: 缴费信息 -->
|
||
<div id="form-section-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">
|
||
<el-form-item label="缴费模式">
|
||
<el-radio-group v-model="formData.payMode" @change="onPayModeChange">
|
||
<el-radio label="dj">趸交</el-radio>
|
||
<el-radio label="fq">分期</el-radio>
|
||
</el-radio-group>
|
||
</el-form-item>
|
||
<el-form-item label="合同金额(元)">
|
||
<el-input
|
||
v-model="formData.contractAmount"
|
||
disabled
|
||
placeholder="自动计算"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<template v-if="formData.payMode === 'dj'">
|
||
<el-form-item label="趸交截止时间">
|
||
<el-date-picker
|
||
v-model="formData.lumpSumDeadline"
|
||
type="date"
|
||
value-format="YYYYMMDD"
|
||
format="YYYY-MM-DD"
|
||
placeholder="选择截止时间"
|
||
clearable
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
</template>
|
||
<template v-if="formData.payMode === 'fq'">
|
||
<el-form-item label="分期期数">
|
||
<el-input
|
||
v-model="formData.installmentCount"
|
||
type="number"
|
||
step="any"
|
||
:min="1"
|
||
style="width: 100%"
|
||
@change="tryGenerateInstallmentDetails"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="每期间隔(月)">
|
||
<el-input
|
||
v-model="formData.installmentIntervalMonths"
|
||
type="number"
|
||
step="any"
|
||
:min="1"
|
||
style="width: 100%"
|
||
@change="tryGenerateInstallmentDetails"
|
||
/>
|
||
</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="tryGenerateInstallmentDetails"
|
||
/>
|
||
</el-form-item>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 缴费明细 -->
|
||
<div class="section-subtitle" style="margin-top: 20px">缴费明细</div>
|
||
<div style="margin-bottom: 14px">
|
||
<el-button type="primary" @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'"
|
||
>
|
||
¥{{ 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
|
||
link
|
||
type="danger"
|
||
size="small"
|
||
class="detail-delete"
|
||
@click.stop="formData.paymentDetails.splice(dIdx, 1)"
|
||
>
|
||
<el-icon><Delete /></el-icon>
|
||
</el-button>
|
||
</div>
|
||
<div v-show="detail._expanded" class="detail-body">
|
||
<div class="detail-body-inner">
|
||
<div class="form-grid">
|
||
<el-form-item label="应缴费金额(元)">
|
||
<el-input
|
||
v-model="detail.dueAmount"
|
||
type="number"
|
||
step="any"
|
||
:min="0"
|
||
disabled
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="转账方式">
|
||
<el-select
|
||
v-model="detail.transferType"
|
||
placeholder="请选择转账方式"
|
||
style="width: 100%"
|
||
>
|
||
<el-option label="微信" value="wxzz" />
|
||
<el-option label="支付宝" value="zzbzz" />
|
||
<el-option label="银行卡" value="yhkzz" />
|
||
<el-option label="现金" value="xj" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="转账金额(元)">
|
||
<el-input
|
||
v-model="detail.transferAmount"
|
||
type="number"
|
||
step="any"
|
||
:min="0"
|
||
style="width: 100%"
|
||
/>
|
||
</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
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="缴费状态">
|
||
<el-select
|
||
v-model="detail.payStatus"
|
||
placeholder="状态"
|
||
style="width: 100%"
|
||
>
|
||
<el-option label="待缴费" value="dfj" />
|
||
<el-option label="缴费确认中" value="jfqrz" />
|
||
<el-option label="已缴费" value="yjf" />
|
||
<el-option label="退费确认中" value="tfqrz" />
|
||
<el-option label="已退费" value="ytf" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="应缴费时间">
|
||
<el-date-picker
|
||
v-model="detail.installmentDueDate"
|
||
type="date"
|
||
value-format="YYYYMMDD"
|
||
format="YYYY-MM-DD"
|
||
placeholder="选择应缴费时间"
|
||
clearable
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
</div>
|
||
<!-- 每条明细的附件上传区 -->
|
||
<div class="payment-detail-card__attachments">
|
||
<div class="attachment-upload">
|
||
<div class="attachment-list">
|
||
<el-upload
|
||
:auto-upload="false"
|
||
:show-file-list="false"
|
||
:multiple="true"
|
||
accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
|
||
@change="(f: any) => handleDetailAttachmentChange(dIdx, f)"
|
||
>
|
||
<div class="upload-card upload-card--sm">
|
||
<el-icon :size="22" color="#10b981"><Plus /></el-icon>
|
||
<span class="upload-card-text">上传凭证</span>
|
||
</div>
|
||
</el-upload>
|
||
<div
|
||
v-for="(file, fIdx) in detail.attachments"
|
||
:key="file.id || fIdx"
|
||
class="attachment-item"
|
||
:class="{ 'is-uploading': file._uploading }"
|
||
>
|
||
<div
|
||
class="attachment-preview"
|
||
@click="
|
||
isImageFile(file) && file.fileUrl
|
||
? handlePreviewAttachment(file)
|
||
: undefined
|
||
"
|
||
>
|
||
<template v-if="isImageFile(file) && file.fileUrl">
|
||
<img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
|
||
</template>
|
||
<template v-else-if="isImageFile(file) && !file.fileUrl">
|
||
<el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
|
||
</template>
|
||
<template v-else-if="isVideoFile(file)">
|
||
<div
|
||
class="attachment-preview__icon attachment-preview__icon--video"
|
||
>
|
||
<el-icon :size="28"><VideoPlay /></el-icon>
|
||
</div>
|
||
</template>
|
||
<template v-else>
|
||
<div class="attachment-preview__icon attachment-preview__icon--doc">
|
||
<el-icon :size="28"><Document /></el-icon>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<div class="attachment-footer">
|
||
<span class="attachment-name" :title="file.fileName">{{
|
||
file.fileName
|
||
}}</span>
|
||
<div class="attachment-actions">
|
||
<el-icon
|
||
class="action-btn"
|
||
title="下载"
|
||
@click="handleDownloadAttachment(file)"
|
||
><Download
|
||
/></el-icon>
|
||
<el-icon
|
||
class="action-btn action-btn--danger"
|
||
title="删除"
|
||
@click="removeDetailAttachment(dIdx, fIdx)"
|
||
><Delete
|
||
/></el-icon>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<span class="upload-hint"
|
||
>支持图片、视频、PDF、Word、Excel等文件,单文件不超过 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
|
||
destroy-on-close
|
||
>
|
||
<div v-if="previewType === 'image'" class="preview-container">
|
||
<img
|
||
:src="previewUrl"
|
||
alt="预览"
|
||
style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
|
||
/>
|
||
</div>
|
||
<div v-else class="preview-container">
|
||
<video
|
||
:src="previewUrl"
|
||
controls
|
||
style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
|
||
/>
|
||
</div>
|
||
</el-dialog>
|
||
|
||
<!-- ========== 批量运输信息弹窗 ========== -->
|
||
<el-dialog
|
||
v-model="batchTransportVisible"
|
||
title="批量运输信息"
|
||
width="1200px"
|
||
class="dialog-form-layout"
|
||
align-center
|
||
destroy-on-close
|
||
:close-on-click-modal="false"
|
||
>
|
||
<p class="sample-transport-hint batch-transport-dialog-hint">
|
||
将以下运输信息应用到已选中的 {{ selectedSamples.length }} 个样本。
|
||
</p>
|
||
<el-form label-position="top" class="biz-dialog-form">
|
||
<div
|
||
class="sample-transport-form-row"
|
||
:class="{
|
||
'sample-transport-form-row--ryxd': batchTransport.transportType === 'ryxd'
|
||
}"
|
||
>
|
||
<el-form-item label="运输类型">
|
||
<el-select
|
||
v-model="batchTransport.transportType"
|
||
placeholder="请选择"
|
||
clearable
|
||
style="width: 100%"
|
||
>
|
||
<el-option label="快递运输" value="kdys" />
|
||
<el-option label="人员携带" value="ryxd" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="目的站点">
|
||
<el-select
|
||
v-model="batchTransport.destSiteId"
|
||
placeholder="选择站点"
|
||
filterable
|
||
clearable
|
||
style="width: 100%"
|
||
>
|
||
<el-option
|
||
v-for="site in siteOptions"
|
||
:key="site.id"
|
||
:label="site.siteName"
|
||
:value="site.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="预计到达时间">
|
||
<el-date-picker
|
||
v-model="batchTransport.etaTime"
|
||
type="datetime"
|
||
value-format="YYYY-MM-DD HH:mm:ss"
|
||
format="YYYY-MM-DD HH:mm"
|
||
placeholder="选择预计到达时间"
|
||
clearable
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<template v-if="batchTransport.transportType === 'kdys'">
|
||
<el-form-item label="快递公司">
|
||
<el-select
|
||
v-model="batchTransport.expressCompanyId"
|
||
placeholder="选择快递公司"
|
||
filterable
|
||
clearable
|
||
style="width: 100%"
|
||
>
|
||
<el-option
|
||
v-for="ec in expressCompanyOptions"
|
||
:key="ec.id"
|
||
:label="ec.expressName"
|
||
:value="ec.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="快递单号">
|
||
<el-input v-model="batchTransport.waybillNo" placeholder="请输入快递单号" />
|
||
</el-form-item>
|
||
<el-form-item label="快递费用(元)">
|
||
<el-input
|
||
v-model="batchTransport.expressFee"
|
||
type="number"
|
||
step="any"
|
||
:min="0"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
</template>
|
||
<template v-if="batchTransport.transportType === 'ryxd'">
|
||
<el-form-item label="携带人员姓名">
|
||
<el-input v-model="batchTransport.carrierName" placeholder="请输入姓名" />
|
||
</el-form-item>
|
||
<el-form-item label="携带人员电话">
|
||
<el-input v-model="batchTransport.carrierPhone" placeholder="请输入电话" />
|
||
</el-form-item>
|
||
</template>
|
||
</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button class="btn-ghost" @click="batchTransportVisible = false">
|
||
<el-icon><Close /></el-icon>
|
||
取消
|
||
</el-button>
|
||
<el-button class="btn-main" type="primary" @click="confirmBatchTransportApply">
|
||
<el-icon><Check /></el-icon>
|
||
确定应用
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</template>
|
||
<script setup lang="ts">
|
||
import DialogFormBody from '@/components/business/dialog-form-body/index.vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import {
|
||
Plus,
|
||
Close,
|
||
Check,
|
||
Delete,
|
||
Document,
|
||
VideoPlay,
|
||
Loading,
|
||
Download,
|
||
ArrowDown,
|
||
ArrowRight
|
||
} from '@element-plus/icons-vue'
|
||
import {
|
||
fetchCollectionProtocolDetail,
|
||
fetchCreateCollectionProtocol,
|
||
fetchUpdateCollectionProtocol,
|
||
type CollectionProtocolRow
|
||
} from '@/api/collection-protocol'
|
||
import { fetchUploadAttachment } from '@/api/business'
|
||
import { downloadAttachmentFile } from '@/utils/download-attachment'
|
||
import {
|
||
fetchCustomerList,
|
||
fetchUserList,
|
||
fetchUserDetail,
|
||
fetchCellSampleTypeList,
|
||
fetchItemUnitList,
|
||
fetchCollectionRegionList,
|
||
fetchHospitalList,
|
||
fetchCollectionProtocolNoList,
|
||
fetchSampleCodeList,
|
||
fetchExpressCompanyList,
|
||
fetchSiteList
|
||
} from '@/api/admin'
|
||
import { fetchEthnicList, type EthnicOption } from '@/api/ethnic'
|
||
import { useUserStore } from '@/store/modules/user'
|
||
import { syncFqPaymentDetailRowCount } from '@/utils/protocol-payment-installment'
|
||
import {
|
||
payModeLabel,
|
||
newbornSexLabel,
|
||
deliveryModeLabel,
|
||
sampleStatusLabel,
|
||
transportTypeLabel,
|
||
transferTypeLabel,
|
||
payStatusLabel
|
||
} from '@/utils/status-tag-registry'
|
||
|
||
const userStore = useUserStore()
|
||
|
||
const customerId = ref<string | null>(null)
|
||
const customerOptions = ref<
|
||
Array<{ id: string; customerName: string; phoneNo: string; idCardNo: string }>
|
||
>([])
|
||
const customerLoading = ref(false)
|
||
const customerSnapshot = ref<{
|
||
id: string
|
||
customerName: string
|
||
phoneNo: string
|
||
idCardNo: string
|
||
} | null>(null)
|
||
|
||
const regionOptions = ref<Api.BizCollectionRegion.RegionItem[]>([])
|
||
const regionLoading = ref(false)
|
||
const hospitalOptions = ref<Api.BizCollectionHospital.HospitalItem[]>([])
|
||
const hospitalLoading = ref(false)
|
||
|
||
const collectorUserId = ref<string | null>(null)
|
||
const collectorUserOptions = ref<Api.User.UserItem[]>([])
|
||
const collectorLoading = ref(false)
|
||
const collectorSnapshot = ref<Api.User.UserItem | null>(null)
|
||
|
||
const salesUserId = ref<string | null>(null)
|
||
const salesUserOptions = ref<Api.User.UserItem[]>([])
|
||
const salesLoading = ref(false)
|
||
const salesSnapshot = ref<Api.User.UserItem | null>(null)
|
||
|
||
const sampleTypeOptions = ref<
|
||
Array<{
|
||
id: string
|
||
typeCode: string
|
||
typeName: string
|
||
storageYears: number | null
|
||
storagePrice: number | null
|
||
}>
|
||
>([])
|
||
const unitOptions = ref<Array<{ id: string; unitName: string; unitCode: string }>>([])
|
||
const protocolNoOptions = ref<Api.BizCollectionProtocolNo.ProtocolNoItem[]>([])
|
||
const protocolNoLoading = ref(false)
|
||
const sampleCodeOptions = ref<Api.BizSampleCode.SampleCodeItem[]>([])
|
||
const sampleCodeLoading = ref(false)
|
||
const expressCompanyOptions = ref<Api.ExpressCompany.ExpressCompanyItem[]>([])
|
||
const siteOptions = ref<Api.Site.SiteItem[]>([])
|
||
const selectedSamples = ref<Array<Record<string, any>>>([])
|
||
let sampleRowSeq = 0
|
||
|
||
type SampleTransportForm = {
|
||
transportType: string
|
||
expressCompanyId: string
|
||
waybillNo: string
|
||
destSiteId: string
|
||
expressFee: number | null
|
||
etaTime: string
|
||
carrierName: string
|
||
carrierPhone: string
|
||
}
|
||
|
||
function createEmptyTransport(): SampleTransportForm {
|
||
return {
|
||
transportType: '',
|
||
expressCompanyId: '',
|
||
waybillNo: '',
|
||
destSiteId: '',
|
||
expressFee: null,
|
||
etaTime: '',
|
||
carrierName: '',
|
||
carrierPhone: ''
|
||
}
|
||
}
|
||
|
||
const batchTransport = reactive<SampleTransportForm>(createEmptyTransport())
|
||
const batchTransportVisible = ref(false)
|
||
const ethnicOptions = ref<EthnicOption[]>([])
|
||
const ethnicLoading = ref(false)
|
||
const DEFAULT_MOTHER_ETHNIC = '汉族'
|
||
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
|
||
|
||
function fullFileUrl(suffix: string) {
|
||
if (!suffix) return ''
|
||
if (suffix.startsWith('http://') || suffix.startsWith('https://') || suffix.startsWith('/'))
|
||
return suffix
|
||
return `${minioBaseUrl}/${suffix}`
|
||
}
|
||
|
||
async function loadEthnicOptions() {
|
||
if (ethnicOptions.value.length) return
|
||
ethnicLoading.value = true
|
||
try {
|
||
const res = await fetchEthnicList({ limit: 200 })
|
||
ethnicOptions.value = res.list || []
|
||
} finally {
|
||
ethnicLoading.value = false
|
||
}
|
||
}
|
||
|
||
function ensureEthnicOptionInList(name: string) {
|
||
const n = (name || '').trim()
|
||
if (!n) return
|
||
if (ethnicOptions.value.some((e) => e.ethnicName === n)) return
|
||
ethnicOptions.value = [
|
||
{ id: '', ethnicName: n, ethnicCode: '', orderNum: null },
|
||
...ethnicOptions.value
|
||
]
|
||
}
|
||
|
||
defineOptions({ name: '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 siteName(id: string) {
|
||
if (!id) return '-'
|
||
return siteOptions.value.find((x) => x.id === id)?.siteName || '-'
|
||
}
|
||
|
||
function expressName(id: string) {
|
||
if (!id) return '-'
|
||
return expressCompanyOptions.value.find((x) => x.id === id)?.expressName || '-'
|
||
}
|
||
|
||
function sampleTransportExtra(transport: Record<string, any> | undefined) {
|
||
if (!transport?.transportType) return '-'
|
||
if (transport.transportType === 'kdys') {
|
||
const parts: string[] = []
|
||
const ec = expressName(String(transport.expressCompanyId || ''))
|
||
if (ec !== '-') parts.push(ec)
|
||
if (transport.waybillNo) parts.push(String(transport.waybillNo))
|
||
if (transport.expressFee != null && transport.expressFee !== '') {
|
||
parts.push(`¥${transport.expressFee}`)
|
||
}
|
||
return parts.length ? parts.join(' · ') : '-'
|
||
}
|
||
if (transport.transportType === 'ryxd') {
|
||
const parts: string[] = []
|
||
if (transport.carrierName) parts.push(String(transport.carrierName))
|
||
if (transport.carrierPhone) parts.push(String(transport.carrierPhone))
|
||
return parts.length ? parts.join(' · ') : '-'
|
||
}
|
||
return '-'
|
||
}
|
||
|
||
function sampleRowLabel(row: Record<string, any>, index: number) {
|
||
if (row.sampleCode) return row.sampleCode
|
||
const st = sampleTypeOptions.value.find((x) => x.id === row.sampleTypeId)
|
||
if (st?.typeName) return st.typeName
|
||
return `样本 ${index + 1}`
|
||
}
|
||
|
||
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: 'party', label: '客户信息' },
|
||
{ key: 'collection', label: '采集信息' },
|
||
{ key: 'sample', label: '样本及运输信息' },
|
||
{ key: 'payment', label: '缴费信息' }
|
||
] as const
|
||
|
||
/* ====== 新增/编辑弹窗 ====== */
|
||
const dialogFormLoading = ref(false)
|
||
const dialogTitle = ref('新增协议单')
|
||
const isEdit = ref(false)
|
||
const submitLoading = ref(false)
|
||
const editingId = ref<string | null>(null)
|
||
const formRef = ref()
|
||
const activeFormTab = ref('basic')
|
||
|
||
const formData = reactive({
|
||
// Basic
|
||
protocolNo: '',
|
||
protocolNoId: '',
|
||
protocolStatus: 'dsx',
|
||
registerDate: '',
|
||
collectorUserId: '',
|
||
protocolAttachments: '',
|
||
remark: '',
|
||
attachments: [] as Array<{
|
||
id?: string
|
||
fileName: string
|
||
fileUrl: string
|
||
fileSize: number
|
||
fileSizeDisplay?: string
|
||
fileType?: string
|
||
_uploading?: boolean
|
||
}>,
|
||
// Party Customer
|
||
customerId: '' as string,
|
||
motherName: '',
|
||
motherPhone: '',
|
||
motherIdCard: '',
|
||
motherBirthYm: '',
|
||
motherEthnic: '',
|
||
motherAddress: '',
|
||
fatherName: '',
|
||
fatherPhone: '',
|
||
fatherIdCard: '',
|
||
newbornName: '',
|
||
newbornIdCard: '',
|
||
newbornDeliveryTime: '',
|
||
newbornSex: '',
|
||
deliveryMode: '',
|
||
birthOrder: null as number | null,
|
||
newbornWeightGram: null as number | null,
|
||
apgar1min: null as number | null,
|
||
apgar5min: null as number | null,
|
||
// Collection Info
|
||
regionId: '',
|
||
hospitalId: '',
|
||
medicalStaff: '',
|
||
salesUserId: '',
|
||
sampleCollectTime: '',
|
||
maternalBloodCollectTime: '',
|
||
// Samples
|
||
samples: [] as Array<Record<string, any>>,
|
||
// Payment
|
||
payMode: 'dj',
|
||
contractAmount: null as number | null,
|
||
installmentCount: null as number | null,
|
||
installmentIntervalMonths: null as number | null,
|
||
lumpSumDeadline: '',
|
||
installmentStartDate: '',
|
||
// Payment Details
|
||
paymentDetails: [] as Array<Record<string, any>>
|
||
})
|
||
|
||
const formRules = {
|
||
protocolNo: [{ required: true, message: '请输入协议号', trigger: 'blur' }],
|
||
protocolStatus: [{ required: true, message: '请选择协议状态', trigger: 'change' }]
|
||
}
|
||
|
||
function resetForm() {
|
||
Object.assign(formData, {
|
||
protocolNo: '',
|
||
protocolNoId: '',
|
||
protocolStatus: 'dsx',
|
||
registerDate: '',
|
||
collectorUserId: '',
|
||
protocolAttachments: '',
|
||
attachments: [],
|
||
remark: '',
|
||
customerId: '',
|
||
motherName: '',
|
||
motherPhone: '',
|
||
motherIdCard: '',
|
||
motherBirthYm: '',
|
||
motherEthnic: '',
|
||
motherAddress: '',
|
||
fatherName: '',
|
||
fatherPhone: '',
|
||
fatherIdCard: '',
|
||
newbornName: '',
|
||
newbornIdCard: '',
|
||
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: '',
|
||
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 = []
|
||
protocolNoOptions.value = []
|
||
selectedSamples.value = []
|
||
batchTransportVisible.value = false
|
||
Object.assign(batchTransport, createEmptyTransport())
|
||
activeFormTab.value = 'basic'
|
||
}
|
||
|
||
function scrollToSection(key: string) {
|
||
activeFormTab.value = key
|
||
const el = document.getElementById(`form-section-${key}`)
|
||
if (el) {
|
||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||
}
|
||
}
|
||
|
||
function normalizeSampleRow(raw: Record<string, any>, 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 onSampleSelectionChange(rows: Array<Record<string, any>>) {
|
||
selectedSamples.value = rows
|
||
}
|
||
|
||
function openBatchTransportDialog() {
|
||
if (!selectedSamples.value.length) {
|
||
ElMessage.warning('请先勾选需要应用运输信息的样本')
|
||
return
|
||
}
|
||
batchTransportVisible.value = true
|
||
}
|
||
|
||
function confirmBatchTransportApply() {
|
||
if (!batchTransport.transportType) {
|
||
ElMessage.warning('请选择运输类型')
|
||
return
|
||
}
|
||
const payload = { ...batchTransport }
|
||
for (const row of selectedSamples.value) {
|
||
if (!row.transport) row.transport = createEmptyTransport()
|
||
Object.assign(row.transport, payload)
|
||
}
|
||
batchTransportVisible.value = false
|
||
ElMessage.success(`已为 ${selectedSamples.value.length} 个样本应用运输信息`)
|
||
}
|
||
|
||
function addSample() {
|
||
formData.samples.push(
|
||
normalizeSampleRow(
|
||
{
|
||
sampleTypeId: '',
|
||
sampleCode: '',
|
||
sampleCodeId: '',
|
||
collectAmount: null,
|
||
unitId: '',
|
||
price: null,
|
||
storageYears: null,
|
||
sampleStatus: 'ycj'
|
||
},
|
||
formData.samples.length
|
||
)
|
||
)
|
||
}
|
||
|
||
function removeSample(index: number) {
|
||
formData.samples.splice(index, 1)
|
||
syncContractAmount()
|
||
}
|
||
|
||
function addPaymentDetail() {
|
||
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()
|
||
}
|
||
|
||
/** 日期加 N 个月,返回 YYYYMMDD 格式(入参支持 YYYYMMDD 或 YYYY-MM-DD) */
|
||
function addMonths(dateStr: string, months: number): string {
|
||
const compact = dateStr.replace(/-/g, '')
|
||
const y = parseInt(compact.slice(0, 4), 10)
|
||
const m = parseInt(compact.slice(4, 6), 10) - 1
|
||
const d = parseInt(compact.slice(6, 8), 10)
|
||
const date = new Date(y, m + months, d)
|
||
const yy = date.getFullYear()
|
||
const mm = String(date.getMonth() + 1).padStart(2, '0')
|
||
const dd = String(date.getDate()).padStart(2, '0')
|
||
return `${yy}${mm}${dd}`
|
||
}
|
||
|
||
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 fqPaymentContext() {
|
||
return {
|
||
payMode: formData.payMode,
|
||
installmentCount: toIntOrUndefined(formData.installmentCount) ?? null,
|
||
contractAmount: formData.contractAmount,
|
||
installmentStartDate: formData.installmentStartDate,
|
||
installmentIntervalMonths: toIntOrUndefined(formData.installmentIntervalMonths) ?? null
|
||
}
|
||
}
|
||
|
||
/** 分期模式:三个参数都填好后生成/补齐缴费明细行(已有明细时仅补齐期次,不整表覆盖) */
|
||
function tryGenerateInstallmentDetails() {
|
||
if (formData.payMode !== 'fq') return
|
||
const count = formData.installmentCount
|
||
const interval = formData.installmentIntervalMonths
|
||
const startDate = formData.installmentStartDate
|
||
if (!count || !interval || !startDate) return
|
||
|
||
if (formData.paymentDetails.length) {
|
||
syncFqPaymentDetailRowCount(fqPaymentContext(), formData.paymentDetails)
|
||
syncPaymentDueAmounts()
|
||
formData.paymentDetails.forEach((d, i) => {
|
||
d.installmentNo = i + 1
|
||
d.installmentDueDate = addMonths(startDate, i * interval)
|
||
})
|
||
return
|
||
}
|
||
|
||
const dueAmounts = calcInstallmentDueAmounts(formData.contractAmount, count)
|
||
const details = []
|
||
for (let i = 0; i < count; i++) {
|
||
details.push({
|
||
transferType: '',
|
||
dueAmount: dueAmounts[i] ?? null,
|
||
transferAmount: null,
|
||
transferTime: '',
|
||
payStatus: 'dfj',
|
||
installmentNo: i + 1,
|
||
installmentDueDate: addMonths(startDate, i * interval),
|
||
attachments: [] as Array<{
|
||
id?: string
|
||
fileName: string
|
||
fileUrl: string
|
||
fileSize: number
|
||
fileSizeDisplay?: string
|
||
fileType?: string
|
||
_uploading?: boolean
|
||
}>
|
||
})
|
||
}
|
||
initPaymentDetailExpandFlags(details)
|
||
formData.paymentDetails = details
|
||
}
|
||
|
||
/** 缴费模式切换回调 */
|
||
function onPayModeChange(val: string) {
|
||
if (val === 'fq') {
|
||
tryGenerateInstallmentDetails()
|
||
} else {
|
||
// 趸交模式:将趸交截止时间同步到所有明细的应缴费时间
|
||
syncPaymentDueAmounts()
|
||
syncLumpSumDueDate()
|
||
}
|
||
}
|
||
|
||
/** 趸交模式:将趸交截止时间同步到所有缴费明细的应缴费时间 */
|
||
function syncLumpSumDueDate() {
|
||
if (formData.payMode !== 'dj') return
|
||
const deadline = formData.lumpSumDeadline || ''
|
||
formData.paymentDetails.forEach((d) => {
|
||
d.installmentDueDate = deadline
|
||
})
|
||
}
|
||
|
||
function handleOpenForAdd() {
|
||
isEdit.value = false
|
||
dialogTitle.value = '新增协议单'
|
||
resetForm()
|
||
formData.motherEthnic = DEFAULT_MOTHER_ETHNIC
|
||
formData.registerDate = new Date().toISOString().slice(0, 10).replace(/-/g, '')
|
||
loadSampleOptions()
|
||
void loadEthnicOptions()
|
||
void remoteSearchCustomers('')
|
||
|
||
// 默认采集专员、销售人员为当前登录人
|
||
const currentUser = userStore.getUserInfo
|
||
const uid = String(currentUser?.id || currentUser?.userId || '')
|
||
if (uid) {
|
||
const displayName = currentUser?.realName || currentUser?.username || ''
|
||
const phone = currentUser?.phone || ''
|
||
const userStub = {
|
||
id: uid,
|
||
nickName: displayName,
|
||
phoneNo: phone,
|
||
deptId: '',
|
||
userNo: '',
|
||
email: '',
|
||
sex: '',
|
||
avatar: '',
|
||
status: '1',
|
||
tenantNo: '',
|
||
orderNum: 0,
|
||
remark: '',
|
||
createTime: '',
|
||
updateTime: ''
|
||
} as Api.User.UserItem
|
||
|
||
// 采集专员默认当前用户
|
||
collectorUserId.value = uid
|
||
formData.collectorUserId = uid
|
||
collectorSnapshot.value = { ...userStub }
|
||
collectorUserOptions.value = [{ ...userStub }]
|
||
|
||
// 销售人员默认当前用户
|
||
salesUserId.value = uid
|
||
formData.salesUserId = uid
|
||
salesSnapshot.value = { ...userStub }
|
||
salesUserOptions.value = [{ ...userStub }]
|
||
}
|
||
}
|
||
|
||
function applyProtocolBundleToForm(bundle: Record<string, any>, row: CollectionProtocolRow) {
|
||
const prot = bundle.protocol as Record<string, any> | null
|
||
if (prot) {
|
||
formData.collectorUserId = String(prot.collectorUserId || formData.collectorUserId || '')
|
||
formData.protocolNo = String(prot.protocolNo || formData.protocolNo || '')
|
||
formData.protocolNoId = String(prot.protocolNoId || formData.protocolNoId || '')
|
||
formData.protocolStatus = String(prot.protocolStatus || formData.protocolStatus || 'dsx')
|
||
formData.registerDate = String(prot.registerDate || formData.registerDate || '')
|
||
formData.remark = String(prot.remark ?? formData.remark ?? '')
|
||
if (formData.protocolNoId) {
|
||
protocolNoOptions.value = [
|
||
{
|
||
id: formData.protocolNoId,
|
||
protocolNo: formData.protocolNo,
|
||
issued: 0,
|
||
issuedUserId: '',
|
||
issuedUserName: '',
|
||
issuedTime: '',
|
||
tenantNo: '',
|
||
orderNum: 0,
|
||
remark: '',
|
||
createTime: '',
|
||
updateTime: ''
|
||
}
|
||
]
|
||
}
|
||
}
|
||
|
||
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.motherAddress = String(pc.motherAddress || '')
|
||
formData.fatherName = String(pc.fatherName || '')
|
||
formData.fatherPhone = String(pc.fatherPhone || '')
|
||
formData.fatherIdCard = String(pc.fatherIdCard || '')
|
||
formData.newbornName = String(pc.newbornName || '')
|
||
formData.newbornIdCard = String(pc.newbornIdCard || '')
|
||
formData.newbornDeliveryTime = String(pc.newbornDeliveryTime || '')
|
||
formData.newbornSex = String(pc.newbornSex ?? '')
|
||
formData.deliveryMode = String(pc.deliveryMode || '')
|
||
formData.birthOrder = pc.birthOrder ?? null
|
||
formData.newbornWeightGram = pc.newbornWeightGram ?? null
|
||
formData.apgar1min = pc.apgar1min ?? null
|
||
formData.apgar5min = pc.apgar5min ?? null
|
||
formData.customerId = String(pc.customerId || '')
|
||
}
|
||
|
||
const ci = bundle.collectionInfo as Record<string, any> | null
|
||
if (ci) {
|
||
formData.regionId = String(ci.regionId || '')
|
||
formData.hospitalId = String(ci.hospitalId || '')
|
||
formData.medicalStaff = String(ci.medicalStaff || '')
|
||
formData.salesUserId = String(ci.salesUserId || '')
|
||
formData.sampleCollectTime = String(ci.sampleCollectTime || '')
|
||
formData.maternalBloodCollectTime = String(ci.maternalBloodCollectTime || '')
|
||
}
|
||
|
||
const samples = bundle.samples as Array<Record<string, any>> | null
|
||
if (samples && samples.length) {
|
||
formData.samples = samples.map((s, i) => normalizeSampleRow(s, i))
|
||
}
|
||
|
||
const pay = bundle.payment as Record<string, any> | null
|
||
if (pay) {
|
||
formData.payMode = String(pay.payMode || 'dj')
|
||
formData.contractAmount = pay.contractAmount ?? null
|
||
formData.lumpSumDeadline = String(pay.lumpSumDeadline || '')
|
||
formData.installmentCount = pay.installmentCount ?? null
|
||
formData.installmentIntervalMonths = pay.installmentIntervalMonths ?? null
|
||
formData.installmentStartDate = String(pay.installmentStartDate || '')
|
||
}
|
||
|
||
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
|
||
}
|
||
syncFqPaymentDetailRowCount(fqPaymentContext(), formData.paymentDetails)
|
||
|
||
const pa =
|
||
(row as any).protocolAttachments || (bundle.protocol as any)?.protocolAttachments || ''
|
||
if (pa && typeof pa === 'string') {
|
||
// legacy URL-only field
|
||
}
|
||
const att = (bundle as any).attachments as Array<Record<string, any>> | null
|
||
if (att && att.length) {
|
||
formData.attachments = att.map((a) => ({ ...a })) as any
|
||
}
|
||
|
||
syncContractAmount()
|
||
}
|
||
|
||
async function handleOpenForEdit() {
|
||
const row = props.listRow
|
||
if (!row || !props.editId) return
|
||
|
||
isEdit.value = true
|
||
dialogTitle.value = '编辑协议单'
|
||
editingId.value = props.editId
|
||
resetForm()
|
||
|
||
formData.protocolNo = String(row.protocolNo || '')
|
||
formData.protocolNoId = String((row as any).protocolNoId || '')
|
||
formData.protocolStatus = String(row.protocolStatus || 'dsx')
|
||
formData.registerDate = String(row.registerDate || '')
|
||
formData.collectorUserId = String(row.collectorUserId || '')
|
||
if (formData.protocolNoId) {
|
||
protocolNoOptions.value = [
|
||
{
|
||
id: formData.protocolNoId,
|
||
protocolNo: formData.protocolNo,
|
||
issued: 0,
|
||
issuedUserId: '',
|
||
issuedUserName: '',
|
||
issuedTime: '',
|
||
tenantNo: '',
|
||
orderNum: 0,
|
||
remark: '',
|
||
createTime: '',
|
||
updateTime: ''
|
||
}
|
||
]
|
||
}
|
||
formData.protocolAttachments = String((row as any).protocolAttachments || '')
|
||
formData.remark = String(row.remark || '')
|
||
|
||
dialogFormLoading.value = true
|
||
|
||
try {
|
||
const [, , bundle] = await Promise.all([
|
||
loadSampleOptions(),
|
||
loadEthnicOptions(),
|
||
fetchCollectionProtocolDetail(String(row.id)).catch(() => null)
|
||
])
|
||
if (bundle) {
|
||
applyProtocolBundleToForm(bundle as Record<string, any>, row)
|
||
}
|
||
|
||
const hydrateTasks: Promise<void>[] = [
|
||
hydrateCollectorUserForEdit(
|
||
formData.collectorUserId,
|
||
String((row as any).collectorUserName || '')
|
||
),
|
||
hydrateSalesUserForEdit(formData.salesUserId, String((row as any).salesUserName || '')),
|
||
hydrateCustomerForEdit(
|
||
formData.customerId,
|
||
formData.motherName,
|
||
formData.motherPhone,
|
||
formData.motherIdCard
|
||
)
|
||
]
|
||
if (formData.regionId) {
|
||
hydrateTasks.push(
|
||
ensureRegionOptionForEdit(formData.regionId, String((row as any).regionName || ''))
|
||
)
|
||
}
|
||
if (formData.regionId && formData.hospitalId) {
|
||
hydrateTasks.push(
|
||
ensureHospitalOptionForEdit(
|
||
formData.regionId,
|
||
formData.hospitalId,
|
||
String((row as any).hospitalName || '')
|
||
)
|
||
)
|
||
}
|
||
await Promise.all(hydrateTasks)
|
||
if (formData.samples.length) {
|
||
ensureSampleCodesInOptions(formData.samples)
|
||
}
|
||
} catch {
|
||
/* 保留列表行已填基础字段 */
|
||
} finally {
|
||
dialogFormLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function handleSubmit() {
|
||
if (!formRef.value) return
|
||
await formRef.value.validate()
|
||
if (!isEdit.value && formData.protocolNoId) {
|
||
const picked = protocolNoOptions.value.find((x) => x.id === formData.protocolNoId)
|
||
if (picked?.usedInProtocol) {
|
||
ElMessage.warning(`协议号「${formData.protocolNo}」已被占用,请更换其他协议号`)
|
||
return
|
||
}
|
||
}
|
||
if (!isEdit.value) {
|
||
for (const sampleRow of formData.samples) {
|
||
if (!sampleRow.sampleCodeId) continue
|
||
const sc = sampleCodeOptions.value.find((x) => x.id === sampleRow.sampleCodeId)
|
||
if (sc?.usedInProtocol) {
|
||
ElMessage.warning(
|
||
`样本编码「${sampleRow.sampleCode || sc.sampleCode}」已被占用,请更换`
|
||
)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
submitLoading.value = true
|
||
try {
|
||
syncFqPaymentDetailRowCount(fqPaymentContext(), formData.paymentDetails)
|
||
syncPaymentDueAmounts()
|
||
const protocolAttachmentUrls = formData.attachments
|
||
.map((a) => a.fileUrl)
|
||
.filter(Boolean)
|
||
.join(',')
|
||
const payload = {
|
||
protocolNo: formData.protocolNo,
|
||
protocolNoId: formData.protocolNoId || undefined,
|
||
protocolStatus: formData.protocolStatus,
|
||
registerDate: formData.registerDate || undefined,
|
||
collectorUserId: formData.collectorUserId || undefined,
|
||
protocolAttachments: protocolAttachmentUrls || formData.protocolAttachments || undefined,
|
||
attachments: formData.attachments.map((a) => ({
|
||
id: a.id,
|
||
fileName: a.fileName,
|
||
fileUrl: a.fileUrl,
|
||
fileSize: a.fileSize,
|
||
fileSizeDisplay: a.fileSizeDisplay,
|
||
fileType: a.fileType
|
||
})),
|
||
remark: formData.remark || undefined,
|
||
partyCustomer: {
|
||
customerId: formData.customerId || undefined,
|
||
motherName: formData.motherName || undefined,
|
||
motherPhone: formData.motherPhone || undefined,
|
||
motherIdCard: formData.motherIdCard || undefined,
|
||
motherBirthYm: formData.motherBirthYm || undefined,
|
||
motherEthnic: formData.motherEthnic || undefined,
|
||
motherAddress: formData.motherAddress || undefined,
|
||
fatherName: formData.fatherName || undefined,
|
||
fatherPhone: formData.fatherPhone || undefined,
|
||
fatherIdCard: formData.fatherIdCard || undefined,
|
||
newbornName: formData.newbornName || undefined,
|
||
newbornIdCard: formData.newbornIdCard || undefined,
|
||
newbornDeliveryTime: formData.newbornDeliveryTime || undefined,
|
||
newbornSex: formData.newbornSex || undefined,
|
||
deliveryMode: formData.deliveryMode || undefined,
|
||
birthOrder: formData.birthOrder ?? undefined,
|
||
newbornWeightGram: formData.newbornWeightGram ?? undefined,
|
||
apgar1min: formData.apgar1min ?? undefined,
|
||
apgar5min: formData.apgar5min ?? undefined
|
||
},
|
||
collectionInfo: {
|
||
regionId: formData.regionId || undefined,
|
||
hospitalId: formData.hospitalId || undefined,
|
||
medicalStaff: formData.medicalStaff || undefined,
|
||
salesUserId: formData.salesUserId || undefined,
|
||
sampleCollectTime: formData.sampleCollectTime || undefined,
|
||
maternalBloodCollectTime: formData.maternalBloodCollectTime || undefined
|
||
},
|
||
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: toIntOrUndefined(formData.installmentIntervalMonths),
|
||
installmentStartDate: formData.installmentStartDate || undefined
|
||
},
|
||
paymentDetails: formData.paymentDetails.map(({ _expanded, ...d }) => ({
|
||
transferType: d.transferType,
|
||
dueAmount: d.dueAmount,
|
||
transferAmount: d.transferAmount,
|
||
transferTime: d.transferTime,
|
||
payStatus: d.payStatus,
|
||
installmentNo: toIntOrUndefined(d.installmentNo),
|
||
installmentDueDate: d.installmentDueDate,
|
||
remark: d.remark,
|
||
financeRemark: d.financeRemark,
|
||
financeConfirmAttachments: d.financeConfirmAttachments,
|
||
financeConfirmAttachmentFiles: d.financeConfirmAttachmentFiles,
|
||
refundAmount: d.refundAmount,
|
||
refundRemark: d.refundRemark,
|
||
refundAttachments: d.refundAttachments,
|
||
refundAttachmentFiles: d.refundAttachmentFiles,
|
||
refundFinanceRemark: d.refundFinanceRemark,
|
||
refundFinanceConfirmAttachments: d.refundFinanceConfirmAttachments,
|
||
refundFinanceConfirmAttachmentFiles: d.refundFinanceConfirmAttachmentFiles,
|
||
attachments: (d.attachments || []).map((a: any) => ({
|
||
id: a.id,
|
||
fileName: a.fileName,
|
||
fileUrl: a.fileUrl,
|
||
fileSize: a.fileSize,
|
||
fileSizeDisplay: a.fileSizeDisplay,
|
||
fileType: a.fileType
|
||
}))
|
||
}))
|
||
}
|
||
|
||
if (isEdit.value && editingId.value) {
|
||
await fetchUpdateCollectionProtocol(editingId.value, payload)
|
||
} else {
|
||
await fetchCreateCollectionProtocol(payload)
|
||
}
|
||
emit('update:modelValue', false)
|
||
emit('saved')
|
||
} finally {
|
||
submitLoading.value = false
|
||
}
|
||
}
|
||
|
||
function createUserStub(id: string, nickName: string, phoneNo = ''): Api.User.UserItem {
|
||
return {
|
||
id,
|
||
nickName: nickName || id,
|
||
phoneNo,
|
||
deptId: '',
|
||
userNo: '',
|
||
email: '',
|
||
sex: '',
|
||
avatar: '',
|
||
status: '1',
|
||
tenantNo: '',
|
||
orderNum: 0,
|
||
remark: '',
|
||
createTime: '',
|
||
updateTime: ''
|
||
}
|
||
}
|
||
|
||
async function hydrateRemoteUserForEdit(
|
||
userId: string,
|
||
displayName: string | undefined,
|
||
assign: (user: Api.User.UserItem) => void,
|
||
clear: () => void
|
||
) {
|
||
const uid = (userId || '').trim()
|
||
if (!uid) {
|
||
clear()
|
||
return
|
||
}
|
||
try {
|
||
const detail = await fetchUserDetail(uid)
|
||
assign(detail)
|
||
return
|
||
} catch {
|
||
/* 回退为列表/占位展示名 */
|
||
}
|
||
assign(createUserStub(uid, (displayName || '').trim()))
|
||
}
|
||
|
||
async function hydrateCollectorUserForEdit(userId: string, displayName?: string) {
|
||
await hydrateRemoteUserForEdit(
|
||
userId,
|
||
displayName,
|
||
(user) => {
|
||
collectorSnapshot.value = { ...user }
|
||
collectorUserId.value = user.id
|
||
collectorUserOptions.value = [{ ...user }]
|
||
formData.collectorUserId = user.id
|
||
},
|
||
() => {
|
||
collectorUserId.value = null
|
||
collectorSnapshot.value = null
|
||
collectorUserOptions.value = []
|
||
formData.collectorUserId = ''
|
||
}
|
||
)
|
||
}
|
||
|
||
async function hydrateSalesUserForEdit(userId: string, displayName?: string) {
|
||
await hydrateRemoteUserForEdit(
|
||
userId,
|
||
displayName,
|
||
(user) => {
|
||
salesSnapshot.value = { ...user }
|
||
salesUserId.value = user.id
|
||
salesUserOptions.value = [{ ...user }]
|
||
formData.salesUserId = user.id
|
||
},
|
||
() => {
|
||
salesUserId.value = null
|
||
salesSnapshot.value = null
|
||
salesUserOptions.value = []
|
||
formData.salesUserId = ''
|
||
}
|
||
)
|
||
}
|
||
|
||
async function hydrateCustomerForEdit(
|
||
customerIdVal: string,
|
||
motherName: string,
|
||
motherPhone: string,
|
||
motherIdCard: string
|
||
) {
|
||
const cid = (customerIdVal || '').trim()
|
||
if (!cid) {
|
||
customerId.value = null
|
||
customerSnapshot.value = null
|
||
customerOptions.value = []
|
||
formData.customerId = ''
|
||
return
|
||
}
|
||
let snapshot = {
|
||
id: cid,
|
||
customerName: motherName,
|
||
phoneNo: motherPhone,
|
||
idCardNo: motherIdCard
|
||
}
|
||
const keyword = motherPhone || motherName || motherIdCard
|
||
if (keyword) {
|
||
try {
|
||
const res = await fetchCustomerList({ page: 1, pageSize: 50, keyword })
|
||
const match = (res.list || []).find((c) => c.id === cid)
|
||
if (match) {
|
||
snapshot = {
|
||
id: match.id,
|
||
customerName: match.customerName || motherName,
|
||
phoneNo: match.phoneNo || motherPhone,
|
||
idCardNo: match.idCardNo || motherIdCard
|
||
}
|
||
}
|
||
} catch {
|
||
/* 使用母亲信息占位 */
|
||
}
|
||
}
|
||
customerSnapshot.value = snapshot
|
||
customerId.value = cid
|
||
customerOptions.value = [snapshot]
|
||
formData.customerId = cid
|
||
}
|
||
|
||
async function ensureRegionOptionForEdit(regionId: string, regionName?: string) {
|
||
const rid = (regionId || '').trim()
|
||
if (!rid) return
|
||
await remoteSearchRegions('')
|
||
if (regionOptions.value.some((r) => r.id === rid)) return
|
||
const stub: Api.BizCollectionRegion.RegionItem = {
|
||
id: rid,
|
||
regionName: (regionName || '').trim() || rid,
|
||
hospitalNames: [],
|
||
tenantNo: '',
|
||
orderNum: 0,
|
||
remark: '',
|
||
createTime: '',
|
||
updateTime: ''
|
||
}
|
||
regionOptions.value = [stub, ...regionOptions.value]
|
||
}
|
||
|
||
async function ensureHospitalOptionForEdit(
|
||
regionId: string,
|
||
hospitalId: string,
|
||
hospitalName?: string
|
||
) {
|
||
const hid = (hospitalId || '').trim()
|
||
if (!hid || !(regionId || '').trim()) return
|
||
await loadHospitalsByRegion(regionId, '')
|
||
if (hospitalOptions.value.some((h) => h.id === hid)) return
|
||
const stub: Api.BizCollectionHospital.HospitalItem = {
|
||
id: hid,
|
||
regionId,
|
||
hospitalName: (hospitalName || '').trim() || hid,
|
||
tenantNo: '',
|
||
orderNum: 0,
|
||
remark: '',
|
||
createTime: '',
|
||
updateTime: ''
|
||
}
|
||
hospitalOptions.value = [stub, ...hospitalOptions.value]
|
||
}
|
||
|
||
function ensureSampleCodesInOptions(samples: Array<Record<string, any>>) {
|
||
const merged = [...sampleCodeOptions.value]
|
||
for (const s of samples) {
|
||
const id = String(s.sampleCodeId || '')
|
||
if (!id || merged.some((x) => x.id === id)) continue
|
||
merged.push({
|
||
id,
|
||
sampleCode: String(s.sampleCode || id),
|
||
sampleCodeType: '',
|
||
issued: 0,
|
||
usedInProtocol: false,
|
||
issuedUserId: '',
|
||
issuedUserName: '',
|
||
issuedTime: '',
|
||
tenantNo: '',
|
||
orderNum: 0,
|
||
remark: '',
|
||
createTime: '',
|
||
updateTime: ''
|
||
})
|
||
}
|
||
sampleCodeOptions.value = merged
|
||
}
|
||
|
||
/* ====== 客户检索(母亲信息) ====== */
|
||
function formatCustomerLabel(c: { customerName: string; phoneNo: string; idCardNo: string }) {
|
||
const parts = [c.customerName || '']
|
||
if (c.phoneNo) parts.push(c.phoneNo)
|
||
if (c.idCardNo) parts.push(c.idCardNo)
|
||
return parts.filter(Boolean).join(' · ')
|
||
}
|
||
|
||
async function remoteSearchCustomers(query: string) {
|
||
customerLoading.value = true
|
||
try {
|
||
const res = await fetchCustomerList({ page: 1, pageSize: 50, keyword: query || undefined })
|
||
let list = [...(res.list || [])]
|
||
// Keep current selection visible
|
||
if (
|
||
customerId.value &&
|
||
customerSnapshot.value?.id === customerId.value &&
|
||
!list.some((x) => x.id === customerId.value)
|
||
) {
|
||
list = [customerSnapshot.value, ...list]
|
||
}
|
||
customerOptions.value = list
|
||
} catch {
|
||
customerOptions.value = []
|
||
} finally {
|
||
customerLoading.value = false
|
||
}
|
||
}
|
||
|
||
function onCustomerChange(val: string | null | undefined) {
|
||
if (!val) {
|
||
customerId.value = null
|
||
customerSnapshot.value = null
|
||
// Clear auto-filled fields only
|
||
formData.motherName = ''
|
||
formData.motherPhone = ''
|
||
formData.motherIdCard = ''
|
||
formData.motherBirthYm = ''
|
||
return
|
||
}
|
||
const c = customerOptions.value.find((x) => x.id === val) || customerSnapshot.value
|
||
if (!c) return
|
||
customerSnapshot.value = { ...c } as any
|
||
customerId.value = c.id
|
||
formData.customerId = c.id
|
||
formData.motherName = c.customerName?.trim() || ''
|
||
formData.motherPhone = c.phoneNo?.trim() || ''
|
||
formData.motherIdCard = c.idCardNo?.trim() || ''
|
||
calcBirthFromIdCard()
|
||
}
|
||
|
||
/* ====== 采集专员检索 ====== */
|
||
async function remoteSearchCollectorUsers(query: string) {
|
||
collectorLoading.value = true
|
||
try {
|
||
const res = await fetchUserList({
|
||
page: 1,
|
||
pageSize: 50,
|
||
keyword: query || undefined,
|
||
status: '1'
|
||
})
|
||
let list = [...(res.list || [])]
|
||
if (
|
||
collectorUserId.value &&
|
||
collectorSnapshot.value?.id === collectorUserId.value &&
|
||
!list.some((x) => x.id === collectorUserId.value)
|
||
) {
|
||
list = [collectorSnapshot.value, ...list]
|
||
}
|
||
collectorUserOptions.value = list
|
||
} catch {
|
||
collectorUserOptions.value = []
|
||
} finally {
|
||
collectorLoading.value = false
|
||
}
|
||
}
|
||
|
||
function onCollectorUserChange(val: string | null | undefined) {
|
||
if (!val) {
|
||
collectorUserId.value = null
|
||
collectorSnapshot.value = null
|
||
formData.collectorUserId = ''
|
||
return
|
||
}
|
||
const u = collectorUserOptions.value.find((x) => x.id === val) || collectorSnapshot.value
|
||
if (!u) return
|
||
collectorSnapshot.value = { ...u }
|
||
collectorUserId.value = u.id
|
||
formData.collectorUserId = u.id
|
||
}
|
||
|
||
/* ====== 销售人员检索 ====== */
|
||
async function remoteSearchSalesUsers(query: string) {
|
||
salesLoading.value = true
|
||
try {
|
||
const res = await fetchUserList({
|
||
page: 1,
|
||
pageSize: 50,
|
||
keyword: query || undefined,
|
||
status: '1'
|
||
})
|
||
let list = [...(res.list || [])]
|
||
if (
|
||
salesUserId.value &&
|
||
salesSnapshot.value?.id === salesUserId.value &&
|
||
!list.some((x) => x.id === salesUserId.value)
|
||
) {
|
||
list = [salesSnapshot.value, ...list]
|
||
}
|
||
salesUserOptions.value = list
|
||
} catch {
|
||
salesUserOptions.value = []
|
||
} finally {
|
||
salesLoading.value = false
|
||
}
|
||
}
|
||
|
||
function onSalesUserChange(val: string | null | undefined) {
|
||
if (!val) {
|
||
salesUserId.value = null
|
||
salesSnapshot.value = null
|
||
formData.salesUserId = ''
|
||
return
|
||
}
|
||
const u = salesUserOptions.value.find((x) => x.id === val) || salesSnapshot.value
|
||
if (!u) return
|
||
salesSnapshot.value = { ...u }
|
||
salesUserId.value = u.id
|
||
formData.salesUserId = u.id
|
||
}
|
||
|
||
/* ====== 样本类型/单位选项 ====== */
|
||
async function loadSampleOptions() {
|
||
try {
|
||
const [stRes, uRes, scRes, ecRes, siteRes] = await Promise.all([
|
||
fetchCellSampleTypeList({ page: 1, pageSize: 100 }),
|
||
fetchItemUnitList({ page: 1, pageSize: 100 }),
|
||
fetchSampleCodeList({ page: 1, pageSize: 100 }),
|
||
fetchExpressCompanyList({ page: 1, pageSize: 100 }),
|
||
fetchSiteList({ page: 1, pageSize: 100 })
|
||
])
|
||
sampleTypeOptions.value = (stRes.list || []) as any
|
||
unitOptions.value = (uRes.list || []) as any
|
||
sampleCodeOptions.value = (scRes.list || []) as any
|
||
expressCompanyOptions.value = ecRes.list || []
|
||
siteOptions.value = siteRes.list || []
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
/* ====== 采集地区下拉检索 ====== */
|
||
async function remoteSearchRegions(query: string) {
|
||
regionLoading.value = true
|
||
try {
|
||
const res = await fetchCollectionRegionList({
|
||
page: 1,
|
||
pageSize: 100,
|
||
keyword: query || undefined
|
||
})
|
||
regionOptions.value = res.list || []
|
||
} catch {
|
||
regionOptions.value = []
|
||
} finally {
|
||
regionLoading.value = false
|
||
}
|
||
}
|
||
|
||
function onRegionChange(val: string | null | undefined) {
|
||
if (!val) {
|
||
formData.regionId = ''
|
||
hospitalOptions.value = []
|
||
formData.hospitalId = ''
|
||
return
|
||
}
|
||
formData.regionId = val
|
||
formData.hospitalId = ''
|
||
hospitalOptions.value = []
|
||
// 联动加载该地区下的医院
|
||
loadHospitalsByRegion(val)
|
||
}
|
||
|
||
function onRegionClear() {
|
||
formData.regionId = ''
|
||
formData.hospitalId = ''
|
||
hospitalOptions.value = []
|
||
}
|
||
|
||
async function loadHospitalsByRegion(regionId: string, keyword?: string) {
|
||
hospitalLoading.value = true
|
||
try {
|
||
const res = await fetchHospitalList(regionId, {
|
||
page: 1,
|
||
pageSize: 100,
|
||
keyword: keyword || undefined
|
||
})
|
||
hospitalOptions.value = res.list || []
|
||
} catch {
|
||
hospitalOptions.value = []
|
||
} finally {
|
||
hospitalLoading.value = false
|
||
}
|
||
}
|
||
|
||
/* ====== 采集医院下拉检索 ====== */
|
||
async function remoteSearchHospitals(query: string) {
|
||
if (!formData.regionId) {
|
||
hospitalOptions.value = []
|
||
return
|
||
}
|
||
await loadHospitalsByRegion(formData.regionId, query)
|
||
}
|
||
|
||
function onHospitalChange(val: string | null | undefined) {
|
||
if (!val) {
|
||
formData.hospitalId = ''
|
||
return
|
||
}
|
||
formData.hospitalId = val
|
||
}
|
||
|
||
/** 分期应缴费金额分配:前 n-1 期均分(分),最后一期补齐差额 */
|
||
function calcInstallmentDueAmounts(contractAmount: number | null, count: number): number[] {
|
||
if (contractAmount == null || !count || count < 1) return []
|
||
const totalCents = Math.round(Number(contractAmount) * 100)
|
||
const baseCents = Math.floor(totalCents / count)
|
||
const amounts: number[] = []
|
||
let sumCents = 0
|
||
for (let i = 0; i < count; i++) {
|
||
if (i === count - 1) {
|
||
amounts.push((totalCents - sumCents) / 100)
|
||
} else {
|
||
amounts.push(baseCents / 100)
|
||
sumCents += baseCents
|
||
}
|
||
}
|
||
return amounts
|
||
}
|
||
|
||
/** 按缴费模式同步各明细应缴费金额 */
|
||
function syncPaymentDueAmounts() {
|
||
const contract = formData.contractAmount
|
||
if (contract == null) return
|
||
if (formData.payMode === 'dj') {
|
||
formData.paymentDetails.forEach((d) => {
|
||
d.dueAmount = contract
|
||
})
|
||
return
|
||
}
|
||
if (formData.payMode === 'fq') {
|
||
const count = formData.installmentCount || formData.paymentDetails.length
|
||
if (!count) return
|
||
const amounts = calcInstallmentDueAmounts(Number(contract), count)
|
||
formData.paymentDetails.forEach((d, i) => {
|
||
if (amounts[i] !== undefined) d.dueAmount = amounts[i]
|
||
})
|
||
}
|
||
}
|
||
|
||
/** 根据非作废样本价格之和同步合同金额,并联动应缴费金额 */
|
||
function syncContractAmount() {
|
||
const total = formData.samples.reduce((sum, s) => {
|
||
if (s.sampleStatus === 'yzf') return sum
|
||
return sum + (Number(s.price) || 0)
|
||
}, 0)
|
||
formData.contractAmount = Math.round(total * 100) / 100
|
||
syncPaymentDueAmounts()
|
||
}
|
||
|
||
/** 样本价格或状态变更 */
|
||
function onSamplePriceChange() {
|
||
syncContractAmount()
|
||
}
|
||
|
||
function onSampleStatusChange(row: Record<string, any>) {
|
||
if (row.sampleStatus == null) {
|
||
row.sampleStatus = ''
|
||
}
|
||
onSamplePriceChange()
|
||
}
|
||
|
||
function onSampleTypeChange(row: Record<string, any>) {
|
||
const st = sampleTypeOptions.value.find((x) => x.id === row.sampleTypeId)
|
||
if (st) {
|
||
row.price = st.storagePrice
|
||
row.storageYears = st.storageYears
|
||
}
|
||
syncContractAmount()
|
||
}
|
||
|
||
/* ====== 协议号下拉检索 ====== */
|
||
function protocolNoOptionLabel(p: Api.BizCollectionProtocolNo.ProtocolNoItem) {
|
||
if (p.usedInProtocol) return `${p.protocolNo}(已占用)`
|
||
if (p.issued) return `${p.protocolNo}(已下发)`
|
||
return p.protocolNo
|
||
}
|
||
|
||
function isProtocolNoOptionDisabled(p: Api.BizCollectionProtocolNo.ProtocolNoItem) {
|
||
if (!p.usedInProtocol) return false
|
||
return !(isEdit.value && p.id === formData.protocolNoId)
|
||
}
|
||
|
||
async function remoteSearchProtocolNos(query: string) {
|
||
protocolNoLoading.value = true
|
||
try {
|
||
const res = await fetchCollectionProtocolNoList({
|
||
page: 1,
|
||
pageSize: 100,
|
||
keyword: query || undefined
|
||
})
|
||
protocolNoOptions.value = res.list || []
|
||
} catch {
|
||
protocolNoOptions.value = []
|
||
} finally {
|
||
protocolNoLoading.value = false
|
||
}
|
||
}
|
||
|
||
function onProtocolNoChange(val: string | null | undefined) {
|
||
if (!val) {
|
||
formData.protocolNo = ''
|
||
formData.protocolNoId = ''
|
||
return
|
||
}
|
||
const p = protocolNoOptions.value.find((x) => x.id === val)
|
||
if (p) {
|
||
formData.protocolNo = p.protocolNo
|
||
formData.protocolNoId = p.id
|
||
}
|
||
}
|
||
|
||
function onProtocolNoClear() {
|
||
formData.protocolNo = ''
|
||
formData.protocolNoId = ''
|
||
}
|
||
|
||
/* ====== 样本编码选择 ====== */
|
||
function sampleCodeOptionLabel(sc: Api.BizSampleCode.SampleCodeItem) {
|
||
if (sc.usedInProtocol) return `${sc.sampleCode}(已占用)`
|
||
if (sc.issued) return `${sc.sampleCode}(已下发)`
|
||
return sc.sampleCode
|
||
}
|
||
|
||
function isSampleCodeOptionDisabled(sc: Api.BizSampleCode.SampleCodeItem, row: Record<string, any>) {
|
||
if (!sc.usedInProtocol) return false
|
||
return String(row.sampleCodeId || '') !== sc.id
|
||
}
|
||
|
||
function onSampleCodeChange(row: Record<string, any>) {
|
||
const sc = sampleCodeOptions.value.find((x) => x.id === row.sampleCodeId)
|
||
if (sc) {
|
||
row.sampleCode = sc.sampleCode
|
||
row.sampleCodeId = sc.id
|
||
}
|
||
}
|
||
|
||
/* ====== 母亲身份证号 ↔ 出生年月联动 ====== */
|
||
function calcBirthFromIdCard() {
|
||
const idCard = (formData.motherIdCard || '').trim()
|
||
if (/^\d{17}[\dXx]$/.test(idCard)) {
|
||
formData.motherBirthYm = idCard.substring(6, 12)
|
||
} else if (/^\d{15}$/.test(idCard)) {
|
||
const yy = idCard.substring(6, 8)
|
||
const mm = idCard.substring(8, 10)
|
||
const prefix = parseInt(yy, 10) > 30 ? '19' : '20'
|
||
formData.motherBirthYm = prefix + yy + mm
|
||
} else if (!idCard) {
|
||
formData.motherBirthYm = ''
|
||
}
|
||
}
|
||
|
||
watch(
|
||
() => formData.motherIdCard,
|
||
() => calcBirthFromIdCard()
|
||
)
|
||
|
||
/* ====== 趸交截止时间联动 ====== */
|
||
watch(
|
||
() => formData.lumpSumDeadline,
|
||
() => {
|
||
if (formData.payMode === 'dj') {
|
||
syncLumpSumDueDate()
|
||
}
|
||
}
|
||
)
|
||
|
||
/* ====== 附件上传 ====== */
|
||
const attachmentUploadRef = ref()
|
||
|
||
function isImageFile(file: any) {
|
||
const url = (file.fileUrl || '').toLowerCase()
|
||
const name = (file.fileName || '').toLowerCase()
|
||
return (
|
||
/\.(jpg|jpeg|png|gif|bmp|webp|svg)(\?|$)/.test(url) ||
|
||
/\.(jpg|jpeg|png|gif|bmp|webp|svg)$/.test(name) ||
|
||
(file.fileType || '').startsWith('image/')
|
||
)
|
||
}
|
||
|
||
function isVideoFile(file: any) {
|
||
const url = (file.fileUrl || '').toLowerCase()
|
||
const name = (file.fileName || '').toLowerCase()
|
||
return (
|
||
/\.(mp4|webm|ogg|mov|avi)(\?|$)/.test(url) ||
|
||
/\.(mp4|webm|ogg|mov|avi)$/.test(name) ||
|
||
(file.fileType || '').startsWith('video/')
|
||
)
|
||
}
|
||
|
||
async function handleAttachmentChange(uploadFile: any) {
|
||
const file = uploadFile.raw || uploadFile
|
||
if (!file) return
|
||
|
||
const maxSizeMb = 20
|
||
if (file.size / 1024 / 1024 > maxSizeMb) {
|
||
ElMessage.error(`文件大小不能超过 ${maxSizeMb}MB`)
|
||
attachmentUploadRef.value?.clearFiles()
|
||
return
|
||
}
|
||
|
||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||
formData.attachments.push({
|
||
id: tempId,
|
||
fileName: file.name,
|
||
fileUrl: '',
|
||
fileSize: file.size,
|
||
fileSizeDisplay: `${(file.size / 1024).toFixed(1)} KB`,
|
||
fileType: file.type || '',
|
||
_uploading: true
|
||
})
|
||
const idx = formData.attachments.length - 1
|
||
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('folder', 'protocol')
|
||
fd.append('file', file)
|
||
const res = await fetchUploadAttachment(fd)
|
||
formData.attachments[idx] = {
|
||
...formData.attachments[idx],
|
||
id: res.id || tempId,
|
||
fileName: file.name || res.fileName,
|
||
fileUrl: res.fileUrl,
|
||
fileSize: res.fileSize || file.size,
|
||
fileSizeDisplay: res.fileSizeDisplay || `${(file.size / 1024).toFixed(1)} KB`,
|
||
fileType: res.fileType || file.type || '',
|
||
_uploading: false
|
||
}
|
||
} catch {
|
||
ElMessage.error('文件上传失败')
|
||
formData.attachments.splice(idx, 1)
|
||
} finally {
|
||
nextTick(() => attachmentUploadRef.value?.clearFiles())
|
||
}
|
||
}
|
||
|
||
function removeAttachment(index: number) {
|
||
formData.attachments.splice(index, 1)
|
||
}
|
||
|
||
/* ====== 缴费明细附件上传 ====== */
|
||
async function handleDetailAttachmentChange(detailIndex: number, uploadFile: any) {
|
||
const file = uploadFile.raw || uploadFile
|
||
if (!file) return
|
||
|
||
const maxSizeMb = 20
|
||
if (file.size / 1024 / 1024 > maxSizeMb) {
|
||
ElMessage.error(`文件大小不能超过 ${maxSizeMb}MB`)
|
||
return
|
||
}
|
||
|
||
const detail = formData.paymentDetails[detailIndex]
|
||
if (!detail) return
|
||
|
||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||
detail.attachments.push({
|
||
id: tempId,
|
||
fileName: file.name,
|
||
fileUrl: '',
|
||
fileSize: file.size,
|
||
fileSizeDisplay: `${(file.size / 1024).toFixed(1)} KB`,
|
||
fileType: file.type || '',
|
||
_uploading: true
|
||
})
|
||
const aIdx = detail.attachments.length - 1
|
||
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('folder', 'protocol-payment')
|
||
fd.append('file', file)
|
||
const res = await fetchUploadAttachment(fd)
|
||
detail.attachments[aIdx] = {
|
||
...detail.attachments[aIdx],
|
||
id: res.id || tempId,
|
||
fileName: file.name || res.fileName,
|
||
fileUrl: res.fileUrl,
|
||
fileSize: res.fileSize || file.size,
|
||
fileSizeDisplay: res.fileSizeDisplay || `${(file.size / 1024).toFixed(1)} KB`,
|
||
fileType: res.fileType || file.type || '',
|
||
_uploading: false
|
||
}
|
||
} catch {
|
||
ElMessage.error('文件上传失败')
|
||
detail.attachments.splice(aIdx, 1)
|
||
}
|
||
}
|
||
|
||
function removeDetailAttachment(detailIndex: number, attachmentIndex: number) {
|
||
const detail = formData.paymentDetails[detailIndex]
|
||
if (detail) {
|
||
detail.attachments.splice(attachmentIndex, 1)
|
||
}
|
||
}
|
||
|
||
/* 附件预览 */
|
||
const previewVisible = ref(false)
|
||
const previewUrl = ref('')
|
||
const previewType = ref<'image' | 'video'>('image')
|
||
|
||
function handlePreviewAttachment(file: any) {
|
||
if (file._uploading) return
|
||
previewUrl.value = fullFileUrl(file.fileUrl)
|
||
previewType.value = isVideoFile(file) ? 'video' : 'image'
|
||
previewVisible.value = true
|
||
}
|
||
|
||
function handleDownloadAttachment(file: any) {
|
||
downloadAttachmentFile(file, { fullFileUrl })
|
||
}
|
||
|
||
/* ====== 弹窗打开时自动初始化 ====== */
|
||
watch(
|
||
() => props.modelValue,
|
||
(visible) => {
|
||
if (visible) {
|
||
if (props.editId) {
|
||
handleOpenForEdit()
|
||
} else {
|
||
handleOpenForAdd()
|
||
}
|
||
}
|
||
}
|
||
)
|
||
</script>
|
||
<style scoped lang="scss">
|
||
/* ═══════════════════════════════════════════
|
||
DIALOG BODY
|
||
═══════════════════════════════════════════ */
|
||
.protocol-dialog-body {
|
||
overflow: visible;
|
||
background: #f5f7fa;
|
||
}
|
||
|
||
.protocol-form {
|
||
:deep(.el-form-item) {
|
||
margin-bottom: 4px;
|
||
}
|
||
}
|
||
|
||
/* ---- Sticky Tab 导航(与查看弹窗 detail-tabs 一致) ---- */
|
||
.detail-tabs {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 1000;
|
||
display: flex;
|
||
gap: 4px;
|
||
background: #f0fdf4;
|
||
padding: 4px;
|
||
border-radius: 8px;
|
||
margin: 0 28px 16px;
|
||
width: fit-content;
|
||
isolation: isolate;
|
||
box-shadow: 0 1px 0 #f0fdf4;
|
||
}
|
||
|
||
.detail-tab {
|
||
padding: 10px 20px;
|
||
cursor: pointer;
|
||
border-radius: 6px;
|
||
font-size: 14px;
|
||
color: #606266;
|
||
transition: all 0.3s;
|
||
|
||
&.active {
|
||
background: #fff;
|
||
color: #059669;
|
||
font-weight: 500;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||
}
|
||
|
||
&:hover:not(.active) {
|
||
color: #059669;
|
||
}
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════
|
||
SECTION CARDS
|
||
═══════════════════════════════════════════ */
|
||
.form-tab-pane {
|
||
padding: 0 28px 4px;
|
||
|
||
& + .form-tab-pane {
|
||
margin-top: 0;
|
||
padding-top: 0;
|
||
}
|
||
}
|
||
|
||
.section-card {
|
||
border-radius: 12px;
|
||
padding: 22px 24px;
|
||
margin-bottom: 20px;
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||
transition: transform 0.2s, box-shadow 0.2s;
|
||
position: relative;
|
||
background: #fff;
|
||
|
||
&:hover {
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||
}
|
||
|
||
&--green {
|
||
background: #f0fdf4;
|
||
}
|
||
}
|
||
|
||
.section-title {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 18px;
|
||
padding-bottom: 12px;
|
||
border-bottom: 1px solid #e2e8f0;
|
||
}
|
||
|
||
.section-title-left {
|
||
display: flex;
|
||
align-items: center;
|
||
|
||
&::before {
|
||
content: '';
|
||
width: 3px;
|
||
height: 18px;
|
||
background: #10b981;
|
||
border-radius: 2px;
|
||
margin-right: 10px;
|
||
flex-shrink: 0;
|
||
}
|
||
}
|
||
|
||
.section-title h3 {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: #1e293b;
|
||
}
|
||
|
||
.section-subtitle {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #374151;
|
||
margin: 18px 0 14px;
|
||
|
||
&::before {
|
||
content: '';
|
||
width: 2px;
|
||
height: 12px;
|
||
background: #10b981;
|
||
border-radius: 1px;
|
||
}
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════
|
||
FORM GRID
|
||
═══════════════════════════════════════════ */
|
||
.form-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 16px 20px;
|
||
|
||
&--col2 {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
}
|
||
}
|
||
|
||
.form-col-span-2 {
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════
|
||
CLIENT SUB CARDS
|
||
═══════════════════════════════════════════ */
|
||
.client-sub-cards {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 16px;
|
||
}
|
||
|
||
.client-sub-card {
|
||
background: #fff;
|
||
border-radius: 10px;
|
||
padding: 18px 20px;
|
||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||
border: 1px solid rgba(0, 0, 0, 0.03);
|
||
transition: transform 0.2s, box-shadow 0.2s;
|
||
|
||
&:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.07);
|
||
}
|
||
}
|
||
|
||
.sub-card-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 14px;
|
||
padding-bottom: 10px;
|
||
border-bottom: 1px solid #f1f5f9;
|
||
|
||
.icon {
|
||
font-size: 22px;
|
||
line-height: 1;
|
||
}
|
||
|
||
h4 {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #1e293b;
|
||
}
|
||
|
||
.search-link {
|
||
margin-left: auto;
|
||
font-size: 12px;
|
||
color: #10b981;
|
||
cursor: pointer;
|
||
font-weight: 500;
|
||
|
||
&:hover {
|
||
text-decoration: underline;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════
|
||
SAMPLE CARDS
|
||
═══════════════════════════════════════════ */
|
||
.sample-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.sample-card {
|
||
background: #fff;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 10px;
|
||
padding: 18px 20px;
|
||
margin-bottom: 14px;
|
||
transition: box-shadow 0.2s;
|
||
|
||
&:hover {
|
||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
|
||
}
|
||
}
|
||
|
||
.sample-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-bottom: 16px;
|
||
padding-bottom: 12px;
|
||
border-bottom: 1px solid #f1f5f9;
|
||
}
|
||
|
||
.sample-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #1e293b;
|
||
}
|
||
|
||
.sample-num {
|
||
color: #64748b;
|
||
font-weight: 500;
|
||
margin-right: 4px;
|
||
}
|
||
|
||
.sample-actions {
|
||
margin-left: auto;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.sample-transport-hint {
|
||
margin: 0 0 12px;
|
||
font-size: 12px;
|
||
color: var(--mint-text-muted, #64748b);
|
||
line-height: 1.5;
|
||
}
|
||
|
||
/* ── Transport Panel (Collapsible) ── */
|
||
.transport-toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: #64748b;
|
||
margin-top: 14px;
|
||
padding: 10px 14px;
|
||
background: #f8fafc;
|
||
border-radius: 8px;
|
||
border: 1px solid #e2e8f0;
|
||
transition: all 0.2s;
|
||
user-select: none;
|
||
|
||
&:hover {
|
||
background: #f1f5f9;
|
||
color: #374151;
|
||
}
|
||
|
||
.chevron {
|
||
display: inline-block;
|
||
transition: transform 0.3s;
|
||
font-size: 12px;
|
||
}
|
||
|
||
&.expanded .chevron {
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
.summary {
|
||
margin-left: auto;
|
||
font-size: 12px;
|
||
color: #94a3b8;
|
||
font-weight: 400;
|
||
}
|
||
}
|
||
|
||
.transport-body {
|
||
max-height: 0;
|
||
overflow: hidden;
|
||
transition: max-height 0.35s ease, padding 0.35s ease;
|
||
background: #f8fafc;
|
||
border-radius: 0 0 8px 8px;
|
||
margin-top: 0;
|
||
padding: 0 16px;
|
||
|
||
&.open {
|
||
max-height: 600px;
|
||
padding: 16px;
|
||
margin-top: -1px;
|
||
border: 1px solid #e2e8f0;
|
||
border-top: none;
|
||
}
|
||
}
|
||
|
||
.sample-transport-form-row {
|
||
display: grid;
|
||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||
gap: 12px 16px;
|
||
align-items: start;
|
||
|
||
:deep(.el-form-item) {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
&--ryxd {
|
||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||
}
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════
|
||
PAYMENT SECTION
|
||
═══════════════════════════════════════════ */
|
||
.payment-main-card {
|
||
background: #fff;
|
||
border-radius: 10px;
|
||
padding: 18px 20px;
|
||
margin-bottom: 18px;
|
||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||
border: 1px solid rgba(0, 0, 0, 0.03);
|
||
}
|
||
|
||
.payment-details {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
/* Detail Card — Collapsible */
|
||
.detail-card {
|
||
background: #fff;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
transition: box-shadow 0.2s;
|
||
|
||
&:hover {
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||
}
|
||
|
||
&.expanded .detail-chevron {
|
||
transform: rotate(90deg);
|
||
}
|
||
}
|
||
|
||
.detail-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 14px 18px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
transition: background 0.2s;
|
||
|
||
&:hover {
|
||
background: #f8fafc;
|
||
}
|
||
}
|
||
|
||
.detail-chevron {
|
||
font-size: 12px;
|
||
color: #94a3b8;
|
||
transition: transform 0.3s;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.detail-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #1e293b;
|
||
}
|
||
|
||
.detail-amount {
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
margin-left: 4px;
|
||
|
||
&.green {
|
||
color: #059669;
|
||
}
|
||
|
||
&.amber {
|
||
color: #d97706;
|
||
}
|
||
}
|
||
|
||
.detail-badge {
|
||
display: inline-block;
|
||
padding: 2px 10px;
|
||
border-radius: 20px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
white-space: nowrap;
|
||
|
||
&.badge-green {
|
||
background: #ecfdf5;
|
||
color: #059669;
|
||
}
|
||
|
||
&.badge-amber {
|
||
background: #fffbeb;
|
||
color: #d97706;
|
||
}
|
||
|
||
&.badge-gray {
|
||
background: #f1f5f9;
|
||
color: #64748b;
|
||
}
|
||
}
|
||
|
||
.detail-summary {
|
||
margin-left: auto;
|
||
font-size: 12px;
|
||
color: #94a3b8;
|
||
}
|
||
|
||
.detail-delete {
|
||
margin-left: 6px;
|
||
}
|
||
|
||
.detail-body-inner {
|
||
padding: 0 18px 18px;
|
||
border-top: 1px solid #f1f5f9;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════
|
||
ATTACHMENTS
|
||
═══════════════════════════════════════════ */
|
||
.attachment-upload {
|
||
width: 100%;
|
||
}
|
||
|
||
.upload-card {
|
||
width: 140px;
|
||
height: 96px;
|
||
border: 1px dashed #a7f3d0;
|
||
border-radius: 8px;
|
||
background: #f0fdf4;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
cursor: pointer;
|
||
transition:
|
||
border-color 0.15s,
|
||
background 0.15s;
|
||
|
||
&:hover {
|
||
border-color: #10b981;
|
||
border-style: solid;
|
||
background: #ecfdf5;
|
||
}
|
||
}
|
||
|
||
.upload-card-text {
|
||
font-size: 12px;
|
||
color: #10b981;
|
||
font-weight: 500;
|
||
line-height: 1;
|
||
}
|
||
|
||
.upload-hint {
|
||
display: block;
|
||
font-size: 11px;
|
||
color: #94a3b8;
|
||
line-height: 1;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.attachment-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
}
|
||
|
||
.attachment-item {
|
||
width: 140px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 8px;
|
||
background: #fff;
|
||
overflow: hidden;
|
||
transition:
|
||
border-color 0.15s,
|
||
box-shadow 0.15s;
|
||
|
||
&:hover {
|
||
border-color: #10b981;
|
||
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.1);
|
||
}
|
||
|
||
&.is-uploading {
|
||
opacity: 0.6;
|
||
pointer-events: none;
|
||
}
|
||
}
|
||
|
||
.attachment-preview {
|
||
width: 100%;
|
||
height: 96px;
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: #f8fafc;
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
|
||
img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
}
|
||
}
|
||
|
||
.attachment-preview__icon {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
|
||
&--video {
|
||
background: #ede9fe;
|
||
color: #7c3aed;
|
||
}
|
||
|
||
&--doc {
|
||
background: #e0f2fe;
|
||
color: #0284c7;
|
||
}
|
||
}
|
||
|
||
.attachment-footer {
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
padding: 6px 8px;
|
||
}
|
||
|
||
.attachment-name {
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
font-size: 11px;
|
||
color: #334155;
|
||
line-height: 1.4;
|
||
word-break: break-all;
|
||
min-height: calc(11px * 1.4 * 2);
|
||
}
|
||
|
||
.attachment-actions {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-top: 4px;
|
||
flex-shrink: 0;
|
||
transition: opacity 0.15s;
|
||
}
|
||
|
||
.action-btn {
|
||
width: 22px;
|
||
height: 22px;
|
||
border-radius: 4px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 12px;
|
||
color: #64748b;
|
||
cursor: pointer;
|
||
background: #f1f5f9;
|
||
transition:
|
||
background 0.15s,
|
||
color 0.15s;
|
||
|
||
&:hover {
|
||
background: #10b981;
|
||
color: #fff;
|
||
}
|
||
|
||
&--danger:hover {
|
||
background: #ef4444;
|
||
color: #fff;
|
||
}
|
||
}
|
||
|
||
.upload-card--sm {
|
||
width: 96px;
|
||
height: 76px;
|
||
|
||
.upload-card-text {
|
||
font-size: 11px;
|
||
}
|
||
}
|
||
|
||
.payment-detail-card__attachments {
|
||
margin-top: 8px;
|
||
padding-top: 8px;
|
||
border-top: 1px dashed #e2e8f0;
|
||
}
|
||
|
||
.preview-container {
|
||
text-align: center;
|
||
}
|
||
|
||
.batch-transport-dialog-hint {
|
||
margin-top: 0;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════
|
||
RESPONSIVE
|
||
═══════════════════════════════════════════ */
|
||
@media (max-width: 900px) {
|
||
.form-grid {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
|
||
&--col2 {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
.client-sub-cards {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|