This commit is contained in:
zlwu committed 2026-05-27 05:23:28 +08:00
1 parent ad8f8d696f
commit 41aa90aa05
115 files changed
+29626 -10210

No files matched your search

@@ -0,0 +1,682 @@
<!-- 输注计划出库链路只读区(生产/质检/物料/放行/交接/应用确认) -->
<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"
>
<el-icon class="paper-att-card__icon"><Document /></el-icon>
<span class="paper-att-card__name" :title="att.fileName">{{
att.fileName || '附件'
}}</span>
<div class="paper-att-card__actions">
<el-icon
v-if="isImageFile(att)"
class="paper-att-card__action"
title="预览"
@click="handlePreviewPaperAttachment(att)"
>
<View />
</el-icon>
<el-icon
class="paper-att-card__action"
title="下载"
@click="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 } 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: center;
justify-content: center;
padding: 6px 4px;
position: relative;
overflow: hidden;
}
.paper-att-card__icon {
color: #0284c7;
font-size: 18px;
margin-bottom: 2px;
}
.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>
@@ -0,0 +1,977 @@
<!-- 出库物料编辑区(细胞 / 质检物料 / 纸质材料) -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="outbound-material-form-sections">
<div class="material-sections">
<!-- 出库细胞 -->
<section class="section-card">
<div class="section-head">
<h3 class="section-title section-title--mint-bar">出库细胞</h3>
<el-button type="primary" size="small" class="btn-main" @click="openCellSelect">
<el-icon><Plus /></el-icon>
添加细胞产品
</el-button>
</div>
<div class="list-body list-wrap inner-table-wrap">
<el-table
:data="cellRows"
class="list-table inner-table"
stripe
max-height="280"
empty-text="请从细胞产品库勾选"
>
<el-table-column type="index" label="序号" min-width="56" align="center" />
<el-table-column
prop="productCode"
label="产品编码"
min-width="160"
show-overflow-tooltip
/>
<el-table-column
prop="productName"
label="产品名称"
min-width="120"
show-overflow-tooltip
/>
<el-table-column prop="productTypeName" label="产品类型" min-width="100" />
<el-table-column prop="specName" label="规格" min-width="90" />
<el-table-column prop="passage" label="代次" min-width="70" align="center" />
<el-table-column
prop="storageSlotName"
label="储物格"
min-width="140"
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-column label="操作" min-width="80" align="center" fixed="right">
<template #default="{ $index }">
<el-button type="danger" link size="small" @click="removeCellRow($index)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</section>
<!-- 使用的物料 -->
<section class="section-card">
<div class="section-head">
<h3 class="section-title section-title--mint-bar">使用的物料</h3>
<el-button type="primary" size="small" class="btn-main" @click="addMaterialRow">
<el-icon><Plus /></el-icon>
添加物料
</el-button>
</div>
<el-table
v-if="materialRows.length"
:data="materialRows"
:row-key="(row) => row._rowKey"
stripe
class="list-table inner-table"
max-height="280"
>
<el-table-column type="index" label="序号" min-width="56" align="center" />
<el-table-column label="物料名称" min-width="180">
<template #default="{ row, $index }">
<el-select
v-model="row.materialId"
filterable
placeholder="搜索并选择物料"
class="inner-cell-input"
@change="(v) => onMaterialSelect($index, v)"
>
<el-option
v-for="opt in materialOptions"
:key="opt.id"
:label="opt.label"
:value="opt.id"
:disabled="isMaterialOptionDisabled(opt.id, $index)"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="规格型号" min-width="120">
<template #default="{ row }">{{ row.specModel || '—' }}</template>
</el-table-column>
<el-table-column label="计划用量" min-width="130">
<template #default="{ row }">
<el-input-number
v-model="row.qty"
:min="0.0001"
:precision="4"
:step="1"
controls-position="right"
class="inner-cell-input"
/>
</template>
</el-table-column>
<el-table-column label="单位" min-width="72" align="center">
<template #default="{ row }">{{ row.unit || '—' }}</template>
</el-table-column>
<el-table-column label="当前库存" min-width="100" align="center">
<template #default="{ row }">
{{ stockQtyDisplay(row.materialId) }}
</template>
</el-table-column>
<el-table-column label="操作" min-width="80" align="center" fixed="right">
<template #default="{ $index }">
<el-button type="danger" link size="small" @click="removeMaterialRow($index)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<p v-else class="section-empty-hint">点击「添加物料」从质检物料库勾选并填写计划用量</p>
</section>
<!-- 其他纸质材料 -->
<section class="section-card">
<div class="section-head">
<h3 class="section-title section-title--mint-bar">其他纸质材料</h3>
<el-button type="primary" size="small" class="btn-main" @click="addPaperRow">
<el-icon><Plus /></el-icon>
添加纸质材料
</el-button>
</div>
<el-table
v-if="paperRows.length"
:data="paperRows"
:row-key="(row) => row._rowKey"
stripe
class="list-table inner-table"
max-height="280"
>
<el-table-column type="index" label="序号" min-width="56" align="center" />
<el-table-column label="名称" min-width="180">
<template #default="{ row }">
<el-input
v-model="row.paperName"
placeholder="请输入材料名称"
maxlength="200"
class="inner-cell-input"
/>
</template>
</el-table-column>
<el-table-column label="份数" min-width="120">
<template #default="{ row }">
<el-input-number
v-model="row.copies"
:min="1"
:precision="0"
:step="1"
controls-position="right"
class="inner-cell-input"
/>
</template>
</el-table-column>
<el-table-column label="附件" min-width="320">
<template #default="{ row, $index }">
<div class="paper-att-cards">
<div
v-for="(att, attIdx) in row.attachments"
:key="attIdx"
class="paper-att-card"
>
<el-icon class="paper-att-card__icon"><Document /></el-icon>
<span class="paper-att-card__name" :title="att.fileName">{{
att.fileName || '附件'
}}</span>
<div class="paper-att-card__actions">
<el-icon
v-if="isImageFile(att)"
class="paper-att-card__action"
title="预览"
@click="handlePreviewPaperAttachment(att)"
>
<View />
</el-icon>
<el-icon
class="paper-att-card__action"
title="下载"
@click="handleDownloadPaperAttachment(att)"
>
<Download />
</el-icon>
<el-icon
class="paper-att-card__action paper-att-card__action--danger"
title="删除"
@click="removePaperAttachment($index, attIdx)"
>
<Delete />
</el-icon>
</div>
</div>
<el-upload
:auto-upload="false"
:show-file-list="false"
accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
@change="(file) => handlePaperAttachmentChange($index, file)"
>
<div
class="paper-att-card paper-att-card--trigger"
:class="{ 'is-loading': row._uploading }"
>
<el-icon class="paper-att-card__trigger-icon"><Plus /></el-icon>
<span>{{ row._uploading ? '上传中' : '上传' }}</span>
</div>
</el-upload>
</div>
</template>
</el-table-column>
<el-table-column label="操作" min-width="80" align="center" fixed="right">
<template #default="{ $index }">
<el-button type="danger" link size="small" @click="removePaperRow($index)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<p v-else class="section-empty-hint">点击「添加纸质材料」逐条填写名称、份数并上传附件;仅作台账留存,不扣库存</p>
</section>
<el-form label-position="top" class="biz-dialog-form">
<el-form-item label="备注">
<el-input
v-model="formRemark"
type="textarea"
:rows="2"
placeholder="可选"
maxlength="512"
show-word-limit
/>
</el-form-item>
</el-form>
</div>
<!-- 细胞产品选择 -->
<el-dialog
v-model="cellSelectVisible"
title="选择细胞产品"
width="1200px"
class="dialog-form-layout"
align-center
destroy-on-close
append-to-body
:close-on-click-modal="false"
style="backdrop-filter: none"
>
<el-input
v-model="cellSearchKeyword"
placeholder="搜索产品编码或名称"
clearable
style="margin-bottom: 12px"
@input="loadCellProductOptions"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<div class="list-body list-wrap">
<el-table
ref="cellSelectTableRef"
class="list-table"
:data="cellProductOptions"
stripe
row-key="id"
height="380"
v-loading="cellProductLoading"
@selection-change="onCellSelectionChange"
>
<el-table-column
type="selection"
min-width="45"
align="center"
:selectable="isCellRowSelectable"
/>
<el-table-column prop="productCode" label="产品编码" min-width="180" 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="passage" label="代次" min-width="70" align="center" />
<el-table-column prop="storageSlotName" label="储物格" min-width="140" show-overflow-tooltip />
<el-table-column label="状态" min-width="90" align="center">
<template #default="{ row }">
<el-tag :type="cellProductStatusTagType(row.productStatus) as any" size="small">
{{ cellProductStatusLabel(row.productStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="batchNo" label="批次号" min-width="110" show-overflow-tooltip />
<el-table-column prop="specName" label="规格" min-width="90" show-overflow-tooltip />
</el-table>
</div>
<template #footer>
<el-button @click="cellSelectVisible = false">取消</el-button>
<el-button type="primary" :disabled="!cellSelectedRows.length" @click="confirmCellSelect">
确定(已选 {{ cellSelectedRows.length }} 项)
</el-button>
</template>
</el-dialog>
<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 { ElMessage } from 'element-plus'
import {
Plus,
Delete,
Search,
Document,
Download,
View
} from '@element-plus/icons-vue'
import { fetchUploadAttachment } from '@/api/business'
import { fetchCellProductList } from '@/api/cell-product'
import { fetchQcMaterialOptions, type QcMaterialOption } from '@/api/qc-material'
import type { OutboundReleaseCellRow } from '@/api/outbound-release-review'
import { downloadAttachmentFile } from '@/utils/download-attachment'
import { cellProductStatusLabel, cellProductStatusTagType } from '@/utils/status-tag-registry'
const props = withDefaults(
defineProps<{
/** 出库交接等需扣库存场景:仅允许选择已冻存细胞,且已选行保留可选 */
requireFrozenCellProduct?: boolean
}>(),
{
requireFrozenCellProduct: false
}
)
type AttachmentLike = {
fileName?: string
fileUrl?: string
attachmentUrl?: string
fileType?: string
}
type PaperAttachment = {
attachmentUrl: string
fileName: string
fileType?: string
}
type PaperRow = {
_rowKey: string
paperName: string
copies?: number
attachments: PaperAttachment[]
_uploading?: boolean
}
type MaterialRow = {
_rowKey: string
materialId: string
materialName: string
specModel: string
unit: string
qty?: number
remark?: string
}
defineOptions({ name: 'OutboundMaterialFormSections' })
const loading = ref(false)
const formRemark = ref('')
const cellRows = ref<OutboundReleaseCellRow[]>([])
const materialRows = ref<MaterialRow[]>([])
const paperRows = ref<PaperRow[]>([])
const materialOptions = ref<QcMaterialOption[]>([])
const cellSelectVisible = ref(false)
const cellSearchKeyword = ref('')
const cellProductOptions = ref<Record<string, any>[]>([])
const cellProductLoading = ref(false)
const cellSelectedRows = ref<Record<string, any>[]>([])
const cellSelectTableRef = ref()
const previewVisible = ref(false)
const previewUrl = ref('')
const previewType = ref<'image' | 'video'>('image')
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
let materialRowSeq = 0
let paperRowSeq = 0
function nextMaterialRowKey() {
materialRowSeq += 1
return `mr_${Date.now()}_${materialRowSeq}`
}
function nextPaperRowKey() {
paperRowSeq += 1
return `pr_${Date.now()}_${paperRowSeq}`
}
function fullFileUrl(url: string) {
if (!url) return ''
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('/')) {
return url
}
return minioBaseUrl ? `${minioBaseUrl}/${url.replace(/^\//, '')}` : url
}
function isImageFile(file: AttachmentLike) {
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: AttachmentLike) {
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 stockQtyDisplay(materialId: string) {
const opt = materialOptions.value.find((o) => o.id === materialId)
if (!opt || opt.stockQty == null) return '—'
return String(opt.stockQty)
}
function isMaterialOptionDisabled(materialId: string, currentIndex: number) {
return materialRows.value.some((r, i) => i !== currentIndex && r.materialId === materialId)
}
function onMaterialSelect(index: number, id: string) {
const row = materialRows.value[index]
if (!row) return
if (!id) {
row.materialId = ''
row.materialName = ''
row.specModel = ''
row.unit = ''
return
}
if (isMaterialOptionDisabled(id, index)) {
ElMessage.warning('该物料已在列表中,请勿重复添加')
row.materialId = ''
return
}
const opt = materialOptions.value.find((o) => o.id === id)
row.materialId = id
row.materialName = opt?.materialName || ''
row.specModel = opt?.specModel || ''
row.unit = opt?.unit || ''
}
function addMaterialRow() {
materialRows.value.push({
_rowKey: nextMaterialRowKey(),
materialId: '',
materialName: '',
specModel: '',
unit: '',
qty: undefined
})
}
function removeMaterialRow(index: number) {
materialRows.value.splice(index, 1)
}
function addPaperRow() {
paperRows.value.push({
_rowKey: nextPaperRowKey(),
paperName: '',
copies: 1,
attachments: []
})
}
function removePaperRow(index: number) {
paperRows.value.splice(index, 1)
}
function removePaperAttachment(rowIndex: number, attachmentIndex: number) {
const row = paperRows.value[rowIndex]
if (!row) return
row.attachments.splice(attachmentIndex, 1)
}
function attachmentFileName(url: string) {
return url.split('/').pop() || '附件'
}
function parseAttachmentList(urlStr: string): PaperAttachment[] {
if (!urlStr?.trim()) return []
return urlStr
.split(',')
.map((u) => u.trim())
.filter(Boolean)
.map((url) => ({
attachmentUrl: url,
fileName: attachmentFileName(url)
}))
}
function mapPaperRowsFromApi(
paperMaterials: Array<{
paperName?: string
copies?: number
attachmentUrl?: string
}> = [],
legacyUrls = ''
) {
if (paperMaterials.length) {
return paperMaterials.map((p) => ({
_rowKey: nextPaperRowKey(),
paperName: p.paperName || '',
copies: p.copies && p.copies > 0 ? p.copies : 1,
attachments: parseAttachmentList(p.attachmentUrl || '')
}))
}
if (!legacyUrls.trim()) return []
return legacyUrls
.split(',')
.map((u) => u.trim())
.filter(Boolean)
.map((attachmentUrl) => ({
_rowKey: nextPaperRowKey(),
paperName: attachmentFileName(attachmentUrl),
copies: 1,
attachments: [{ attachmentUrl, fileName: attachmentFileName(attachmentUrl) }]
}))
}
function removeCellRow(index: number) {
cellRows.value.splice(index, 1)
}
function isCellRowSelectable(row: Record<string, any>) {
const ids = new Set(cellRows.value.map((c) => c.cellProductId))
if (ids.has(row.id)) return false
if (props.requireFrozenCellProduct && row.productStatus && row.productStatus !== 'frozen') {
return false
}
return true
}
function onCellSelectionChange(rows: Record<string, any>[]) {
cellSelectedRows.value = rows
}
function openCellSelect() {
cellSearchKeyword.value = ''
cellSelectedRows.value = []
cellSelectVisible.value = true
loadCellProductOptions()
}
async function loadCellProductOptions() {
cellProductLoading.value = true
try {
const res = await fetchCellProductList({
page: 1,
pageSize: 100,
keyword: cellSearchKeyword.value?.trim() || undefined,
productStatus: props.requireFrozenCellProduct ? 'frozen' : undefined
})
cellProductOptions.value = res.list || []
} finally {
cellProductLoading.value = false
}
}
function confirmCellSelect() {
if (!cellSelectedRows.value.length) return
const existing = new Set(cellRows.value.map((c) => c.cellProductId))
let added = 0
for (const row of cellSelectedRows.value) {
if (existing.has(row.id)) continue
cellRows.value.push({
cellProductId: row.id,
productCode: row.productCode || '',
productName: row.productName || '',
productTypeName: row.productTypeName || '',
specName: row.specName || '',
passage: row.passage || '',
batchNo: row.batchNo || '',
storageSlotName: row.storageSlotName || '',
productStatus: row.productStatus || ''
})
existing.add(row.id)
added += 1
}
if (added) {
cellSelectVisible.value = false
}
}
async function loadFromApiData(data: {
remark?: string
cells?: OutboundReleaseCellRow[]
qcMaterials?: Array<{
qcMaterialId?: string
materialName?: string
specModel?: string
unit?: string
qty?: number
remark?: string
}>
paperMaterials?: Array<{
paperName?: string
copies?: number
attachmentUrl?: string
}>
paperAttachments?: string
}) {
formRemark.value = data.remark || ''
cellRows.value = (data.cells || []).map((c) => ({ ...c }))
materialRows.value = (data.qcMaterials || []).map((m) => ({
_rowKey: nextMaterialRowKey(),
materialId: m.qcMaterialId || '',
materialName: m.materialName || '',
specModel: m.specModel || '',
unit: m.unit || '',
qty: m.qty,
remark: m.remark || ''
}))
paperRows.value = mapPaperRowsFromApi(data.paperMaterials, data.paperAttachments)
}
async function ensureMaterialOptions() {
if (materialOptions.value.length) return
try {
materialOptions.value = (await fetchQcMaterialOptions()) || []
} catch {
materialOptions.value = []
}
}
function validateMaterial() {
if (paperRows.value.some((r) => r._uploading)) {
ElMessage.warning('纸质材料附件上传中,请稍候')
return false
}
for (const row of materialRows.value) {
if (!row.materialId) {
ElMessage.warning('请为每条物料选择名称')
return false
}
const qty = row.qty
if (qty == null || !Number.isFinite(Number(qty)) || Number(qty) <= 0) {
ElMessage.warning('物料计划用量须大于 0')
return false
}
}
for (const row of paperRows.value) {
if (!row.paperName?.trim()) {
ElMessage.warning('请填写每条纸质材料的名称')
return false
}
const copies = row.copies
if (copies == null || !Number.isFinite(Number(copies)) || Number(copies) <= 0) {
ElMessage.warning('纸质材料份数须大于 0')
return false
}
if (!row.attachments.length) {
ElMessage.warning('请为每条纸质材料上传附件')
return false
}
}
return true
}
function buildSavePayload() {
return {
remark: formRemark.value.trim() || undefined,
paperMaterials: paperRows.value.map((p) => ({
paperName: p.paperName.trim(),
copies: Number(p.copies),
attachmentUrl: p.attachments.map((a) => a.attachmentUrl).join(',')
})),
cells: cellRows.value.map((c) => ({
cellProductId: String(c.cellProductId || ''),
remark: c.remark
})),
qcMaterials: materialRows.value.map((m) => ({
materialId: m.materialId,
qty: Number(m.qty),
remark: m.remark
}))
}
}
onMounted(() => {
ensureMaterialOptions()
})
function resetState() {
cellRows.value = []
materialRows.value = []
paperRows.value = []
formRemark.value = ''
cellSelectVisible.value = false
}
function handlePreviewPaperAttachment(att: PaperAttachment) {
if (!att.attachmentUrl) return
previewUrl.value = fullFileUrl(att.attachmentUrl)
previewType.value = isVideoFile(att) ? 'video' : 'image'
previewVisible.value = true
}
function handleDownloadPaperAttachment(att: PaperAttachment) {
if (!att.attachmentUrl) return
downloadAttachmentFile(
{
fileName: att.fileName,
fileUrl: att.attachmentUrl,
fileType: att.fileType || ''
},
{ fullFileUrl }
)
}
async function handlePaperAttachmentChange(
index: number,
uploadFile: { raw?: File }
) {
const file = uploadFile.raw
const row = paperRows.value[index]
if (!file || !row || row._uploading) return
row._uploading = true
try {
const fd = new FormData()
fd.append('folder', 'outbound-release-material')
fd.append('file', file)
const res = await fetchUploadAttachment(fd)
row.attachments.push({
attachmentUrl: res.fileUrl,
fileName: file.name || res.fileName || attachmentFileName(res.fileUrl),
fileType: res.fileType || file.type || ''
})
} catch {
ElMessage.error('文件上传失败')
} finally {
row._uploading = false
}
}
defineExpose({
loading,
loadFromApiData,
resetState,
validateMaterial,
buildSavePayload,
ensureMaterialOptions
})
</script>
<style scoped lang="scss">
.material-summary {
margin-bottom: 16px;
padding: 12px 16px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px 20px;
}
.material-summary-row {
display: flex;
gap: 8px;
font-size: 13px;
}
.material-summary-label {
color: #64748b;
flex-shrink: 0;
}
.material-summary-value {
color: #1e293b;
font-weight: 500;
}
.material-sections {
display: flex;
flex-direction: column;
gap: 20px;
}
.section-card {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 12px 16px 16px;
background: #fff;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
gap: 12px;
}
.section-title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #1e293b;
}
.section-empty-hint {
margin: 0;
font-size: 13px;
color: #94a3b8;
}
.inner-cell-input {
width: 100%;
}
.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: center;
justify-content: center;
padding: 6px 4px;
position: relative;
overflow: hidden;
}
.paper-att-card__icon {
color: #0284c7;
font-size: 18px;
margin-bottom: 2px;
}
.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;
}
&--danger:hover {
background: #ef4444;
}
}
.paper-att-card--trigger {
border: 1px dashed #a7f3d0;
background: transparent;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
&:hover {
border-color: #10b981;
background: #ecfdf5;
}
&.is-loading {
pointer-events: none;
opacity: 0.6;
}
}
.paper-att-card__trigger-icon {
font-size: 16px;
color: #10b981;
margin-bottom: 2px;
}
.paper-att-card--trigger span {
font-size: 11px;
color: #64748b;
}
.preview-container {
text-align: center;
}
</style>
@@ -103,6 +103,8 @@
const props = defineProps<{
visible: boolean
taskId?: string
/** 不展示样本信息、运输信息、样本接收(出库放行审核等) */
hideSampleContext?: boolean
}>()
const emit = defineEmits<{
@@ -265,9 +267,10 @@
}
]
: []
const sampleSections: ViewSection[] = isTempQc
? []
: [
const sampleSections: ViewSection[] =
props.hideSampleContext || isTempQc
? []
: [
{
title: '样本信息',
fields: [
@@ -84,7 +84,11 @@
</template>
<Teleport to="body">
<QcTaskViewDialog v-model:visible="taskViewVisible" :task-id="taskViewId" />
<QcTaskViewDialog
v-model:visible="taskViewVisible"
:task-id="taskViewId"
:hide-sample-context="hideSampleContext"
/>
</Teleport>
</div>
</template>
@@ -144,6 +148,8 @@
const props = defineProps<{
qcInfo?: SampleQcInfo | null
sampleReception?: SampleReceptionBrief | null
/** 任务详情弹窗不展示样本信息、样本接收(出库放行等场景) */
hideSampleContext?: boolean
}>()
const taskViewVisible = ref(false)
@@ -45,12 +45,12 @@
<dt>{{ field.label }}</dt>
<dd>
<el-tag v-if="field.tagType" :type="field.tagType" size="small">{{
field.value ?? '—'
formatViewValue(field.value)
}}</el-tag>
<ul v-else-if="field.items?.length" class="view-plain-list">
<li v-for="(item, idx) in field.items" :key="idx">{{ item }}</li>
</ul>
<template v-else>{{ field.value ?? '—' }}</template>
<template v-else>{{ formatViewValue(field.value) }}</template>
</dd>
</div>
</dl>
@@ -84,6 +84,13 @@
return val != null && (typeof val === 'object' || typeof val === 'function')
}
/** 查看弹窗字段展示:null/undefined/空字符串 → —;布尔转是/否 */
function formatViewValue(val: unknown): string {
if (val === null || val === undefined || val === '') return '—'
if (typeof val === 'boolean') return val ? '是' : '否'
return String(val)
}
export type TagType = 'success' | 'warning' | 'danger' | 'info' | '' | 'primary'
export interface ViewField {