提交“
This commit is contained in:
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>
|
||||
-14
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user