|
|
@@ -0,0 +1,264 @@
|
|
|
+<!-- 细胞产品 — 来源链质检只读 -->
|
|
|
+<!-- eslint-disable vue/no-v-model-argument -->
|
|
|
+<template>
|
|
|
+ <el-dialog
|
|
|
+ :model-value="modelValue"
|
|
|
+ title="来源链质检"
|
|
|
+ width="1040px"
|
|
|
+ class="dialog-form-layout cell-lineage-qc-dialog"
|
|
|
+ align-center
|
|
|
+ append-to-body
|
|
|
+ modal-class="protocol-dialog-mask"
|
|
|
+ destroy-on-close
|
|
|
+ :close-on-click-modal="false"
|
|
|
+ style="backdrop-filter: none"
|
|
|
+ @update:model-value="emit('update:modelValue', $event)"
|
|
|
+ @closed="resetState"
|
|
|
+ >
|
|
|
+ <div v-loading="loading" class="lineage-qc-body">
|
|
|
+ <div v-if="data" class="lineage-qc-summary">
|
|
|
+ <span class="lineage-qc-summary__code">{{ data.productCode || '—' }}</span>
|
|
|
+ <span class="lineage-qc-summary__name">{{ data.productName || '—' }}</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <el-empty
|
|
|
+ v-if="!loading && (!data?.nodes || !data.nodes.length)"
|
|
|
+ description="暂无来源链数据"
|
|
|
+ />
|
|
|
+
|
|
|
+ <el-collapse v-else-if="data?.nodes?.length" v-model="activeNames" class="lineage-qc-collapse">
|
|
|
+ <el-collapse-item
|
|
|
+ v-for="(node, idx) in data.nodes"
|
|
|
+ :key="`${node.nodeType}-${node.id}-${idx}`"
|
|
|
+ :name="String(idx)"
|
|
|
+ >
|
|
|
+ <template #title>
|
|
|
+ <div class="lineage-qc-node-title">
|
|
|
+ <el-tag v-if="node.isCurrent" type="success" size="small">当前</el-tag>
|
|
|
+ <el-tag v-if="node.nodeType === 'sample'" type="info" size="small">样本</el-tag>
|
|
|
+ <el-tag v-else type="primary" size="small">细胞产品</el-tag>
|
|
|
+ <span class="lineage-qc-node-title__label">{{ node.label || '—' }}</span>
|
|
|
+ <span v-if="node.productName" class="lineage-qc-node-title__sub">{{
|
|
|
+ node.productName
|
|
|
+ }}</span>
|
|
|
+ <span v-if="node.passage" class="lineage-qc-node-title__sub"
|
|
|
+ >代次 {{ node.passage }}</span
|
|
|
+ >
|
|
|
+ <span v-if="node.receptionSourceLabel" class="lineage-qc-node-title__sub">{{
|
|
|
+ node.receptionSourceLabel
|
|
|
+ }}</span>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <div v-if="!(node.workOrders || []).length" class="lineage-qc-empty">暂无质检工单</div>
|
|
|
+ <div v-for="wo in node.workOrders || []" :key="wo.id" class="lineage-qc-wo">
|
|
|
+ <div class="lineage-qc-wo__head">
|
|
|
+ <span class="lineage-qc-wo__no">{{ wo.workOrderNo || '—' }}</span>
|
|
|
+ <el-tag v-if="wo.workOrderType" type="info" size="small">{{
|
|
|
+ workOrderTypeLabel(wo.workOrderType)
|
|
|
+ }}</el-tag>
|
|
|
+ <el-tag
|
|
|
+ v-if="wo.workOrderStatus"
|
|
|
+ :type="workOrderStatusTagType(wo.workOrderStatus)"
|
|
|
+ size="small"
|
|
|
+ >
|
|
|
+ {{ wo.workOrderStatusLabel || wo.workOrderStatus }}
|
|
|
+ </el-tag>
|
|
|
+ </div>
|
|
|
+ <QcWorkOrderTaskResultPanel :tasks="mapTasks(wo.tasks)" />
|
|
|
+ </div>
|
|
|
+ </el-collapse-item>
|
|
|
+ </el-collapse>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <template #footer>
|
|
|
+ <el-button class="btn-ghost" @click="emit('update:modelValue', false)">
|
|
|
+ <el-icon><Close /></el-icon>
|
|
|
+ 关闭
|
|
|
+ </el-button>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+ import { ElMessage } from 'element-plus'
|
|
|
+ import { Close } from '@element-plus/icons-vue'
|
|
|
+ import {
|
|
|
+ fetchCellProductLineageQc,
|
|
|
+ type CellProductLineageQc,
|
|
|
+ type CellProductLineageQcTask
|
|
|
+ } from '@/api/cell-product'
|
|
|
+ import QcWorkOrderTaskResultPanel from '@/views/quality-inspection/qc-work-order/QcWorkOrderTaskResultPanel.vue'
|
|
|
+ import type { QcWorkOrderTaskView } from '@/views/quality-inspection/qc-work-order/composables/useQcWorkOrderTaskViewSections'
|
|
|
+
|
|
|
+ defineOptions({ name: 'CellProductLineageQcDialog' })
|
|
|
+
|
|
|
+ const props = defineProps<{
|
|
|
+ modelValue: boolean
|
|
|
+ productId: string
|
|
|
+ }>()
|
|
|
+
|
|
|
+ const emit = defineEmits<{
|
|
|
+ (e: 'update:modelValue', val: boolean): void
|
|
|
+ }>()
|
|
|
+
|
|
|
+ const loading = ref(false)
|
|
|
+ const data = ref<CellProductLineageQc | null>(null)
|
|
|
+ const activeNames = ref<string[]>([])
|
|
|
+
|
|
|
+ function workOrderTypeLabel(type?: string) {
|
|
|
+ const map: Record<string, string> = {
|
|
|
+ inbound: '入库',
|
|
|
+ temporary: '临时',
|
|
|
+ outbound: '出库'
|
|
|
+ }
|
|
|
+ return map[type || ''] || type || '—'
|
|
|
+ }
|
|
|
+
|
|
|
+ function workOrderStatusTagType(status?: string): 'success' | 'warning' | 'info' | 'danger' {
|
|
|
+ if (status === 'ywc') return 'success'
|
|
|
+ if (status === 'yzf') return 'danger'
|
|
|
+ if (status === 'ypz') return 'warning'
|
|
|
+ return 'info'
|
|
|
+ }
|
|
|
+
|
|
|
+ function mapTasks(tasks?: CellProductLineageQcTask[]): QcWorkOrderTaskView[] {
|
|
|
+ return (tasks || []).map((t) => ({
|
|
|
+ ...(t as any),
|
|
|
+ results: t.results || [],
|
|
|
+ attachmentFiles: t.attachmentFiles || [],
|
|
|
+ materialUsages: []
|
|
|
+ }))
|
|
|
+ }
|
|
|
+
|
|
|
+ async function loadData() {
|
|
|
+ const id = (props.productId || '').trim()
|
|
|
+ if (!id) return
|
|
|
+ loading.value = true
|
|
|
+ data.value = null
|
|
|
+ try {
|
|
|
+ const res = await fetchCellProductLineageQc(id)
|
|
|
+ data.value = res || null
|
|
|
+ activeNames.value = (res?.nodes || []).map((_, i) => String(i))
|
|
|
+ } catch {
|
|
|
+ ElMessage.error('加载来源链质检失败')
|
|
|
+ data.value = null
|
|
|
+ activeNames.value = []
|
|
|
+ } finally {
|
|
|
+ loading.value = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function resetState() {
|
|
|
+ data.value = null
|
|
|
+ activeNames.value = []
|
|
|
+ loading.value = false
|
|
|
+ }
|
|
|
+
|
|
|
+ watch(
|
|
|
+ () => [props.modelValue, props.productId] as const,
|
|
|
+ ([vis, id]) => {
|
|
|
+ if (vis && id) loadData()
|
|
|
+ },
|
|
|
+ { immediate: true }
|
|
|
+ )
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+ .lineage-qc-body {
|
|
|
+ min-height: 160px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .lineage-qc-summary {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ align-items: baseline;
|
|
|
+ gap: 8px 16px;
|
|
|
+ margin-bottom: 16px;
|
|
|
+ padding: 12px 16px;
|
|
|
+ background: #f8fafc;
|
|
|
+ border: 1px solid #e2e8f0;
|
|
|
+ border-radius: 8px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .lineage-qc-summary__code {
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #1e293b;
|
|
|
+ }
|
|
|
+
|
|
|
+ .lineage-qc-summary__name {
|
|
|
+ font-size: 13px;
|
|
|
+ color: var(--mint-text-muted, #64748b);
|
|
|
+ }
|
|
|
+
|
|
|
+ .lineage-qc-collapse {
|
|
|
+ border: none;
|
|
|
+
|
|
|
+ :deep(.el-collapse-item__header) {
|
|
|
+ height: auto;
|
|
|
+ min-height: 48px;
|
|
|
+ line-height: 1.4;
|
|
|
+ padding: 10px 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ :deep(.el-collapse-item__wrap) {
|
|
|
+ border-bottom: none;
|
|
|
+ }
|
|
|
+
|
|
|
+ :deep(.el-collapse-item__content) {
|
|
|
+ padding-bottom: 16px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .lineage-qc-node-title {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ padding-right: 12px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .lineage-qc-node-title__label {
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #1e293b;
|
|
|
+ }
|
|
|
+
|
|
|
+ .lineage-qc-node-title__sub {
|
|
|
+ font-size: 12px;
|
|
|
+ color: var(--mint-text-muted, #64748b);
|
|
|
+ }
|
|
|
+
|
|
|
+ .lineage-qc-empty {
|
|
|
+ padding: 12px 4px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: var(--mint-text-muted, #64748b);
|
|
|
+ }
|
|
|
+
|
|
|
+ .lineage-qc-wo {
|
|
|
+ margin-bottom: 12px;
|
|
|
+ padding: 12px 14px;
|
|
|
+ border: 1px solid #e2e8f0;
|
|
|
+ border-radius: 8px;
|
|
|
+ background: #fff;
|
|
|
+
|
|
|
+ &:last-child {
|
|
|
+ margin-bottom: 0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .lineage-qc-wo__head {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ margin-bottom: 12px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .lineage-qc-wo__no {
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #1e293b;
|
|
|
+ }
|
|
|
+</style>
|