Files
zkxw/web-admin/src/views/research-donation/list/DonationViewDialog.vue
T
2026-05-24 09:34:01 +08:00

1481 lines
47 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 donation-view-dialog"
align-center
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@closed="handleClosed"
>
<div
class="donation-view-body"
:class="{ 'is-detail-loading': loading && !bundle }"
v-loading="loading && !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': loading && !bundle }"
@click="scrollToSection(tab.key)"
>
{{ tab.label }}
</div>
</div>
<template v-if="bundle">
<div>
<!-- KPI 摘要条 -->
<div class="kpi-strip">
<div class="kpi-card">
<div class="kpi-card__icon" aria-hidden="true">
<el-icon><Document /></el-icon>
</div>
<div class="kpi-card__main">
<div class="kpi-label">捐赠编号</div>
<div class="kpi-value kpi-value--emphasis">{{ dash(donation?.donationNo) }}</div>
</div>
</div>
<div class="kpi-card">
<div class="kpi-card__icon" aria-hidden="true">
<el-icon><CircleCheck /></el-icon>
</div>
<div class="kpi-card__main">
<div class="kpi-label">状态</div>
<div class="kpi-value">
<el-tag :type="donationStatusTagType(String(donation?.donationStatus || ''))" size="small">
{{ donationStatusLabel(String(donation?.donationStatus || '')) }}
</el-tag>
</div>
</div>
</div>
<div class="kpi-card">
<div class="kpi-card__icon" aria-hidden="true">
<el-icon><User /></el-icon>
</div>
<div class="kpi-card__main">
<div class="kpi-label">母亲姓名</div>
<div class="kpi-value">{{ dash(party?.motherName) }}</div>
</div>
</div>
<div class="kpi-card">
<div class="kpi-card__icon" aria-hidden="true">
<el-icon><Calendar /></el-icon>
</div>
<div class="kpi-card__main">
<div class="kpi-label">捐赠日期</div>
<div class="kpi-value">{{ formatYmd(donation?.donationDate) }}</div>
</div>
</div>
</div>
<!-- 基本信息 -->
<div id="view-section-basic" class="section-card section-card--white">
<div class="section-title"><h3>基本信息</h3></div>
<div class="data-grid data-grid--4">
<div class="data-item"
><div class="data-label">捐赠编号</div
><div class="data-value">{{ dash(donation?.donationNo) }}</div></div
>
<div class="data-item"
><div class="data-label">状态</div
><div class="data-value"
><el-tag :type="donationStatusTagType(String(donation?.donationStatus || ''))" size="small">{{ donationStatusLabel(String(donation?.donationStatus || '')) }}</el-tag></div
></div
>
<div class="data-item"
><div class="data-label">捐赠日期</div
><div class="data-value">{{ formatYmd(donation?.donationDate) }}</div></div
>
<div class="data-item"
><div class="data-label">来源类型</div
><div class="data-value">{{
sourceTypeLabel(String(donation?.sourceType || ''))
}}</div></div
>
<div class="data-item"
><div class="data-label">采集专员</div
><div class="data-value">{{ collectorDisplay }}</div></div
>
<div class="data-item"
><div class="data-label">创建时间</div
><div class="data-value">{{ dash(donation?.createTime) }}</div></div
>
</div>
<!-- 备注 -->
<div class="remark-row" v-if="donation?.remark">
<div class="data-item data-item--full"
><div class="data-label">备注</div
><div class="data-value data-value--muted">{{ dash(donation?.remark) }}</div></div
>
</div>
<!-- 知情同意书 -->
<div v-if="consentAttachmentFiles.length" class="attachment-row">
<div class="data-label">知情同意书</div>
<ProtocolAttachmentReadonly
:files="consentAttachmentFiles"
@preview="openPreview"
@download="downloadFile"
/>
</div>
<!-- 其他附件 -->
<div v-if="otherAttachmentFiles.length" class="attachment-row">
<div class="data-label">其他附件</div>
<ProtocolAttachmentReadonly
:files="otherAttachmentFiles"
@download="downloadFile"
@preview="openPreview"
/>
</div>
</div>
<!-- 状态变更记录(独立区域) -->
<div id="view-section-status-change" class="section-card section-card--white">
<div class="section-title"><h3>状态变更记录</h3></div>
<el-empty v-if="!hasAnyStatusChange" description="暂无状态变更记录" :image-size="64" />
<div v-else class="data-grid data-grid--4">
<template v-if="donation?.effectiveTime">
<div class="data-item"
><div class="data-label">生效时间</div
><div class="data-value">{{ dash(donation.effectiveTime) }}</div></div
>
<div class="data-item"
><div class="data-label">生效描述</div
><div class="data-value data-value--muted">{{
dash(donation.effectiveDesc)
}}</div></div
>
</template>
<template v-if="donation?.expireTime">
<div class="data-item"
><div class="data-label">过期时间</div
><div class="data-value">{{ dash(donation.expireTime) }}</div></div
>
<div class="data-item"
><div class="data-label">过期描述</div
><div class="data-value data-value--muted">{{
dash(donation.expireDesc)
}}</div></div
>
</template>
<template v-if="donation?.voidTime">
<div class="data-item"
><div class="data-label">作废时间</div
><div class="data-value">{{ dash(donation.voidTime) }}</div></div
>
<div class="data-item"
><div class="data-label">作废描述</div
><div class="data-value data-value--muted">{{
dash(donation.voidDesc)
}}</div></div
>
</template>
<template v-if="donation?.convertTime">
<div class="data-item"
><div class="data-label">转细胞存储时间</div
><div class="data-value">{{ dash(donation.convertTime) }}</div></div
>
<div class="data-item"
><div class="data-label">转细胞存储描述</div
><div class="data-value data-value--muted">{{
dash(donation.convertDesc)
}}</div></div
>
</template>
</div>
</div>
<!-- 客户信息 -->
<div id="view-section-party" class="section-card section-card--green">
<div class="section-title"><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">{{
party?.customerId ? dash(party?.motherName) : '—'
}}</div></div
>
<div class="data-item"
><div class="data-label">母亲姓名</div
><div class="data-value">{{ dash(party?.motherName) }}</div></div
>
<div class="data-item"
><div class="data-label">母亲联系电话</div
><div class="data-value">{{ dash(party?.motherPhone) }}</div></div
>
<div class="data-item"
><div class="data-label">母亲身份证号</div
><div class="data-value">{{ dash(party?.motherIdCard) }}</div></div
>
<div class="data-item"
><div class="data-label">母亲出生年月</div
><div class="data-value">{{ formatYm(party?.motherBirthYm) }}</div></div
>
<div class="data-item"
><div class="data-label">母亲民族</div
><div class="data-value">{{ dash(party?.motherEthnic) }}</div></div
>
</div>
<div class="sub-card-full-row"
><div class="data-label">母亲家庭住址</div
><div class="data-value data-value--muted">{{
dash(party?.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(party?.fatherName) }}</div></div
>
<div class="data-item"
><div class="data-label">父亲电话</div
><div class="data-value">{{ dash(party?.fatherPhone) }}</div></div
>
<div class="data-item data-item--full"
><div class="data-label">父亲身份证号</div
><div class="data-value">{{ dash(party?.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(party?.newbornName) }}</div></div
>
<div class="data-item"
><div class="data-label">新生儿身份证</div
><div class="data-value">{{ dash(party?.newbornIdCard) }}</div></div
>
<div class="data-item"
><div class="data-label">新生儿分娩时间</div
><div class="data-value">{{ dash(party?.newbornDeliveryTime) }}</div></div
>
<div class="data-item"
><div class="data-label">新生儿性别</div
><div class="data-value">{{
newbornSexLabel(String(party?.newbornSex || ''))
}}</div></div
>
<div class="data-item"
><div class="data-label">分娩方式</div
><div class="data-value">{{
deliveryModeLabel(String(party?.deliveryMode || ''))
}}</div></div
>
<div class="data-item"
><div class="data-label">胎次</div
><div class="data-value">{{ dash(party?.birthOrder) }}</div></div
>
<div class="data-item"
><div class="data-label">新生儿体重(克)</div
><div class="data-value">{{ dash(party?.newbornWeightGram) }}</div></div
>
<div class="data-item"
><div class="data-label">新生儿评分1分钟</div
><div class="data-value">{{ dash(party?.apgar1min) }}</div></div
>
<div class="data-item data-item--full"
><div class="data-label">新生儿评分5分钟</div
><div class="data-value">{{ dash(party?.apgar5min) }}</div></div
>
</div>
</div>
</div>
</div>
<!-- 采集信息 -->
<div id="view-section-collection" class="section-card section-card--white">
<div class="section-title"><h3>采集信息</h3></div>
<div class="data-grid data-grid--4">
<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(collection?.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">{{ dash(collection?.sampleCollectTime) }}</div></div
>
<div class="data-item"
><div class="data-label">母血采集时间</div
><div class="data-value">{{ dash(collection?.maternalBloodCollectTime) }}</div></div
>
</div>
</div>
<!-- 样本及运输 -->
<div id="view-section-sample" class="section-card section-card--white">
<div class="section-title"><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">
<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">{{ dash(row.sampleCode) }}</div>
</div>
<div class="data-item">
<div class="data-label">采集量</div>
<div class="data-value"
>{{ dash(row.collectAmount) }} {{ unitName(row.unitId) }}</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.transport?.transportType"
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">{{
transportTypeLabel(String(row.transport?.transportType || ''))
}}</div>
</div>
<div class="data-item">
<div class="data-label">目的站点</div>
<div class="data-value">{{
siteName(String(row.transport?.destSiteId || ''))
}}</div>
</div>
<div class="data-item">
<div class="data-label">预计到达</div>
<div class="data-value">{{ dash(row.transport?.etaTime) }}</div>
</div>
<div class="data-item data-item--full">
<div class="data-label">快递/携带</div>
<div class="data-value">{{ sampleTransportExtra(row.transport) }}</div>
</div>
</div>
</div>
<div class="sample-card__section sample-card__section--reception">
<div class="sample-card__section-label">样本接收信息</div>
<template v-if="row.sampleReception">
<div class="data-grid data-grid--4">
<div class="data-item">
<div class="data-label">接收时间</div>
<div class="data-value">{{ dash(row.sampleReception.receptionTime) }}</div>
</div>
<div class="data-item">
<div class="data-label">经手人</div>
<div class="data-value">{{ dash(row.sampleReception.handlerName) }}</div>
</div>
<div class="data-item">
<div class="data-label">接收温度(℃)</div>
<div class="data-value">
{{
row.sampleReception.receiveTemperature
? row.sampleReception.receiveTemperature + '℃'
: '—'
}}
</div>
</div>
<div class="data-item">
<div class="data-label">净重(g)</div>
<div class="data-value">{{ formatMetric(row.sampleReception.netWeightG) }}</div>
</div>
<div class="data-item">
<div class="data-label">结论</div>
<div class="data-value">
<el-tag
:type="acceptConclusionTagType(String(row.sampleReception.acceptConclusion || ''))"
size="small"
>
{{ acceptConclusionLabel(String(row.sampleReception.acceptConclusion || '')) }}
</el-tag>
</div>
</div>
<div class="data-item data-item--full">
<div class="data-label">检查项</div>
<div
v-if="sampleReceptionCheckList(row.sampleReception).length"
class="check-list check-list-view"
>
<el-checkbox
v-for="chk in sampleReceptionCheckList(row.sampleReception)"
:key="chk.itemCode + String(chk.checkItemId)"
:model-value="chk.checked"
disabled
>
{{ chk.itemName }}
</el-checkbox>
</div>
<div v-else class="data-value">—</div>
</div>
<div v-if="row.sampleReception.remark" class="data-item data-item--full">
<div class="data-label">备注</div>
<div class="data-value data-value--muted">{{ dash(row.sampleReception.remark) }}</div>
</div>
</div>
<div
v-if="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>
</template>
<div v-else class="sample-reception-empty">暂未接收</div>
</div>
<SampleQcInfoCards :qc-info="row.qcInfo" :sample-reception="row.sampleReception" />
</div>
</div>
</div>
<el-empty v-else description="暂无样本" :image-size="48" />
</div>
<!-- 审核记录 -->
<div
v-if="showAuditHistory"
id="view-section-audit"
class="section-card section-card--white"
>
<div class="section-title"><h3>审核记录</h3></div>
<el-table
v-loading="auditLoading"
class="list-table sub-view-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"
/>
<el-table-column
prop="auditStatus"
label="审核状态"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
:type="String(row.auditStatus) === 'shtg' ? 'success' : 'warning'"
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>
</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 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 { computed, nextTick, ref, watch } from 'vue'
import { Calendar, CircleCheck, Close, Document, User } from '@element-plus/icons-vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import SampleQcInfoCards from '@/components/business/sample-qc-info-cards/index.vue'
import {
fetchResearchDonationAuditList,
fetchResearchDonationDetail,
type ResearchDonationRow
} from '@/api/research-donation'
import {
fetchCellSampleTypeList,
fetchItemUnitList,
fetchExpressCompanyList,
fetchSiteList,
fetchCollectionRegionList,
fetchHospitalList,
fetchUserList
} from '@/api/admin'
import { downloadAttachmentFile } from '@/utils/download-attachment'
import {
donationStatusLabel,
donationStatusTagType,
sourceTypeLabel,
auditStatusLabel,
auditStatusTagType,
newbornSexLabel,
deliveryModeLabel,
sampleStatusLabel,
sampleStatusTagType,
transportTypeLabel,
acceptConclusionLabel,
acceptConclusionTagType
} from '@/utils/status-tag-registry'
defineOptions({ name: 'DonationViewDialog' })
const props = withDefaults(
defineProps<{
modelValue: boolean
donationId: string | null
listRow?: ResearchDonationRow | null
/** 展示审核记录 Tab(科研捐赠审核页) */
showAuditHistory?: boolean
}>(),
{ showAuditHistory: false }
)
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
}>()
const baseFormTabs = [
{ key: 'basic', label: '基本信息' },
{ key: 'status-change', label: '状态变更记录' },
{ key: 'party', label: '客户信息' },
{ key: 'collection', label: '采集信息' },
{ key: 'sample', label: '样本及运输信息' }
] as const
const formTabs = computed(() => {
const tabs: Array<{ key: string; label: string }> = [...baseFormTabs]
if (props.showAuditHistory) {
tabs.push({ key: 'audit', label: '审核记录' })
}
return tabs
})
const loading = ref(false)
const auditLoading = ref(false)
const bundle = ref<Record<string, any> | null>(null)
const auditRecords = ref<Array<Record<string, unknown>>>([])
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 donation = computed(() => bundle.value?.donation as Record<string, any> | undefined)
const party = computed(() => bundle.value?.party as Record<string, any> | null)
const collection = computed(() => bundle.value?.collection as Record<string, any> | null)
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 consentAttachmentFiles = computed(() => {
const files = donation.value?.consentAttachmentFiles
if (Array.isArray(files) && files.length) return files
const str = String(donation.value?.consentFormUrls || '').trim()
if (!str) return []
return str
.split(',')
.filter(Boolean)
.map((url: string) => ({ fileUrl: url, fileName: extractFileName(url) }))
})
const otherAttachmentFiles = computed(() => {
const files = donation.value?.attachmentFiles
if (Array.isArray(files) && files.length) return files
const str = String(donation.value?.attachmentUrls || '').trim()
if (!str) return []
return str
.split(',')
.filter(Boolean)
.map((url: string) => ({ fileUrl: url, fileName: extractFileName(url) }))
})
const dialogTitle = computed(() => {
const no = donation.value?.donationNo || props.listRow?.donationNo
return no ? `查看捐赠协议单 — ${no}` : '查看捐赠协议单'
})
const hasAnyStatusChange = computed(() => {
if (!donation.value) return false
return !!(
donation.value.effectiveTime ||
donation.value.expireTime ||
donation.value.voidTime ||
donation.value.convertTime
)
})
const collectorDisplay = computed(() => {
const fromList = props.listRow?.collectorUserName
if (fromList) return String(fromList)
const fromBundle = donation.value?.collectorUserName
if (fromBundle) return String(fromBundle)
const id = donation.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 = collection.value?.salesUserName
if (fromBundle) return String(fromBundle)
const id = collection.value?.salesUserId
const u = salesUserOptions.value.find((x) => x.id === id)
return u?.nickName || dash(id)
})
const regionDisplay = computed(() => {
const fromList = props.listRow?.regionName
if (fromList) return String(fromList)
const fromBundle = collection.value?.regionName
if (fromBundle) return String(fromBundle)
const id = collection.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 = collection.value?.hospitalName
if (fromBundle) return String(fromBundle)
const id = collection.value?.hospitalId
const h = hospitalOptions.value.find((x) => x.id === id)
return h?.hospitalName || dash(id)
})
function dash(val: unknown) {
if (val === null || val === undefined || val === '') return '—'
return String(val)
}
function formatMetric(v: unknown) {
if (v === null || v === undefined || v === '') return '—'
const n = Number(v)
return Number.isFinite(n) ? String(n) : '—'
}
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 formatYm(val: unknown) {
const s = String(val || '')
if (s.length === 6) return `${s.slice(0, 4)}-${s.slice(4, 6)}`
return dash(val)
}
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 unitName(id: string) {
return unitOptions.value.find((x) => x.id === id)?.unitName || dash(id)
}
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 sampleReceptionCheckList(reception: Record<string, any>) {
const list = reception?.checkResults
return Array.isArray(list) ? list : []
}
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 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 extractFileName(url: string): string {
if (!url) return ''
const parts = url.split('/')
return parts[parts.length - 1] || url
}
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: fullFileUrl })
}
async function loadLookups(data: Record<string, any>) {
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 || []
const coll = data.collection as Record<string, any> | null
const don = data.donation as Record<string, any> | undefined
const tasks: Promise<void>[] = []
if (coll?.regionId) {
tasks.push(
fetchCollectionRegionList({ page: 1, pageSize: 100 }).then((res) => {
regionOptions.value = res.list || []
})
)
tasks.push(
fetchHospitalList(String(coll.regionId), { page: 1, pageSize: 100 }).then((res) => {
hospitalOptions.value = res.list || []
})
)
}
if (don?.collectorUserId) {
tasks.push(
fetchUserList({ page: 1, pageSize: 50, status: '1' }).then((res) => {
collectorUserOptions.value = res.list || []
})
)
}
if (coll?.salesUserId) {
tasks.push(
fetchUserList({ page: 1, pageSize: 50, status: '1' }).then((res) => {
salesUserOptions.value = res.list || []
})
)
}
await Promise.all(tasks)
}
async function loadAuditRecords() {
if (!props.showAuditHistory || !props.donationId) {
auditRecords.value = []
return
}
auditLoading.value = true
try {
const no =
String(donation.value?.donationNo || props.listRow?.donationNo || '').trim() || undefined
const res = await fetchResearchDonationAuditList({
page: 1,
pageSize: 100,
keyword: no
})
auditRecords.value = res.list || []
} catch {
auditRecords.value = []
} finally {
auditLoading.value = false
}
}
async function loadDetail() {
if (!props.donationId) return
loading.value = true
bundle.value = null
auditRecords.value = []
try {
const data = await fetchResearchDonationDetail(props.donationId)
bundle.value = data as Record<string, any>
await loadLookups(data as Record<string, any>)
await loadAuditRecords()
} finally {
loading.value = false
}
}
function scrollToSection(key: string) {
if (loading.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 = []
activeTab.value = 'basic'
loading.value = false
auditLoading.value = false
}
watch(
() => [props.modelValue, props.donationId] as const,
([visible, id]) => {
if (visible && id) loadDetail()
}
)
</script>
<style scoped lang="scss">
.donation-view-body {
overflow: visible;
&.is-detail-loading {
min-height: min(680px, calc(100vh - 220px));
}
}
/* ---- Sticky Tab 导航 ---- */
.detail-tabs {
position: sticky;
top: 0;
z-index: 1000;
display: flex;
gap: 4px;
background: #f0fdf4;
padding: 4px;
border-radius: 8px;
margin-bottom: 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;
}
}
/* ---- KPI 摘要条(与采集协议单查看弹窗一致:一行四列 + 薄荷绿通栏) ---- */
.kpi-strip {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0;
margin-bottom: 24px;
padding: 6px;
background: linear-gradient(135deg, #ecfdf5 0%, #f8fafc 52%, #ecfdf5 100%);
border: 1px solid #a7f3d0;
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(167, 243, 208, 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 #a7f3d0;
color: #059669;
font-size: 18px;
}
.kpi-card__main {
min-width: 0;
flex: 1;
}
.kpi-label {
margin-bottom: 4px;
font-size: 11px;
font-weight: 500;
color: #64748b;
}
.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: #10b981;
border-radius: 2px;
margin-right: 10px;
flex-shrink: 0;
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: #1e293b;
line-height: 1.4;
}
}
/* ---- 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));
}
}
.data-item {
min-width: 0;
&--full {
grid-column: 1 / -1;
}
}
.data-label {
font-size: 12px;
color: #94a3b8;
margin-bottom: 3px;
}
.data-value {
font-size: 14px;
color: #1e293b;
font-weight: 500;
word-break: break-word;
&--muted {
color: #64748b;
font-weight: 400;
}
}
/* ---- 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: #94a3b8;
}
}
.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: #64748b;
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;
}
/* ---- Sub View Table ---- */
.sub-view-table {
margin-bottom: 8px;
}
/* ---- 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);
}
}
.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: #10b981;
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: #94a3b8;
}
.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 {
font-size: 11px;
font-weight: 600;
color: #94a3b8;
margin-bottom: 10px;
letter-spacing: 0.3px;
}
.data-value--code {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', monospace;
font-size: 13px;
color: #475569;
}
/* ---- 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(167, 243, 208, 0.75);
&:nth-last-child(-n + 2) {
border-bottom: none;
}
}
.client-sub-cards {
grid-template-columns: 1fr;
}
.data-grid--3,
.data-grid--4 {
grid-template-columns: repeat(2, minmax(0, 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(167, 243, 208, 0.75);
}
.kpi-strip .kpi-card:last-child {
border-bottom: none;
}
.client-sub-cards {
grid-template-columns: 1fr;
}
.data-grid--3,
.data-grid--4 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>