feat: expose lineage QC API and wire viewer in outbound material dialog
Add GET /cell-products/{id}/lineage-qc, frontend client, readonly dialog,
and a 查看质检 action on outbound cell rows.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
40d93934a9
commit
33c4408557
4 files changed
+372
-10
No files matched your search
@@ -2800,6 +2800,19 @@ async def get_cell_product_chain(
|
||||
return success_response(chain)
|
||||
|
||||
|
||||
@router.get("/cell-products/{product_id}/lineage-qc")
|
||||
async def get_cell_product_lineage_qc(
|
||||
product_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
data = cell_product_svc.get_product_lineage_qc(db, product_id, tenant_no)
|
||||
if not data:
|
||||
raise HTTPException(status_code=404, detail="细胞产品不存在")
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.post("/cell-products")
|
||||
async def create_cell_product(
|
||||
body: CellProductCreate,
|
||||
|
||||
@@ -76,3 +76,54 @@ export function fetchCheckCellProductCode(productCode: string, excludeId?: strin
|
||||
export function fetchCellProductChain(productId: string) {
|
||||
return request.get<any>({ url: `/web/admin/cell-products/${productId}/chain` })
|
||||
}
|
||||
|
||||
export type CellProductLineageQcTask = {
|
||||
id?: string
|
||||
taskNo?: string
|
||||
itemName?: string
|
||||
itemCode?: string
|
||||
taskType?: string
|
||||
taskStatus?: string
|
||||
taskStatusLabel?: string
|
||||
conclusion?: string
|
||||
conclusionLabel?: string
|
||||
remark?: string
|
||||
completedTime?: string
|
||||
companyName?: string
|
||||
results?: Array<{ paramName?: string; paramValue?: string; paramUnit?: string }>
|
||||
attachmentFiles?: Array<{ fileName?: string; fileUrl?: string; fileType?: string }>
|
||||
}
|
||||
|
||||
export type CellProductLineageQcWorkOrder = {
|
||||
id?: string
|
||||
workOrderNo?: string
|
||||
workOrderType?: string
|
||||
workOrderStatus?: string
|
||||
workOrderStatusLabel?: string
|
||||
tasks?: CellProductLineageQcTask[]
|
||||
}
|
||||
|
||||
export type CellProductLineageQcNode = {
|
||||
nodeType?: 'sample' | 'cell_product' | string
|
||||
id?: string
|
||||
label?: string
|
||||
productName?: string
|
||||
passage?: string | null
|
||||
receptionSource?: string | null
|
||||
receptionSourceLabel?: string | null
|
||||
isCurrent?: boolean
|
||||
workOrders?: CellProductLineageQcWorkOrder[]
|
||||
}
|
||||
|
||||
export type CellProductLineageQc = {
|
||||
productId?: string
|
||||
productCode?: string
|
||||
productName?: string
|
||||
nodes?: CellProductLineageQcNode[]
|
||||
}
|
||||
|
||||
export function fetchCellProductLineageQc(productId: string) {
|
||||
return request.get<CellProductLineageQc>({
|
||||
url: `/web/admin/cell-products/${productId}/lineage-qc`
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
+44
-10
@@ -21,10 +21,22 @@
|
||||
<span class="material-summary-label">申请单号</span>
|
||||
<span class="material-summary-value">{{ scheduleSummary.applicationNo || '—' }}</span>
|
||||
</div>
|
||||
<div class="material-summary-row">
|
||||
<span class="material-summary-label">患者姓名</span>
|
||||
<span class="material-summary-value">{{ scheduleSummary.patientName || '—' }}</span>
|
||||
</div>
|
||||
<template v-if="showPatient">
|
||||
<div class="material-summary-row">
|
||||
<span class="material-summary-label">患者姓名</span>
|
||||
<span class="material-summary-value">{{ scheduleSummary.patientName || '—' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="material-summary-row">
|
||||
<span class="material-summary-label">课题名称</span>
|
||||
<span class="material-summary-value">{{ scheduleSummary.projectTitle || '—' }}</span>
|
||||
</div>
|
||||
<div class="material-summary-row">
|
||||
<span class="material-summary-label">客户名称</span>
|
||||
<span class="material-summary-value">{{ scheduleSummary.customerName || '—' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="material-summary-row">
|
||||
<span class="material-summary-label">{{ pickupTimeLabel }}</span>
|
||||
<span class="material-summary-value">{{ scheduleSummary.infusionTime || '—' }}</span>
|
||||
@@ -99,12 +111,16 @@
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="操作"
|
||||
min-width="100"
|
||||
min-width="200"
|
||||
align="center"
|
||||
header-align="center"
|
||||
fixed="right"
|
||||
>
|
||||
<template #default="{ $index }">
|
||||
<template #default="{ row, $index }">
|
||||
<el-button type="primary" link @click="openLineageQc(row)">
|
||||
<el-icon><View /></el-icon>
|
||||
查看质检
|
||||
</el-button>
|
||||
<el-button type="danger" link @click="removeCellRow($index)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
@@ -406,6 +422,8 @@
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<CellProductLineageQcDialog v-model="lineageQcVisible" :product-id="lineageQcProductId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -434,6 +452,7 @@
|
||||
import { cellProductStatusLabel, cellProductStatusTagType } from '@/utils/status-tag-registry'
|
||||
import { resolveCellProductSpecDisplay } from '@/utils/cell-product-spec'
|
||||
import { getPilotBizConfig, resolvePilotBizType } from '@/config/pilot-biz-type'
|
||||
import CellProductLineageQcDialog from '@/components/business/cell-product-lineage-qc-dialog/index.vue'
|
||||
|
||||
type AttachmentLike = {
|
||||
fileName?: string
|
||||
@@ -473,13 +492,15 @@
|
||||
scheduleSummary?: {
|
||||
applicationNo?: string
|
||||
patientName?: string
|
||||
projectTitle?: string
|
||||
customerName?: string
|
||||
infusionTime?: string
|
||||
}
|
||||
}>()
|
||||
|
||||
const pickupTimeLabel = computed(
|
||||
() => getPilotBizConfig(resolvePilotBizType(props.bizType)).pickupTimeLabel
|
||||
)
|
||||
const bizConfig = computed(() => getPilotBizConfig(resolvePilotBizType(props.bizType)))
|
||||
const pickupTimeLabel = computed(() => bizConfig.value.pickupTimeLabel)
|
||||
const showPatient = computed(() => bizConfig.value.showPatient)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', val: boolean): void
|
||||
@@ -504,6 +525,9 @@
|
||||
const previewUrl = ref('')
|
||||
const previewType = ref<'image' | 'video'>('image')
|
||||
|
||||
const lineageQcVisible = ref(false)
|
||||
const lineageQcProductId = ref('')
|
||||
|
||||
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
|
||||
|
||||
let paperRowSeq = 0
|
||||
@@ -608,6 +632,16 @@
|
||||
cellRows.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function openLineageQc(row: OutboundReleaseCellRow) {
|
||||
const id = String(row.cellProductId || '')
|
||||
if (!id) {
|
||||
ElMessage.warning('缺少细胞产品信息')
|
||||
return
|
||||
}
|
||||
lineageQcProductId.value = id
|
||||
lineageQcVisible.value = true
|
||||
}
|
||||
|
||||
function isCellRowSelectable(row: Record<string, any>) {
|
||||
const ids = new Set(cellRows.value.map((c) => c.cellProductId))
|
||||
return !ids.has(row.id)
|
||||
@@ -807,7 +841,7 @@
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 8px 20px;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user