This commit is contained in:
zlwu committed 2026-06-15 11:43:16 +08:00
1 parent 427bcb1551
commit 31b04575e2
247 files changed
+39284 -20367

No files matched your search

@@ -0,0 +1,444 @@
<!-- 样本接收 · 卡片列表(待接收 / 已接收) -->
<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>
<span class="sample-reception-card__code">{{ row.sampleCode || '—' }}</span>
<el-tag
:type="
protocolStatusTagTypeBySource(
String(row.protocolStatus || ''),
String(row.sourceType || 'cell_storage')
)
"
size="small"
>
{{
protocolStatusLabelBySource(
String(row.protocolStatus || ''),
String(row.sourceType || 'cell_storage')
)
}}
</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.acceptConclusion === 'js' ? 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>{{ row.sampleCollectTime || '—' }}</dd>
</div>
<div
class="sample-reception-card__meta-item"
:class="{ 'sample-reception-card__meta-item--key': activeTab === 'pending' }"
>
<dt>预计到达时间</dt>
<dd>{{ 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>{{ 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 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 { CircleCheck, View, EditPen } from '@element-plus/icons-vue'
import {
transportTypeLabel,
acceptConclusionLabel,
protocolStatusLabel,
protocolStatusTagType,
donationStatusLabel,
donationStatusTagType
} from '@/utils/status-tag-registry'
import type { TagType } from '@/utils/status-tag-registry'
import type { QcProtocolPaymentSummary } from '@/api/qc-work-order'
import QcWorkOrderProtocolPaymentPanel from '@/views/quality-inspection/qc-work-order/QcWorkOrderProtocolPaymentPanel.vue'
defineOptions({ name: 'SampleReceptionCardList' })
type ReceptionTab = 'pending' | 'received'
const props = defineProps<{
rows: Record<string, unknown>[]
loading: boolean
activeTab: ReceptionTab
page: number
pageSize: number
total: number
selectedIds: string[]
}>()
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): string {
return source === 'research_donation' ? donationStatusLabel(status) : protocolStatusLabel(status)
}
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
}
</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: #94a3b8;
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>