Files
zkxw/web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue
T
zlwuandCursor ac9db561a4 fix(web-admin): 补齐捐赠查看操作日志接线并保留审核动作语义
修复 DonationViewDialog 缺失的操作日志状态与加载调用,清理误插入的重置语句,并避免显式 audit 动作被状态映射覆盖。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 12:15:10 +08:00

3764 lines
118 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 采集协议单 — 只读查看(字段与新增/编辑弹窗一致) -->
<template>
<el-dialog
:model-value="modelValue"
:title="dialogTitle"
width="1600px"
class="dialog-form-layout protocol-view-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@closed="handleClosed"
>
<div
class="protocol-view-body"
:class="{ 'is-detail-loading': detailLoading && !bundle }"
v-loading="detailLoading && !bundle"
element-loading-text="加载中…"
>
<div class="detail-tabs">
<div
v-for="tab in formTabs"
:key="tab.key"
class="detail-tab"
:class="{ active: activeTab === tab.key, 'is-disabled': detailLoading && !bundle }"
@click="scrollToSection(tab.key)"
>
{{ tab.label }}
</div>
</div>
<template v-if="bundle">
<div>
<!-- 基本信息 -->
<div id="view-section-basic" class="section-card section-card--white">
<div class="section-title section-title--banner"><h3>基本信息</h3></div>
<div class="data-grid data-grid--4">
<div class="data-item">
<div class="data-label">所属项目</div>
<div class="data-value">{{ protocol?.projectName || '未归属' }}</div>
</div>
<div class="data-item">
<div class="data-label">项目组</div>
<div class="data-value">{{ protocol?.teamName || '—' }}</div>
</div>
<div class="data-item"
><div class="data-label">协议状态</div
><div class="data-value"
><el-tag
:type="protocolStatusTagType(String(protocol?.protocolStatus || ''))"
size="small"
>{{
protocolStatusDisplayLabel(
String(protocol?.protocolStatus || ''),
String(protocol?.voidType || '')
)
}}</el-tag
></div
></div
>
<div class="data-item"
><div class="data-label">母亲姓名</div
><div class="data-value">{{ basicMotherNameDisplay }}</div></div
>
<div class="data-item"
><div class="data-label">登记日期</div
><div class="data-value">{{ formatYmd(protocol?.registerDate) }}</div></div
>
<div class="data-item"
><div class="data-label">创建时间</div
><div class="data-value">{{ dash(protocol?.createTime) }}</div></div
>
</div>
<div v-if="protocol?.remark && String(protocol.remark).trim()" class="remark-row">
<div class="data-item data-item--full">
<div class="data-label">备注</div>
<div class="data-value data-value--muted">{{ dash(protocol?.remark) }}</div>
</div>
</div>
</div>
<!-- 采集信息 -->
<div id="view-section-collection" class="section-card section-card--white">
<div class="section-title section-title--banner"><h3>采集信息</h3></div>
<div class="data-grid data-grid--7 data-grid--collection-row">
<div class="data-item"
><div class="data-label">采集地区</div
><div class="data-value">{{ regionDisplay }}</div></div
>
<div class="data-item"
><div class="data-label">采集医院</div
><div class="data-value">{{ hospitalDisplay }}</div></div
>
<div class="data-item"
><div class="data-label">采集医护</div
><div class="data-value">{{ dash(collectionInfo?.medicalStaff) }}</div></div
>
<div class="data-item"
><div class="data-label">市场专员</div
><div class="data-value">{{ salesDisplay }}</div></div
>
<div class="data-item"
><div class="data-label">地区经理</div
><div class="data-value">{{ regionManagerDisplay }}</div></div
>
<div class="data-item"
><div class="data-label">样本采集时间</div
><div class="data-value">{{ dash(collectionInfo?.sampleCollectTime) }}</div></div
>
<div class="data-item"
><div class="data-label">母血采集时间</div
><div class="data-value">{{
dash(collectionInfo?.maternalBloodCollectTime)
}}</div></div
>
</div>
</div>
<!-- 样本及运输(字典异步补齐,不阻塞整弹窗) -->
<div
id="view-section-sample"
v-loading="lookupLoading"
class="section-card section-card--white"
>
<div class="section-title section-title--banner"><h3>样本及运输信息</h3></div>
<div v-if="samples.length" class="sample-cards">
<div
v-for="(row, sIdx) in samples"
:key="row.id || sIdx"
class="sample-card"
:class="{
'is-highlight':
!!highlightSampleCode && String(row.sampleCode || '') === highlightSampleCode
}"
:id="row.sampleCode ? `view-sample-${row.sampleCode}` : undefined"
>
<div class="sample-card__header">
<div class="sample-card__title">
<span class="sample-card__seq">{{ sIdx + 1 }}</span>
{{ sampleTypeName(row.sampleTypeId) }}
</div>
<el-tag :type="sampleStatusTagType(String(row.sampleStatus || ''))" size="small">
{{ sampleStatusLabel(String(row.sampleStatus || '')) }}
</el-tag>
</div>
<div class="sample-card__body">
<div class="sample-card__section">
<div class="sample-card__section-label">样本信息</div>
<div class="data-grid data-grid--4">
<div class="data-item">
<div class="data-label">样本编码</div>
<div class="data-value data-value--code">
<SampleCodeWithSpecialDisplay
:sample-code="row.sampleCode"
:is-special="!!row.isSpecial"
:special-remark="row.specialRemark"
monospace
/>
</div>
</div>
<div class="data-item">
<div class="data-label">原价格(元)</div>
<div class="data-value data-value--price">{{
row.originalPrice ? `¥${row.originalPrice}` : '—'
}}</div>
</div>
<div class="data-item">
<div class="data-label">优惠价格(元)</div>
<div class="data-value data-value--price">{{
row.price ? `¥${row.price}` : '—'
}}</div>
</div>
<div class="data-item">
<div class="data-label">存储年限(年)</div>
<div class="data-value">{{ dash(row.storageYears) }}</div>
</div>
<div v-if="row.remark" class="data-item data-item--full">
<div class="data-label">备注</div>
<div class="data-value data-value--muted">{{ dash(row.remark) }}</div>
</div>
</div>
</div>
<div
v-if="
row.freight?.id ||
row.freight?.transportMode ||
row.transport?.etaTime
"
class="sample-card__section sample-card__section--transport"
>
<div class="sample-card__section-label">运输与运费</div>
<div class="data-grid data-grid--4">
<div class="data-item">
<div class="data-label">运输方式</div>
<div class="data-value">{{
dash(row.freight?.transportModeLabel || row.freight?.transportMode)
}}</div>
</div>
<div class="data-item">
<div class="data-label">运费(元)</div>
<div class="data-value"
>{{
String(row.freight?.transportMode || '').trim() === 'sfkd' &&
(row.freight?.freightAmount == null || row.freight?.freightAmount === '')
? '月结'
: row.freight?.freightAmount != null
? '¥' + formatMoneyDisplay(row.freight.freightAmount)
: '—'
}}{{
Number(row.freight?.linkedSampleCount || 0) > 1 ? '(共用)' : ''
}}</div
>
</div>
<div class="data-item">
<div class="data-label">预计到达</div>
<div class="data-value">{{ dash(row.transport?.etaTime) }}</div>
</div>
</div>
</div>
<div class="sample-card__section sample-card__section--reception">
<SampleReceptionDualReadonlyPanel
:qc-reception="row.sampleReception"
:production-reception="
row.productionReception || row.sampleReception?.productionReception
"
source-type="cell_storage"
:sample-type-name="row.sampleTypeName"
/>
<div
v-if="
row.sampleReception &&
sampleReceptionAttachmentFiles(row.sampleReception).length
"
class="attachment-row sample-card__attachments"
>
<div class="data-label">接收附件</div>
<ProtocolAttachmentReadonly
:files="sampleReceptionAttachmentFiles(row.sampleReception)"
@preview="openPreview"
@download="downloadFile"
/>
</div>
</div>
<SampleQcInfoGroupedPanel
:gate-qc-info="row.qcInfo"
:process-qc-info-list="row.processQcInfoList"
:sample-reception="row.sampleReception"
:show-section-label="false"
/>
<SampleProductionInfoCards :production-info="row.productionInfo" />
<SampleStorageReleaseInfo :storage-release-info="row.storageReleaseInfo" />
</div>
</div>
</div>
<el-empty v-else description="暂无样本" :image-size="48" />
</div>
<div
v-if="showHandlePanels"
id="view-section-storage-cert-handle"
class="section-card section-card--white"
>
<div class="section-title section-title--banner"><h3>储存证办理</h3></div>
<ProtocolHandlePanel
v-if="protocolId"
mode="storage-cert"
:protocol-id="protocolId"
:sample-code="highlightSampleCode || undefined"
:initial="storageCertInfo"
@saved="loadDetail"
/>
</div>
<div
v-if="showHandlePanels"
id="view-section-insurance-handle"
class="section-card section-card--white"
>
<div class="section-title section-title--banner"><h3>保险办理</h3></div>
<ProtocolHandlePanel
v-if="protocolId"
mode="insurance"
:protocol-id="protocolId"
:sample-code="highlightSampleCode || undefined"
:initial="fullmoonInsuranceInfo"
@saved="loadDetail"
/>
</div>
<!-- 客户信息 -->
<div id="view-section-party" class="section-card section-card--green">
<div class="section-title section-title--banner"><h3>客户信息</h3></div>
<div class="client-sub-cards">
<!-- 母亲信息 -->
<div class="client-sub-card">
<div class="sub-card-header"
><span class="sub-card-icon">🤱</span><h4>母亲信息</h4></div
>
<div class="data-grid data-grid--4">
<div class="data-item"
><div class="data-label">关联客户</div
><div class="data-value">{{
partyCustomer?.customerId ? dash(partyCustomer?.motherName) : '—'
}}</div></div
>
<div class="data-item"
><div class="data-label">母亲姓名</div
><div class="data-value">{{ dash(partyCustomer?.motherName) }}</div></div
>
<div class="data-item"
><div class="data-label">母亲联系电话</div
><div class="data-value">{{ dash(partyCustomer?.motherPhone) }}</div></div
>
<div class="data-item"
><div class="data-label">母亲身份证号</div
><div class="data-value">{{ dash(partyCustomer?.motherIdCard) }}</div></div
>
<div class="data-item"
><div class="data-label">母亲出生年月</div
><div class="data-value">{{ formatYm(partyCustomer?.motherBirthYm) }}</div></div
>
<div class="data-item"
><div class="data-label">母亲民族</div
><div class="data-value">{{ dash(partyCustomer?.motherEthnic) }}</div></div
>
<div class="data-item"
><div class="data-label">母亲邮箱</div
><div class="data-value">{{ dash(partyCustomer?.motherEmail) }}</div></div
>
</div>
<div class="sub-card-full-row"
><div class="data-label">母亲家庭住址</div
><div class="data-value data-value--muted">{{
dash(partyCustomer?.motherAddress)
}}</div></div
>
</div>
<!-- 父亲信息 -->
<div class="client-sub-card">
<div class="sub-card-header"
><span class="sub-card-icon">👨</span><h4>父亲信息</h4></div
>
<div class="data-grid data-grid--4">
<div class="data-item"
><div class="data-label">父亲姓名</div
><div class="data-value">{{ dash(partyCustomer?.fatherName) }}</div></div
>
<div class="data-item"
><div class="data-label">父亲电话</div
><div class="data-value">{{ dash(partyCustomer?.fatherPhone) }}</div></div
>
<div class="data-item data-item--full"
><div class="data-label">父亲身份证号</div
><div class="data-value">{{ dash(partyCustomer?.fatherIdCard) }}</div></div
>
</div>
</div>
<!-- 新生儿信息 -->
<div class="client-sub-card">
<div class="sub-card-header"
><span class="sub-card-icon">👶</span><h4>新生儿信息</h4></div
>
<div class="data-grid data-grid--4">
<div class="data-item"
><div class="data-label">新生儿姓名</div
><div class="data-value">{{ dash(partyCustomer?.newbornName) }}</div></div
>
<div class="data-item"
><div class="data-label">新生儿身份证</div
><div class="data-value">{{ dash(partyCustomer?.newbornIdCard) }}</div></div
>
<div class="data-item"
><div class="data-label">预产期</div
><div class="data-value">{{
dash(partyCustomer?.expectedDeliveryDate)
}}</div></div
>
<div class="data-item"
><div class="data-label">新生儿分娩时间</div
><div class="data-value">{{
dash(partyCustomer?.newbornDeliveryTime)
}}</div></div
>
<div class="data-item"
><div class="data-label">新生儿性别</div
><div class="data-value">{{
newbornSexLabel(String(partyCustomer?.newbornSex || ''))
}}</div></div
>
<div class="data-item"
><div class="data-label">分娩方式</div
><div class="data-value">{{
deliveryModeLabel(String(partyCustomer?.deliveryMode || ''))
}}</div></div
>
<div class="data-item"
><div class="data-label">胎次</div
><div class="data-value">{{ dash(partyCustomer?.birthOrder) }}</div></div
>
<div class="data-item"
><div class="data-label">新生儿体重(克)</div
><div class="data-value">{{ dash(partyCustomer?.newbornWeightGram) }}</div></div
>
<div class="data-item"
><div class="data-label">新生儿评分</div
><div class="data-value">{{ dash(partyCustomer?.apgar1min) }}</div></div
>
</div>
</div>
</div>
</div>
<!-- 储存证与新生儿保险(只读,字段对齐样本信息管理录入项) -->
<div
v-if="showHandleSummary"
id="view-section-storage-insurance"
class="section-card section-card--white"
>
<div class="section-title section-title--banner"><h3>储存证与新生儿保险</h3></div>
<div class="handle-summary-cards">
<div v-if="showStorageCert" class="handle-summary-card">
<div class="sub-card-header"><h4>储存证</h4></div>
<div class="data-grid data-grid--2">
<div class="data-item">
<div class="data-label">办理时间</div>
<div class="data-value">{{ dash(storageCertInfo?.handleTime) }}</div>
</div>
<div class="data-item">
<div class="data-label">办理人员</div>
<div class="data-value">{{ dash(storageCertInfo?.handlerUserName) }}</div>
</div>
</div>
<div class="attachment-row">
<div class="data-label">储存证附件</div>
<ProtocolAttachmentReadonly
:files="storageCertAttachments"
@preview="openPreview"
@download="downloadFile"
/>
</div>
<div class="remark-row">
<div class="data-item data-item--full">
<div class="data-label">备注</div>
<div class="data-value data-value--muted">{{
dash(storageCertInfo?.remark)
}}</div>
</div>
</div>
</div>
<div v-if="showFullmoonInsurance" class="handle-summary-card">
<div class="sub-card-header"><h4>新生儿保险</h4></div>
<div class="data-grid data-grid--2">
<div class="data-item">
<div class="data-label">办理时间</div>
<div class="data-value">{{ dash(fullmoonInsuranceInfo?.handleTime) }}</div>
</div>
<div class="data-item">
<div class="data-label">办理人员</div>
<div class="data-value">{{ dash(fullmoonInsuranceInfo?.handlerUserName) }}</div>
</div>
</div>
<div class="attachment-row">
<div class="data-label">保险附件</div>
<ProtocolAttachmentReadonly
:files="fullmoonInsuranceAttachments"
@preview="openPreview"
@download="downloadFile"
/>
</div>
<div class="remark-row">
<div class="data-item data-item--full">
<div class="data-label">备注</div>
<div class="data-value data-value--muted">{{
dash(fullmoonInsuranceInfo?.handleRemark)
}}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 缴费信息 -->
<div id="view-section-payment" class="section-card section-card--white">
<div class="section-title section-title--banner"><h3>缴费信息</h3></div>
<!-- v2: 按样本缴费只读(对齐新增/编辑弹窗) -->
<template v-if="paymentModelVersion >= 2">
<el-alert
v-if="presignDepositHint"
type="info"
:closable="false"
show-icon
style="margin-bottom: 12px"
:title="presignDepositHintText"
/>
<div
v-if="depositTransfers.length"
class="sample-pay-readonly__batch-title"
style="margin-bottom: 8px"
>
意向金转账(关联预签约订单)
</div>
<el-table
v-if="depositTransfers.length"
:data="depositTransfers"
class="list-table inner-table"
stripe
style="margin-bottom: 16px"
>
<el-table-column type="index" label="#" min-width="50" align="center" />
<el-table-column label="转账方式" min-width="120" align="center">
<template #default="{ row }">
{{ transferTypeLabel(String(row.transferType || '')) || '—' }}
</template>
</el-table-column>
<el-table-column label="转账金额(元)" min-width="130" align="center">
<template #default="{ row }">{{ fmtMoney(row.transferAmount) }}</template>
</el-table-column>
<el-table-column label="转账时间" min-width="170" align="center">
<template #default="{ row }">
{{ formatDisplayOrDash(row.transferTime) }}
</template>
</el-table-column>
<el-table-column label="转账凭证" min-width="220" align="left">
<template #default="{ row }">
<ProtocolAttachmentReadonly
v-if="(row.attachments || []).length"
:files="row.attachments || []"
size="compact"
@preview="openPreview"
@download="downloadFile"
/>
<span v-else class="muted-dash">—</span>
</template>
</el-table-column>
<el-table-column
label="备注"
min-width="140"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.remark || '—' }}</template>
</el-table-column>
<el-table-column
label="财务备注"
min-width="140"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.financeRemark || '—' }}</template>
</el-table-column>
<el-table-column label="状态" min-width="110" align="center">
<template #default="{ row }">
<el-tag :type="payStatusTagType(String(row.payStatus || ''))" size="small">
{{ payStatusLabel(String(row.payStatus || '')) }}
</el-tag>
</template>
</el-table-column>
</el-table>
<div class="payment-summary payment-summary--3">
<div class="payment-metric">
<div class="pm-label">协议总金额(元)</div>
<div class="pm-value pm-value--amount">{{
fmtMoney(protocol?.protocolTotalAmount)
}}</div>
</div>
<div class="payment-metric">
<div class="pm-label">样本合同合计(元)</div>
<div class="pm-value pm-value--amount">{{ fmtMoney(sampleContractSum) }}</div>
</div>
<div class="payment-metric">
<div class="pm-label">已确认收款(元)</div>
<div class="pm-value pm-value--amount pm-value--received">{{
fmtMoney(v2ReceivedAmount)
}}</div>
</div>
</div>
<div v-if="samplePayments.length" class="sample-pay-readonly">
<div
v-for="(sp, sIdx) in samplePayments"
:key="sp.id || sIdx"
class="sample-pay-readonly__card"
>
<div class="sample-pay-readonly__head">
<div class="sample-pay-readonly__title">
<span>{{ samplePayTitle(sp) }}</span>
<el-tag v-if="sp.sampleCode" size="small" type="info">{{
sp.sampleCode
}}</el-tag>
<el-tag :type="payModeTagType(String(sp.payMode || ''))" size="small">
{{ payModeLabel(String(sp.payMode || '')) }}
</el-tag>
</div>
<span class="sample-pay-readonly__amt">{{ fmtMoney(sp.contractAmount) }}</span>
</div>
<div
class="data-grid"
:class="
String(sp.payMode) === 'fq' ? 'data-grid--6' : 'data-grid--3'
"
>
<div class="data-item">
<div class="data-label">缴费模式</div>
<div class="data-value">{{ payModeLabel(String(sp.payMode || '')) }}</div>
</div>
<div class="data-item">
<div class="data-label">合同金额(元)</div>
<div class="data-value data-value--price">{{
fmtMoney(sp.contractAmount)
}}</div>
</div>
<template v-if="String(sp.payMode) === 'dj'">
<div class="data-item">
<div class="data-label">趸交截止时间</div>
<div class="data-value">{{ formatYmd(sp.lumpSumDeadline) }}</div>
</div>
</template>
<template v-else>
<div class="data-item">
<div class="data-label">分期期数</div>
<div class="data-value">{{ dash(sp.installmentCount) }}</div>
</div>
<div class="data-item">
<div class="data-label">每期间隔(月)</div>
<div class="data-value">{{ dash(sp.installmentIntervalMonths) }}</div>
</div>
<div class="data-item">
<div class="data-label">分期开始时间</div>
<div class="data-value">{{ formatYmd(sp.installmentStartDate) }}</div>
</div>
<div class="data-item">
<div class="data-label">提前催缴预警(天)</div>
<div class="data-value">{{ dash(sp.installmentAdvanceDays ?? 30) }}</div>
</div>
</template>
</div>
<div class="sample-pay-readonly__batch-title">缴费明细</div>
<div v-if="!(sp.schedules || []).length" class="sample-pay-readonly__empty">
暂无缴费明细
</div>
<div
v-for="(sch, schIdx) in sp.schedules || []"
:key="sch.id || schIdx"
class="sample-pay-readonly__sch"
>
<div class="sample-pay-readonly__sch-title">
第 {{ sch.scheduleNo || schIdx + 1 }} 期
</div>
<div class="data-grid data-grid--3">
<div class="data-item">
<div class="data-label">应缴费金额(元)</div>
<div class="data-value data-value--price">{{
fmtMoney(sch.dueAmount)
}}</div>
</div>
<div class="data-item">
<div class="data-label">应缴费时间</div>
<div class="data-value">{{ formatYmd(sch.dueDate) }}</div>
</div>
<div class="data-item">
<div class="data-label">已缴费金额(元)</div>
<div class="data-value data-value--price">{{
fmtMoney(sch.paidAmount)
}}</div>
</div>
</div>
<div class="sample-pay-readonly__tr-title">
转账记录
<span v-if="(sch.transfers || []).length" class="sample-pay-readonly__tr-count"
>({{ (sch.transfers || []).length }} 笔)</span
>
</div>
<div class="list-body list-wrap view-inner-table-wrap">
<el-table
:data="sch.transfers || []"
class="list-table inner-table"
stripe
empty-text="暂无转账记录"
>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
/>
<el-table-column
label="转账方式"
min-width="140"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ transferTypeLabel(String(row.transferType || '')) || '—' }}
</template>
</el-table-column>
<el-table-column
label="转账金额(元)"
min-width="140"
align="center"
header-align="center"
>
<template #default="{ row }">{{ fmtMoney(row.transferAmount) }}</template>
</el-table-column>
<el-table-column
label="转账时间"
min-width="190"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ formatDisplayOrDash(row.transferTime) }}
</template>
</el-table-column>
<el-table-column
label="转账凭证"
min-width="280"
align="left"
header-align="center"
>
<template #default="{ row }">
<ProtocolAttachmentReadonly
v-if="transferAttachments(row).length"
:files="transferAttachments(row)"
size="compact"
@preview="openPreview"
@download="downloadFile"
/>
<span v-else class="muted-dash">—</span>
</template>
</el-table-column>
<el-table-column
label="备注"
min-width="140"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.remark || '—' }}</template>
</el-table-column>
<el-table-column
label="财务备注"
min-width="140"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.financeRemark || '—' }}</template>
</el-table-column>
<el-table-column
label="状态"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
:type="payStatusTagType(String(row.payStatus || ''))"
size="small"
>
{{ payStatusLabel(String(row.payStatus || '')) }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</div>
<el-empty v-else description="暂无按样本缴费方案" :image-size="40" />
<div class="sample-pay-readonly__batch-title sample-pay-readonly__batch-title--mt"
>退费登记</div
>
<el-table
v-if="refundRecords.length"
:data="refundRecords"
class="list-table inner-table"
stripe
>
<el-table-column
prop="refundAmount"
label="退费金额"
min-width="120"
align="center"
>
<template #default="{ row }">{{ fmtMoney(row.refundAmount) }}</template>
</el-table-column>
<el-table-column prop="refundTime" label="退费时间" min-width="160" align="center">
<template #default="{ row }">{{ formatDisplayOrDash(row.refundTime) }}</template>
</el-table-column>
<el-table-column
prop="refundRemark"
label="备注"
min-width="180"
align="center"
show-overflow-tooltip
/>
<el-table-column
label="附件"
min-width="280"
align="left"
header-align="center"
>
<template #default="{ row }">
<ProtocolAttachmentReadonly
v-if="refundRecordAttachments(row).length"
:files="refundRecordAttachments(row)"
size="compact"
@preview="openPreview"
@download="downloadFile"
/>
<span v-else class="muted-dash">—</span>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无退费登记" :image-size="40" />
</template>
<!-- v1: 旧整单缴费只读(样式对齐 v2:期汇总 + 转账记录) -->
<template v-else>
<div class="payment-summary payment-summary--3">
<div class="payment-metric">
<div class="pm-label">合同金额(元)</div>
<div class="pm-value pm-value--amount">{{
fmtMoney(payment?.contractAmount)
}}</div>
</div>
<div class="payment-metric">
<div class="pm-label">已确认收款(元)</div>
<div class="pm-value pm-value--amount pm-value--received">{{
fmtMoney(receivedAmount)
}}</div>
</div>
<div class="payment-metric">
<div class="pm-label">缴费模式</div>
<div class="pm-value">{{ payModeLabel(String(payment?.payMode || '')) }}</div>
</div>
</div>
<div class="sample-pay-readonly">
<div class="sample-pay-readonly__card">
<div class="sample-pay-readonly__head">
<div class="sample-pay-readonly__title">
<span>协议缴费</span>
<el-tag :type="payModeTagType(String(payment?.payMode || ''))" size="small">
{{ payModeLabel(String(payment?.payMode || '')) }}
</el-tag>
</div>
<span class="sample-pay-readonly__amt">{{
fmtMoney(payment?.contractAmount)
}}</span>
</div>
<div
class="data-grid"
:class="
isInstallmentPayMode(String(payment?.payMode || ''))
? 'data-grid--6'
: 'data-grid--3'
"
>
<div class="data-item">
<div class="data-label">缴费模式</div>
<div class="data-value">{{
payModeLabel(String(payment?.payMode || ''))
}}</div>
</div>
<div class="data-item">
<div class="data-label">合同金额(元)</div>
<div class="data-value data-value--price">{{
fmtMoney(payment?.contractAmount)
}}</div>
</div>
<template v-if="String(payment?.payMode) === 'dj'">
<div class="data-item">
<div class="data-label">趸交截止时间</div>
<div class="data-value">{{ formatYmd(payment?.lumpSumDeadline) }}</div>
</div>
</template>
<template v-else>
<div class="data-item">
<div class="data-label">分期期数</div>
<div class="data-value">{{ dash(payment?.installmentCount) }}</div>
</div>
<div class="data-item">
<div class="data-label">每期间隔(月)</div>
<div class="data-value">{{
dash(payment?.installmentIntervalMonths)
}}</div>
</div>
<div class="data-item">
<div class="data-label">分期开始时间</div>
<div class="data-value">{{ formatYmd(payment?.installmentStartDate) }}</div>
</div>
<div class="data-item">
<div class="data-label">提前催缴预警(天)</div>
<div class="data-value">{{
dash(payment?.installmentAdvanceDays ?? 30)
}}</div>
</div>
</template>
</div>
<div class="sample-pay-readonly__batch-title">缴费明细</div>
<div v-if="!v1PayPeriods.length" class="sample-pay-readonly__empty">
暂无缴费明细
</div>
<div
v-for="(sch, schIdx) in v1PayPeriods"
:key="sch.scheduleNo || schIdx"
class="sample-pay-readonly__sch"
>
<div class="sample-pay-readonly__sch-title">
第 {{ sch.scheduleNo || schIdx + 1 }} 期
</div>
<div class="data-grid data-grid--3">
<div class="data-item">
<div class="data-label">应缴费金额(元)</div>
<div class="data-value data-value--price">{{
fmtMoney(sch.dueAmount)
}}</div>
</div>
<div class="data-item">
<div class="data-label">应缴费时间</div>
<div class="data-value">{{ formatYmd(sch.dueDate) }}</div>
</div>
<div class="data-item">
<div class="data-label">已缴费金额(元)</div>
<div class="data-value data-value--price">{{
fmtMoney(sch.paidAmount)
}}</div>
</div>
</div>
<div class="sample-pay-readonly__tr-title">
转账记录
<span v-if="sch.transfers.length" class="sample-pay-readonly__tr-count"
>({{ sch.transfers.length }} 笔)</span
>
</div>
<div class="list-body list-wrap view-inner-table-wrap">
<el-table
:data="sch.transfers"
class="list-table inner-table"
stripe
empty-text="暂无转账记录"
>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
/>
<el-table-column
label="转账方式"
min-width="140"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ transferTypeLabel(String(row.transferType || '')) || '—' }}
</template>
</el-table-column>
<el-table-column
label="转账金额(元)"
min-width="140"
align="center"
header-align="center"
>
<template #default="{ row }">{{ fmtMoney(row.transferAmount) }}</template>
</el-table-column>
<el-table-column
label="转账时间"
min-width="190"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ formatDisplayOrDash(row.transferTime) }}
</template>
</el-table-column>
<el-table-column
label="转账凭证"
min-width="280"
align="left"
header-align="center"
>
<template #default="{ row }">
<ProtocolAttachmentReadonly
v-if="detailAttachments(row).length"
:files="detailAttachments(row)"
size="compact"
@preview="openPreview"
@download="downloadFile"
/>
<span v-else class="muted-dash">—</span>
</template>
</el-table-column>
<el-table-column
label="状态"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
:type="payStatusTagType(String(row.payStatus || ''))"
size="small"
>
{{ payStatusLabel(String(row.payStatus || '')) }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</div>
</template>
</div>
<!-- 审核记录 -->
<div
v-if="showAuditHistory"
id="view-section-audit"
class="section-card section-card--white"
>
<div class="section-title section-title--banner"><h3>审核记录</h3></div>
<div class="list-body list-wrap view-inner-table-wrap">
<el-table
v-loading="auditLoading"
class="list-table inner-table"
:data="auditRecords"
stripe
empty-text="暂无审核记录"
>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
/>
<el-table-column
prop="createTime"
label="审核时间"
min-width="170"
align="center"
header-align="center"
>
<template #default="{ row }">{{ formatDisplayOrDash(row.createTime) }}</template>
</el-table-column>
<el-table-column
prop="auditStatus"
label="审核状态"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="auditStatusTagType(String(row.auditStatus || ''))" size="small">
{{ auditStatusLabel(String(row.auditStatus || '')) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="auditRemark"
label="审核备注"
min-width="200"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="auditorUserName"
label="审核人"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">{{ auditorDisplay(row) }}</template>
</el-table-column>
</el-table>
</div>
</div>
<!-- 附件信息 -->
<div id="view-section-attachment" class="section-card section-card--white">
<div class="section-title section-title--banner"><h3>附件信息</h3></div>
<div class="protocol-basic-attachments-block protocol-basic-attachments-block--flush">
<div class="data-item protocol-attachments-item">
<div class="data-label">协议附件</div>
<div class="data-value protocol-attachments-value">
<ProtocolAttachmentReadonly
:files="protocolAttachments"
@preview="openPreview"
@download="downloadFile"
/>
</div>
</div>
<div class="data-item protocol-attachments-item">
<div class="data-label">知情同意书</div>
<div class="data-value protocol-attachments-value">
<ProtocolAttachmentReadonly
:files="consentAttachments"
@preview="openPreview"
@download="downloadFile"
/>
</div>
</div>
<div class="data-item protocol-attachments-item">
<div class="data-label">孕妇健康调查表</div>
<div class="data-value protocol-attachments-value">
<ProtocolAttachmentReadonly
:files="healthSurveyAttachments"
@preview="openPreview"
@download="downloadFile"
/>
</div>
</div>
</div>
</div>
<!-- 状态变更记录(置底) -->
<div id="view-section-status-change" class="section-card section-card--white">
<div class="section-title section-title--banner"><h3>状态变更记录</h3></div>
<div class="list-body list-wrap view-inner-table-wrap">
<el-table
v-loading="statusChangeLoading"
class="list-table inner-table"
:data="statusChangeRecords"
stripe
empty-text="暂无状态变更记录"
>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
/>
<el-table-column label="变更类型" min-width="110" align="center" header-align="center">
<template #default="{ row }">
<el-tag :type="statusChangeTagType(String(row.changeType || ''))" size="small">
{{ statusChangeTypeLabel(String(row.changeType || '')) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="变更时间" min-width="170" align="center" header-align="center">
<template #default="{ row }">{{ dash(row.changeTime) }}</template>
</el-table-column>
<el-table-column label="状态流转" min-width="160" align="center" header-align="center">
<template #default="{ row }">{{ statusChangeFlow(row) }}</template>
</el-table-column>
<el-table-column
label="变更说明"
min-width="200"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ statusChangeDesc(row) }}</template>
</el-table-column>
<el-table-column label="操作人" min-width="120" align="center" header-align="center">
<template #default="{ row }">{{ dash(row.operatorUserName) }}</template>
</el-table-column>
</el-table>
</div>
</div>
<!-- 操作日志(Issue #24) -->
<div id="view-section-operation-log" class="section-card section-card--white">
<div class="section-title section-title--banner"><h3>操作日志</h3></div>
<div class="list-body list-wrap view-inner-table-wrap">
<el-table
v-loading="operationLogLoading"
class="list-table inner-table"
:data="operationLogRecords"
stripe
empty-text="暂无操作日志"
>
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column label="动作" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag :type="operationActionTagType(String(row.action || ''))" size="small">
{{ operationActionLabel(String(row.action || '')) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="来源端" min-width="90" align="center" header-align="center">
<template #default="{ row }">{{ operationChannelLabel(String(row.clientChannel || '')) }}</template>
</el-table-column>
<el-table-column label="操作时间" min-width="170" align="center" header-align="center">
<template #default="{ row }">{{ dash(row.operateTime) }}</template>
</el-table-column>
<el-table-column label="操作人" min-width="120" align="center" header-align="center">
<template #default="{ row }">{{ dash(row.operatorUserName) }}</template>
</el-table-column>
<el-table-column label="变更摘要" min-width="260" align="left" header-align="center" show-overflow-tooltip>
<template #default="{ row }">{{ operationSummary(row) }}</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</template>
</div>
<template #footer>
<el-button class="btn-ghost" @click="emit('update:modelValue', false)">
<el-icon><Close /></el-icon>
关闭
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="previewVisible"
title="文件预览"
width="800px"
align-center
append-to-body
destroy-on-close
>
<div v-if="previewType === 'image'" class="preview-container">
<img
:src="previewUrl"
alt="预览"
style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
/>
</div>
<div v-else class="preview-container">
<video
:src="previewUrl"
controls
style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
/>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { formatDisplayOrDash } from '@/utils/datetime-display'
import { computed, nextTick, ref, watch } from 'vue'
import { Close } from '@element-plus/icons-vue'
import ProtocolAttachmentReadonly from './ProtocolAttachmentReadonly.vue'
import SampleQcInfoGroupedPanel from '@/components/business/sample-qc-info-grouped-panel/index.vue'
import SampleReceptionDualReadonlyPanel from '@/components/business/sample-reception-dual-readonly-panel/index.vue'
import SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue'
import SampleProductionInfoCards from '@/components/business/sample-production-info-cards/index.vue'
import SampleStorageReleaseInfo from '@/components/business/sample-storage-release-info/index.vue'
import ProtocolHandlePanel from '@/components/business/protocol-handle-panel/index.vue'
import {
fetchCollectionProtocolAuditList,
fetchCollectionProtocolDetail,
fetchCollectionProtocolStatusChanges,
fetchCollectionProtocolOperationLogs,
type CollectionProtocolRow
} from '@/api/collection-protocol'
import {
fetchCellSampleTypeList,
fetchItemUnitList,
fetchExpressCompanyList,
fetchSiteList,
fetchCollectionRegionList,
fetchHospitalList,
fetchUserList
} from '@/api/admin'
import { downloadAttachmentFile } from '@/utils/download-attachment'
import { formatMoneyDisplay } from '@/utils/money-format'
import {
protocolStatusDisplayLabel,
protocolStatusTagType,
auditStatusLabel,
auditStatusTagType,
payModeLabel,
payModeTagType,
newbornSexLabel,
deliveryModeLabel,
sampleStatusLabel,
sampleStatusTagType,
transportTypeLabel,
payStatusLabel,
payStatusTagType
} from '@/utils/status-tag-registry'
import { loadTransferTypeDict, transferTypeLabel } from '@/utils/protocol-payment-labels'
import { loadVoidTypeDict, voidTypeLabel } from '@/utils/void-type-dict'
import { formatProtocolDocSubject } from '@/utils/protocol-doc-label'
import { isInstallmentPayMode } from '@/utils/protocol-payment-presets'
import { sumSampleContractAmounts } from '@/utils/protocol-sample-payment'
const props = withDefaults(
defineProps<{
modelValue: boolean
protocolId: string | null
listRow?: CollectionProtocolRow | null
/** 展示审核记录 Tab(采集协议单审核页) */
showAuditHistory?: boolean
/** 只读摘要:展示储存证办理字段 */
showStorageCert?: boolean
/** 只读摘要:与储存证并排展示新生儿保险办理字段 */
showFullmoonInsurance?: boolean
/** 样本信息管理:储存证/保险轻量办理(可编辑,与只读摘要互斥) */
showHandlePanels?: boolean
highlightSampleCode?: string
}>(),
{
showAuditHistory: false,
showStorageCert: false,
showFullmoonInsurance: false,
showHandlePanels: false,
highlightSampleCode: ''
}
)
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
}>()
const baseFormTabs = [
{ key: 'basic', label: '基本信息' },
{ key: 'collection', label: '采集信息' },
{ key: 'sample', label: '样本及运输信息' },
{ key: 'party', label: '客户信息' },
{ key: 'payment', label: '缴费信息' }
] as const
/** 协议审核等页:只读展示储存证/保险(与样本信息录入字段对齐);办理编辑用 showHandlePanels */
const showHandleSummary = computed(
() => !props.showHandlePanels && (props.showStorageCert || props.showFullmoonInsurance)
)
const formTabs = computed(() => {
const tabs: Array<{ key: string; label: string }> = [...baseFormTabs]
if (props.showHandlePanels) {
const sampleIdx = tabs.findIndex((t) => t.key === 'sample')
tabs.splice(sampleIdx + 1, 0, { key: 'storage-cert-handle', label: '储存证办理' })
tabs.splice(sampleIdx + 2, 0, { key: 'insurance-handle', label: '保险办理' })
}
if (showHandleSummary.value) {
const partyIdx = tabs.findIndex((t) => t.key === 'party')
tabs.splice(partyIdx + 1, 0, { key: 'storage-insurance', label: '储存证与保险' })
}
if (props.showAuditHistory) {
tabs.push({ key: 'audit', label: '审核记录' })
}
tabs.push({ key: 'attachment', label: '附件信息' })
tabs.push({ key: 'status-change', label: '状态变更记录' })
tabs.push({ key: 'operation-log', label: '操作日志' })
return tabs
})
const detailLoading = ref(false)
const lookupLoading = ref(false)
const auditLoading = ref(false)
const statusChangeLoading = ref(false)
const bundle = ref<Record<string, any> | null>(null)
const auditRecords = ref<Array<Record<string, unknown>>>([])
const statusChangeRecords = ref<Array<Record<string, unknown>>>([])
const operationLogLoading = ref(false)
const operationLogRecords = ref<Array<Record<string, unknown>>>([])
const OPERATION_ACTION_LABEL: Record<string, string> = {
create: '创建',
update: '修改',
delete: '删除',
void: '作废',
activate: '生效',
expire: '过期',
to_research: '转科研',
to_storage: '转细胞存储',
audit: '审核'
}
const OPERATION_ACTION_TAG: Record<string, 'success' | 'warning' | 'danger' | 'primary' | 'info'> = {
create: 'success',
update: 'primary',
delete: 'danger',
void: 'danger',
activate: 'success',
expire: 'warning',
to_research: 'primary',
to_storage: 'primary',
audit: 'info'
}
function operationActionLabel(action: string) {
return OPERATION_ACTION_LABEL[action] || action || '—'
}
function operationActionTagType(action: string) {
return OPERATION_ACTION_TAG[action] || 'info'
}
function operationChannelLabel(channel: string) {
if (channel === 'mini') return '小程序'
if (channel === 'web') return 'Web'
return channel || '—'
}
function operationSummary(row: Record<string, unknown>) {
const before = (row.before || {}) as Record<string, any>
const after = (row.after || {}) as Record<string, any>
const beforeProto = (before.protocol || before.donation || {}) as Record<string, any>
const afterProto = (after.protocol || after.donation || {}) as Record<string, any>
const beforeParty = (before.partyCustomer || before.party || {}) as Record<string, any>
const afterParty = (after.partyCustomer || after.party || {}) as Record<string, any>
const parts: string[] = []
const beforeStatus = String(beforeProto.protocolStatus || beforeProto.donationStatus || '')
const afterStatus = String(afterProto.protocolStatus || afterProto.donationStatus || '')
if (beforeStatus || afterStatus) {
if (beforeStatus && afterStatus && beforeStatus !== afterStatus) {
parts.push(`状态 ${beforeStatus}→${afterStatus}`)
} else if (afterStatus) {
parts.push(`状态 ${afterStatus}`)
}
}
for (const key of ['motherName', 'customerName', 'donorName', 'name']) {
const b = String(beforeParty[key] || '').trim()
const a = String(afterParty[key] || '').trim()
if (b || a) {
if (b && a && b !== a) parts.push(`姓名 ${b}→${a}`)
else if (a && !b) parts.push(`姓名 ${a}`)
break
}
}
const beforeSamples = Array.isArray(before.samples) ? before.samples : []
const afterSamples = Array.isArray(after.samples) ? after.samples : []
if (beforeSamples.length || afterSamples.length) {
const pick = (list: any[]) =>
list
.map((s) => `${s?.sampleCode || ''}:${s?.sampleStatus || s?.status || ''}`)
.filter((x) => x !== ':')
.join(',')
const bs = pick(beforeSamples)
const as = pick(afterSamples)
if (bs || as) {
if (bs && as && bs !== as) parts.push(`样本 ${bs}→${as}`)
else if (as) parts.push(`样本 ${as}`)
}
}
if (!parts.length) {
const remark = String(row.remark || '').trim()
return remark || '见前后快照'
}
return parts.join(';')
}
const STATUS_CHANGE_TYPE_LABEL: Record<string, string> = {
activate: '生效',
expire: '过期',
void: '作废',
to_research: '转科研',
to_storage: '转细胞存储'
}
const STATUS_CHANGE_TAG: Record<string, 'success' | 'warning' | 'danger' | 'primary' | 'info'> = {
activate: 'success',
expire: 'warning',
void: 'danger',
to_research: 'primary',
to_storage: 'primary'
}
function statusChangeTypeLabel(changeType: string) {
return STATUS_CHANGE_TYPE_LABEL[changeType] || changeType || '—'
}
function statusChangeTagType(changeType: string) {
return STATUS_CHANGE_TAG[changeType] || 'info'
}
function statusChangeFlow(row: Record<string, unknown>) {
const from = String(row.fromStatus || '')
const to = String(row.toStatus || '')
const voidType = String(row.voidType || '')
const toLabel = protocolStatusDisplayLabel(to, to === 'yzf' ? voidType : '')
if (!from) return toLabel || '—'
return `${protocolStatusDisplayLabel(from)} → ${toLabel}`
}
function statusChangeDesc(row: Record<string, unknown>) {
const desc = String(row.changeDesc || '').trim()
if (String(row.changeType || '') === 'void') {
const vt = voidTypeLabel(String(row.voidType || ''))
if (vt && desc) return `作废类型:${vt};${desc}`
if (vt) return `作废类型:${vt}`
}
return desc || '—'
}
const activeTab = ref('basic')
const sampleTypeOptions = ref<Array<{ id: string; typeName: string }>>([])
const unitOptions = ref<Array<{ id: string; unitName: string }>>([])
const siteOptions = ref<Array<{ id: string; siteName: string }>>([])
const expressCompanyOptions = ref<Array<{ id: string; expressName: string }>>([])
const regionOptions = ref<Array<{ id: string; regionName: string }>>([])
const hospitalOptions = ref<Array<{ id: string; hospitalName: string }>>([])
const collectorUserOptions = ref<Array<{ id: string; nickName: string }>>([])
const salesUserOptions = ref<Array<{ id: string; nickName: string }>>([])
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
const protocol = computed(() => bundle.value?.protocol as Record<string, any> | undefined)
const partyCustomer = computed(() => bundle.value?.partyCustomer as Record<string, any> | null)
const collectionInfo = computed(() => bundle.value?.collectionInfo as Record<string, any> | null)
const payment = computed(() => bundle.value?.payment as Record<string, any> | null)
const paymentModelVersion = computed(() =>
Number((bundle.value?.protocol as any)?.paymentModelVersion || 1)
)
const samplePayments = computed(
() => (bundle.value?.samplePayments as Array<Record<string, any>>) || []
)
const refundRecords = computed(
() => (bundle.value?.refundRecords as Array<Record<string, any>>) || []
)
const depositTransfers = computed(
() => (bundle.value?.depositTransfers as Array<Record<string, any>>) || []
)
const presignDepositHint = computed(
() => (bundle.value?.presignDepositHint as Record<string, any>) || null
)
const sampleContractSum = computed(() => sumSampleContractAmounts(samplePayments.value))
const presignDepositHintText = computed(() => {
const h = presignDepositHint.value
if (!h) return ''
const amt = Number(h.confirmedDepositAmount || 0)
const cnt = Number(h.confirmedTransferCount || 0)
return `来源预签约已确认意向金合计 ¥${formatMoneyDisplay(amt)}(${cnt} 笔),下方列表与预签同一数据,不自动计入样本缴费`
})
const v2ReceivedAmount = computed(() => {
let sum = 0
for (const sp of samplePayments.value) {
for (const sch of sp.schedules || []) {
for (const tr of sch.transfers || []) {
if (String(tr.payStatus || '') === 'yjf') {
sum += Number(tr.transferAmount || 0) || 0
}
}
}
}
return sum
})
const samples = computed(() => {
const rows = (bundle.value?.samples as Array<Record<string, any>>) || []
return [...rows].sort((a, b) => {
const oa = Number(a.orderNum ?? 0)
const ob = Number(b.orderNum ?? 0)
if (oa !== ob) return oa - ob
return String(a.createTime || '').localeCompare(String(b.createTime || ''))
})
})
/** 严格按接口返回的库内明细展示,不做期次补齐 */
const paymentDetails = computed(
() => (bundle.value?.paymentDetails as Array<Record<string, any>>) || []
)
/**
* v1 明细按期归组,结构对齐 v2 schedules(期汇总 + 多笔转账)
* - 趸交:整单视为第 1 期
* - 分期:按 installmentNo 归组
*/
const v1PayPeriods = computed(() => {
const details = paymentDetails.value
if (!details.length) return [] as Array<{
scheduleNo: number
dueAmount: number | null
dueDate: string
paidAmount: number
transfers: Array<Record<string, any>>
}>
const paidOf = (rows: Array<Record<string, any>>) =>
rows
.filter((d) => String(d.payStatus || '') === 'yjf')
.reduce((sum, d) => sum + (Number(d.transferAmount) || 0), 0)
const isFq = isInstallmentPayMode(String(payment.value?.payMode || ''))
if (!isFq) {
return [
{
scheduleNo: 1,
dueAmount:
details[0]?.dueAmount != null
? Number(details[0].dueAmount)
: payment.value?.contractAmount != null
? Number(payment.value.contractAmount)
: null,
dueDate: String(
payment.value?.lumpSumDeadline || details[0]?.installmentDueDate || ''
),
paidAmount: paidOf(details),
transfers: details
}
]
}
const groups = new Map<number, Array<Record<string, any>>>()
for (const d of details) {
const no = Number(d.installmentNo) || 0
const list = groups.get(no) || []
list.push(d)
groups.set(no, list)
}
return [...groups.entries()]
.sort((a, b) => a[0] - b[0])
.map(([no, rows]) => ({
scheduleNo: no || 1,
dueAmount: rows[0]?.dueAmount != null ? Number(rows[0].dueAmount) : null,
dueDate: String(rows[0]?.installmentDueDate || ''),
paidAmount: paidOf(rows),
transfers: rows
}))
})
/** 已收金额 = 已缴费明细的转账金额之和(排除待缴费、退费确认中、已退费) */
const receivedAmount = computed(() => {
return paymentDetails.value
.filter((d) => {
const s = String(d.payStatus || '')
return s === 'yjf' || s === 'jfqrz'
})
.reduce((sum, d) => {
const amt = Number(d.transferAmount || 0)
return sum + (isNaN(amt) ? 0 : amt)
}, 0)
})
const protocolAttachments = computed(() => {
const list = bundle.value?.attachments
return Array.isArray(list) ? list : []
})
const consentAttachments = computed(() => {
const list = bundle.value?.consentAttachmentFiles
return Array.isArray(list) ? list : []
})
const healthSurveyAttachments = computed(() => {
const list = bundle.value?.healthSurveyAttachmentFiles
return Array.isArray(list) ? list : []
})
const storageCertInfo = computed(() => {
const sc = bundle.value?.storageCert as Record<string, any> | undefined | null
if (sc) return sc
if (!props.showStorageCert || !props.listRow) return null
const row = props.listRow as Record<string, any>
return {
makeStatus: row.makeStatus ?? '0',
certImageUrls: row.certImageUrls ?? '',
makeDate: row.makeDate ?? '',
handleTime: row.handleTime ?? '',
handlerUserId: row.handlerUserId ?? '',
handlerUserName: row.handlerUserName ?? '',
remark: row.remark ?? ''
}
})
const storageCertAttachments = computed(() => {
const urls = String(storageCertInfo.value?.certImageUrls || '').trim()
if (!urls) return []
return urls
.split(',')
.map((u) => u.trim())
.filter(Boolean)
.map((url, index) => {
const rawName = url.split('/').pop() || ''
const fileName = decodeURIComponent(rawName) || `附件${index + 1}`
return { id: `cert_${index}`, fileName, fileUrl: url, fileType: '' }
})
})
const fullmoonInsuranceInfo = computed(() => {
const fm = bundle.value?.fullmoonTodo as Record<string, any> | undefined
if (fm) return fm
if (!props.showFullmoonInsurance || !props.listRow) return null
const row = props.listRow as Record<string, any>
return {
handleTime: row.handleTime ?? '',
handlerUserId: row.handlerUserId ?? '',
handlerUserName: row.handlerUserName ?? '',
handleRemark: row.handleRemark ?? row.remark ?? '',
insuranceAttachments: row.insuranceAttachments ?? ''
}
})
const fullmoonInsuranceAttachments = computed(() => {
const urls = String(fullmoonInsuranceInfo.value?.insuranceAttachments || '').trim()
if (!urls) return []
return urls
.split(',')
.map((u) => u.trim())
.filter(Boolean)
.map((url, index) => {
const rawName = url.split('/').pop() || ''
const fileName = decodeURIComponent(rawName) || `附件${index + 1}`
return { id: `ins_${index}`, fileName, fileUrl: url, fileType: '' }
})
})
/** 金额只读展示:千分位 + ¥ 前缀,null 显示 — */
function fmtMoney(v: unknown): string {
if (v == null || v === '') return '—'
return '¥' + formatMoneyDisplay(v as string | number)
}
const basicMotherNameDisplay = computed(() =>
dash(
partyCustomer.value?.motherName || props.listRow?.motherName || protocol.value?.motherName
)
)
const dialogTitle = computed(() => {
const name =
partyCustomer.value?.motherName || props.listRow?.motherName || protocol.value?.motherName
return `查看 — ${formatProtocolDocSubject('cell_storage', { motherName: name })}`
})
const collectorDisplay = computed(() => {
const fromList = props.listRow?.collectorUserName
if (fromList) return String(fromList)
const fromBundle = protocol.value?.collectorUserName
if (fromBundle) return String(fromBundle)
const id = protocol.value?.collectorUserId
const u = collectorUserOptions.value.find((x) => x.id === id)
return u?.nickName || dash(id)
})
const salesDisplay = computed(() => {
const fromList = props.listRow?.salesUserName
if (fromList) return String(fromList)
const fromBundle = collectionInfo.value?.salesUserName
if (fromBundle) return String(fromBundle)
const id = collectionInfo.value?.salesUserId
const u = salesUserOptions.value.find((x) => x.id === id)
return u?.nickName || dash(id)
})
const regionManagerDisplay = computed(() => {
const fromList = (props.listRow as any)?.regionManagerUserName
if (fromList) return String(fromList)
const fromBundle = (collectionInfo.value as any)?.regionManagerUserName
if (fromBundle) return String(fromBundle)
const id = (collectionInfo.value as any)?.regionManagerUserId
return dash(id)
})
const regionDisplay = computed(() => {
const fromList = props.listRow?.regionName
if (fromList) return String(fromList)
const fromBundle = collectionInfo.value?.regionName
if (fromBundle) return String(fromBundle)
const id = collectionInfo.value?.regionId
const r = regionOptions.value.find((x) => x.id === id)
return r?.regionName || dash(id)
})
const hospitalDisplay = computed(() => {
const fromList = props.listRow?.hospitalName
if (fromList) return String(fromList)
const fromBundle = collectionInfo.value?.hospitalName
if (fromBundle) return String(fromBundle)
const id = collectionInfo.value?.hospitalId
const h = hospitalOptions.value.find((x) => x.id === id)
return h?.hospitalName || dash(id)
})
function dash(val: unknown) {
return formatDisplayOrDash(val)
}
function formatYmd(val: unknown) {
const s = String(val || '').replace(/-/g, '')
if (s.length === 8) return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`
return dash(val)
}
function formatInstallmentAmounts(values?: number[] | null) {
if (!values?.length) return '—'
return values.map((v) => fmtMoney(v)).join(' / ')
}
function formatYm(val: unknown) {
const s = String(val || '')
if (s.length === 6) return `${s.slice(0, 4)}-${s.slice(4, 6)}`
return dash(val)
}
function payDetailColorClass(payStatus: string): string {
const s = String(payStatus || '')
if (s === 'ytf') return 'red'
if (s === 'tfqrz') return 'amber'
return 'green'
}
/** 缴费明细是否已完成财务收款确认(jfqrz 仅已提交待财务处理) */
function isReceiptFinanceConfirmed(detail: Record<string, any>) {
const s = String(detail.payStatus || '')
return s !== 'jfqrz' && s !== 'dfj'
}
interface MiniFlowStep {
key: string
label: string
dotClass: string
dotIcon: string
labelClass: string
connectorToNext?: string
hasDetailCard?: boolean
}
function buildMiniFlowSteps(detail: Record<string, any>): MiniFlowStep[] {
const s = String(detail.payStatus || '')
if (s === 'dfj') return []
const steps: MiniFlowStep[] = []
steps.push({
key: 'pay',
label: '缴费确认信息',
dotClass: 'done',
dotIcon: '✓',
labelClass: 'done',
connectorToNext: 'done',
hasDetailCard: true
})
if (s === 'jfqrz') {
steps.push({
key: 'receipt-finance',
label: '缴费财务确认信息',
dotClass: 'current-amber',
dotIcon: '●',
labelClass: 'current-amber',
hasDetailCard: true
})
} else {
steps.push({
key: 'receipt-finance',
label: '缴费财务确认信息',
dotClass: 'done',
dotIcon: '✓',
labelClass: 'done',
connectorToNext: s === 'yjf' ? undefined : 'done',
hasDetailCard: true
})
}
if (s === 'tfqrz' || s === 'ytf') {
steps.push({
key: 'refund-apply',
label: '退费确认信息',
dotClass: s === 'tfqrz' ? 'current-amber' : 'done',
dotIcon: s === 'tfqrz' ? '●' : '✓',
labelClass: s === 'tfqrz' ? 'current-amber' : 'done',
connectorToNext: s === 'ytf' ? 'done-red' : undefined,
hasDetailCard: true
})
}
if (s === 'ytf') {
steps.push({
key: 'refund-finance',
label: '退费财务确认信息',
dotClass: 'done-red',
dotIcon: '✓',
labelClass: 'done-red',
hasDetailCard: true
})
}
if (steps.length) {
delete steps[steps.length - 1].connectorToNext
}
return steps
}
function miniFlowConnectorStyle(total: number, index: number): string {
const leftPct = ((index + 0.5) / total) * 100
const widthPct = 100 / total
return `left: calc(${leftPct}% + 12px); width: calc(${widthPct}% - 12px);`
}
interface OverviewTimelineStep {
key: string
label: string
subLabel: string
dotClass: string
dotIcon: string
labelClass: string
connectorToNext?: string
}
/** 缴费总览 — 横向时间线节点(每个缴费明细对应一个节点) */
function buildOverviewTimelineSteps(): OverviewTimelineStep[] {
const details = paymentDetails.value
if (!details.length) return []
const steps: OverviewTimelineStep[] = details.map((d, i) => {
const s = String(d.payStatus || '')
const name = isInstallmentPayMode(String(payment.value?.payMode || ''))
? `第${d.installmentNo || i + 1}期`
: `缴费${i + 1}`
if (s === 'ytf') {
return {
key: `ov-${i}`,
label: name,
subLabel: '已退费',
dotClass: 'refund',
dotIcon: '✓',
labelClass: 'refund'
}
}
if (s === 'tfqrz') {
return {
key: `ov-${i}`,
label: name,
subLabel: '退费中',
dotClass: 'refund-current',
dotIcon: '●',
labelClass: 'refund-current',
connectorToNext: 'refund-current'
}
}
if (s === 'yjf') {
return {
key: `ov-${i}`,
label: name,
subLabel: '付款完成',
dotClass: 'done',
dotIcon: '✓',
labelClass: 'done',
connectorToNext: 'done'
}
}
if (s === 'jfqrz') {
return {
key: `ov-${i}`,
label: name,
subLabel: '确认中',
dotClass: 'current',
dotIcon: '●',
labelClass: 'done',
connectorToNext: 'current'
}
}
return {
key: `ov-${i}`,
label: name,
subLabel: '已付款',
dotClass: 'pending',
dotIcon: '○',
labelClass: ''
}
})
// 填充 connectorToNext:下一个节点的连线颜色由本节点的状态决定
for (let i = 0; i < steps.length - 1; i++) {
if (!steps[i].connectorToNext) {
steps[i].connectorToNext = steps[i].dotClass === 'pending' ? '' : 'done'
}
}
return steps
}
function timelineConnectorStyle(total: number, index: number): string {
const leftPct = ((index + 0.5) / total) * 100
const widthPct = 100 / total
return `left: calc(${leftPct}% + 14px); width: calc(${widthPct}% - 14px);`
}
function auditorDisplay(row: Record<string, unknown>) {
const name = String(row.auditorUserName || '').trim()
if (name) return name
return dash(row.auditorUserId)
}
function sampleTypeName(id: string) {
return sampleTypeOptions.value.find((x) => x.id === id)?.typeName || dash(id)
}
function samplePayTitle(sp: Record<string, any>) {
const code = String(sp.sampleCode || '').trim()
const sample = samples.value.find((s) => String(s.sampleCode || '') === code)
if (sample?.sampleTypeName) return String(sample.sampleTypeName)
if (sample?.sampleTypeId) return sampleTypeName(String(sample.sampleTypeId))
return code || '样本'
}
function ensureUnitsInOptions(samples: Array<Record<string, any>>) {
const merged = [...unitOptions.value]
for (const s of samples) {
const id = String(s.unitId || '')
if (!id) continue
const name = String(s.unitName || '').trim()
const existing = merged.find((x) => x.id === id)
if (existing) {
if (name && (!existing.unitName || existing.unitName === existing.id)) {
existing.unitName = name
}
continue
}
merged.push({ id, unitName: name || id })
}
unitOptions.value = merged
}
function siteName(id: string) {
return siteOptions.value.find((x) => x.id === id)?.siteName || dash(id)
}
function expressName(id: string) {
return expressCompanyOptions.value.find((x) => x.id === id)?.expressName || dash(id)
}
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 sampleReceptionAttachmentFiles(reception: Record<string, any>) {
const list = reception?.receiveAttachmentFiles
if (Array.isArray(list) && list.length) return list
const urls = String(reception?.receiveAttachments || '')
if (!urls.trim()) return []
return urls
.split(',')
.map((u, i) => {
const url = u.trim()
if (!url) return null
const name = url.includes('/') ? url.split('/').pop() || `附件${i + 1}` : url
return { id: `recv_${i}`, fileUrl: url, fileName: name }
})
.filter(Boolean) as Array<Record<string, unknown>>
}
function detailAttachments(detail: Record<string, any>) {
return Array.isArray(detail.attachments) ? detail.attachments : []
}
function transferAttachments(tr: Record<string, any>) {
if (Array.isArray(tr.attachments) && tr.attachments.length) return tr.attachments
return urlsToAttachmentFiles(String(tr.transferAttachments || ''))
}
function financeConfirmAttachmentFiles(detail: Record<string, any>) {
if (Array.isArray(detail.financeConfirmAttachmentFiles)) {
return detail.financeConfirmAttachmentFiles
}
return urlsToAttachmentFiles(String(detail.financeConfirmAttachments || ''))
}
function refundRecordAttachments(row: Record<string, any>) {
if (Array.isArray(row.refundAttachmentFiles) && row.refundAttachmentFiles.length) {
return row.refundAttachmentFiles
}
return urlsToAttachmentFiles(String(row.refundAttachments || ''))
}
function refundAttachmentFiles(detail: Record<string, any>) {
if (Array.isArray(detail.refundAttachmentFiles)) {
return detail.refundAttachmentFiles
}
return urlsToAttachmentFiles(String(detail.refundAttachments || ''))
}
function refundFinanceConfirmAttachmentFiles(detail: Record<string, any>) {
if (Array.isArray(detail.refundFinanceConfirmAttachmentFiles)) {
return detail.refundFinanceConfirmAttachmentFiles
}
return urlsToAttachmentFiles(String(detail.refundFinanceConfirmAttachments || ''))
}
function urlsToAttachmentFiles(urls: string) {
if (!urls.trim()) return []
return urls
.split(',')
.map((u, i) => {
const url = u.trim()
if (!url) return null
const name = url.includes('/') ? url.split('/').pop() || `附件${i + 1}` : url
return { id: `url_${i}`, fileUrl: url, fileName: name, fileSize: 0 }
})
.filter(Boolean) as Array<Record<string, any>>
}
function fullFileUrl(suffix: string) {
if (!suffix) return ''
if (suffix.startsWith('http://') || suffix.startsWith('https://') || suffix.startsWith('/'))
return suffix
return `${minioBaseUrl}/${suffix}`
}
function isVideoFile(file: { fileType?: string; fileName?: string }) {
const t = (file.fileType || '').toLowerCase()
if (t.startsWith('video/')) return true
return /\.(mp4|webm|ogg|mov)$/i.test(file.fileName || '')
}
const previewVisible = ref(false)
const previewUrl = ref('')
const previewType = ref<'image' | 'video'>('image')
function openPreview(file: Record<string, any>) {
if (!file.fileUrl) return
previewUrl.value = fullFileUrl(file.fileUrl)
previewType.value = isVideoFile(file) ? 'video' : 'image'
previewVisible.value = true
}
function downloadFile(file: Record<string, any>) {
downloadAttachmentFile(file, { fullFileUrl })
}
async function loadBaseLookups() {
const [stRes, uRes, ecRes, siteRes] = await Promise.all([
fetchCellSampleTypeList({ page: 1, pageSize: 100 }),
fetchItemUnitList({ 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
expressCompanyOptions.value = ecRes.list || []
siteOptions.value = siteRes.list || []
}
async function loadConditionalLookups(data: Record<string, any>) {
const ci = data.collectionInfo as Record<string, any> | null
const p = data.protocol as Record<string, any> | undefined
const tasks: Promise<void>[] = []
if (ci?.regionId) {
tasks.push(
fetchCollectionRegionList({ page: 1, pageSize: 100 }).then((res) => {
regionOptions.value = res.list || []
})
)
tasks.push(
fetchHospitalList(String(ci.regionId), { page: 1, pageSize: 100 }).then((res) => {
hospitalOptions.value = res.list || []
})
)
}
if (p?.collectorUserId) {
tasks.push(
fetchUserList({ page: 1, pageSize: 50, status: '1' }).then((res) => {
collectorUserOptions.value = res.list || []
})
)
}
if (ci?.salesUserId) {
tasks.push(
fetchUserList({ page: 1, pageSize: 50, status: '1' }).then((res) => {
salesUserOptions.value = res.list || []
})
)
}
if (tasks.length) {
await Promise.all(tasks)
}
}
async function finishLookups(data: Record<string, any>, baseLookupsPromise: Promise<void>) {
lookupLoading.value = true
try {
await baseLookupsPromise
ensureUnitsInOptions((data.samples as Array<Record<string, any>>) || [])
await loadConditionalLookups(data)
} finally {
lookupLoading.value = false
}
}
async function loadStatusChangeRecords() {
if (!props.protocolId) {
statusChangeRecords.value = []
return
}
statusChangeLoading.value = true
try {
const res = await fetchCollectionProtocolStatusChanges(props.protocolId)
statusChangeRecords.value = res.list || []
} catch {
statusChangeRecords.value = []
} finally {
statusChangeLoading.value = false
}
}
async function loadOperationLogRecords() {
if (!props.protocolId) {
operationLogRecords.value = []
return
}
operationLogLoading.value = true
try {
const res = await fetchCollectionProtocolOperationLogs(props.protocolId)
operationLogRecords.value = res.list || []
} catch {
operationLogRecords.value = []
} finally {
operationLogLoading.value = false
}
}
async function loadAuditRecords() {
if (!props.showAuditHistory || !props.protocolId) {
auditRecords.value = []
return
}
auditLoading.value = true
try {
const res = await fetchCollectionProtocolAuditList({
page: 1,
pageSize: 100,
collectionProtocolId: props.protocolId
})
auditRecords.value = res.list || []
} catch {
auditRecords.value = []
} finally {
auditLoading.value = false
}
}
async function loadDetail() {
if (!props.protocolId) return
detailLoading.value = true
lookupLoading.value = true
bundle.value = null
auditRecords.value = []
statusChangeRecords.value = []
operationLogRecords.value = []
const baseLookupsPromise = loadBaseLookups().catch(() => {})
const auditPromise = props.showAuditHistory ? loadAuditRecords() : null
const statusChangePromise = loadStatusChangeRecords()
const operationLogPromise = loadOperationLogRecords()
let data: Record<string, any> | null = null
try {
data = (await fetchCollectionProtocolDetail(props.protocolId)) as Record<string, any>
bundle.value = data
} finally {
detailLoading.value = false
}
if (!data) {
lookupLoading.value = false
return
}
void finishLookups(data, baseLookupsPromise)
void auditPromise
void statusChangePromise
void operationLogPromise
if (props.highlightSampleCode) {
nextTick(() => {
document
.getElementById(`view-sample-${props.highlightSampleCode}`)
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
})
}
}
function scrollToSection(key: string) {
if (detailLoading.value && !bundle.value) return
activeTab.value = key
nextTick(() => {
document
.getElementById(`view-section-${key}`)
?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
}
function handleClosed() {
bundle.value = null
auditRecords.value = []
statusChangeRecords.value = []
operationLogRecords.value = []
activeTab.value = 'basic'
detailLoading.value = false
lookupLoading.value = false
auditLoading.value = false
statusChangeLoading.value = false
}
watch(
() => [props.modelValue, props.protocolId] as const,
([visible, id]) => {
if (visible) {
void loadTransferTypeDict()
void loadVoidTypeDict()
}
if (visible && id) loadDetail()
}
)
watch(
() => [props.modelValue, props.highlightSampleCode, bundle.value] as const,
([visible, code]) => {
if (!visible || !code || !bundle.value) return
nextTick(() => {
document.getElementById(`view-sample-${code}`)?.scrollIntoView({
behavior: 'smooth',
block: 'center'
})
})
}
)
</script>
<style scoped lang="scss">
.protocol-view-body {
overflow: visible;
&.is-detail-loading {
min-height: min(680px, calc(100vh - 220px));
}
}
/* ---- KPI 摘要条(统一薄荷绿通栏 + 图标徽章) ---- */
.kpi-strip {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0;
margin-bottom: 24px;
padding: 6px;
background: linear-gradient(135deg, #e8f4ee 0%, #f8fafc 52%, #e8f4ee 100%);
border: 1px solid #a8d4c4;
border-radius: 12px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.85);
}
.kpi-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 18px;
background: transparent;
border-radius: 8px;
transition: background 0.2s;
&:not(:last-child) {
border-right: 1px solid rgba(168, 212, 196, 0.75);
}
&:hover {
background: rgba(255, 255, 255, 0.55);
}
}
.kpi-card__icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: 10px;
background: #fff;
border: 1px solid #a8d4c4;
color: #065f46;
font-size: 18px;
}
.kpi-card__main {
min-width: 0;
flex: 1;
}
.kpi-label {
margin-bottom: 4px;
font-size: 11px;
font-weight: 500;
color: var(--mint-text-muted, #334155);
}
.kpi-value {
font-size: 15px;
font-weight: 600;
color: #0f172a;
display: flex;
align-items: center;
gap: 6px;
&--emphasis {
font-size: 16px;
font-weight: 700;
color: #047857;
letter-spacing: 0.02em;
}
}
/* ---- Section Card ---- */
.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;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
&--white {
background: #fff;
}
&--green {
background: #f0fdf4;
}
}
.section-title {
display: flex;
align-items: center;
margin-bottom: 18px;
padding-bottom: 12px;
border-bottom: 1px solid #e2e8f0;
&::before {
content: '';
width: 3px;
height: 18px;
background: #047857;
border-radius: 2px;
margin-right: 10px;
flex-shrink: 0;
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: #1e293b;
line-height: 1.4;
}
&--banner {
margin: -22px -24px 18px;
padding: 10px 24px;
border-bottom: 1px solid rgba(16, 185, 129, 0.12);
border-radius: 12px 12px 0 0;
background: var(--primary-bg, #ecfdf5);
h3 {
color: var(--primary-dark, #059669);
}
}
}
/* ---- Data Grid ---- */
.data-grid {
display: grid;
gap: 14px 20px;
&--2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
&--3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
&--4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
&--6 {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
&--7 {
grid-template-columns: repeat(7, minmax(0, 1fr));
}
}
.data-grid--collection-row {
gap: 12px 14px;
}
.data-item {
min-width: 0;
&--full {
grid-column: 1 / -1;
}
}
.data-label {
font-size: 12px;
color: var(--mint-text-muted, #334155);
margin-bottom: 3px;
}
.data-value {
font-size: 14px;
color: #1e293b;
font-weight: 500;
word-break: break-word;
&--muted {
color: var(--mint-text-muted, #334155);
font-weight: 400;
}
}
/* 协议附件 / 知情同意书 / 孕妇健康调查表(三行堆叠) */
.protocol-basic-attachments-block {
margin-top: 14px;
display: flex;
flex-direction: column;
gap: 14px;
&--flush {
margin-top: 0;
}
}
.handle-summary-cards {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.handle-summary-card {
background: #fff;
border-radius: 10px;
padding: 18px 20px;
border: 1px solid rgba(0, 0, 0, 0.03);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
.attachment-row,
.remark-row {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed #e2e8f0;
}
}
.protocol-attachments-item {
.protocol-attachments-value {
font-weight: 400;
}
:deep(.protocol-attachment-empty) {
font-size: 14px;
color: var(--mint-text-muted, #334155);
}
}
/* ---- Attachment / Remark Row ---- */
.attachment-row {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed #e2e8f0;
display: flex;
flex-direction: column;
gap: 8px;
.data-label {
font-size: 12px;
color: var(--mint-text-muted, #334155);
}
}
.remark-row {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed #e2e8f0;
}
/* ---- Status Change Section ---- */
.status-change-section {
margin-top: 14px;
padding-top: 14px;
border-top: 1px dashed #e2e8f0;
}
.status-change-title {
font-size: 12px;
font-weight: 600;
color: var(--mint-text-muted, #334155);
margin-bottom: 12px;
}
/* ---- 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;
h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #1e293b;
}
}
.sub-card-icon {
font-size: 22px;
line-height: 1;
}
.sub-card-full-row {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed #e2e8f0;
display: flex;
flex-direction: column;
gap: 2px;
}
/* ---- 审核记录内嵌列表(对齐 OpenSpec 嵌套列表 14px 标准) ---- */
.view-inner-table-wrap.list-body.list-wrap {
margin-top: 4px;
border: 1px solid var(--mint-border, #e2e8f0);
border-radius: var(--mint-radius, 10px);
overflow: hidden;
background: #fff;
:deep(.el-table) {
--el-table-header-bg-color: var(--mint-table-head, #f8fafc);
--el-table-border-color: var(--mint-border, #e2e8f0);
--el-table-row-hover-bg-color: var(--mint-row-hover, #f0fdf4);
--el-table-text-color: var(--mint-text, #0f172a);
--el-table-header-text-color: var(--mint-text-muted, #64748b);
font-size: 14px;
}
:deep(.el-table th.el-table__cell) {
font-size: 12px;
font-weight: 600;
color: var(--mint-text-muted, #64748b);
}
:deep(.el-table td.el-table__cell) {
font-size: 14px;
font-weight: 500;
color: var(--mint-text, #0f172a);
}
:deep(.el-empty) {
padding: 24px 0;
}
}
/* ---- Sample Cards ---- */
.sample-cards {
display: flex;
flex-direction: column;
gap: 14px;
}
.sample-card {
background: #fff;
border-radius: 10px;
border: 1px solid rgba(0, 0, 0, 0.05);
overflow: hidden;
transition:
transform 0.2s,
box-shadow 0.2s;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
}
&.is-highlight {
border-color: var(--primary, #10b981);
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.18);
}
}
.sample-card__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 18px;
background: #f8fafc;
border-bottom: 1px solid #f1f5f9;
}
.sample-card__title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: #1e293b;
}
.sample-card__seq {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 6px;
background: #047857;
color: #fff;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
}
.sample-card__body {
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 14px;
}
.sample-card__section {
&--transport,
&--reception {
margin-top: 2px;
padding-top: 12px;
border-top: 1px dashed #e2e8f0;
}
}
:deep(.sample-card__section--qc) {
margin-top: 2px;
padding-top: 12px;
border-top: 1px dashed #e2e8f0;
}
.sample-reception-empty {
font-size: 13px;
color: var(--mint-text-muted, #334155);
}
.check-list {
display: flex;
flex-wrap: wrap;
gap: 12px 20px;
}
.check-list-view {
pointer-events: none;
:deep(.el-checkbox.is-disabled) {
opacity: 1;
cursor: default;
.el-checkbox__label {
color: var(--el-text-color-regular);
}
.el-checkbox__input.is-disabled {
opacity: 1;
cursor: default;
.el-checkbox__inner {
background-color: var(--el-checkbox-bg-color);
border-color: var(--el-checkbox-input-border-color);
cursor: default;
}
&.is-checked .el-checkbox__inner {
background-color: var(--el-checkbox-checked-bg-color);
border-color: var(--el-checkbox-checked-input-border-color);
&::after {
border-color: var(--el-checkbox-checked-icon-color);
}
}
}
}
}
.sample-card__attachments {
margin-top: 12px;
}
.sample-card__section-label {
margin: 0 0 10px;
padding-left: 10px;
font-size: 13px;
font-weight: 600;
line-height: 1.4;
color: #334155;
border-left: 3px solid #10b981;
}
/* 样本卡内子组件小节标题:与 sample-card__section-label 统一 */
:deep(.sample-card__section-label),
:deep(.sample-qc-grouped-panel__subtitle),
:deep(.detail-section-title) {
margin: 0 0 10px;
padding-left: 10px;
font-size: 13px;
font-weight: 600;
line-height: 1.4;
color: #334155;
border-left: 3px solid #10b981;
}
.data-value--code {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', monospace;
font-size: 13px;
color: var(--mint-text-muted, #334155);
}
.data-value--price {
color: #065f46;
font-weight: 700;
}
/* ---- Payment Summary (Metrics) ---- */
.payment-summary {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 18px;
&--3 {
grid-template-columns: repeat(3, 1fr);
}
}
.payment-metric {
background: #fff;
border-radius: 8px;
padding: 12px 16px;
border: 1px solid #e2e8f0;
}
.pm-label {
font-size: 11px;
color: var(--mint-text-muted, #334155);
margin-bottom: 4px;
}
.pm-value {
font-size: 16px;
font-weight: 700;
color: #1e293b;
&--amount {
color: #065f46;
}
&--received {
color: #047857;
}
}
/* ---- Payment Detail Cards ---- */
.payment-details {
display: flex;
flex-direction: column;
gap: 16px;
}
.payment-detail-card {
background: #fff;
border-radius: 8px;
padding: 14px 16px;
border: 1px solid #e2e8f0;
&--refunded {
border-left: 3px solid #dc2626;
border-color: #e2e8f0;
border-left-color: #dc2626;
}
&--refund-pending {
border-left: 3px solid #d97706;
border-color: #e2e8f0;
border-left-color: #d97706;
}
}
/* ---- PDC Header ---- */
.pdc-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid #f1f5f9;
}
.pdc-header-left {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: #1e293b;
&::before {
content: '';
width: 2px;
height: 14px;
border-radius: 1px;
flex-shrink: 0;
}
&.green::before {
background: #047857;
}
&.red::before {
background: #dc2626;
}
&.amber::before {
background: #d97706;
}
}
.pdc-header-amount {
font-size: 14px;
font-weight: 700;
margin-left: 4px;
&.green {
color: #065f46;
}
&.red {
color: #dc2626;
}
&.amber {
color: #d97706;
}
}
/* ---- PDC Info Row ---- */
.pdc-info-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px 20px;
margin-bottom: 12px;
}
.pdc-info-item {
display: flex;
flex-direction: column;
gap: 2px;
.info-label {
font-size: 11px;
color: var(--mint-text-muted, #334155);
}
.info-value {
font-size: 13px;
color: #1e293b;
font-weight: 500;
}
}
.pdc-attachments-row {
margin-bottom: 8px;
padding-top: 8px;
border-top: 1px dashed #e2e8f0;
display: flex;
flex-direction: column;
gap: 4px;
}
/* ---- Payment Section Subtitle ---- */
.section-subtitle {
margin: 0 0 14px;
font-size: 12px;
font-weight: 600;
color: var(--mint-text-muted, #334155);
}
.sample-pay-readonly {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
}
.sample-pay-readonly__card {
padding: 12px 14px;
border: 1px solid var(--mint-border, #e2e8f0);
border-radius: 10px;
background: #fff;
}
.sample-pay-readonly__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
font-size: 13px;
font-weight: 600;
}
.sample-pay-readonly__title {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.sample-pay-readonly__amt {
flex-shrink: 0;
color: #047857;
}
.sample-pay-readonly__batch-title {
margin: 14px 0 8px;
padding-left: 10px;
font-size: 13px;
font-weight: 600;
line-height: 1.4;
color: #334155;
border-left: 3px solid #10b981;
&--mt {
margin-top: 20px;
}
}
.sample-pay-readonly__empty {
margin: 4px 0 8px;
font-size: 12px;
color: var(--mint-text-muted, #94a3b8);
&--sm {
margin: 0;
padding: 2px 0 0;
}
}
.sample-pay-readonly__sch {
margin-top: 10px;
padding: 12px 12px 10px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid #eef2f7;
}
.sample-pay-readonly__sch-title {
margin-bottom: 10px;
font-size: 13px;
font-weight: 600;
color: #0f172a;
}
.sample-pay-readonly__tr-title {
margin: 12px 0 8px;
font-size: 12px;
font-weight: 600;
color: #475569;
}
.sample-pay-readonly__tr-count {
font-weight: 400;
color: #94a3b8;
}
.sample-pay-readonly__sch .view-inner-table-wrap {
margin-top: 0;
}
.muted-dash {
color: var(--mint-text-muted, #94a3b8);
}
/* ---- v1 缴费明细(简洁平铺) ---- */
.pay-detail-simple {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
}
.pay-detail-simple__card {
padding: 12px 14px;
border: 1px solid var(--mint-border, #e2e8f0);
border-radius: 10px;
background: #fff;
}
.pay-detail-simple__head {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.pay-detail-simple__title {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
font-size: 13px;
font-weight: 600;
color: #0f172a;
}
.pay-detail-simple__amt {
flex-shrink: 0;
font-size: 14px;
font-weight: 700;
color: #047857;
}
.pay-detail-simple__attach {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: flex-start;
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed #e2e8f0;
}
.pay-detail-simple__attach-label {
flex-shrink: 0;
margin-top: 4px;
font-size: 12px;
color: #64748b;
}
.pay-detail-simple__extra {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed #e2e8f0;
}
/* ---- Mini Flow ---- */
.mini-flow {
display: flex;
align-items: center;
gap: 0;
margin: 12px 0;
padding: 10px 14px;
background: #f8fafc;
border-radius: 8px;
position: relative;
}
.mini-flow-step {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
flex: 1;
z-index: 1;
}
.mini-flow-dot {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
z-index: 1;
&.done {
background: #047857;
color: #fff;
box-shadow: 0 0 0 3px rgba(4, 120, 87, 0.15);
}
&.done-red {
background: #dc2626;
color: #fff;
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.15);
}
&.current-amber {
background: #fff;
border: 2px solid #d97706;
color: #d97706;
box-shadow: 0 0 0 3px rgba(217, 119, 6, 0.15);
animation: pulseAmber 2s ease-in-out infinite;
}
&.pending-gray {
background: #f1f5f9;
color: #94a3b8;
border: 2px solid #e2e8f0;
}
}
.mini-flow-label {
font-size: 10px;
margin-top: 6px;
text-align: center;
max-width: 96px;
line-height: 1.3;
&.done {
color: #065f46;
font-weight: 600;
}
&.done-red {
color: #dc2626;
font-weight: 600;
}
&.current-amber {
color: #d97706;
font-weight: 600;
}
&.pending-gray {
color: #94a3b8;
}
}
/* ---- Flow Section: track + detail cards ---- */
.flow-section {
margin-top: 12px;
}
.flow-detail-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
margin-top: 14px;
}
.flow-detail-card {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
&--current-amber {
border-color: #fde68a;
background: #fffbeb;
}
&--done-red {
border-left: 3px solid #dc2626;
background: #fff;
}
&--pending-gray {
border-style: dashed;
background: #f8fafc;
}
}
.flow-detail-card__header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid #f1f5f9;
background: #f8fafc;
.flow-detail-card--done-red & {
background: #fef2f2;
border-bottom-color: #fecaca;
}
.flow-detail-card--current-amber & {
background: #fffbeb;
border-bottom-color: #fde68a;
}
.flow-detail-card--pending-gray & {
background: #f1f5f9;
border-bottom-color: #e2e8f0;
}
}
.flow-detail-card__dot {
width: 20px;
height: 20px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
flex-shrink: 0;
&.done {
background: #047857;
color: #fff;
}
&.done-red {
background: #dc2626;
color: #fff;
}
&.current-amber {
background: #fff;
border: 2px solid #d97706;
color: #d97706;
}
&.pending-gray {
background: #f1f5f9;
border: 2px solid #e2e8f0;
color: #94a3b8;
}
}
.flow-detail-card__title {
font-size: 13px;
font-weight: 600;
color: #1e293b;
&.done {
color: #065f46;
}
&.done-red {
color: #dc2626;
}
&.current-amber {
color: #d97706;
}
&.pending-gray {
color: #94a3b8;
}
}
.flow-detail-card__body {
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.flow-detail-card__kv {
display: flex;
flex-direction: column;
gap: 2px;
}
.flow-detail-card__label {
font-size: 11px;
color: var(--mint-text-muted, #334155);
}
.flow-detail-card__value {
font-size: 13px;
color: #1e293b;
font-weight: 500;
word-break: break-word;
&.red {
color: #dc2626;
font-weight: 700;
}
&.amber {
color: #d97706;
font-weight: 700;
}
&.green {
color: #065f46;
font-weight: 700;
}
}
.flow-detail-card__empty {
font-size: 12px;
color: var(--mint-text-muted, #334155);
font-style: italic;
}
.mini-flow-connector {
position: absolute;
top: 22px;
height: 2px;
z-index: 0;
&.done {
background: #047857;
}
&.done-red {
background: #dc2626;
}
&.current-amber {
background: #d97706;
}
&.pending-gray {
background: #e2e8f0;
}
}
/* ---- Refund Area ---- */
.refund-area {
margin-top: 12px;
padding: 14px 16px;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 10px;
&.red-tint {
background: #fef2f2;
border: 1px solid #fecaca;
}
&.amber-tint {
background: #fffbeb;
border: 1px solid #fde68a;
}
}
.refund-area-title {
font-size: 12px;
font-weight: 600;
margin-bottom: 2px;
display: flex;
align-items: center;
gap: 4px;
&.red {
color: #dc2626;
}
&.amber {
color: #d97706;
}
}
.refund-area-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px 20px;
.data-label {
font-size: 11px;
}
.data-value {
font-size: 13px;
&.red {
color: #dc2626;
font-weight: 700;
}
&.amber {
color: #d97706;
font-weight: 700;
}
}
.full-col {
grid-column: 1 / -1;
}
}
/* ---- Finance Area ---- */
.finance-area {
margin-top: 12px;
padding: 12px 16px;
border-radius: 8px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
display: flex;
flex-direction: column;
gap: 8px;
}
.finance-area-title {
font-size: 12px;
font-weight: 600;
color: #065f46;
margin-bottom: 2px;
}
.finance-area-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 20px;
.data-label {
font-size: 11px;
}
.data-value {
font-size: 13px;
}
}
@keyframes pulseAmber {
0%,
100% {
box-shadow: 0 0 0 3px rgba(217, 119, 6, 0.15);
}
50% {
box-shadow: 0 0 0 6px rgba(217, 119, 6, 0.25);
}
}
/* ---- Payment Overview Timeline ---- */
.payment-timeline {
margin-top: 16px;
padding-top: 14px;
border-top: 1px dashed #e2e8f0;
}
.timeline-title {
font-size: 12px;
color: var(--mint-text-muted, #334155);
font-weight: 600;
margin-bottom: 12px;
}
.timeline {
display: flex;
align-items: center;
gap: 0;
position: relative;
}
.timeline-step {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
flex: 1;
}
.timeline-dot {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
z-index: 1;
transition: transform 0.2s;
&.done {
background: #047857;
color: #fff;
box-shadow: 0 0 0 4px rgba(4, 120, 87, 0.15);
}
&.current {
background: #fff;
border: 2px solid #047857;
color: #047857;
box-shadow: 0 0 0 4px rgba(4, 120, 87, 0.1);
}
&.pending {
background: #f1f5f9;
color: #94a3b8;
border: 2px solid #e2e8f0;
}
&.refund {
background: #dc2626;
color: #fff;
box-shadow: 0 0 0 4px rgba(220, 38, 38, 0.15);
}
&.refund-current {
background: #fff;
border: 2px solid #d97706;
color: #d97706;
box-shadow: 0 0 0 4px rgba(217, 119, 6, 0.15);
animation: pulseAmber 2s ease-in-out infinite;
}
}
.timeline-label {
font-size: 11px;
color: var(--mint-text-muted, #334155);
margin-top: 8px;
text-align: center;
max-width: 80px;
line-height: 1.4;
&.done {
color: #065f46;
font-weight: 600;
}
&.refund {
color: #dc2626;
font-weight: 600;
}
&.refund-current {
color: #d97706;
font-weight: 600;
}
}
.timeline-connector {
position: absolute;
top: 14px;
height: 2px;
background: #e2e8f0;
z-index: 0;
&.done {
background: #047857;
}
&.current {
background: #047857;
}
&.refund {
background: #dc2626;
}
&.refund-current {
background: #d97706;
}
}
/* ---- Preview ---- */
.preview-container {
text-align: center;
}
/* ---- Responsive ---- */
@media (max-width: 1200px) {
.kpi-strip {
grid-template-columns: repeat(2, 1fr);
}
.kpi-strip .kpi-card {
border-right: none !important;
border-bottom: 1px solid rgba(168, 212, 196, 0.75);
&:nth-last-child(-n + 2) {
border-bottom: none;
}
}
.client-sub-cards,
.handle-summary-cards {
grid-template-columns: 1fr;
}
.protocol-attach-remark-row.data-grid--2 {
grid-template-columns: 1fr;
}
.payment-summary {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 900px) {
.kpi-strip {
grid-template-columns: repeat(2, 1fr);
}
.kpi-strip .kpi-card:nth-last-child(-n + 2) {
border-bottom: 1px solid rgba(168, 212, 196, 0.75);
}
.kpi-strip .kpi-card:last-child {
border-bottom: none;
}
.client-sub-cards,
.handle-summary-cards {
grid-template-columns: 1fr;
}
.data-grid--4,
.data-grid--6,
.data-grid--7 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.payment-summary,
.payment-summary--3 {
grid-template-columns: repeat(2, 1fr);
}
.pdc-info-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.refund-area-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.finance-area-grid {
grid-template-columns: 1fr;
}
}
</style>