Files
zkxw/web-admin/src/views/quality-inspection/sample-reception/SampleReceptionCardList.vue
T
2026-07-07 11:10:48 +08:00

477 lines
15 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>
</div>
<dl class="sample-reception-card__meta">
<div class="sample-reception-card__meta-item">
<dt>样本类型</dt>
<dd>{{ row.sampleTypeName || '—' }}</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>{{ row.handlerName || '—' }}</dd>
</div>
</template>
</dl>
</div>
<div class="sample-reception-card__ops">
<QcWorkOrderProtocolPaymentPanel
v-if="showProtocolPayment(row)"
:summary="protocolPaymentSummary(row)"
compact
class="sample-reception-card__ops-payment"
/>
<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>
</div>
</template>
<script setup lang="ts">
import { formatDisplayOrDash } from '@/utils/datetime-display'
import { CircleCheck, View, EditPen } from '@element-plus/icons-vue'
import {
transportTypeLabel,
acceptConclusionLabel,
protocolStatusLabel,
protocolStatusDisplayLabel,
protocolStatusTagType,
donationStatusLabel,
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'
defineOptions({ name: 'SampleReceptionCardList' })
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]
}>()
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 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__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;
}
}
}
.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-card__ops-payment {
flex: 1 1 240px;
min-width: 0;
margin-right: auto;
:deep(.qc-protocol-payment) {
border: none;
background: #e8f4ee;
border-radius: 8px;
}
:deep(.qc-protocol-payment--compact) {
width: 100%;
padding: 10px 12px;
box-sizing: border-box;
}
:deep(.qc-protocol-payment__compact-details) {
border-top: none;
margin-top: 8px;
padding-top: 8px;
}
:deep(.qc-protocol-payment__detail-chip) {
border: none;
background: rgb(255 255 255 / 60%);
}
}
.sample-reception-cards-empty {
padding: 24px 0;
}
</style>