提交“

This commit is contained in:
zlwu committed 2026-05-30 14:03:11 +08:00
1 parent 69ee0f505c
commit 9b88581450
614 files changed
+90318 -689022

No files matched your search

@@ -1,208 +0,0 @@
<!-- 临床应用缴费/退费提交信息只读展示 -->
<template>
<div class="clinical-payment-submit-readonly">
<h4 v-if="title" class="detail-block-title">{{ title }}</h4>
<dl class="view-kv-grid view-kv-grid--4">
<div class="view-kv">
<dt>申请单号</dt>
<dd>{{ dash(detail?.applicationNo) }}</dd>
</div>
<div class="view-kv">
<dt>患者姓名</dt>
<dd>{{ dash(detail?.patientName) }}</dd>
</div>
<div class="view-kv">
<dt>渠道名称</dt>
<dd>{{ dash(detail?.channelName) }}</dd>
</div>
<div class="view-kv">
<dt>细胞类型/规格</dt>
<dd>{{ dash(detail?.cellTypeSpec) }}</dd>
</div>
<div class="view-kv">
<dt>输注时间</dt>
<dd>{{ dash(detail?.infusionTime) }}</dd>
</div>
<div v-if="showPayStatus" class="view-kv">
<dt>缴费状态</dt>
<dd>
<el-tag :type="payStatusTagType(String(detail?.payStatus || ''))" size="small">
{{ payStatusLabel(String(detail?.payStatus || '')) }}
</el-tag>
</dd>
</div>
<template v-if="mode === 'refund'">
<div class="view-kv">
<dt>缴费金额(元)</dt>
<dd>{{ formatAmount(detail?.paymentAmount ?? detail?.amount) }}</dd>
</div>
</template>
<template v-if="mode === 'receipt' || mode === 'full'">
<div class="view-kv">
<dt>缴费金额(元)</dt>
<dd>{{ formatAmount(detail?.paymentAmount ?? detail?.amount) }}</dd>
</div>
<div v-if="detail?.remark" class="view-kv view-kv-span-2">
<dt>备注</dt>
<dd>{{ dash(detail?.remark) }}</dd>
</div>
<div class="view-field-block view-kv-span-2 clinical-payment-submit-readonly__attachments">
<div class="view-field-block__label">转账凭证</div>
<ProtocolAttachmentReadonly
:files="receiptAttachmentFiles"
@preview="(f) => emit('preview', f)"
@download="(f) => emit('download', f)"
/>
</div>
</template>
<template v-if="mode === 'refund' || (mode === 'full' && hasRefundInfo)">
<div class="view-kv">
<dt>退费金额(元)</dt>
<dd>{{ formatAmount(detail?.refundAmount) }}</dd>
</div>
<div v-if="detail?.refundRemark" class="view-kv view-kv-span-2">
<dt>退费备注</dt>
<dd>{{ dash(detail?.refundRemark) }}</dd>
</div>
<div class="view-field-block view-kv-span-2 clinical-payment-submit-readonly__attachments">
<div class="view-field-block__label">退费附件</div>
<ProtocolAttachmentReadonly
:files="refundAttachmentFiles"
@preview="(f) => emit('preview', f)"
@download="(f) => emit('download', f)"
/>
</div>
</template>
</dl>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import { formatMoneyDisplay } from '@/utils/money-format'
import { payStatusLabel, payStatusTagType } from '@/utils/status-tag-registry'
const props = withDefaults(
defineProps<{
detail?: Record<string, unknown> | null
title?: string
mode?: 'receipt' | 'refund' | 'full'
showPayStatus?: boolean
}>(),
{ mode: 'receipt', showPayStatus: false }
)
const emit = defineEmits<{
(e: 'preview', file: Record<string, unknown>): void
(e: 'download', file: Record<string, unknown>): void
}>()
function dash(v: unknown) {
if (v == null || v === '') return '—'
return String(v)
}
function formatAmount(v: unknown) {
if (v == null || v === '') return '—'
return '¥' + formatMoneyDisplay(v)
}
function urlsToFiles(urlField: unknown) {
const urls = String(urlField || '')
if (!urls.trim()) return []
return urls
.split(',')
.map((u, i) => {
const url = u.trim()
if (!url) return null
const name = url.includes('/') ? url.split('/').pop() || `附件${i + 1}` : url
return { id: `url_${i}`, fileUrl: url, fileName: name }
})
.filter(Boolean) as Array<Record<string, unknown>>
}
const receiptAttachmentFiles = computed(() => urlsToFiles(props.detail?.transferAttachments))
const refundAttachmentFiles = computed(() => urlsToFiles(props.detail?.refundAttachments))
const hasRefundInfo = computed(() => {
const d = props.detail
if (!d) return false
return (
d.refundAmount != null ||
Boolean(d.refundRemark) ||
Boolean(String(d.refundAttachments || '').trim())
)
})
</script>
<style scoped lang="scss">
.clinical-payment-submit-readonly {
width: 100%;
}
.detail-block-title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
color: var(--mint-text, #334155);
line-height: 1.4;
}
.view-kv-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 20px;
margin: 0;
}
.view-kv-grid--4 {
@media (min-width: 720px) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.view-kv {
min-width: 0;
dt {
margin: 0 0 4px;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
font-weight: 500;
}
dd {
margin: 0;
font-size: 14px;
color: var(--mint-text, #334155);
line-height: 1.5;
word-break: break-word;
}
}
.view-kv-span-2 {
grid-column: 1 / -1;
}
.view-field-block__label {
font-size: 12px;
color: var(--mint-text-muted, #64748b);
font-weight: 500;
margin-bottom: 8px;
}
.clinical-payment-submit-readonly__attachments {
margin-top: 4px;
}
.form-section-divider--inner {
height: 1px;
margin: 16px 0;
background: var(--mint-border, #e2e8f0);
}
</style>
@@ -1,729 +0,0 @@
<!-- 临床应用 · 输注计划缴费明细(查看态,对齐 ProtocolViewDialog 缴费明细) -->
<template>
<div
class="payment-detail-card"
:class="{
'payment-detail-card--refunded': payStatus === 'ytf',
'payment-detail-card--refund-pending': payStatus === 'tfqrz'
}"
>
<div class="pdc-header">
<div class="pdc-header-left" :class="payDetailColorClass(payStatus)">
缴费明细 ·
<span class="pdc-header-amount" :class="payDetailColorClass(payStatus)">
{{ fmtMoney(planAmount) }}
</span>
</div>
<el-tag :type="payStatusTagType(payStatus)" size="small">
{{ payStatusLabel(payStatus) }}
</el-tag>
</div>
<div class="pdc-info-row">
<div class="pdc-info-item">
<span class="info-label">应缴费金额(元)</span>
<span class="info-value">{{ fmtMoney(planAmount) }}</span>
</div>
<div class="pdc-info-item">
<span class="info-label">实缴金额(元)</span>
<span class="info-value">{{ fmtMoney(payment?.transferAmount ?? planAmount) }}</span>
</div>
<div v-if="payment?.remark" class="pdc-info-item">
<span class="info-label">备注</span>
<span class="info-value">{{ dash(payment?.remark) }}</span>
</div>
<div v-if="payStatus !== 'dfj' && payment?.updateTime" class="pdc-info-item">
<span class="info-label">提交时间</span>
<span class="info-value">{{ dash(payment?.updateTime) }}</span>
</div>
</div>
<div v-if="transferAttachmentFiles.length" class="pdc-attachments-row">
<div class="data-label">转账凭证</div>
<ProtocolAttachmentReadonly
:files="transferAttachmentFiles"
@preview="(f) => emit('preview', f)"
@download="(f) => emit('download', f)"
/>
</div>
<template v-if="flowSteps.length">
<div class="flow-section">
<div class="mini-flow">
<template v-for="(step, sIdx) in flowSteps" :key="`c-${step.key}`">
<div
v-if="step.connectorToNext"
class="mini-flow-connector"
:class="step.connectorToNext"
:style="miniFlowConnectorStyle(flowSteps.length, sIdx)"
/>
</template>
<div v-for="step in flowSteps" :key="step.key" class="mini-flow-step">
<div class="mini-flow-dot" :class="step.dotClass">{{ step.dotIcon }}</div>
<div class="mini-flow-label" :class="step.labelClass">{{ step.label }}</div>
</div>
</div>
<div class="flow-detail-cards">
<template v-for="step in flowSteps" :key="`d-${step.key}`">
<div
v-if="step.hasDetailCard"
class="flow-detail-card"
:class="'flow-detail-card--' + step.dotClass"
>
<div class="flow-detail-card__header">
<span class="flow-detail-card__dot" :class="step.dotClass">{{
step.dotIcon
}}</span>
<span class="flow-detail-card__title" :class="step.labelClass">{{
step.label
}}</span>
</div>
<div class="flow-detail-card__body">
<template v-if="step.key === 'pay'">
<div class="flow-detail-card__kv">
<span class="flow-detail-card__label">确认状态</span>
<span class="flow-detail-card__value">
<el-tag type="success" size="small">已提交</el-tag>
</span>
</div>
<div v-if="payment?.remark" class="flow-detail-card__kv">
<span class="flow-detail-card__label">备注</span>
<span class="flow-detail-card__value">{{ payment.remark }}</span>
</div>
<div v-if="transferAttachmentFiles.length" class="flow-detail-card__kv">
<span class="flow-detail-card__label">转账凭证</span>
<ProtocolAttachmentReadonly
:files="transferAttachmentFiles"
@preview="(f) => emit('preview', f)"
@download="(f) => emit('download', f)"
/>
</div>
</template>
<template v-else-if="step.key === 'receipt-finance'">
<div class="flow-detail-card__kv">
<span class="flow-detail-card__label">确认状态</span>
<span class="flow-detail-card__value">
<el-tag v-if="isReceiptFinanceConfirmed" type="success" size="small"
>已确认</el-tag
>
<el-tag v-else type="warning" size="small">待确认</el-tag>
</span>
</div>
<div
v-if="isReceiptFinanceConfirmed && receiptFinanceTime"
class="flow-detail-card__kv"
>
<span class="flow-detail-card__label">确认时间</span>
<span class="flow-detail-card__value">{{ receiptFinanceTime }}</span>
</div>
<div
v-if="isReceiptFinanceConfirmed && payment?.financeRemark"
class="flow-detail-card__kv"
>
<span class="flow-detail-card__label">财务备注</span>
<span class="flow-detail-card__value">{{ payment.financeRemark }}</span>
</div>
<div
v-if="isReceiptFinanceConfirmed && financeAttachmentFiles.length"
class="flow-detail-card__kv"
>
<span class="flow-detail-card__label">财务附件</span>
<ProtocolAttachmentReadonly
:files="financeAttachmentFiles"
@preview="(f) => emit('preview', f)"
@download="(f) => emit('download', f)"
/>
</div>
</template>
<template v-else-if="step.key === 'refund-apply'">
<div class="flow-detail-card__kv">
<span class="flow-detail-card__label">退费金额(元)</span>
<span class="flow-detail-card__value" :class="payDetailColorClass(payStatus)">{{
fmtMoney(payment?.refundAmount)
}}</span>
</div>
<div v-if="payment?.refundRemark" class="flow-detail-card__kv">
<span class="flow-detail-card__label">退费备注</span>
<span class="flow-detail-card__value">{{ payment.refundRemark }}</span>
</div>
<div v-if="refundAttachmentFiles.length" class="flow-detail-card__kv">
<span class="flow-detail-card__label">退费附件</span>
<ProtocolAttachmentReadonly
:files="refundAttachmentFiles"
@preview="(f) => emit('preview', f)"
@download="(f) => emit('download', f)"
/>
</div>
</template>
<template v-else-if="step.key === 'refund-finance'">
<div class="flow-detail-card__kv">
<span class="flow-detail-card__label">确认状态</span>
<span class="flow-detail-card__value">
<el-tag type="danger" size="small">已退费</el-tag>
</span>
</div>
<div v-if="refundFinanceTime" class="flow-detail-card__kv">
<span class="flow-detail-card__label">确认时间</span>
<span class="flow-detail-card__value">{{ refundFinanceTime }}</span>
</div>
<div v-if="payment?.refundFinanceRemark" class="flow-detail-card__kv">
<span class="flow-detail-card__label">财务备注</span>
<span class="flow-detail-card__value">{{ payment.refundFinanceRemark }}</span>
</div>
<div v-if="refundFinanceAttachmentFiles.length" class="flow-detail-card__kv">
<span class="flow-detail-card__label">财务附件</span>
<ProtocolAttachmentReadonly
:files="refundFinanceAttachmentFiles"
@preview="(f) => emit('preview', f)"
@download="(f) => emit('download', f)"
/>
</div>
</template>
</div>
</div>
</template>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import { formatMoneyDisplay } from '@/utils/money-format'
import { payStatusLabel, payStatusTagType } from '@/utils/status-tag-registry'
const props = defineProps<{
payment?: Record<string, any> | null
}>()
const emit = defineEmits<{
(e: 'preview', file: Record<string, unknown>): void
(e: 'download', file: Record<string, unknown>): void
}>()
interface MiniFlowStep {
key: string
label: string
dotClass: string
dotIcon: string
labelClass: string
connectorToNext?: string
hasDetailCard?: boolean
}
const payStatus = computed(() => String(props.payment?.payStatus || 'dfj'))
const planAmount = computed(
() => props.payment?.paymentAmount ?? props.payment?.amount ?? null
)
const isReceiptFinanceConfirmed = computed(() => {
const s = payStatus.value
return s !== 'jfqrz' && s !== 'dfj'
})
const receiptFinanceTime = computed(
() => props.payment?.financeConfirmTime || props.payment?.updateTime || ''
)
const refundFinanceTime = computed(
() => props.payment?.refundFinanceConfirmTime || props.payment?.updateTime || ''
)
const transferAttachmentFiles = computed(() =>
urlsToAttachmentFiles(String(props.payment?.transferAttachments || ''))
)
const financeAttachmentFiles = computed(() =>
urlsToAttachmentFiles(String(props.payment?.financeConfirmAttachments || ''))
)
const refundAttachmentFiles = computed(() =>
urlsToAttachmentFiles(String(props.payment?.refundAttachments || ''))
)
const refundFinanceAttachmentFiles = computed(() =>
urlsToAttachmentFiles(String(props.payment?.refundFinanceConfirmAttachments || ''))
)
const flowSteps = computed(() => buildMiniFlowSteps(props.payment || {}))
function dash(v: unknown) {
if (v == null || v === '') return '—'
return String(v)
}
function fmtMoney(v: unknown) {
if (v == null || v === '') return '—'
return '¥' + formatMoneyDisplay(v)
}
function urlsToAttachmentFiles(urls: string) {
if (!urls.trim()) return []
return urls
.split(',')
.map((u, i) => {
const url = u.trim()
if (!url) return null
const name = url.includes('/') ? url.split('/').pop() || `附件${i + 1}` : url
return { id: `url_${i}`, fileUrl: url, fileName: name, fileSize: 0 }
})
.filter(Boolean) as Array<Record<string, unknown>>
}
function payDetailColorClass(status: string) {
if (status === 'ytf') return 'red'
if (status === 'tfqrz') return 'amber'
return 'green'
}
function buildMiniFlowSteps(detail: Record<string, any>): MiniFlowStep[] {
const s = String(detail.payStatus || '')
if (s === 'dfj') return []
const steps: MiniFlowStep[] = []
steps.push({
key: 'pay',
label: '缴费确认',
dotClass: 'done',
dotIcon: '✓',
labelClass: 'done',
connectorToNext: 'done',
hasDetailCard: true
})
if (s === 'jfqrz') {
steps.push({
key: 'receipt-finance',
label: '财务确认',
dotClass: 'current-amber',
dotIcon: '●',
labelClass: 'current-amber',
hasDetailCard: true
})
} else {
steps.push({
key: 'receipt-finance',
label: '财务确认',
dotClass: 'done',
dotIcon: '✓',
labelClass: 'done',
connectorToNext: s === 'yjf' ? undefined : 'done',
hasDetailCard: true
})
}
if (s === 'tfqrz' || s === 'ytf') {
steps.push({
key: 'refund-apply',
label: '退费申请',
dotClass: s === 'tfqrz' ? 'current-amber' : 'done',
dotIcon: s === 'tfqrz' ? '●' : '✓',
labelClass: s === 'tfqrz' ? 'current-amber' : 'done',
connectorToNext: s === 'tfqrz' ? 'pending-gray' : 'done-red',
hasDetailCard: true
})
}
if (s === 'tfqrz') {
steps.push({
key: 'refund-finance',
label: '退费确认',
dotClass: 'pending-gray',
dotIcon: '○',
labelClass: 'pending-gray',
hasDetailCard: false
})
} else if (s === 'ytf') {
steps.push({
key: 'refund-finance',
label: '退费确认',
dotClass: 'done-red',
dotIcon: '✓',
labelClass: 'done-red',
hasDetailCard: true
})
}
if (steps.length) {
delete steps[steps.length - 1].connectorToNext
}
return steps
}
function miniFlowConnectorStyle(total: number, index: number) {
const leftPct = ((index + 0.5) / total) * 100
const widthPct = 100 / total
return `left: calc(${leftPct}% + 12px); width: calc(${widthPct}% - 12px);`
}
</script>
<style scoped lang="scss">
.payment-detail-card {
background: #fff;
border-radius: 8px;
padding: 14px 16px;
border: 1px solid #e2e8f0;
&--refunded {
border-left: 3px solid #dc2626;
border-color: #e2e8f0;
border-left-color: #dc2626;
}
&--refund-pending {
border-left: 3px solid #d97706;
border-color: #e2e8f0;
border-left-color: #d97706;
}
}
.pdc-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid #f1f5f9;
}
.pdc-header-left {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: #1e293b;
&::before {
content: '';
width: 2px;
height: 14px;
border-radius: 1px;
flex-shrink: 0;
}
&.green::before {
background: #10b981;
}
&.red::before {
background: #dc2626;
}
&.amber::before {
background: #d97706;
}
}
.pdc-header-amount {
font-size: 14px;
font-weight: 700;
margin-left: 4px;
&.green {
color: #059669;
}
&.red {
color: #dc2626;
}
&.amber {
color: #d97706;
}
}
.pdc-info-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px 20px;
margin-bottom: 12px;
}
.pdc-info-item {
display: flex;
flex-direction: column;
gap: 2px;
.info-label {
font-size: 11px;
color: #94a3b8;
}
.info-value {
font-size: 13px;
color: #1e293b;
font-weight: 500;
}
}
.pdc-attachments-row {
margin-bottom: 8px;
padding-top: 8px;
border-top: 1px dashed #e2e8f0;
display: flex;
flex-direction: column;
gap: 4px;
.data-label {
font-size: 11px;
color: #94a3b8;
font-weight: 500;
}
}
.flow-section {
margin-top: 12px;
}
.mini-flow {
display: flex;
align-items: center;
gap: 0;
margin: 12px 0;
padding: 10px 14px;
background: #f8fafc;
border-radius: 8px;
position: relative;
}
.mini-flow-step {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
flex: 1;
z-index: 1;
}
.mini-flow-dot {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
z-index: 1;
&.done {
background: #10b981;
color: #fff;
box-shadow: 0 0 0 3px rgb(16 185 129 / 15%);
}
&.done-red {
background: #dc2626;
color: #fff;
box-shadow: 0 0 0 3px rgb(220 38 38 / 15%);
}
&.current-amber {
background: #fff;
border: 2px solid #d97706;
color: #d97706;
box-shadow: 0 0 0 3px rgb(217 119 6 / 15%);
}
&.pending-gray {
background: #f1f5f9;
color: #94a3b8;
border: 2px solid #e2e8f0;
}
}
.mini-flow-label {
font-size: 10px;
margin-top: 6px;
text-align: center;
max-width: 80px;
line-height: 1.3;
&.done {
color: #059669;
font-weight: 600;
}
&.done-red {
color: #dc2626;
font-weight: 600;
}
&.current-amber {
color: #d97706;
font-weight: 600;
}
&.pending-gray {
color: #94a3b8;
}
}
.mini-flow-connector {
position: absolute;
top: 22px;
height: 2px;
z-index: 0;
&.done {
background: #10b981;
}
&.done-red {
background: #dc2626;
}
&.pending-gray {
background: #e2e8f0;
}
}
.flow-detail-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
margin-top: 14px;
}
.flow-detail-card {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
&--current-amber {
border-color: #fde68a;
background: #fffbeb;
}
&--done-red {
border-left: 3px solid #dc2626;
background: #fff;
}
&--pending-gray {
border-style: dashed;
background: #f8fafc;
}
}
.flow-detail-card__header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid #f1f5f9;
background: #f8fafc;
.flow-detail-card--done-red & {
background: #fef2f2;
border-bottom-color: #fecaca;
}
.flow-detail-card--current-amber & {
background: #fffbeb;
border-bottom-color: #fde68a;
}
}
.flow-detail-card__dot {
width: 20px;
height: 20px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
flex-shrink: 0;
&.done {
background: #10b981;
color: #fff;
}
&.done-red {
background: #dc2626;
color: #fff;
}
&.current-amber {
background: #fff;
border: 2px solid #d97706;
color: #d97706;
}
&.pending-gray {
background: #f1f5f9;
border: 2px solid #e2e8f0;
color: #94a3b8;
}
}
.flow-detail-card__title {
font-size: 13px;
font-weight: 600;
color: #1e293b;
&.done {
color: #059669;
}
&.done-red {
color: #dc2626;
}
&.current-amber {
color: #d97706;
}
}
.flow-detail-card__body {
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.flow-detail-card__kv {
display: flex;
flex-direction: column;
gap: 2px;
}
.flow-detail-card__label {
font-size: 11px;
color: #94a3b8;
}
.flow-detail-card__value {
font-size: 13px;
color: #1e293b;
font-weight: 500;
word-break: break-word;
&.red {
color: #dc2626;
}
&.amber {
color: #d97706;
}
&.green {
color: #059669;
}
}
@media (max-width: 768px) {
.pdc-info-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
@@ -1,755 +0,0 @@
<!-- 输注计划出库链路只读区(生产/质检/物料/放行/交接/应用确认) -->
<template>
<div v-loading="loading" class="infusion-schedule-outbound-chain">
<template v-if="detail?.id || scheduleId">
<div class="outbound-chain-section">
<div class="section-subtitle">出库生产工单 + 任务</div>
<SampleProductionInfoCards :production-info="detail?.productionInfo" />
</div>
<div class="outbound-chain-section">
<div class="section-subtitle">出库质检工单 + 任务</div>
<SampleQcInfoCards :qc-info="detail?.qcInfo" hide-sample-context />
</div>
<div class="outbound-chain-section">
<div class="section-subtitle">出库物料准备</div>
<div class="outbound-material-view">
<div v-if="!hasOutboundMaterial" class="outbound-material-empty">暂无出库物料准备记录</div>
<template v-else>
<div v-if="outboundMaterialCells.length" class="outbound-material-block">
<h4 class="outbound-material-subtitle">出库细胞</h4>
<el-table
:data="outboundMaterialCells"
class="list-table inner-table"
stripe
size="small"
>
<el-table-column type="index" label="序号" min-width="56" align="center" />
<el-table-column
prop="productCode"
label="产品编码"
min-width="150"
show-overflow-tooltip
/>
<el-table-column
prop="productName"
label="产品名称"
min-width="120"
show-overflow-tooltip
/>
<el-table-column prop="productTypeName" label="类型" min-width="90" />
<el-table-column prop="specName" label="规格" min-width="90" />
<el-table-column
prop="storageSlotName"
label="储物格"
min-width="130"
show-overflow-tooltip
/>
<el-table-column label="状态" min-width="90" align="center">
<template #default="{ row }">
<el-tag
v-if="row.productStatus"
:type="cellProductStatusTagType(row.productStatus) as any"
size="small"
>
{{ cellProductStatusLabel(row.productStatus) }}
</el-tag>
<span v-else>—</span>
</template>
</el-table-column>
</el-table>
</div>
<div v-if="outboundMaterialQcRows.length" class="outbound-material-block">
<h4 class="outbound-material-subtitle">使用的物料</h4>
<el-table
:data="outboundMaterialQcRows"
class="list-table inner-table"
stripe
size="small"
>
<el-table-column type="index" label="序号" min-width="56" align="center" />
<el-table-column
prop="materialName"
label="物料名称"
min-width="140"
show-overflow-tooltip
/>
<el-table-column prop="specModel" label="规格型号" min-width="110" />
<el-table-column label="计划用量" min-width="100" align="center">
<template #default="{ row }">{{ row.qty ?? '—' }}</template>
</el-table-column>
<el-table-column prop="unit" label="单位" min-width="72" align="center" />
</el-table>
</div>
<div v-if="outboundPaperRows.length" class="outbound-material-block">
<h4 class="outbound-material-subtitle">其他纸质材料</h4>
<el-table
:data="outboundPaperRows"
class="list-table inner-table"
stripe
size="small"
>
<el-table-column type="index" label="序号" min-width="56" align="center" />
<el-table-column prop="paperName" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column label="份数" min-width="80" align="center">
<template #default="{ row }">{{ row.copies ?? '—' }}</template>
</el-table-column>
<el-table-column label="附件" min-width="320">
<template #default="{ row }">
<div v-if="parsePaperAttachments(row.attachmentUrl).length" class="paper-att-cards">
<div
v-for="(att, attIdx) in parsePaperAttachments(row.attachmentUrl)"
:key="attIdx"
class="paper-att-card"
:class="{
'paper-att-card--image': isImageFile(att),
'paper-att-card--video': isVideoFile(att)
}"
:title="att.fileName"
>
<div
class="paper-att-card__preview"
:class="{
'is-clickable': isImageFile(att) || isVideoFile(att)
}"
@click="
(isImageFile(att) || isVideoFile(att)) &&
handlePreviewPaperAttachment(att)
"
>
<img
v-if="isImageFile(att)"
:src="attachmentFullUrl(att.attachmentUrl)"
:alt="att.fileName || '附件'"
/>
<template v-else-if="isVideoFile(att)">
<div class="paper-att-card__type-icon paper-att-card__type-icon--video">
<el-icon><VideoPlay /></el-icon>
</div>
</template>
<template v-else>
<el-icon class="paper-att-card__type-icon paper-att-card__type-icon--doc">
<Document />
</el-icon>
<span class="paper-att-card__name">{{ att.fileName || '附件' }}</span>
</template>
</div>
<div class="paper-att-card__actions">
<el-icon
v-if="isImageFile(att) || isVideoFile(att)"
class="paper-att-card__action"
title="预览"
@click.stop="handlePreviewPaperAttachment(att)"
>
<View />
</el-icon>
<el-icon
class="paper-att-card__action"
title="下载"
@click.stop="handleDownloadPaperAttachment(att)"
>
<Download />
</el-icon>
</div>
</div>
</div>
<span v-else>—</span>
</template>
</el-table-column>
</el-table>
</div>
<p v-if="outboundMaterialRemark" class="outbound-material-remark">
备注:{{ outboundMaterialRemark }}
</p>
</template>
</div>
</div>
<div class="outbound-chain-section">
<div class="section-subtitle">出库放行审核</div>
<div class="data-grid data-grid--3">
<div class="data-item">
<div class="data-label">审核结论</div>
<div class="data-value">
<el-tag :type="reviewConclusionTag as any" size="small">
{{ reviewConclusionDisplay }}
</el-tag>
</div>
</div>
<div class="data-item">
<div class="data-label">审核人</div>
<div class="data-value">{{ dash(review.reviewByName) }}</div>
</div>
<div class="data-item">
<div class="data-label">审核时间</div>
<div class="data-value">{{ dash(review.reviewTime) }}</div>
</div>
<div class="data-item data-item--full">
<div class="data-label">审核备注</div>
<div class="data-value data-value--muted">{{ dash(review.reviewRemark) }}</div>
</div>
</div>
<div v-if="reviewAttachmentFiles.length" class="attachment-row">
<div class="data-label">审核附件</div>
<ProtocolAttachmentReadonly
:files="reviewAttachmentFiles"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
</div>
</div>
<div class="outbound-chain-section">
<div class="section-subtitle">出库交接</div>
<div class="data-grid data-grid--3">
<div class="data-item">
<div class="data-label">交接人</div>
<div class="data-value">{{ dash(outboundHandover.handoverPerson) }}</div>
</div>
<div class="data-item">
<div class="data-label">交接时间</div>
<div class="data-value">{{ dash(outboundHandover.handoverTime) }}</div>
</div>
<div class="data-item">
<div class="data-label">交接地点</div>
<div class="data-value">{{ dash(outboundHandover.handoverLocation) }}</div>
</div>
<div class="data-item">
<div class="data-label">接收人</div>
<div class="data-value">{{ dash(outboundHandover.receiverPerson) }}</div>
</div>
<div class="data-item data-item--full">
<div class="data-label">备注</div>
<div class="data-value data-value--muted">{{ dash(outboundHandover.remark) }}</div>
</div>
</div>
</div>
<div class="outbound-chain-section">
<div class="section-subtitle">应用确认</div>
<div class="data-grid data-grid--3">
<template v-if="hasInfusionConfirm(infusionConfirm)">
<div class="data-item">
<div class="data-label">确认人</div>
<div class="data-value">{{ dash(infusionConfirm.confirmUserName) }}</div>
</div>
<div class="data-item">
<div class="data-label">确认时间</div>
<div class="data-value">{{ dash(infusionConfirm.confirmTime) }}</div>
</div>
<div class="data-item data-item--full">
<div class="data-label">确认说明</div>
<div class="data-value data-value--muted">{{ dash(infusionConfirm.confirmDesc) }}</div>
</div>
<div class="data-item data-item--full">
<div class="data-label">备注</div>
<div class="data-value data-value--muted">{{ dash(infusionConfirm.remark) }}</div>
</div>
</template>
<template v-else-if="isInfusionConfirmed">
<div class="data-item">
<div class="data-label">确认状态</div>
<div class="data-value">
<el-tag :type="infusionStatusTagType('yrqr') as any" size="small">
{{ infusionStatusLabel('yrqr') }}
</el-tag>
</div>
</div>
<div class="data-item data-item--full">
<div class="data-label">说明</div>
<div class="data-value data-value--muted">
已在输注计划管理中确认,暂无独立应用确认记录
</div>
</div>
</template>
<div v-else class="data-item">
<div class="data-label">确认状态</div>
<div class="data-value">
<el-tag type="info" size="small">未确认</el-tag>
</div>
</div>
</div>
</div>
</template>
<el-dialog
v-model="previewVisible"
title="文件预览"
width="800px"
align-center
destroy-on-close
append-to-body
style="backdrop-filter: none"
>
<div v-if="previewType === 'image'" class="preview-container">
<img
:src="previewUrl"
alt="预览"
style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
/>
</div>
<div v-else class="preview-container">
<video
:src="previewUrl"
controls
style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
/>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Document, Download, View, VideoPlay } from '@element-plus/icons-vue'
import SampleProductionInfoCards from '@/components/business/sample-production-info-cards/index.vue'
import SampleQcInfoCards from '@/components/business/sample-qc-info-cards/index.vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import { fetchInfusionScheduleMgmtDetail } from '@/api/admin'
import {
storageReleaseConclusionLabel,
storageReleaseConclusionTagType,
infusionStatusLabel,
infusionStatusTagType,
cellProductStatusLabel,
cellProductStatusTagType
} from '@/utils/status-tag-registry'
import { downloadAttachmentFile } from '@/utils/download-attachment'
defineOptions({ name: 'InfusionScheduleOutboundChainView' })
const props = withDefaults(
defineProps<{
scheduleId?: string
/** 外部已加载的详情(如 ScheduleViewDialog),传入则不再请求 */
detailData?: Record<string, any> | null
/** 懒加载:为 false 时不请求(申请单查看折叠态) */
active?: boolean
}>(),
{
scheduleId: '',
detailData: null,
active: true
}
)
const loading = ref(false)
const fetchedDetail = ref<Record<string, any> | null>(null)
const previewVisible = ref(false)
const previewUrl = ref('')
const previewType = ref<'image' | 'video'>('image')
type PaperAttachmentView = {
fileName: string
attachmentUrl: string
fileUrl: string
fileType?: string
}
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
const detail = computed(() => props.detailData || fetchedDetail.value)
const review = computed(() => detail.value?.reviewInfo || {})
const outboundHandover = computed(() => detail.value?.outboundHandover || {})
const infusionConfirm = computed(() => detail.value?.infusionConfirm || {})
const outboundMaterial = computed(() => detail.value?.outboundMaterial || null)
const outboundMaterialCells = computed(() => outboundMaterial.value?.cells || [])
const outboundMaterialQcRows = computed(() => outboundMaterial.value?.qcMaterials || [])
const outboundMaterialRemark = computed(() => outboundMaterial.value?.remark || '')
const outboundPaperRows = computed(() => {
const materials = outboundMaterial.value?.paperMaterials || []
if (materials.length) {
return materials.map((p: Record<string, any>) => ({
paperName: p.paperName || '',
copies: p.copies,
attachmentUrl: p.attachmentUrl || ''
}))
}
const urls = outboundMaterial.value?.paperAttachments || ''
if (!urls.trim()) return []
return urls
.split(',')
.map((u: string) => u.trim())
.filter(Boolean)
.map((attachmentUrl: string) => ({
paperName: paperAttachmentName(attachmentUrl),
copies: 1,
attachmentUrl
}))
})
const hasOutboundMaterial = computed(
() =>
outboundMaterialCells.value.length > 0 ||
outboundMaterialQcRows.value.length > 0 ||
outboundPaperRows.value.length > 0 ||
!!outboundMaterialRemark.value.trim()
)
const reviewAttachmentFiles = computed(() => {
const urls = review.value?.reviewAttachments || ''
if (!urls.trim()) return []
return urls
.split(',')
.map((u: string) => u.trim())
.filter(Boolean)
.map((fileUrl: string, i: number) => ({
id: `att_${i}`,
fileUrl,
fileName: fileUrl.split('/').pop() || ''
}))
})
const reviewConclusionValue = computed(() => review.value?.reviewConclusion || '')
const reviewConclusionDisplay = computed(() =>
reviewConclusionValue.value
? storageReleaseConclusionLabel(reviewConclusionValue.value)
: '待审核'
)
const reviewConclusionTag = computed(() =>
reviewConclusionValue.value
? storageReleaseConclusionTagType(reviewConclusionValue.value)
: 'info'
)
const isInfusionConfirmed = computed(() => {
const d = detail.value || {}
const sched = d.infusionSchedule || {}
return sched.infusionStatus === 'yrqr' || d.infusionStatus === 'yrqr'
})
function dash(v: unknown) {
if (v == null || v === '') return '—'
return String(v)
}
function hasInfusionConfirm(confirm: Record<string, any>) {
return !!(confirm?.id || confirm?.confirmTime || confirm?.confirmUserName)
}
function paperAttachmentName(url: string) {
return url.split('/').pop() || '附件'
}
function splitUrls(str: string): string[] {
if (!str?.trim()) return []
return str
.split(',')
.map((u) => u.trim())
.filter(Boolean)
}
function parsePaperAttachments(urlStr: string): PaperAttachmentView[] {
return splitUrls(urlStr).map((url) => ({
fileName: paperAttachmentName(url),
attachmentUrl: url,
fileUrl: url
}))
}
function isImageFile(file: PaperAttachmentView) {
const url = (file.fileUrl || file.attachmentUrl || '').toLowerCase()
const name = (file.fileName || '').toLowerCase()
return (
/\.(jpg|jpeg|png|gif|bmp|webp|svg)(\?|$)/.test(url) ||
/\.(jpg|jpeg|png|gif|bmp|webp|svg)$/.test(name) ||
(file.fileType || '').startsWith('image/')
)
}
function isVideoFile(file: PaperAttachmentView) {
const url = (file.fileUrl || file.attachmentUrl || '').toLowerCase()
const name = (file.fileName || '').toLowerCase()
return (
/\.(mp4|webm|ogg|mov|avi)(\?|$)/.test(url) ||
/\.(mp4|webm|ogg|mov|avi)$/.test(name) ||
(file.fileType || '').startsWith('video/')
)
}
function attachmentFullUrl(url: string) {
const u = String(url || '').trim()
if (!u) return ''
if (/^https?:\/\//i.test(u)) return u
return minioBaseUrl ? `${minioBaseUrl}/${u.replace(/^\//, '')}` : u
}
function handlePreviewPaperAttachment(att: PaperAttachmentView) {
if (!att.attachmentUrl) return
previewUrl.value = attachmentFullUrl(att.attachmentUrl)
previewType.value = isVideoFile(att) ? 'video' : 'image'
previewVisible.value = true
}
function handleDownloadPaperAttachment(att: PaperAttachmentView) {
if (!att.attachmentUrl) return
downloadAttachmentFile(
{
fileName: att.fileName,
fileUrl: att.attachmentUrl,
fileType: att.fileType || ''
},
{ fullFileUrl: attachmentFullUrl }
)
}
function handleAttachmentPreview(file: Record<string, unknown>) {
const url = attachmentFullUrl(String(file.fileUrl || ''))
if (url) window.open(url, '_blank')
}
function handleAttachmentDownload(file: Record<string, unknown>) {
downloadAttachmentFile(file, { fullFileUrl: attachmentFullUrl })
}
async function loadDetail() {
const id = (props.scheduleId || '').trim()
if (!id || props.detailData) return
loading.value = true
try {
const res: any = await fetchInfusionScheduleMgmtDetail({ scheduleId: id })
fetchedDetail.value =
res && typeof res === 'object' && !Array.isArray(res)
? (res as Record<string, any>)
: ((res as any)?.data ?? null)
} catch {
fetchedDetail.value = null
} finally {
loading.value = false
}
}
watch(
() => [props.scheduleId, props.active, props.detailData] as const,
([id, active, external]) => {
if (external) {
fetchedDetail.value = null
return
}
if (!id || !active) return
loadDetail()
},
{ immediate: true }
)
</script>
<style lang="scss" scoped>
.infusion-schedule-outbound-chain {
margin-top: 16px;
padding-top: 16px;
border-top: 1px dashed #e2e8f0;
}
.outbound-chain-section {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
}
.section-subtitle {
margin: 0 0 10px;
font-size: 13px;
font-weight: 600;
color: #475569;
}
.outbound-material-view {
width: 100%;
}
.outbound-material-empty {
font-size: 13px;
color: #94a3b8;
}
.outbound-material-block {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
}
.outbound-material-subtitle {
margin: 0 0 8px;
font-size: 12px;
font-weight: 600;
color: #64748b;
}
.outbound-material-remark {
margin: 12px 0 0;
font-size: 13px;
color: #64748b;
}
.attachment-row {
margin-top: 12px;
}
.data-grid {
display: grid;
gap: 12px 16px;
&--3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.data-item--full {
grid-column: 1 / -1;
}
.data-label {
font-size: 11px;
color: #64748b;
margin-bottom: 4px;
}
.data-value {
font-size: 14px;
color: #0f172a;
font-weight: 500;
word-break: break-word;
&--muted {
color: #475569;
font-weight: 400;
}
}
:deep(.sample-card__section-label) {
display: none;
}
:deep(.sample-card__section--qc),
:deep(.sample-card__section--production) {
margin-top: 0;
padding-top: 0;
border-top: none;
}
.paper-att-cards {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.paper-att-card {
width: 100px;
height: 72px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #f8fafc;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: center;
padding: 0;
position: relative;
overflow: hidden;
}
.paper-att-card__preview {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 6px 4px;
overflow: hidden;
&.is-clickable {
cursor: pointer;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.paper-att-card--image,
.paper-att-card--video {
.paper-att-card__preview {
padding: 0;
}
}
.paper-att-card__type-icon {
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
margin-bottom: 2px;
&--doc {
color: #0284c7;
}
&--video {
width: 36px;
height: 36px;
border-radius: 8px;
background: #ede9fe;
color: #7c3aed;
font-size: 20px;
margin-bottom: 4px;
}
}
.paper-att-card__name {
width: 100%;
font-size: 11px;
color: #334155;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.2;
}
.paper-att-card__actions {
position: absolute;
top: 2px;
right: 2px;
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.15s;
}
.paper-att-card:hover .paper-att-card__actions {
opacity: 1;
}
.paper-att-card__action {
width: 18px;
height: 18px;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
color: #fff;
background: rgba(0, 0, 0, 0.35);
cursor: pointer;
&:hover {
background: #10b981;
}
}
.preview-container {
text-align: center;
}
</style>
@@ -1,133 +0,0 @@
<template>
<teleport to="body">
<div
v-show="visibleProxy"
class="order-operate-bridge-layer"
@click.self="visibleProxy = false"
>
<div class="order-operate-bridge-dialog">
<iframe ref="frameRef" class="order-operate-bridge-frame" :src="frameSrc" frameborder="0" />
</div>
</div>
</teleport>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const props = defineProps<{
modelValue: boolean
orderId: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const visibleProxy = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const frameSrc = computed(() => {
return `#/demo/order?embedded=1`
})
const frameRef = ref<HTMLIFrameElement | null>(null)
const handleMessage = (ev: MessageEvent) => {
const data = ev?.data as any
if (!data || typeof data !== 'object') return
if (data.type === 'sqxp-order-operate-close') {
visibleProxy.value = false
}
}
const postOpen = (id: string) => {
const win = frameRef.value?.contentWindow
if (!win) return
win.postMessage({ type: 'sqxp-order-operate-open', orderId: id }, '*')
}
onMounted(() => {
window.addEventListener('message', handleMessage)
})
onBeforeUnmount(() => {
window.removeEventListener('message', handleMessage)
})
watch(
() => [visibleProxy.value, props.orderId] as const,
([vis, id]) => {
if (!vis) return
const orderId = (id || '').trim()
if (!orderId) return
// 给 iframe 一帧时间完成渲染/初始化后再发消息,避免首次打开时丢消息
requestAnimationFrame(() => postOpen(orderId))
},
{ immediate: true }
)
</script>
<style lang="scss" scoped>
.order-operate-bridge-layer {
position: fixed;
inset: 0;
z-index: 4000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.28);
padding: 48px 24px;
box-sizing: border-box;
}
.order-operate-bridge-dialog {
width: min(1400px, calc(100vw - 48px));
max-height: calc(100vh - 96px);
background: #fff;
border-radius: 12px;
overflow: hidden;
box-shadow:
0 20px 64px rgba(15, 23, 42, 0.25),
0 2px 12px rgba(15, 23, 42, 0.12);
display: flex;
flex-direction: column;
}
.order-operate-bridge-frame {
width: 100%;
height: 100%;
min-height: 680px;
max-height: calc(100vh - 96px);
display: block;
background: transparent;
border: 0;
flex: 1 1 auto;
}
.order-operate-bridge-empty {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
}
@media (max-width: 900px) {
.order-operate-bridge-layer {
padding: 20px 10px;
}
.order-operate-bridge-dialog {
width: calc(100vw - 20px);
max-height: calc(100vh - 40px);
border-radius: 10px;
}
.order-operate-bridge-frame {
min-height: 520px;
max-height: calc(100vh - 40px);
}
}
</style>
@@ -1,72 +0,0 @@
<template>
<teleport to="body">
<div v-show="visibleProxy" class="order-operate-local-layer" @click.self="visibleProxy = false">
<iframe
class="order-operate-local-frame"
:src="frameSrc"
frameborder="0"
allowtransparency="true"
/>
</div>
</teleport>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted } from 'vue'
const props = defineProps<{
modelValue: boolean
orderId: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const visibleProxy = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const frameSrc = computed(() => {
const id = encodeURIComponent((props.orderId || '').trim())
return id ? `#/demo/order?bridge=1&viewOrderId=${id}` : '#/demo/order?bridge=1'
})
const handleMessage = (ev: MessageEvent) => {
const data = ev?.data as any
if (!data || typeof data !== 'object') return
if (data.type === 'sqxp-order-operate-close') {
visibleProxy.value = false
}
}
onMounted(() => {
window.addEventListener('message', handleMessage)
})
onBeforeUnmount(() => {
window.removeEventListener('message', handleMessage)
})
</script>
<style lang="scss" scoped>
.order-operate-local-layer {
position: fixed;
inset: 0;
z-index: 4000;
display: flex;
flex-direction: column;
background: transparent;
overflow: hidden;
}
.order-operate-local-frame {
width: 100%;
height: 100%;
min-height: 0;
display: block;
background: transparent;
border: 0;
}
</style>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,272 +0,0 @@
<!-- 生产任务 — 只读查看(任务信息 + 产出细胞产品) -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<SystemViewDialog
v-model:visible="innerVisible"
title="生产任务详情"
width="960px"
:grid-columns="3"
:icon="Operation"
:meta="viewDetail?.taskNo || ''"
:sections="viewSections"
:loading="loading"
append-to-body
compact
modal-class="production-task-view-dialog-mask"
>
<template #task-outputs>
<div v-if="viewOutputs.length" class="view-subtable-wrap">
<el-table :data="viewOutputs" stripe class="list-table view-subtable">
<el-table-column
type="index"
label="序号"
min-width="56"
align="center"
header-align="center"
:index="(index) => index + 1"
/>
<el-table-column
label="产品编码"
min-width="140"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{
row.productCode || row.outputBarcode || '—'
}}</template>
</el-table-column>
<el-table-column
label="产品名称"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.productName || row.outputName || '—' }}</template>
</el-table-column>
<el-table-column label="产品类型" min-width="100" align="center" header-align="center">
<template #default="{ row }">{{ row.productTypeName || '—' }}</template>
</el-table-column>
<el-table-column label="规格" min-width="80" align="center" header-align="center">
<template #default="{ row }">{{ row.specName || '—' }}</template>
</el-table-column>
<el-table-column
prop="passage"
label="代次"
min-width="70"
align="center"
header-align="center"
/>
<el-table-column
prop="batchNo"
label="批次号"
min-width="110"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column label="储物格位置" min-width="160" align="center" header-align="center">
<template #default="{ row }">{{ row.storageSlotName || '—' }}</template>
</el-table-column>
<el-table-column
label="预计细胞数量(亿)"
min-width="130"
align="center"
header-align="center"
>
<template #default="{ row }">{{ row.expectedCellCount ?? '—' }}</template>
</el-table-column>
<el-table-column label="活性" min-width="80" align="center" header-align="center">
<template #default="{ row }">{{
row.viability != null ? row.viability : '—'
}}</template>
</el-table-column>
<el-table-column label="冻存日期" min-width="110" align="center" header-align="center">
<template #default="{ row }">{{ formatDateOnly(row.freezeDate) }}</template>
</el-table-column>
<el-table-column label="有效期" min-width="110" align="center" header-align="center">
<template #default="{ row }">{{ formatDateOnly(row.expiryDate) }}</template>
</el-table-column>
<el-table-column label="数量" min-width="80" align="center" header-align="center">
<template #default="{ row }">{{ row.quantity ?? '—' }}</template>
</el-table-column>
<el-table-column
prop="unit"
label="单位"
min-width="70"
align="center"
header-align="center"
/>
<el-table-column label="状态" min-width="90" align="center" header-align="center">
<template #default="{ row }">
<el-tag :type="productStatusTagType(row.productStatus)" size="small">
{{ productStatusLabel(row.productStatus) }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<p v-else class="view-empty-hint">暂无产出产品</p>
</template>
</SystemViewDialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Operation } from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import type { ViewSection } from '@/components/business/system-view-dialog/index.vue'
import { fetchProductionTaskDetail } from '@/api/production-task'
import { productionConclusionLabel, productionConclusionTagType } from '@/utils/production-labels'
const props = defineProps<{
visible: boolean
taskId?: string
}>()
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
}>()
const innerVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val)
})
const loading = ref(false)
const viewDetail = ref<Record<string, any> | null>(null)
const TASK_STATUS_LABELS: Record<string, string> = {
dpz: '待配置',
dzx: '待执行',
jxz: '执行中',
ywc: '已完成'
}
const TASK_TYPE_LABELS: Record<string, string> = {
cell_storage: '入库生产单',
outbound: '出库生产单'
}
watch(
() => [props.visible, props.taskId] as const,
async ([vis, id]) => {
if (!vis || !id) {
viewDetail.value = null
return
}
loading.value = true
try {
viewDetail.value = await fetchProductionTaskDetail(id)
} catch {
viewDetail.value = null
} finally {
loading.value = false
}
},
{ immediate: true }
)
function taskStatusLabel(status?: string) {
return TASK_STATUS_LABELS[status || ''] || status || '—'
}
function taskStatusTagType(status?: string) {
if (status === 'ywc') return 'success' as const
if (status === 'jxz') return 'warning' as const
if (status === 'dzx') return 'info' as const
return 'info' as const
}
function taskTypeLabel(taskType?: string) {
return TASK_TYPE_LABELS[taskType || ''] || taskType || '—'
}
function taskTypeTagType(taskType?: string) {
if (taskType === 'cell_storage') return 'primary' as const
if (taskType === 'outbound') return 'warning' as const
return 'info' as const
}
function productStatusTagType(status?: string) {
const map: Record<string, 'success' | 'warning' | 'danger' | 'info'> = {
frozen: 'success',
in_production: 'warning',
voided: 'danger',
thawed: 'info'
}
return map[status || ''] || 'info'
}
function productStatusLabel(status?: string) {
const map: Record<string, string> = {
frozen: '已冻存',
in_production: '生产中',
voided: '已作废',
thawed: '已复苏'
}
return map[status || ''] || status || '—'
}
function formatDateOnly(val: unknown) {
if (!val) return '—'
const s = String(val)
return s.length >= 10 ? s.slice(0, 10) : s
}
const viewOutputs = computed(() => {
const rows = (viewDetail.value?.outputs as Array<Record<string, any>>) || []
return rows.map((row) => ({
...row,
productCode: row.productCode || row.outputBarcode || '',
productName: row.productName || row.outputName || ''
}))
})
const viewSections = computed<ViewSection[]>(() => {
const d = viewDetail.value
if (!d) return []
return [
{
title: '任务信息',
fields: [
{ label: '任务号', value: d.taskNo || '—' },
{ label: '任务名称', value: d.taskName || '—' },
{ label: '工单号', value: d.workOrderNo || '—' },
{
label: '任务类型',
value: taskTypeLabel(d.taskType),
tagType: taskTypeTagType(d.taskType)
},
{
label: '任务状态',
value: taskStatusLabel(d.taskStatus),
tagType: taskStatusTagType(d.taskStatus)
},
{ label: '协议号', value: d.protocolNo || '—' },
{ label: '样本编码', value: d.sampleCode || '—' },
{ label: '开始时间', value: d.startedTime || '—' },
{
label: '生产结论',
value: productionConclusionLabel(d.conclusion),
tagType: productionConclusionTagType(d.conclusion)
},
{ label: '完成时间', value: d.completedTime || '—' },
{ label: '备注', value: d.remark || '—', span: true }
]
},
{
title: '产出(细胞产品)',
fields: [],
slot: 'task-outputs'
}
]
})
</script>
<style lang="scss">
.production-task-view-dialog-mask {
z-index: 4000 !important;
}
</style>
@@ -1,14 +0,0 @@
export type FinanceAttachmentItem = {
id: string
fileName: string
fileUrl: string
fileType?: string
_uploading?: boolean
}
export function financeAttachmentsToUrlString(items: FinanceAttachmentItem[]) {
return items
.filter((f) => f.fileUrl && !f._uploading)
.map((f) => f.fileUrl)
.join(',')
}
@@ -1,362 +0,0 @@
<!-- 财务确认弹窗:附件上传 + 财务备注(对齐采集协议单附件 UI) -->
<template>
<div class="protocol-finance-confirm-fields">
<el-form-item v-if="showRemark" :label="remarkLabel" class="form-col-span-2">
<el-input
:model-value="financeRemark"
type="textarea"
:rows="3"
placeholder="选填"
@update:model-value="emit('update:financeRemark', $event)"
/>
</el-form-item>
<el-form-item :label="attachmentLabel" class="form-col-span-2">
<div class="attachment-upload">
<div class="attachment-list">
<el-upload
:auto-upload="false"
:show-file-list="false"
:multiple="true"
accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
@change="handleAttachmentChange"
>
<div class="upload-card">
<el-icon :size="22" color="#10b981"><Plus /></el-icon>
<span class="upload-card-text">上传附件</span>
</div>
</el-upload>
<div
v-for="(file, fIdx) in attachments"
:key="file.id || fIdx"
class="attachment-item"
:class="{ 'is-uploading': file._uploading }"
>
<div
class="attachment-preview"
@click="isImageFile(file) && file.fileUrl ? handlePreview(file) : undefined"
>
<template v-if="isImageFile(file) && file.fileUrl">
<img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
</template>
<template v-else-if="isImageFile(file) && !file.fileUrl">
<el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
</template>
<template v-else-if="isVideoFile(file)">
<div class="attachment-preview__icon attachment-preview__icon--video">
<el-icon :size="28"><VideoPlay /></el-icon>
</div>
</template>
<template v-else>
<div class="attachment-preview__icon attachment-preview__icon--doc">
<el-icon :size="28"><Document /></el-icon>
</div>
</template>
</div>
<div class="attachment-footer">
<span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
<div class="attachment-actions">
<el-icon
v-if="file.fileUrl"
class="action-btn"
title="下载"
@click.stop="downloadFile(file)"
>
<Download />
</el-icon>
<el-icon class="action-btn" title="删除" @click.stop="removeAttachment(fIdx)">
<Delete />
</el-icon>
</div>
</div>
</div>
</div>
<p class="upload-hint">支持图片、视频、文档等。</p>
</div>
</el-form-item>
</div>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { Plus, Document, Delete, Download, Loading, VideoPlay } from '@element-plus/icons-vue'
import { fetchUploadAttachment } from '@/api/business'
import { downloadAttachmentFile } from '@/utils/download-attachment'
import type { FinanceAttachmentItem } from './finance-attachments'
const props = withDefaults(
defineProps<{
financeRemark: string
attachments: FinanceAttachmentItem[]
uploadFolder?: string
remarkLabel?: string
attachmentLabel?: string
showRemark?: boolean
}>(),
{
uploadFolder: 'protocol-finance-confirm',
remarkLabel: '财务备注',
attachmentLabel: '财务确认附件',
showRemark: true
}
)
const emit = defineEmits<{
(e: 'update:financeRemark', val: string): void
(e: 'update:attachments', val: FinanceAttachmentItem[]): void
}>()
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
function fullFileUrl(url: string) {
if (!url) return ''
if (/^https?:\/\//i.test(url)) return url
return minioBaseUrl ? `${minioBaseUrl}/${url.replace(/^\//, '')}` : url
}
function isImageFile(file: FinanceAttachmentItem) {
const t = file.fileType || ''
const n = file.fileName || ''
return t.startsWith('image/') || /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(n)
}
function isVideoFile(file: FinanceAttachmentItem) {
const t = file.fileType || ''
const n = file.fileName || ''
return t.startsWith('video/') || /\.(mp4|webm|mov|avi|mkv)$/i.test(n)
}
function handlePreview(file: FinanceAttachmentItem) {
if (!file.fileUrl) return
window.open(fullFileUrl(file.fileUrl), '_blank')
}
function downloadFile(file: FinanceAttachmentItem) {
downloadAttachmentFile(file, { fullFileUrl })
}
function removeAttachment(index: number) {
const next = [...props.attachments]
next.splice(index, 1)
emit('update:attachments', next)
}
/** Mutable snapshot used to avoid race conditions when multiple files are picked at once */
let _pendingList: FinanceAttachmentItem[] | null = null
function _getList(): FinanceAttachmentItem[] {
if (_pendingList) return _pendingList
return props.attachments
}
async function handleAttachmentChange(uploadFile: { raw?: File }) {
const file = uploadFile.raw
if (!file) return
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const uploading: FinanceAttachmentItem = {
id: tempId,
fileName: file.name,
fileUrl: '',
fileType: file.type || '',
_uploading: true
}
// Append to the latest snapshot so concurrent calls don't overwrite each other
const current = _getList()
const listWithTemp = [...current, uploading]
_pendingList = listWithTemp
emit('update:attachments', listWithTemp)
try {
const fd = new FormData()
fd.append('folder', props.uploadFolder)
fd.append('file', file)
const res = await fetchUploadAttachment(fd)
// Read from latest snapshot (NOT the closure-captured listWithTemp) to avoid
// overwriting sibling uploads that may have already completed while this one was in-flight
const latest = _pendingList || props.attachments
const updated = latest.map((a) =>
a.id === tempId
? {
id: res.id || tempId,
fileName: file.name || res.fileName,
fileUrl: res.fileUrl,
fileType: res.fileType || file.type || '',
_uploading: false
}
: a
)
_pendingList = updated
emit('update:attachments', updated)
} catch {
ElMessage.error('文件上传失败')
const latest = _pendingList || props.attachments
const remaining = latest.filter((a) => a.id !== tempId)
_pendingList = remaining.length > 0 ? remaining : null
emit('update:attachments', remaining)
}
}
</script>
<style scoped lang="scss">
.protocol-finance-confirm-fields {
width: 100%;
}
.attachment-upload {
width: 100%;
}
.attachment-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.upload-card {
width: 140px;
height: 96px;
border: 1px dashed #a7f3d0;
border-radius: 8px;
background: #f0fdf4;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
transition:
border-color 0.15s,
background 0.15s;
&:hover {
border-color: #10b981;
border-style: solid;
background: #ecfdf5;
}
}
.upload-card-text {
font-size: 12px;
color: #10b981;
font-weight: 500;
line-height: 1;
}
.upload-hint {
display: block;
font-size: 11px;
color: #94a3b8;
line-height: 1;
margin-top: 8px;
}
.attachment-item {
width: 140px;
display: flex;
flex-direction: column;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
overflow: hidden;
transition:
border-color 0.15s,
box-shadow 0.15s;
&:hover {
border-color: #10b981;
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.1);
}
&.is-uploading {
opacity: 0.6;
pointer-events: none;
}
}
.attachment-preview {
width: 100%;
height: 96px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: #f8fafc;
overflow: hidden;
cursor: pointer;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.attachment-preview__icon {
width: 48px;
height: 48px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
&--video {
background: #ede9fe;
color: #7c3aed;
}
&--doc {
background: #e0f2fe;
color: #0284c7;
}
}
.attachment-footer {
display: flex;
flex-direction: column;
flex: 1;
padding: 6px 8px;
}
.attachment-name {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
font-size: 11px;
color: #334155;
line-height: 1.4;
word-break: break-all;
min-height: calc(11px * 1.4 * 2);
}
.attachment-actions {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
margin-top: 4px;
flex-shrink: 0;
transition: opacity 0.15s;
}
.action-btn {
width: 22px;
height: 22px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #64748b;
cursor: pointer;
background: #f1f5f9;
transition:
background 0.15s,
color 0.15s;
&:hover {
background: #10b981;
color: #fff;
}
}
</style>
@@ -1,258 +0,0 @@
<!-- 缴费明细只读信息(核对用) -->
<template>
<div class="protocol-payment-detail-readonly">
<h4 v-if="title" class="detail-block-title">{{ title }}</h4>
<dl class="view-kv-grid view-kv-grid--4">
<div class="view-kv">
<dt>协议号</dt>
<dd>{{ dash(detail?.protocolNo) }}</dd>
</div>
<div class="view-kv">
<dt>协议状态</dt>
<dd>{{ protocolStatusLabel(String(detail?.protocolStatus || '')) }}</dd>
</div>
<div class="view-kv">
<dt>缴费方式</dt>
<dd>{{ payModeLabel(String(detail?.payMode || '')) }}</dd>
</div>
<div class="view-kv">
<dt>缴费状态</dt>
<dd>
<el-tag :type="payStatusTagType(String(detail?.payStatus || ''))" size="small">
{{ payStatusLabel(String(detail?.payStatus || '')) }}
</el-tag>
</dd>
</div>
<template v-if="showCustomer">
<div class="view-kv">
<dt>客户姓名</dt>
<dd>{{ dash(detail?.motherName) }}</dd>
</div>
<div class="view-kv">
<dt>身份证号</dt>
<dd>{{ maskIdCard(String(detail?.motherIdCard || '')) }}</dd>
</div>
<div class="view-kv">
<dt>新生儿姓名</dt>
<dd>{{ dash(detail?.newbornName) }}</dd>
</div>
<div class="view-kv">
<dt>新生儿身份证号</dt>
<dd>{{ maskIdCard(String(detail?.newbornIdCard || '')) }}</dd>
</div>
</template>
<div v-if="detail?.payMode === 'fq'" class="view-kv">
<dt>分期期序号</dt>
<dd>{{ dash(detail?.installmentNo) }}</dd>
</div>
<div v-if="detail?.payMode === 'fq'" class="view-kv">
<dt>应缴费时间</dt>
<dd>{{ formatPaymentDueDate(detail?.installmentDueDate) }}</dd>
</div>
<div class="view-kv">
<dt>应缴费金额(元)</dt>
<dd>{{ formatPaymentAmount(detail?.dueAmount) }}</dd>
</div>
<div class="view-kv">
<dt>转账方式</dt>
<dd>{{ transferTypeLabel(String(detail?.transferType || '')) }}</dd>
</div>
<div class="view-kv">
<dt>转账金额(元)</dt>
<dd>{{ formatPaymentAmount(detail?.transferAmount) }}</dd>
</div>
<div class="view-kv">
<dt>转账时间</dt>
<dd>{{ dash(detail?.transferTime) }}</dd>
</div>
<div v-if="detail?.remark" class="view-kv view-kv-span-2">
<dt>备注</dt>
<dd>{{ dash(detail?.remark) }}</dd>
</div>
<div class="view-field-block view-kv-span-2 payment-detail-readonly__attachments">
<div class="view-field-block__label">转账凭证</div>
<ProtocolAttachmentReadonly
:files="transferAttachmentFiles"
@preview="(f) => emit('preview', f)"
@download="(f) => emit('download', f)"
/>
</div>
</dl>
<template v-if="showRefundApply && hasRefundApplyInfo">
<div class="form-section-divider form-section-divider--inner" />
<h4 v-if="refundApplyTitle" class="detail-block-title">{{ refundApplyTitle }}</h4>
<dl class="view-kv-grid view-kv-grid--4">
<div class="view-kv">
<dt>退费金额(元)</dt>
<dd>{{ formatPaymentAmount(detail?.refundAmount) }}</dd>
</div>
<div v-if="detail?.refundRemark" class="view-kv view-kv-span-2">
<dt>退费备注</dt>
<dd>{{ dash(detail?.refundRemark) }}</dd>
</div>
<div class="view-field-block view-kv-span-2 payment-detail-readonly__attachments">
<div class="view-field-block__label">退费附件</div>
<ProtocolAttachmentReadonly
:files="refundAttachmentFiles"
@preview="(f) => emit('preview', f)"
@download="(f) => emit('download', f)"
/>
</div>
</dl>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import { maskIdCard } from '@/utils/mask-sensitive'
import {
formatPaymentAmount,
formatPaymentDueDate,
payModeLabel,
payStatusLabel,
payStatusTagType,
protocolStatusLabel,
transferTypeLabel
} from '@/utils/protocol-payment-labels'
const props = withDefaults(
defineProps<{
detail?: Record<string, unknown> | null
title?: string
showCustomer?: boolean
showRefundApply?: boolean
refundApplyTitle?: string
}>(),
{ showCustomer: true, showRefundApply: false, refundApplyTitle: '退费申请(请核对)' }
)
const emit = defineEmits<{
(e: 'preview', file: Record<string, unknown>): void
(e: 'download', file: Record<string, unknown>): void
}>()
function dash(v: unknown) {
if (v == null || v === '') return '—'
return String(v)
}
const transferAttachmentFiles = computed(() => {
const row = props.detail
if (!row) return []
if (Array.isArray(row.attachments)) {
return row.attachments as Array<Record<string, unknown>>
}
const urls = String(row.transferAttachments || '')
if (!urls.trim()) return []
return urls
.split(',')
.map((u, i) => {
const url = u.trim()
if (!url) return null
const name = url.includes('/') ? url.split('/').pop() || `附件${i + 1}` : url
return { id: `url_${i}`, fileUrl: url, fileName: name }
})
.filter(Boolean) as Array<Record<string, unknown>>
})
const refundAttachmentFiles = computed(() => {
const row = props.detail
if (!row) return []
if (Array.isArray(row.refundAttachmentFiles)) {
return row.refundAttachmentFiles as Array<Record<string, unknown>>
}
const urls = String(row.refundAttachments || '')
if (!urls.trim()) return []
return urls
.split(',')
.map((u, i) => {
const url = u.trim()
if (!url) return null
const name = url.includes('/') ? url.split('/').pop() || `附件${i + 1}` : url
return { id: `url_${i}`, fileUrl: url, fileName: name }
})
.filter(Boolean) as Array<Record<string, unknown>>
})
const hasRefundApplyInfo = computed(() => {
const row = props.detail
if (!row) return false
const amount = row.refundAmount
const hasAmount = amount != null && amount !== '' && Number(amount) > 0
return (
hasAmount ||
String(row.refundRemark || '').trim() !== '' ||
refundAttachmentFiles.value.length > 0
)
})
</script>
<style scoped lang="scss">
.protocol-payment-detail-readonly {
width: 100%;
}
.detail-block-title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
color: var(--mint-text, #334155);
line-height: 1.4;
}
.view-kv-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 20px;
margin: 0;
}
.view-kv-grid--4 {
@media (min-width: 720px) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.view-kv {
min-width: 0;
dt {
margin: 0 0 4px;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
font-weight: 500;
}
dd {
margin: 0;
font-size: 14px;
color: var(--mint-text, #334155);
line-height: 1.5;
word-break: break-word;
}
}
.view-kv-span-2 {
grid-column: 1 / -1;
}
.view-field-block__label {
font-size: 12px;
color: var(--mint-text-muted, #64748b);
font-weight: 500;
margin-bottom: 8px;
}
.payment-detail-readonly__attachments {
margin-top: 4px;
}
.form-section-divider--inner {
height: 1px;
margin: 16px 0;
background: var(--mint-border, #e2e8f0);
}
</style>
@@ -1,443 +0,0 @@
<!-- 质检任务 — 只读查看(参数、结论、附件) -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<SystemViewDialog
v-model:visible="innerVisible"
title="质检任务详情"
width="960px"
:grid-columns="3"
:icon="Tickets"
:meta="viewDetail?.taskNo || ''"
:sections="viewSections"
:loading="loading"
append-to-body
compact
modal-class="qc-task-view-dialog-mask"
>
<template #reception-extra>
<div class="view-reception-attachments">
<div class="view-kv view-kv-span-2">
<dt>接收附件</dt>
<dd>
<ProtocolAttachmentReadonly
:files="viewReceptionAttachmentFiles"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
</dd>
</div>
</div>
</template>
<template #material-usages>
<div v-if="viewMaterialUsages.length" class="view-subtable-wrap">
<el-table :data="viewMaterialUsages" stripe class="list-table view-subtable">
<el-table-column
type="index"
label="序号"
min-width="56"
align="center"
header-align="center"
:index="(index) => index + 1"
/>
<el-table-column
prop="materialName"
label="物料名称"
min-width="160"
align="center"
header-align="center"
/>
<el-table-column label="规格型号" min-width="120" align="center" header-align="center">
<template #default="{ row }">{{ row.specModel || '—' }}</template>
</el-table-column>
<el-table-column label="用量" min-width="100" align="center" header-align="center">
<template #default="{ row }">
{{ row.qty != null && row.qty !== '' ? row.qty : '—' }}
</template>
</el-table-column>
<el-table-column label="单位" min-width="72" align="center" header-align="center">
<template #default="{ row }">{{ row.unit || '—' }}</template>
</el-table-column>
<el-table-column label="备注" min-width="140" align="center" header-align="center">
<template #default="{ row }">{{ row.remark || '—' }}</template>
</el-table-column>
</el-table>
</div>
<p v-else class="view-empty-hint">暂无物料使用记录</p>
</template>
<template #task-attachments>
<ProtocolAttachmentReadonly
:files="viewDetail?.attachmentFiles || []"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
</template>
</SystemViewDialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Tickets } from '@element-plus/icons-vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import type { ViewSection } from '@/components/business/system-view-dialog/index.vue'
import { fetchQcTaskDetail, type QcTaskDetail } from '@/api/qc-task'
import {
fetchQcTaskResults,
fetchQcTaskMaterialUsages,
type QcTaskMaterialUsageItem
} from '@/api/qc-task-result'
import { fetchQcMaterialOptions, type QcMaterialOption } from '@/api/qc-material'
import {
taskTypeLabel,
taskStatusLabel,
qcBizTypeLabel,
qcBizTypeTagType,
receptionSourceLabel,
acceptConclusionLabel,
transportTypeLabel,
workOrderStatusLabel,
qcTaskConclusionLabel
} from '@/utils/qc-labels'
import { downloadAttachmentFile } from '@/utils/download-attachment'
const props = defineProps<{
visible: boolean
taskId?: string
/** 不展示样本信息、运输信息、样本接收(出库放行审核等) */
hideSampleContext?: boolean
}>()
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
}>()
const innerVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val)
})
type ViewMaterialUsageRow = {
materialId: string
materialName: string
specModel: string
unit: string
qty: number | undefined
remark: string
}
const loading = ref(false)
const viewDetail = ref<QcTaskDetail | null>(null)
const viewMaterialUsages = ref<ViewMaterialUsageRow[]>([])
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
function materialRowsFromApi(items: QcTaskMaterialUsageItem[]): ViewMaterialUsageRow[] {
const merged = new Map<string, ViewMaterialUsageRow>()
for (const u of items || []) {
const mid = (u.materialId || '').trim()
if (!mid) continue
const qtyRaw = Math.abs(Number(u.changeQty) || 0)
const prev = merged.get(mid)
if (prev) {
prev.qty = (Number(prev.qty) || 0) + qtyRaw
} else {
merged.set(mid, {
materialId: mid,
materialName: u.materialName || '',
specModel: '',
unit: '',
qty: qtyRaw > 0 ? qtyRaw : undefined,
remark: u.remark || ''
})
}
}
return Array.from(merged.values())
}
function enrichMaterialUsageRows(rows: ViewMaterialUsageRow[], options: QcMaterialOption[]) {
for (const row of rows) {
const opt = options.find((o) => o.id === row.materialId)
if (opt) {
row.materialName = opt.materialName || row.materialName
row.specModel = opt.specModel || ''
row.unit = opt.unit || ''
}
}
}
watch(
() => [props.visible, props.taskId] as const,
async ([vis, id]) => {
if (!vis || !id) {
viewDetail.value = null
viewMaterialUsages.value = []
return
}
loading.value = true
try {
const detail = await fetchQcTaskDetail(id)
try {
const results = await fetchQcTaskResults(id)
;(detail as QcTaskDetail).results = results || []
} catch {
;(detail as QcTaskDetail).results = []
}
viewMaterialUsages.value = []
if (detail.taskType !== 'dsfjc') {
try {
const [savedUsages, opts] = await Promise.all([
fetchQcTaskMaterialUsages(id).catch(() => [] as QcTaskMaterialUsageItem[]),
fetchQcMaterialOptions().catch(() => [] as QcMaterialOption[])
])
const rows = materialRowsFromApi(savedUsages || [])
enrichMaterialUsageRows(rows, opts || [])
viewMaterialUsages.value = rows
} catch {
viewMaterialUsages.value = []
}
}
viewDetail.value = detail
} catch {
viewDetail.value = null
viewMaterialUsages.value = []
} finally {
loading.value = false
}
},
{ immediate: true }
)
function formatMetric(v: unknown) {
if (v === null || v === undefined || v === '') return '—'
return String(v)
}
function formatCheckResults(items?: Array<{ itemName?: string; checked?: boolean }>) {
if (!items?.length) return '—'
return items.map((x) => `${x.itemName || ''}${x.checked ? '(✓)' : '(×)'}`).join('、')
}
const viewReceptionAttachmentFiles = computed(() => {
const r = viewDetail.value?.reception
if (!r) return []
if (Array.isArray(r.receiveAttachmentFiles) && r.receiveAttachmentFiles.length) {
return r.receiveAttachmentFiles as Array<Record<string, unknown>>
}
const urls = String(r.receiveAttachments || '')
if (!urls.trim()) return []
return urls
.split(',')
.map((u) => u.trim())
.filter(Boolean)
.map((fileUrl, i) => ({ id: `rec_${i}`, fileUrl, fileName: fileUrl.split('/').pop() || '' }))
})
const viewSections = computed<ViewSection[]>(() => {
const d = viewDetail.value
if (!d) return []
const r = d.reception || {}
const isTempQc = d.qcBizType === 'temporary'
const transportFields: ViewSection['fields'] = [
{ label: '运输方式', value: transportTypeLabel(String(r.transportType || '')) }
]
if (r.transportType === 'kdys') {
transportFields.push(
{ label: '快递公司', value: r.expressCompanyName || '—' },
{ label: '快递单号', value: r.waybillNo || '—' }
)
} else if (r.transportType === 'ryxd') {
transportFields.push(
{ label: '携带人', value: r.carrierName || '—' },
{ label: '携带人电话', value: r.carrierPhone || '—' }
)
}
if (r.etaTime) {
transportFields.push({ label: '预计到达', value: r.etaTime })
}
const cellProductSection: ViewSection[] = isTempQc
? [
{
title: '质检对象',
fields: [
{ label: '产品编码', value: d.cellProductCode || '—' },
{ label: '产品名称', value: d.cellProductName || '—' },
{ label: '产品类型', value: d.cellProductTypeName || '—' },
{ label: '代次', value: d.cellProductPassage || '—' },
{ label: '规格', value: d.cellProductSpecName || '—' }
]
}
]
: []
const sampleSections: ViewSection[] =
props.hideSampleContext || isTempQc
? []
: [
{
title: '样本信息',
fields: [
{ label: '协议号', value: r.protocolNo || d.protocolNo || '—' },
{ label: '样本编码', value: r.sampleCode || d.sampleCode || '—' },
{
label: '样本来源',
value:
r.sourceTypeLabel ||
d.receptionSourceLabel ||
receptionSourceLabel(d.receptionSource || ''),
tagType: 'primary'
},
{ label: '样本类型', value: r.sampleTypeName || d.sampleTypeName || '—' }
]
},
...(transportFields.length > 1 || r.transportType
? [{ title: '运输信息', fields: transportFields }]
: []),
{
title: '样本接收',
slot: 'reception-extra',
fields: [
{ label: '接收时间', value: r.receptionTime || d.receptionTime || '—' },
{ label: '接收经手人', value: r.handlerName || d.handlerName || '—' },
{
label: '接收温度(℃)',
value: r.receiveTemperature ? `${r.receiveTemperature}℃` : '—'
},
{ label: '体积(ml)', value: formatMetric(r.volumeMl) },
{ label: '净重(g)', value: formatMetric(r.netWeightG) },
{
label: '结论',
value:
r.acceptConclusionLabel || acceptConclusionLabel(String(r.acceptConclusion || ''))
},
{ label: '检查项', value: formatCheckResults(r.checkResults), span: true },
{ label: '备注', value: r.remark || '—', span: true }
]
}
]
const taskResultSummaryFields: ViewSection['fields'] = [
{
label: '任务状态',
value: taskStatusLabel(d.taskStatus),
tagType: d.taskStatus === 'ywc' ? 'success' : d.taskStatus === 'jxz' ? 'warning' : 'info'
},
{
label: '检测结论',
value: d.conclusionLabel || qcTaskConclusionLabel(d.conclusion || '') || '—',
tagType: d.conclusion === 'tg' ? 'success' : d.conclusion === 'btg' ? 'danger' : 'info'
},
{ label: '备注', value: d.remark || '—' }
]
const resultSection: ViewSection[] = [
{
title: '质检结论',
fields: taskResultSummaryFields
},
...(d.results && d.results.length > 0
? [
{
title: '检测参数',
fields: d.results.map((item) => ({
label: item.paramName + (item.paramUnit ? `(${item.paramUnit})` : ''),
value: item.paramValue || '—'
}))
}
]
: [{ title: '检测参数', fields: [] }])
]
const tailSections: ViewSection[] = []
if (d.taskType !== 'dsfjc') {
tailSections.push({ title: '物料使用', fields: [], slot: 'material-usages' })
}
if (d.attachmentFiles?.length) {
tailSections.push({
title: d.taskType === 'dsfjc' ? '检测材料' : '检测附件',
fields: [],
slot: 'task-attachments'
})
}
return [
{
title: '任务信息',
fields: [
{ label: '任务号', value: d.taskNo },
{
label: '业务类型',
value: d.qcBizTypeLabel || qcBizTypeLabel(d.qcBizType || ''),
tagType: qcBizTypeTagType(d.qcBizType || '')
},
{ label: '工单号', value: d.workOrderNo || '—' },
{
label: '工单状态',
value: d.workOrderStatus ? workOrderStatusLabel(d.workOrderStatus) : '—',
tagType:
d.workOrderStatus === 'ywc'
? 'success'
: d.workOrderStatus === 'ypz'
? 'warning'
: 'info'
},
{
label: '检测类型',
value: taskTypeLabel(d.taskType),
tagType: d.taskType === 'sysz' ? 'success' : 'info'
},
{ label: '检测项目', value: d.itemName },
{ label: '项目编码', value: d.itemCode || '—' },
{ label: '第三方公司', value: d.companyName || '—' },
{ label: '完成时间', value: d.completedTime || '—' },
{ label: '创建时间', value: d.createTime || '—' }
]
},
...cellProductSection,
...sampleSections,
...resultSection,
...tailSections
]
})
function attachmentFullUrl(url: string) {
const u = String(url || '').trim()
if (!u) return ''
if (/^https?:\/\//i.test(u)) return u
return minioBaseUrl ? `${minioBaseUrl}/${u.replace(/^\//, '')}` : u
}
function handleAttachmentPreview(file: Record<string, unknown>) {
const url = attachmentFullUrl(String(file.fileUrl || ''))
if (url) window.open(url, '_blank')
}
function handleAttachmentDownload(file: Record<string, unknown>) {
downloadAttachmentFile(file, { fullFileUrl: attachmentFullUrl })
}
</script>
<style lang="scss">
/* 叠在协议/捐赠查看弹窗之上 */
.qc-task-view-dialog-mask {
z-index: 4000 !important;
}
</style>
<style scoped lang="scss">
.view-reception-attachments {
margin-top: 4px;
.view-kv {
display: grid;
grid-template-columns: 96px 1fr;
gap: 8px 12px;
font-size: 13px;
align-items: start;
dt {
margin: 0;
color: #64748b;
font-weight: 500;
}
dd {
margin: 0;
color: #1e293b;
}
}
}
</style>
@@ -1,303 +0,0 @@
<!-- 样本查看 — 生产信息卡片(协议单查看弹窗复用) -->
<template>
<div class="sample-card__section sample-card__section--production">
<div class="sample-card__section-label">生产信息</div>
<div v-if="!productionInfo" class="sample-production-empty">暂无生产信息</div>
<template v-else>
<div class="production-work-order-row">
<div class="qc-kv">
<span class="qc-kv__label">工单号</span>
<span class="qc-kv__value qc-kv__value--code">{{
productionInfo.workOrderNo || '—'
}}</span>
</div>
<div class="qc-kv">
<span class="qc-kv__label">工单状态</span>
<span class="qc-kv__value">
<el-tag
:type="productionWorkOrderStatusTagType(productionInfo.workOrderStatus || '')"
size="small"
>
{{
productionInfo.workOrderStatusLabel ||
productionWorkOrderStatusLabel(productionInfo.workOrderStatus || '')
}}
</el-tag>
</span>
</div>
</div>
<div
v-if="productionInfo.workOrderStatus === 'dpz' && !productionInfo.tasks?.length"
class="sample-production-hint"
>
工单待配置生产任务
</div>
<div v-if="productionInfo.tasks?.length" class="production-task-cards">
<div v-for="task in productionInfo.tasks" :key="task.id" class="production-task-card">
<div class="production-task-card__header">
<span class="production-task-card__name">{{
task.taskName || task.taskNo || '—'
}}</span>
<el-tag :type="productionTaskStatusTagType(task.taskStatus || '')" size="small">
{{ task.taskStatusLabel || productionTaskStatusLabel(task.taskStatus || '') }}
</el-tag>
</div>
<div class="production-task-card__body">
<div class="qc-kv">
<span class="qc-kv__label">任务号</span>
<span class="qc-kv__value">{{ task.taskNo || '—' }}</span>
</div>
<div class="qc-kv">
<span class="qc-kv__label">开始时间</span>
<span class="qc-kv__value">{{ task.startedTime || '—' }}</span>
</div>
<div class="qc-kv">
<span class="qc-kv__label">完成时间</span>
<span class="qc-kv__value">{{ task.completedTime || '—' }}</span>
</div>
<div class="qc-kv">
<span class="qc-kv__label">生产结论</span>
<span class="qc-kv__value">
<el-tag
v-if="productionConclusionTagType(task.conclusion)"
:type="productionConclusionTagType(task.conclusion)"
size="small"
>
{{ task.conclusionLabel || productionConclusionLabel(task.conclusion) }}
</el-tag>
<template v-else>—</template>
</span>
</div>
</div>
<div class="production-task-card__foot">
<button
type="button"
class="production-task-card__link"
@click.stop="openTaskView(task)"
>
点击查看详情
</button>
</div>
</div>
</div>
<div v-else-if="productionInfo.workOrderStatus !== 'dpz'" class="sample-production-hint">
暂无生产任务
</div>
</template>
<Teleport to="body">
<ProductionTaskViewDialog v-model:visible="taskViewVisible" :task-id="taskViewId" />
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import ProductionTaskViewDialog from '@/components/business/production-task-view-dialog/index.vue'
import { productionConclusionLabel, productionConclusionTagType } from '@/utils/production-labels'
export type ProductionTaskInput = {
inputType?: string
inputCode?: string
inputName?: string
}
export type ProductionTaskOutput = {
outputType?: string
outputCode?: string
outputName?: string
}
export type ProductionTask = {
id?: string
taskNo?: string
taskName?: string
taskStatus?: string
taskStatusLabel?: string
conclusion?: string
conclusionLabel?: string
startedTime?: string
completedTime?: string
inputs?: ProductionTaskInput[]
outputs?: ProductionTaskOutput[]
}
export type ProductionInfo = {
workOrderNo?: string
workOrderStatus?: string
workOrderStatusLabel?: string
tasks?: ProductionTask[]
}
const PRODUCTION_WORK_ORDER_STATUS_LABELS: Record<string, string> = {
dpz: '待配置',
jxz: '进行中',
ywc: '已完成'
}
const PRODUCTION_TASK_STATUS_LABELS: Record<string, string> = {
dpz: '待配置',
dzx: '待执行',
jxz: '执行中',
ywc: '已完成'
}
defineProps<{
productionInfo?: ProductionInfo | null
}>()
const taskViewVisible = ref(false)
const taskViewId = ref('')
function openTaskView(task: ProductionTask) {
if (!task.id) return
taskViewId.value = task.id
taskViewVisible.value = true
}
function productionWorkOrderStatusLabel(status: string) {
return PRODUCTION_WORK_ORDER_STATUS_LABELS[status] || status || '—'
}
function productionWorkOrderStatusTagType(status: string) {
if (status === 'ywc') return 'success' as const
if (status === 'jxz') return 'warning' as const
return 'info' as const
}
function productionTaskStatusLabel(status: string) {
return PRODUCTION_TASK_STATUS_LABELS[status] || status || '—'
}
function productionTaskStatusTagType(status: string) {
if (status === 'ywc') return 'success' as const
if (status === 'jxz') return 'warning' as const
if (status === 'dzx') return 'info' as const
return 'info' as const
}
</script>
<style scoped lang="scss">
.sample-card__section--production {
margin-top: 4px;
padding-top: 14px;
border-top: 1px dashed #e2e8f0;
}
.sample-card__section-label {
font-size: 11px;
font-weight: 600;
color: #94a3b8;
margin-bottom: 10px;
letter-spacing: 0.3px;
}
.sample-production-empty,
.sample-production-hint {
font-size: 13px;
color: #94a3b8;
padding: 8px 0;
}
.production-work-order-row {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 12px 24px;
margin-bottom: 12px;
padding: 0 2px;
}
.production-work-order-row .qc-kv {
flex: 1;
min-width: 140px;
}
.production-task-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 10px;
}
.production-task-card {
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
overflow: hidden;
}
.production-task-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid #f1f5f9;
}
.production-task-card__name {
font-size: 13px;
font-weight: 600;
color: #1e293b;
text-align: left;
flex: 1;
word-break: break-word;
}
.production-task-card__body {
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.production-task-card__foot {
padding: 2px 12px 8px;
text-align: right;
border-top: 1px dashed #f1f5f9;
}
.production-task-card__link {
padding: 0;
border: none;
background: none;
font-size: 11px;
line-height: 1.4;
color: #10b981;
cursor: pointer;
&:hover {
color: #059669;
text-decoration: underline;
}
}
.qc-kv {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 12px;
}
.qc-kv__label {
color: #94a3b8;
flex-shrink: 0;
min-width: 56px;
}
.qc-kv__value {
color: #1e293b;
font-weight: 500;
word-break: break-word;
&--code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
}
</style>
@@ -1,306 +0,0 @@
<!-- 样本查看 — 质检信息卡片(协议单/捐赠单查看弹窗复用) -->
<template>
<div class="sample-card__section sample-card__section--qc">
<div class="sample-card__section-label">质检信息</div>
<div v-if="!qcInfo" class="sample-qc-empty">{{ emptyHint }}</div>
<template v-else>
<div class="qc-work-order-row">
<div class="qc-kv">
<span class="qc-kv__label">工单号</span>
<span class="qc-kv__value qc-kv__value--code">{{ qcInfo.workOrderNo || '—' }}</span>
</div>
<div class="qc-kv">
<span class="qc-kv__label">业务类型</span>
<span class="qc-kv__value">{{
qcInfo.qcBizTypeLabel || qcBizTypeLabel(qcInfo.qcBizType || '')
}}</span>
</div>
<div class="qc-kv">
<span class="qc-kv__label">工单状态</span>
<span class="qc-kv__value">
<el-tag :type="workOrderStatusTagType(qcInfo.workOrderStatus || '')" size="small">
{{
qcInfo.workOrderStatusLabel || workOrderStatusLabel(qcInfo.workOrderStatus || '')
}}
</el-tag>
</span>
</div>
</div>
<div v-if="qcInfo.workOrderStatus === 'dpz' && !qcInfo.tasks?.length" class="sample-qc-hint">
工单待配置检测项目
</div>
<div v-if="qcInfo.tasks?.length" class="qc-detail-cards qc-detail-cards--tasks">
<div
v-for="task in qcInfo.tasks"
:key="task.id"
class="qc-detail-card qc-detail-card--task"
>
<div class="qc-detail-card__header">
<span class="qc-detail-card__name">{{ task.itemName || '—' }}</span>
<el-tag type="info" size="small">{{
task.taskTypeLabel || taskTypeLabel(task.taskType || '')
}}</el-tag>
</div>
<div class="qc-detail-card__body">
<div class="qc-kv">
<span class="qc-kv__label">任务号</span>
<span class="qc-kv__value">{{ task.taskNo || '—' }}</span>
</div>
<div class="qc-kv">
<span class="qc-kv__label">任务状态</span>
<span class="qc-kv__value">
<el-tag :type="taskStatusTagType(task.taskStatus)" size="small">
{{ task.taskStatusLabel || taskStatusLabel(task.taskStatus || '') }}
</el-tag>
</span>
</div>
<div class="qc-kv">
<span class="qc-kv__label">检测结论</span>
<span class="qc-kv__value">
<el-tag
v-if="task.conclusion"
:type="qcTaskConclusionTagType(task.conclusion)"
size="small"
>
{{ task.conclusionLabel || qcTaskConclusionLabel(task.conclusion) }}
</el-tag>
<span v-else class="qc-kv__muted">—</span>
</span>
</div>
</div>
<div class="qc-detail-card__foot">
<button type="button" class="qc-detail-card__link" @click.stop="openTaskView(task)">
点击查看详情
</button>
</div>
</div>
</div>
<div v-else-if="qcInfo.workOrderStatus !== 'dpz'" class="sample-qc-hint"> 暂无检测任务 </div>
</template>
<Teleport to="body">
<QcTaskViewDialog
v-model:visible="taskViewVisible"
:task-id="taskViewId"
:hide-sample-context="hideSampleContext"
/>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import {
qcBizTypeLabel,
workOrderStatusLabel,
taskTypeLabel,
taskStatusLabel,
qcTaskConclusionLabel,
qcTaskConclusionTagType
} from '@/utils/qc-labels'
import { workOrderStatusTagType, taskStatusTagType } from '@/utils/status-tag-registry'
import QcTaskViewDialog from '@/components/business/qc-task-view-dialog/index.vue'
export type SampleQcTask = {
id?: string
taskNo?: string
taskType?: string
taskTypeLabel?: string
itemName?: string
itemCode?: string
companyName?: string
taskStatus?: string
taskStatusLabel?: string
conclusion?: string
conclusionLabel?: string
completedTime?: string
remark?: string
}
export type SampleQcInfo = {
workOrderNo?: string
workOrderStatus?: string
workOrderStatusLabel?: string
qcBizType?: string
qcBizTypeLabel?: string
configuredTime?: string
hasLab?: boolean
hasThirdParty?: boolean
labItems?: Array<{ id?: string; itemCode?: string; itemName?: string }>
thirdPartyItems?: Array<{
id?: string
itemCode?: string
itemName?: string
companyName?: string
}>
tasks?: SampleQcTask[]
}
export type SampleReceptionBrief = {
acceptConclusion?: string
}
const props = defineProps<{
qcInfo?: SampleQcInfo | null
sampleReception?: SampleReceptionBrief | null
/** 任务详情弹窗不展示样本信息、样本接收(出库放行等场景) */
hideSampleContext?: boolean
}>()
const taskViewVisible = ref(false)
const taskViewId = ref('')
const emptyHint = computed(() => {
const rec = props.sampleReception
if (!rec) {
return '暂未接收样本,接收结论为「接收」后将自动生成质检工单'
}
if (rec.acceptConclusion === 'bj') {
return '样本拒收,不生成质检工单'
}
if (rec.acceptConclusion === 'js') {
return '已接收,质检工单生成中或待配置'
}
return '暂无质检信息'
})
function openTaskView(task: SampleQcTask) {
if (!task.id) return
taskViewId.value = task.id
taskViewVisible.value = true
}
</script>
<style scoped lang="scss">
.sample-card__section--qc {
margin-top: 4px;
padding-top: 14px;
border-top: 1px dashed #e2e8f0;
}
.sample-card__section-label {
font-size: 11px;
font-weight: 600;
color: #94a3b8;
margin-bottom: 10px;
letter-spacing: 0.3px;
}
.sample-qc-empty,
.sample-qc-hint {
font-size: 13px;
color: #94a3b8;
padding: 8px 0;
}
.qc-work-order-row {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 12px 24px;
margin-bottom: 12px;
padding: 0 2px;
}
.qc-work-order-row .qc-kv {
flex: 1;
min-width: 140px;
}
.qc-detail-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 10px;
&--tasks {
margin-bottom: 0;
}
}
.qc-detail-card {
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
overflow: hidden;
}
.qc-detail-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid #f1f5f9;
}
.qc-detail-card__name {
font-size: 13px;
font-weight: 600;
color: #1e293b;
text-align: left;
flex: 1;
word-break: break-word;
}
.qc-detail-card__body {
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.qc-detail-card__foot {
padding: 2px 12px 8px;
text-align: right;
border-top: 1px dashed #f1f5f9;
}
.qc-detail-card__link {
padding: 0;
border: none;
background: none;
font-size: 11px;
line-height: 1.4;
color: #10b981;
cursor: pointer;
&:hover {
color: #059669;
text-decoration: underline;
}
}
.qc-kv__muted {
color: #94a3b8;
font-weight: 400;
}
.qc-kv {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 12px;
}
.qc-kv__label {
color: #94a3b8;
flex-shrink: 0;
min-width: 56px;
}
.qc-kv__value {
color: #1e293b;
font-weight: 500;
word-break: break-word;
&--code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
}
</style>
@@ -1,182 +0,0 @@
<!-- 样本查看 — 入库放行审核信息(协议单查看弹窗复用) -->
<template>
<div class="sample-card__section sample-card__section--storage-release">
<div class="sample-card__section-label">入库放行审核信息</div>
<div v-if="!storageReleaseInfo" class="sample-storage-release-empty">
暂无入库放行审核信息
</div>
<template v-else>
<div class="storage-release-info-grid">
<div class="qc-kv">
<span class="qc-kv__label">审核结论</span>
<span class="qc-kv__value">
<el-tag
v-if="storageReleaseInfo.reviewConclusion"
:type="storageReleaseInfo.reviewConclusion === 'tg' ? 'success' : 'danger'"
size="small"
>
{{
storageReleaseInfo.reviewConclusionLabel ||
conclusionLabel(storageReleaseInfo.reviewConclusion)
}}
</el-tag>
<span v-else class="qc-kv__muted">待审核</span>
</span>
</div>
<div class="qc-kv">
<span class="qc-kv__label">审核人</span>
<span class="qc-kv__value">{{ storageReleaseInfo.reviewByName || '—' }}</span>
</div>
<div class="qc-kv">
<span class="qc-kv__label">审核时间</span>
<span class="qc-kv__value">{{ storageReleaseInfo.reviewTime || '—' }}</span>
</div>
<div v-if="storageReleaseInfo.reviewRemark" class="qc-kv qc-kv--full">
<span class="qc-kv__label">审核备注</span>
<span class="qc-kv__value qc-kv__value--muted">{{
storageReleaseInfo.reviewRemark
}}</span>
</div>
</div>
<div v-if="attachmentFiles.length" class="storage-release-attachments">
<div class="qc-kv">
<span class="qc-kv__label">审核附件</span>
<span class="qc-kv__value">
<ProtocolAttachmentReadonly
:files="attachmentFiles"
@preview="handlePreview"
@download="handleDownload"
/>
</span>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import { downloadAttachmentFile } from '@/utils/download-attachment'
export type StorageReleaseInfo = {
reviewConclusion?: string
reviewConclusionLabel?: string
reviewByName?: string
reviewTime?: string
reviewRemark?: string
reviewAttachments?: string
}
const props = defineProps<{
storageReleaseInfo?: StorageReleaseInfo | null
}>()
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
const attachmentFiles = computed(() => {
const urls = String(props.storageReleaseInfo?.reviewAttachments || '').trim()
if (!urls) return []
return urls
.split(',')
.map((u) => u.trim())
.filter(Boolean)
.map((fileUrl, i) => ({
id: `srr_${i}`,
fileUrl,
fileName: fileUrl.split('/').pop() || `附件${i + 1}`
}))
})
function conclusionLabel(conclusion: string) {
if (conclusion === 'tg') return '通过'
if (conclusion === 'btg') return '不通过'
return conclusion
}
function fullFileUrl(suffix: string) {
if (!suffix) return ''
if (suffix.startsWith('http://') || suffix.startsWith('https://') || suffix.startsWith('/'))
return suffix
return minioBaseUrl ? `${minioBaseUrl}/${suffix.replace(/^\//, '')}` : suffix
}
function handlePreview(file: Record<string, any>) {
const url = fullFileUrl(String(file.fileUrl || ''))
if (url) window.open(url, '_blank')
}
function handleDownload(file: Record<string, any>) {
downloadAttachmentFile(file, { fullFileUrl })
}
</script>
<style scoped lang="scss">
.sample-card__section--storage-release {
margin-top: 4px;
padding-top: 14px;
border-top: 1px dashed #e2e8f0;
}
.sample-card__section-label {
font-size: 11px;
font-weight: 600;
color: #94a3b8;
margin-bottom: 10px;
letter-spacing: 0.3px;
}
.sample-storage-release-empty {
font-size: 13px;
color: #94a3b8;
padding: 8px 0;
}
.storage-release-info-grid {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 12px 24px;
padding: 0 2px;
}
.storage-release-attachments {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #f1f5f9;
}
.qc-kv {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 12px;
&--full {
flex-basis: 100%;
}
}
.qc-kv__label {
color: #94a3b8;
flex-shrink: 0;
min-width: 56px;
}
.qc-kv__value {
color: #1e293b;
font-weight: 500;
word-break: break-word;
&--muted {
color: #64748b;
font-weight: 400;
}
}
.qc-kv__muted {
color: #94a3b8;
font-weight: 400;
}
</style>
@@ -1,119 +0,0 @@
<!-- 工单编辑(备注;质检工单可选工单状态) -->
<template>
<el-dialog
v-model="dialogVisible"
:title="title"
width="560px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
@close="handleClose"
>
<el-form label-position="top" class="biz-dialog-form">
<div class="form-grid-2">
<el-form-item v-if="showWorkOrderStatus" label="工单状态" class="form-col-span-2">
<el-select v-model="workOrderStatus" placeholder="请选择" style="width: 100%">
<el-option
v-for="opt in workOrderStatusOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input
v-model="remark"
type="textarea"
:rows="4"
placeholder="选填"
maxlength="512"
show-word-limit
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="handleClose"
><el-icon><Close /></el-icon>取消</el-button
>
<el-button class="btn-main" type="primary" :loading="submitting" @click="handleSubmit">
<el-icon><Check /></el-icon>保存
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Close, Check } from '@element-plus/icons-vue'
import { workOrderStatus as workOrderStatusRegistry, workOrderStatusLabel } from '@/utils/status-tag-registry'
export type WorkOrderRemarkSubmitPayload = {
remark: string
workOrderStatus?: string
}
const props = withDefaults(
defineProps<{
visible: boolean
title?: string
initialRemark?: string
initialWorkOrderStatus?: string
showWorkOrderStatus?: boolean
submitting?: boolean
}>(),
{
title: '编辑备注',
initialRemark: '',
initialWorkOrderStatus: '',
showWorkOrderStatus: false,
submitting: false
}
)
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
(e: 'submit', payload: WorkOrderRemarkSubmitPayload): void
}>()
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val)
})
const remark = ref('')
const workOrderStatus = ref('')
const workOrderStatusOptions = computed(() =>
Object.keys(workOrderStatusRegistry).map((value) => ({
value,
label: workOrderStatusLabel(value)
}))
)
function handleClose() {
dialogVisible.value = false
}
function handleSubmit() {
const payload: WorkOrderRemarkSubmitPayload = {
remark: remark.value.trim()
}
if (props.showWorkOrderStatus) {
payload.workOrderStatus = workOrderStatus.value
}
emit('submit', payload)
}
watch(
() => props.visible,
(val) => {
if (val) {
remark.value = props.initialRemark || ''
workOrderStatus.value = props.initialWorkOrderStatus || ''
}
}
)
</script>
@@ -1,7 +1,7 @@
<!-- 系统logo -->
<template>
<div class="flex-cc">
<img :style="logoStyle" src="/logo.png" alt="中科兴皖" class="block object-contain" />
<img :style="logoStyle" src="/logo.png" alt="山峡化工" class="block object-contain" />
</div>
</template>
@@ -6,8 +6,8 @@
</div>
<header class="exception-page__brand">
<img src="/logo.png" alt="中科兴皖" class="exception-page__logo" />
<span class="exception-page__brand-name">中科兴皖</span>
<img src="/logo.png" alt="山峡化工" class="exception-page__logo" />
<span class="exception-page__brand-name">山峡化工</span>
</header>
<main class="exception-page__main">