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