Files
zkxw/web-admin/src/views/quality-inspection/sample-reception/SampleReceptionCardList.vue
T
zlwuandCursor c40feab76a fix(web-admin): 样本接收展示协议知情同意书等附件
质检样本接收列表与弹窗需查看采集协议侧附件。后端待接收/已接收列表补齐协议附件字段;前端列表增加知情同意书缩略图,接收弹窗右侧改为上运输、下协议附件分组展示。

关联 #43

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 21:53:56 +08:00

540 lines
17 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>
<div class="list-body list-wrap sample-reception-cards-body">
<div v-loading="loading" class="sample-reception-cards-wrap">
<article
v-for="(row, idx) in rows"
:key="String(row.protocolSampleId || row.id || idx)"
class="sample-reception-card"
:class="{ 'is-eta-overdue': isPendingOverdue(row) }"
@dblclick="emit('card-dblclick', row)"
>
<header class="sample-reception-card__head">
<div class="sample-reception-card__head-main">
<div class="sample-reception-card__title-row">
<el-checkbox
v-if="activeTab === 'pending'"
:model-value="isSelected(String(row.protocolSampleId || ''))"
class="sample-reception-card__checkbox"
@change="(val) => emit('toggle-select', row, Boolean(val))"
@click.stop
/>
<span class="sample-reception-card__index">{{ cardIndex(idx) }}</span>
<SampleCodeWithSpecialDisplay
class="sample-reception-card__code"
:sample-code="row.sampleCode"
:is-special="!!row.isSpecial"
:special-remark="row.specialRemark"
monospace
/>
<el-tag
:type="
protocolStatusTagTypeBySource(
String(row.protocolStatus || ''),
String(row.sourceType || 'cell_storage')
)
"
size="small"
>
{{
protocolStatusLabelBySource(
String(row.protocolStatus || ''),
String(row.sourceType || 'cell_storage'),
String(row.voidType || '')
)
}}
</el-tag>
<el-tag
:type="row.sourceType === 'research_donation' ? 'warning' : 'success'"
size="small"
>
{{ row.sourceTypeLabel || '细胞存储' }}
</el-tag>
<el-tag
v-if="activeTab === 'received'"
:type="row.acceptConclusion === 'js' ? 'success' : 'danger'"
size="small"
>
{{ acceptConclusionLabel(String(row.acceptConclusion || '')) }}
</el-tag>
<el-button
v-if="showProtocolPayment(row)"
class="btn-op-item sample-reception-card__fee-trigger"
@click.stop="openProtocolFeeDialog(row)"
>
<el-icon><Wallet /></el-icon>协议费用
</el-button>
</div>
<dl class="sample-reception-card__meta">
<div class="sample-reception-card__meta-item">
<dt>样本类型</dt>
<dd>{{ row.sampleTypeName || '—' }}</dd>
</div>
<div class="sample-reception-card__meta-item sample-reception-card__meta-item--attachment">
<dt>{{ attachmentMeta(row).label }}</dt>
<dd>
<ProtocolAttachmentMiniReadonly
:files="attachmentMeta(row).files"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
</dd>
</div>
<div class="sample-reception-card__meta-item sample-reception-card__meta-item--attachment">
<dt>{{ consentAttachmentMeta(row).label }}</dt>
<dd>
<ProtocolAttachmentMiniReadonly
:files="consentAttachmentMeta(row).files"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
</dd>
</div>
<div v-if="activeTab === 'received'" class="sample-reception-card__meta-item">
<dt>实验室入库编码</dt>
<dd>
{{ row.labStorageCode || '—' }}
</dd>
</div>
<div class="sample-reception-card__meta-item">
<dt>母亲姓名</dt>
<dd>{{ row.motherName || '—' }}</dd>
</div>
<div class="sample-reception-card__meta-item">
<dt>新生儿姓名</dt>
<dd>{{ row.newbornName || '—' }}</dd>
</div>
<div
class="sample-reception-card__meta-item"
:class="{ 'sample-reception-card__meta-item--key': activeTab === 'pending' }"
>
<dt>样本采集时间</dt>
<dd>{{ formatDisplayOrDash(row.sampleCollectTime) }}</dd>
</div>
<div
class="sample-reception-card__meta-item"
:class="{ 'sample-reception-card__meta-item--key': activeTab === 'pending' }"
>
<dt>母血采集时间</dt>
<dd>{{ formatDisplayOrDash(row.maternalBloodCollectTime) }}</dd>
</div>
<div
class="sample-reception-card__meta-item"
:class="{ 'sample-reception-card__meta-item--key': activeTab === 'pending' }"
>
<dt>预计到达时间</dt>
<dd>{{ formatDisplayOrDash(row.etaTime) }}</dd>
</div>
<div class="sample-reception-card__meta-item">
<dt>运输方式</dt>
<dd>{{ transportTypeLabel(String(row.transportType || '')) }}</dd>
</div>
<template v-if="row.transportType === 'kdys'">
<div class="sample-reception-card__meta-item">
<dt>快递公司</dt>
<dd>{{ row.expressCompanyName || '—' }}</dd>
</div>
<div class="sample-reception-card__meta-item">
<dt>快递单号</dt>
<dd>{{ row.waybillNo || '—' }}</dd>
</div>
</template>
<template v-else-if="row.transportType === 'ryxd'">
<div class="sample-reception-card__meta-item">
<dt>携带人</dt>
<dd>{{ row.carrierName || '—' }}</dd>
</div>
<div class="sample-reception-card__meta-item">
<dt>携带人联系电话</dt>
<dd>{{ row.carrierPhone || '—' }}</dd>
</div>
</template>
<template v-else>
<div class="sample-reception-card__meta-item">
<dt>快递单号</dt>
<dd>{{ row.waybillNo || '—' }}</dd>
</div>
</template>
<template v-if="activeTab === 'received'">
<div class="sample-reception-card__meta-item">
<dt>接收时间</dt>
<dd>{{ formatDisplayOrDash(row.receptionTime) }}</dd>
</div>
<div class="sample-reception-card__meta-item">
<dt>接收记录创建时间</dt>
<dd>{{ formatDisplayOrDash(row.createTime) }}</dd>
</div>
<div class="sample-reception-card__meta-item">
<dt>经手人</dt>
<dd>{{ row.handlerName || '—' }}</dd>
</div>
</template>
</dl>
</div>
<div class="sample-reception-card__ops">
<el-button
v-if="activeTab === 'pending'"
class="btn-op-item primary"
type="primary"
@click.stop="emit('receive', row)"
>
<el-icon><CircleCheck /></el-icon>样本接收
</el-button>
<template v-else>
<el-button class="btn-op-item" @click.stop="emit('view', row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button
v-if="canEditReceived(row)"
class="btn-op-item"
@click.stop="emit('edit', row)"
>
<el-icon><EditPen /></el-icon>编辑
</el-button>
</template>
</div>
</header>
</article>
<el-empty
v-if="!loading && !rows.length"
class="sample-reception-cards-empty"
:description="activeTab === 'pending' ? '暂无待接收样本' : '暂无已接收记录'"
:image-size="72"
/>
</div>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="page"
:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(v) => emit('page-size-change', v)"
@current-change="(v) => emit('page-change', v)"
/>
</div>
<el-dialog
v-model="protocolFeeDialogVisible"
title="协议费用"
width="720px"
class="dialog-form-layout sample-reception-fee-dialog"
append-to-body
align-center
destroy-on-close
modal-class="protocol-dialog-mask"
@closed="protocolFeeDialogSummary = null"
>
<QcWorkOrderProtocolPaymentPanel :summary="protocolFeeDialogSummary" embedded />
<template #footer>
<el-button @click="protocolFeeDialogVisible = false">
<el-icon><Close /></el-icon>关闭
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { formatDisplayOrDash } from '@/utils/datetime-display'
import { CircleCheck, Close, EditPen, View, Wallet } from '@element-plus/icons-vue'
import {
transportTypeLabel,
acceptConclusionLabel,
protocolStatusDisplayLabel,
protocolStatusTagType,
donationStatusDisplayLabel,
donationStatusTagType
} from '@/utils/status-tag-registry'
import type { TagType } from '@/utils/status-tag-registry'
import type { QcProtocolPaymentSummary } from '@/api/qc-work-order'
import SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue'
import QcWorkOrderProtocolPaymentPanel from '@/views/quality-inspection/qc-work-order/QcWorkOrderProtocolPaymentPanel.vue'
import ProtocolAttachmentMiniReadonly from '@/components/business/protocol-attachment-mini-readonly/index.vue'
import {
getConsentProtocolAttachment,
getSourceProtocolAttachment
} from '@/utils/protocol-source-attachment'
import { useAttachmentActions } from '@/composables/useAttachmentActions'
defineOptions({ name: 'SampleReceptionCardList' })
const protocolFeeDialogVisible = ref(false)
const protocolFeeDialogSummary = ref<QcProtocolPaymentSummary | null>(null)
type ReceptionTab = 'pending' | 'received'
const props = withDefaults(
defineProps<{
rows: Record<string, unknown>[]
loading: boolean
activeTab: ReceptionTab
page: number
pageSize: number
total: number
selectedIds: string[]
canEditReceived?: (row: Record<string, unknown>) => boolean
}>(),
{
canEditReceived: () => false
}
)
const emit = defineEmits<{
receive: [row: Record<string, unknown>]
view: [row: Record<string, unknown>]
edit: [row: Record<string, unknown>]
'toggle-select': [row: Record<string, unknown>, checked: boolean]
'card-dblclick': [row: Record<string, unknown>]
'page-change': [page: number]
'page-size-change': [size: number]
}>()
const { handleAttachmentPreview, handleAttachmentDownload } = useAttachmentActions()
function attachmentMeta(row: Record<string, unknown>) {
return getSourceProtocolAttachment(row)
}
function consentAttachmentMeta(row: Record<string, unknown>) {
return getConsentProtocolAttachment(row)
}
function protocolStatusLabelBySource(status: string, source: string, voidType?: string): string {
return source === 'research_donation'
? donationStatusDisplayLabel(status, voidType)
: protocolStatusDisplayLabel(status, voidType)
}
function protocolStatusTagTypeBySource(status: string, source: string): TagType {
return source === 'research_donation'
? donationStatusTagType(status)
: protocolStatusTagType(status)
}
function parseEtaLocalDate(eta: unknown): Date | null {
const s = String(eta || '').trim()
if (!s) return null
const normalized = s.replace('T', ' ').slice(0, 19)
const m = normalized.match(/^(\d{4})-(\d{2})-(\d{2})/)
if (!m) return null
const y = Number(m[1])
const mo = Number(m[2]) - 1
const d = Number(m[3])
const dt = new Date(y, mo, d)
if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== d) return null
return dt
}
function startOfLocalDay(d: Date) {
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
}
function isEtaTodayOrEarlier(etaTime: unknown) {
const etaDay = parseEtaLocalDate(etaTime)
if (!etaDay) return false
return etaDay.getTime() <= startOfLocalDay(new Date()).getTime()
}
function isPendingOverdue(row: Record<string, unknown>) {
return props.activeTab === 'pending' && isEtaTodayOrEarlier(row.etaTime)
}
function cardIndex(i: number) {
return (props.page - 1) * props.pageSize + i + 1
}
function isSelected(id: string) {
return props.selectedIds.includes(id)
}
function showProtocolPayment(row: Record<string, unknown>) {
const source = String(row.sourceType || 'cell_storage').trim()
return source === 'cell_storage' || source === ''
}
function protocolPaymentSummary(row: Record<string, unknown>): QcProtocolPaymentSummary | null {
const summary = row.paymentSummary
if (!summary || typeof summary !== 'object') return null
return summary as QcProtocolPaymentSummary
}
function openProtocolFeeDialog(row: Record<string, unknown>) {
protocolFeeDialogSummary.value = protocolPaymentSummary(row)
protocolFeeDialogVisible.value = true
}
function canEditReceived(row: Record<string, unknown>) {
return props.canEditReceived(row)
}
</script>
<style scoped lang="scss">
.sample-reception-cards-body {
border-top: 1px solid var(--mint-border, #e2e8f0);
}
.sample-reception-cards-wrap {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 120px;
padding: 16px;
}
.sample-reception-card {
display: flex;
flex-direction: column;
min-width: 0;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
overflow: hidden;
cursor: default;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
&:hover {
border-color: #a8d4c4;
box-shadow: 0 2px 8px rgba(4, 120, 87, 0.08);
}
&.is-eta-overdue {
border-color: #fca5a5;
background: #fef2f2;
&:hover {
border-color: #f87171;
box-shadow: 0 2px 8px rgba(220, 38, 38, 0.1);
}
.sample-reception-card__head {
background: #fee2e2;
}
}
}
.sample-reception-card__head {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 10px;
padding: 14px 16px;
background: #fafafa;
}
.sample-reception-card__head-main {
min-width: 0;
}
.sample-reception-card__title-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.sample-reception-card__fee-trigger {
flex-shrink: 0;
margin-left: auto;
}
.sample-reception-card__checkbox {
flex-shrink: 0;
margin-right: -4px;
}
.sample-reception-card__index {
flex-shrink: 0;
font-size: 12px;
color: var(--mint-text-muted, #334155);
min-width: 20px;
}
.sample-reception-card__code {
font-size: 15px;
font-weight: 600;
color: #0f172a;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
word-break: break-all;
}
.sample-reception-card__meta {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 8px 12px;
margin: 0;
}
.sample-reception-card__meta-item {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
dt {
margin: 0;
font-size: 11px;
line-height: 1.35;
color: #0f172a;
}
dd {
margin: 0;
font-size: 13px;
line-height: 1.4;
color: #0f172a;
word-break: break-word;
}
&--key {
dt {
font-weight: 600;
}
dd {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
}
&--attachment {
dd {
margin-top: 2px;
}
}
}
.sample-reception-card__ops {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px 12px;
flex-shrink: 0;
padding-top: 10px;
}
.sample-reception-cards-empty {
padding: 24px 0;
}
</style>
<style lang="scss">
/* append-to-body 弹窗:嵌入面板去掉顶部分割,避免与 dialog body 叠线 */
.sample-reception-fee-dialog {
.qc-protocol-payment {
padding: 0;
border-top: none;
background: transparent;
}
.qc-protocol-payment--embedded {
margin: 0;
}
}
</style>