477 lines
15 KiB
Vue
477 lines
15 KiB
Vue
<!-- 样本接收 · 卡片列表(待接收 / 已接收) -->
|
||
<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>
|