质检样本接收列表与弹窗需查看采集协议侧附件。后端待接收/已接收列表补齐协议附件字段;前端列表增加知情同意书缩略图,接收弹窗右侧改为上运输、下协议附件分组展示。 关联 #43 Co-authored-by: Cursor <cursoragent@cursor.com>
540 lines
17 KiB
Vue
540 lines
17 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>
|
||
<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>
|