This commit is contained in:
zlwu committed 2026-06-16 15:17:32 +08:00
1 parent 4f24d3b687
commit 1ff73b9a74
202 files changed
+11989 -10045

No files matched your search

@@ -1044,61 +1044,63 @@
class="section-card section-card--white"
>
<div class="section-title section-title--banner"><h3>审核记录</h3></div>
<el-table
v-loading="auditLoading"
class="list-table sub-view-table"
:data="auditRecords"
stripe
empty-text="暂无审核记录"
>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
/>
<el-table-column
prop="createTime"
label="审核时间"
min-width="170"
align="center"
header-align="center"
/>
<el-table-column
prop="auditStatus"
label="审核状态"
min-width="120"
align="center"
header-align="center"
<div class="list-body list-wrap view-inner-table-wrap">
<el-table
v-loading="auditLoading"
class="list-table inner-table"
:data="auditRecords"
stripe
empty-text="暂无审核记录"
>
<template #default="{ row }">
<el-tag
:type="String(row.auditStatus) === 'shtg' ? 'success' : 'warning'"
size="small"
>
{{ auditStatusLabel(String(row.auditStatus || '')) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="auditRemark"
label="审核备注"
min-width="200"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="auditorUserName"
label="审核人"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">{{ auditorDisplay(row) }}</template>
</el-table-column>
</el-table>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
/>
<el-table-column
prop="createTime"
label="审核时间"
min-width="170"
align="center"
header-align="center"
/>
<el-table-column
prop="auditStatus"
label="审核状态"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
:type="String(row.auditStatus) === 'shtg' ? 'success' : 'warning'"
size="small"
>
{{ auditStatusLabel(String(row.auditStatus || '')) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="auditRemark"
label="审核备注"
min-width="200"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="auditorUserName"
label="审核人"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">{{ auditorDisplay(row) }}</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</template>
@@ -1851,26 +1853,21 @@
}
}
async function loadAuditRecords(protocolNoHint?: string) {
async function loadAuditRecords() {
if (!props.showAuditHistory || !props.protocolId) {
auditRecords.value = []
return
}
auditLoading.value = true
try {
const pno =
String(
protocolNoHint || protocol.value?.protocolNo || props.listRow?.protocolNo || ''
).trim() || undefined
const res = await fetchCollectionProtocolAuditList({
page: 1,
pageSize: 100,
keyword: pno
collectionProtocolId: props.protocolId
})
const pid = props.protocolId
auditRecords.value = (res.list || []).filter(
(row) => String(row.collectionProtocolId || '') === pid
)
auditRecords.value = res.list || []
} catch {
auditRecords.value = []
} finally {
auditLoading.value = false
}
@@ -1883,10 +1880,8 @@
bundle.value = null
auditRecords.value = []
const protocolNoHint = String(props.listRow?.protocolNo || '').trim()
const baseLookupsPromise = loadBaseLookups().catch(() => {})
const auditEarlyPromise =
props.showAuditHistory && protocolNoHint ? loadAuditRecords(protocolNoHint) : null
const auditPromise = props.showAuditHistory ? loadAuditRecords() : null
let data: Record<string, any> | null = null
try {
@@ -1902,10 +1897,7 @@
}
void finishLookups(data, baseLookupsPromise)
if (props.showAuditHistory && !auditEarlyPromise) {
void loadAuditRecords()
}
void auditPromise
}
function scrollToSection(key: string) {
@@ -2292,9 +2284,38 @@
gap: 2px;
}
/* ---- Sub View Table ---- */
.sub-view-table {
margin-bottom: 8px;
/* ---- 审核记录内嵌列表(对齐 OpenSpec 嵌套列表 14px 标准) ---- */
.view-inner-table-wrap.list-body.list-wrap {
margin-top: 4px;
border: 1px solid var(--mint-border, #e2e8f0);
border-radius: var(--mint-radius, 10px);
overflow: hidden;
background: #fff;
:deep(.el-table) {
--el-table-header-bg-color: var(--mint-table-head, #f8fafc);
--el-table-border-color: var(--mint-border, #e2e8f0);
--el-table-row-hover-bg-color: var(--mint-row-hover, #f0fdf4);
--el-table-text-color: var(--mint-text, #0f172a);
--el-table-header-text-color: var(--mint-text-muted, #64748b);
font-size: 14px;
}
:deep(.el-table th.el-table__cell) {
font-size: 12px;
font-weight: 600;
color: var(--mint-text-muted, #64748b);
}
:deep(.el-table td.el-table__cell) {
font-size: 14px;
font-weight: 500;
color: var(--mint-text, #0f172a);
}
:deep(.el-empty) {
padding: 24px 0;
}
}
/* ---- Sample Cards ---- */
@@ -429,116 +429,195 @@
<el-dialog
v-model="occupancyDialogVisible"
:title="occupancyDialogTitle"
width="920px"
top="5vh"
class="dialog-form-layout"
width="1280px"
class="dialog-form-layout storage-occupancy-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
>
<el-tabs v-model="occupancyActiveTab">
<el-tabs v-model="occupancyActiveTab" class="storage-occupancy-tabs">
<el-tab-pane label="在库物品" name="stored">
<div class="occupancy-toolbar">
<el-button
v-if="occupancyTotal === 0"
type="primary"
size="small"
@click="handleOpenStoreDialog"
>
入库
</el-button>
<span v-else class="occupancy-full-tip">该储物格已有物品在库,一格只能存放一个物品</span>
<div class="list-toolbar occupancy-list-toolbar">
<div class="list-toolbar-row">
<el-button
v-if="occupancyTotal === 0"
class="btn-main"
type="primary"
@click="handleOpenStoreDialog"
>
<el-icon><Plus /></el-icon>
入库
</el-button>
<span v-else class="occupancy-full-tip">该储物格已有物品在库,一格只能存放一个物品</span>
</div>
</div>
<el-table
:data="occupancyList"
v-loading="occupancyLoading"
size="small"
max-height="360"
>
<el-table-column type="index" label="序号" min-width="60" />
<el-table-column label="物品类型" min-width="120">
<template #default="{ row }">
<el-tag :type="row.itemType === 'cell_product' ? 'primary' : 'info'" size="small">
{{ row.itemType === 'cell_product' ? '细胞产品' : '其他' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="itemCode" label="物品编码" min-width="140" />
<el-table-column prop="itemName" label="物品名称" min-width="140" />
<el-table-column label="来源" min-width="120">
<template #default="{ row }">
{{
{ production: '生产入库', manual: '手动入库', transfer: '转移' }[
row.itemSource
] || row.itemSource
}}
</template>
</el-table-column>
<el-table-column prop="storedAt" label="入库时间" min-width="170" />
<el-table-column label="操作" min-width="80" fixed="right">
<template #default="{ row }">
<el-button type="danger" link size="small" @click="handleRemoveOccupancy(row)">
出库
</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="occupancyTotal > occupancyPageSize" class="occupancy-pagination">
<el-pagination
small
layout="total, prev, pager, next"
:total="occupancyTotal"
:page-size="occupancyPageSize"
v-model:current-page="occupancyPage"
@current-change="loadOccupancies"
/>
<div class="list-body list-wrap view-inner-table-wrap">
<el-table
class="list-table inner-table"
:data="occupancyList"
v-loading="occupancyLoading"
stripe
row-key="id"
empty-text="暂无在库物品"
>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (occupancyPage - 1) * occupancyPageSize + index + 1"
/>
<el-table-column label="物品类型" min-width="120" align="center" header-align="center">
<template #default="{ row }">
<el-tag :type="row.itemType === 'cell_product' ? 'primary' : 'info'" size="small">
{{ row.itemType === 'cell_product' ? '细胞产品' : '其他' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="itemCode"
label="物品编码"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="itemName"
label="物品名称"
min-width="140"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column label="来源" min-width="120" align="center" header-align="center">
<template #default="{ row }">
{{
{ production: '生产入库', manual: '手动入库', transfer: '转移' }[
row.itemSource
] || row.itemSource
}}
</template>
</el-table-column>
<el-table-column
prop="storedAt"
label="入库时间"
min-width="170"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
min-width="120"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item" @click="handleRemoveOccupancy(row)">
<el-icon><Top /></el-icon>出库
</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="occupancyPage"
:page-size="occupancyPageSize"
:total="occupancyTotal"
layout="total, prev, pager, next"
@current-change="(val) => ((occupancyPage = val), loadOccupancies())"
/>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="出入库历史" name="history">
<el-table :data="historyList" v-loading="historyLoading" size="small" max-height="400">
<el-table-column type="index" label="序号" min-width="60" />
<el-table-column label="物品类型" min-width="120">
<template #default="{ row }">
<el-tag :type="row.itemType === 'cell_product' ? 'primary' : 'info'" size="small">
{{ row.itemType === 'cell_product' ? '细胞产品' : '其他' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="itemCode" label="物品编码" min-width="120" />
<el-table-column prop="itemName" label="物品名称" min-width="120" />
<el-table-column label="来源" min-width="100">
<template #default="{ row }">
{{
{ production: '生产入库', manual: '手动入库', transfer: '转移' }[
row.itemSource
] || row.itemSource
}}
</template>
</el-table-column>
<el-table-column prop="storedAt" label="入库时间" min-width="170" />
<el-table-column prop="removedAt" label="出库时间" min-width="170" />
<el-table-column label="状态" min-width="100">
<template #default="{ row }">
<el-tag
:type="row.occupancyStatus === 'stored' ? 'success' : 'warning'"
size="small"
>
{{ row.occupancyStatus === 'stored' ? '在库' : '已出库' }}
</el-tag>
</template>
</el-table-column>
</el-table>
<div v-if="historyTotal > historyPageSize" class="occupancy-pagination">
<el-pagination
small
layout="total, prev, pager, next"
:total="historyTotal"
:page-size="historyPageSize"
v-model:current-page="historyPage"
@current-change="loadHistory"
/>
<div class="list-body list-wrap view-inner-table-wrap">
<el-table
class="list-table inner-table"
:data="historyList"
v-loading="historyLoading"
stripe
row-key="id"
empty-text="暂无出入库历史"
>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (historyPage - 1) * historyPageSize + index + 1"
/>
<el-table-column label="物品类型" min-width="120" align="center" header-align="center">
<template #default="{ row }">
<el-tag :type="row.itemType === 'cell_product' ? 'primary' : 'info'" size="small">
{{ row.itemType === 'cell_product' ? '细胞产品' : '其他' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="itemCode"
label="物品编码"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="itemName"
label="物品名称"
min-width="140"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column label="来源" min-width="120" align="center" header-align="center">
<template #default="{ row }">
{{
{ production: '生产入库', manual: '手动入库', transfer: '转移' }[
row.itemSource
] || row.itemSource
}}
</template>
</el-table-column>
<el-table-column
prop="storedAt"
label="入库时间"
min-width="170"
align="center"
header-align="center"
/>
<el-table-column
prop="removedAt"
label="出库时间"
min-width="170"
align="center"
header-align="center"
/>
<el-table-column label="状态" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag
:type="row.occupancyStatus === 'stored' ? 'success' : 'warning'"
size="small"
>
{{ row.occupancyStatus === 'stored' ? '在库' : '已出库' }}
</el-tag>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="historyPage"
:page-size="historyPageSize"
:total="historyTotal"
layout="total, prev, pager, next"
@current-change="(val) => ((historyPage = val), loadHistory())"
/>
</div>
</div>
</el-tab-pane>
@@ -576,11 +655,12 @@
</el-form>
<div class="occupancy-slot-save-bar">
<el-button
class="btn-main"
type="primary"
size="small"
:loading="occupancySlotSaving"
@click="handleOccupancySlotSave"
>
<el-icon><Check /></el-icon>
保存
</el-button>
</div>
@@ -635,7 +715,7 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Close, Check } from '@element-plus/icons-vue'
import { Plus, Close, Check, Top } from '@element-plus/icons-vue'
import {
fetchStorageFacilityList,
fetchCreateStorageFacility,
@@ -1678,14 +1758,14 @@
}
.shelf-label {
width: 28px;
width: 36px;
flex-shrink: 0;
background: #ea580c;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-size: 14px;
font-weight: 600;
border-radius: 4px 0 0 4px;
}
@@ -1693,12 +1773,12 @@
.shelf-cells {
display: flex;
flex-wrap: wrap;
gap: 4px;
gap: 6px;
}
.shelf-cell {
width: 40px;
height: 40px;
width: 80px;
height: 80px;
border: 1px solid #fed7aa;
display: flex;
flex-direction: column;
@@ -1739,7 +1819,7 @@
}
.add-icon {
font-size: 20px;
font-size: 32px;
color: #fb923c;
line-height: 1;
}
@@ -1754,14 +1834,14 @@
position: absolute;
top: 0;
right: 0;
width: 14px;
height: 14px;
width: 22px;
height: 22px;
border: none;
border-radius: 0 4px 0 4px;
background: rgba(245, 108, 108, 0.85);
color: #fff;
font-size: 10px;
line-height: 14px;
font-size: 14px;
line-height: 22px;
text-align: center;
cursor: pointer;
padding: 0;
@@ -1779,24 +1859,25 @@
}
.cell-code {
font-size: 10px;
font-size: 12px;
font-weight: 600;
color: #303133;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 34px;
max-width: 72px;
padding: 0 4px;
}
.cell-badge {
position: absolute;
top: 2px;
right: 2px;
min-width: 16px;
height: 16px;
line-height: 16px;
font-size: 10px;
top: 4px;
right: 4px;
min-width: 20px;
height: 20px;
line-height: 20px;
font-size: 11px;
text-align: center;
color: #fff;
background: #f56c6c;
@@ -1804,8 +1885,10 @@
padding: 0 4px;
}
.occupancy-toolbar {
.occupancy-list-toolbar {
margin-bottom: 12px;
padding-bottom: 0;
border-bottom: none;
}
.occupancy-full-tip {
@@ -1813,15 +1896,59 @@
color: var(--mint-text-muted, #64748b);
}
.occupancy-pagination {
margin-top: 12px;
display: flex;
justify-content: flex-end;
:deep(.el-dialog.storage-occupancy-dialog) {
min-height: min(720px, calc(100vh - 100px));
}
.storage-occupancy-tabs {
:deep(.el-tabs__content) {
min-height: 520px;
}
}
.view-inner-table-wrap.list-body.list-wrap {
margin-top: 0;
border: 1px solid var(--mint-border, #e2e8f0);
border-radius: var(--mint-radius, 10px);
overflow: hidden;
background: #fff;
:deep(.el-table) {
--el-table-header-bg-color: var(--mint-table-head, #f8fafc);
--el-table-border-color: var(--mint-border, #e2e8f0);
--el-table-row-hover-bg-color: var(--mint-row-hover, #f0fdf4);
--el-table-text-color: var(--mint-text, #0f172a);
--el-table-header-text-color: var(--mint-text-muted, #64748b);
font-size: 14px;
}
:deep(.el-table th.el-table__cell) {
font-size: 12px;
font-weight: 600;
color: var(--mint-text-muted, #64748b);
}
:deep(.el-table td.el-table__cell) {
font-size: 14px;
font-weight: 500;
color: var(--mint-text, #0f172a);
}
:deep(.el-empty) {
padding: 24px 0;
}
.pagination.list-pager-bar {
display: flex;
justify-content: flex-end;
}
}
.occupancy-slot-save-bar {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--el-border-color-lighter);
display: flex;
justify-content: flex-end;
}
</style>
@@ -553,11 +553,112 @@
v-model:visible="viewVisible"
:title="viewRow?.productName || '产品详情'"
:icon="Box"
:meta="viewRow?.productCode || ''"
:code="viewRow?.productCode"
code-label="产品编码"
:sections="viewSections"
width="1200px"
:grid-columns="4"
:grid-columns="3"
>
<template #basicInfo>
<div v-if="viewRow" class="cell-product-basic">
<div class="cell-product-basic__content">
<div class="cell-product-basic__summary">
<div class="cell-product-basic__headline">
<span class="cell-product-basic__name">{{ viewCellVal(viewRow.productName) }}</span>
<el-tag
:type="productStatusTag(String(viewRow.productStatus || '')).type"
size="small"
>
{{ productStatusTag(String(viewRow.productStatus || '')).label }}
</el-tag>
</div>
<div class="cell-product-basic__meta">
<span>{{ viewCellVal(viewRow.productTypeName) }}</span>
<span class="cell-product-basic__meta-sep">·</span>
<span>{{ viewCellVal(viewRow.specName) }}</span>
</div>
</div>
<div class="cell-product-basic__panels">
<div class="cell-product-basic__panel">
<div class="cell-product-basic__panel-title">批次与存储</div>
<div class="cell-product-basic__grid">
<div class="cell-product-basic__field">
<div class="cell-product-basic__field-label">代次</div>
<div class="cell-product-basic__field-value">{{ viewCellVal(viewRow.passage) }}</div>
</div>
<div class="cell-product-basic__field">
<div class="cell-product-basic__field-label">批次号</div>
<div class="cell-product-basic__field-value">{{ viewCellVal(viewRow.batchNo) }}</div>
</div>
<div class="cell-product-basic__field cell-product-basic__field--wide">
<div class="cell-product-basic__field-label">储物格位置</div>
<div class="cell-product-basic__field-value">{{
viewCellVal(viewRow.storageSlotName)
}}</div>
</div>
<div class="cell-product-basic__field">
<div class="cell-product-basic__field-label">冻存日期</div>
<div class="cell-product-basic__field-value">{{ viewCellVal(viewRow.freezeDate) }}</div>
</div>
<div class="cell-product-basic__field">
<div class="cell-product-basic__field-label">有效期</div>
<div class="cell-product-basic__field-value">{{ viewCellVal(viewRow.expiryDate) }}</div>
</div>
</div>
</div>
<div class="cell-product-basic__panel">
<div class="cell-product-basic__panel-title">质量与数量</div>
<div class="cell-product-basic__grid">
<div class="cell-product-basic__field">
<div class="cell-product-basic__field-label">预计细胞数量(亿)</div>
<div class="cell-product-basic__field-value">{{
viewRow.expectedCellCount != null ? String(viewRow.expectedCellCount) : '—'
}}</div>
</div>
<div class="cell-product-basic__field">
<div class="cell-product-basic__field-label">活性(%)</div>
<div class="cell-product-basic__field-value">{{
viewRow.viability != null ? String(viewRow.viability) : '—'
}}</div>
</div>
<div class="cell-product-basic__field">
<div class="cell-product-basic__field-label">数量</div>
<div class="cell-product-basic__field-value">{{
viewRow.quantity != null ? String(viewRow.quantity) : '—'
}}</div>
</div>
<div class="cell-product-basic__field">
<div class="cell-product-basic__field-label">单位</div>
<div class="cell-product-basic__field-value">{{ viewCellVal(viewRow.unit) }}</div>
</div>
</div>
</div>
</div>
<div v-if="viewRow.remark" class="cell-product-basic__panel cell-product-basic__panel--remark">
<div class="cell-product-basic__panel-title">备注</div>
<p class="cell-product-basic__remark">{{ viewRow.remark }}</p>
</div>
</div>
<aside v-if="viewRow.productCode" class="cell-product-basic__aside">
<div class="cell-product-qr-card">
<QrcodeVue
:value="String(viewRow.productCode)"
:size="120"
level="M"
render-as="svg"
foreground="#065f46"
background="#ffffff"
/>
<p class="cell-product-qr-card__label">扫码识别产品编码</p>
<p class="cell-product-qr-card__code">{{ viewRow.productCode }}</p>
</div>
</aside>
</div>
</template>
<template #images>
<div class="attachment-upload attachment-upload--readonly">
<div v-if="viewProductImageFiles.length" class="attachment-list">
@@ -617,6 +718,7 @@
} from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import type { ViewSection } from '@/components/business/system-view-dialog/index.vue'
import QrcodeVue from 'qrcode.vue'
import SourceChainGraph from './SourceChainGraph.vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
@@ -676,6 +778,11 @@
return map[status] || { label: status, type: 'info' }
}
function viewCellVal(val: unknown): string {
if (val === null || val === undefined || val === '') return '—'
return String(val)
}
/* ---- 搜索 & 分页 ---- */
const searchForm = reactive({ keyword: '', productStatus: '', passage: '', sourceType: '' })
const tableData = ref<any[]>([])
@@ -886,43 +993,11 @@
/* ---- 查看弹窗 sections ---- */
const viewSections = computed((): ViewSection[] => {
if (!viewRow.value) return []
const row = viewRow.value
return [
{
title: '基本信息',
fields: [
{ label: '产品编码', value: row.productCode || '—' },
{ label: '产品名称', value: row.productName || '—' },
{ label: '产品类型', value: row.productTypeName || '—' },
{ label: '规格', value: row.specName || '—' },
{ label: '代次', value: row.passage || '—' },
{ label: '批次号', value: row.batchNo || '—' },
{ label: '储物格位置', value: row.storageSlotName || '—' },
{
label: '预计细胞数量(亿)',
value: row.expectedCellCount != null ? String(row.expectedCellCount) : '—'
},
{
label: '活性(%)',
value: row.viability != null ? String(row.viability) : '—'
},
{ label: '数量', value: row.quantity != null ? String(row.quantity) : '—' },
{ label: '单位', value: row.unit || '—' },
{ label: '冻存日期', value: row.freezeDate || '—' },
{ label: '有效期', value: row.expiryDate || '—' },
{
label: '状态',
value: productStatusTag(row.productStatus).label,
tagType: productStatusTag(row.productStatus).type as
| 'success'
| 'warning'
| 'danger'
| 'info'
| ''
| 'primary'
},
{ label: '备注', value: row.remark || '—', span: true }
]
slot: 'basicInfo',
fields: []
},
{
title: '产品图片',
@@ -1338,6 +1413,172 @@
</script>
<style scoped lang="scss">
.cell-product-basic {
display: flex;
gap: 20px;
align-items: flex-start;
}
.cell-product-basic__content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.cell-product-basic__summary {
padding: 16px 18px;
border: 1px solid #c5e0d4;
border-radius: 10px;
background: linear-gradient(135deg, #ecfdf5 0%, #ffffff 72%);
}
.cell-product-basic__headline {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 8px;
}
.cell-product-basic__name {
font-size: 17px;
font-weight: 700;
color: #1e293b;
line-height: 1.35;
letter-spacing: -0.01em;
}
.cell-product-basic__meta {
font-size: 13px;
color: var(--mint-text-muted, #64748b);
line-height: 1.5;
}
.cell-product-basic__meta-sep {
margin: 0 6px;
color: #cbd5e1;
}
.cell-product-basic__panels {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.cell-product-basic__panel {
padding: 14px 16px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
}
.cell-product-basic__panel--remark {
margin-top: 0;
}
.cell-product-basic__panel-title {
margin-bottom: 12px;
padding-left: 8px;
font-size: 12px;
font-weight: 600;
color: var(--mint-text-muted, #64748b);
line-height: 1;
border-left: 2px solid var(--primary, #10b981);
}
.cell-product-basic__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 16px;
}
.cell-product-basic__field {
min-width: 0;
}
.cell-product-basic__field--wide {
grid-column: 1 / -1;
}
.cell-product-basic__field-label {
margin-bottom: 4px;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
line-height: 1.4;
}
.cell-product-basic__field-value {
font-size: 14px;
font-weight: 500;
color: #1e293b;
line-height: 1.5;
word-break: break-word;
}
.cell-product-basic__remark {
margin: 0;
font-size: 14px;
font-weight: 400;
color: #334155;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.cell-product-basic__aside {
flex-shrink: 0;
width: 156px;
position: sticky;
top: 0;
}
.cell-product-qr-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 12px;
border: 1px solid #c5e0d4;
border-radius: 10px;
background: linear-gradient(180deg, #f0fdf4 0%, #ffffff 100%);
text-align: center;
}
.cell-product-qr-card__label {
margin: 12px 0 6px;
font-size: 12px;
font-weight: 500;
color: var(--mint-text-muted, #64748b);
line-height: 1.4;
}
.cell-product-qr-card__code {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #065f46;
line-height: 1.4;
word-break: break-all;
}
@media (max-width: 960px) {
.cell-product-basic {
flex-direction: column;
}
.cell-product-basic__aside {
width: 100%;
max-width: 180px;
margin: 0 auto;
position: static;
}
.cell-product-basic__panels {
grid-template-columns: 1fr;
}
}
.cell-product-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
@@ -273,7 +273,7 @@
>
<div class="route-config-body">
<!-- 左侧:工艺库 -->
<div class="route-config-left">
<div class="route-config-left" v-loading="processLibraryLoading">
<div class="route-config-left-header">
<h3>工艺库</h3>
<el-input
@@ -318,7 +318,7 @@
</el-collapse-item>
</el-collapse>
<el-empty
v-if="filteredProcessLibrary.length === 0"
v-if="filteredProcessLibrary.length === 0 && !processLibraryLoading"
description="暂无工艺数据"
:image-size="48"
/>
@@ -1403,6 +1403,7 @@
// 工艺库数据
const processLibrary = ref<Record<string, any>[]>([])
const processLibraryLoading = ref(false)
const expandedCategories = ref<string[]>([])
const chineseOrdinals = [
'一',
@@ -1829,6 +1830,7 @@
})
async function loadConfigReferenceData() {
processLibraryLoading.value = true
try {
const [processes, categories, sops, outputTypes, equipments] = await Promise.all([
fetchAllProcesses(),
@@ -1871,6 +1873,13 @@
// 展开所有分类
expandedCategories.value = Object.keys(catMap)
processLibrary.value = Object.values(catMap)
} catch {
processLibrary.value = []
expandedCategories.value = []
} finally {
processLibraryLoading.value = false
}
try {
await Promise.all([
searchItemUnitOptions(''),
searchReagentOptions(''),
@@ -101,16 +101,18 @@
<template #footer>
<div v-if="phase === 'form'">
<el-button class="btn-ghost" @click="handleClose">取消</el-button>
<el-button class="btn-main" type="primary" :loading="submitting" @click="handleCreateTask"
>生成任务</el-button
>
<el-button class="btn-ghost" @click="handleClose">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-main" type="primary" :loading="submitting" @click="handleCreateTask">
<el-icon><Check /></el-icon>生成任务
</el-button>
</div>
</template>
<!-- 阶段二:三栏编辑 -->
<div v-if="phase === 'edit'" class="route-config-body">
<div class="route-config-left">
<div class="route-config-left" v-loading="processLibraryLoading">
<div class="route-config-left-header">
<h3>工艺库</h3>
<el-input
@@ -151,7 +153,7 @@
</el-collapse-item>
</el-collapse>
<el-empty
v-if="!filteredProcessLibrary.length"
v-if="!filteredProcessLibrary.length && !processLibraryLoading"
description="暂无工艺数据"
:image-size="48"
/>
@@ -223,7 +225,7 @@
<script setup lang="ts">
import { reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { Rank, Plus, CopyDocument, Delete } from '@element-plus/icons-vue'
import { Rank, Plus, CopyDocument, Delete, Close, Check } from '@element-plus/icons-vue'
import { VueDraggable as draggable } from 'vue-draggable-plus'
import { fetchCreateProductionTask } from '@/api/production-task'
import { fetchUserList } from '@/api/admin'
@@ -278,6 +280,7 @@
stepList,
selectedStepId,
stepListLoading,
processLibraryLoading,
processSearchKeyword,
expandedCategories,
filteredProcessLibrary
@@ -79,7 +79,11 @@
<!-- 可折叠工艺库侧栏(只读时隐藏) -->
<template v-if="!readonly">
<transition name="slide-lib">
<div v-show="editor.processLibraryExpanded.value" class="execution-lib-panel">
<div
v-show="editor.processLibraryExpanded.value"
v-loading="editor.processLibraryLoading.value"
class="execution-lib-panel"
>
<div class="execution-lib-header">
<h3>工艺库</h3>
<el-input
@@ -124,7 +128,7 @@
</el-collapse-item>
</el-collapse>
<el-empty
v-if="!editor.filteredProcessLibrary.value.length"
v-if="!editor.filteredProcessLibrary.value.length && !editor.processLibraryLoading.value"
description="暂无工艺数据"
:image-size="48"
/>
@@ -514,6 +518,7 @@
/* 工艺库折叠侧栏 */
.execution-lib-panel {
position: relative;
width: 260px;
flex-shrink: 0;
min-height: 0;
@@ -13,7 +13,7 @@ export function useProductionViewDialog(buildSections: ProductionViewSectionBuil
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
const isOutboundContextView = computed(() => isOutboundProductionView(detail.value))
const viewDialogWidth = computed(() => (isOutboundContextView.value ? '1200px' : '720px'))
const viewDialogWidth = computed(() => (isOutboundContextView.value ? '1280px' : '1120px'))
const viewDialogGridColumns = computed<2 | 3 | 4>(() => (isOutboundContextView.value ? 4 : 2))
const viewDialogCompact = computed(() => isOutboundContextView.value)
@@ -20,10 +20,10 @@
</div>
</div>
<component
:is="activeTabComponent"
<ProductionWorkOrderTabPanel
ref="activeTabRef"
:key="activeTabKey"
:tab-key="activeTabKey"
page-mode="manage"
@dispatch="handleOpenDispatch"
@dispatch-qc="handleOpenDispatchQc"
@@ -115,7 +115,7 @@
parseProductionWorkOrderTabKey,
type ProductionWorkOrderTabKey
} from '@/utils/production-work-order-tabs'
import { getProductionWorkOrderTabComponent } from './tabs/tab-registry'
import ProductionWorkOrderTabPanel from './tabs/ProductionWorkOrderTabPanel.vue'
import DispatchTaskDialog from './DispatchTaskDialog.vue'
import DispatchQcWorkOrderDialog from './DispatchQcWorkOrderDialog.vue'
import WorkOrderRemarkDialog from '@/components/business/work-order-remark-dialog/index.vue'
@@ -135,7 +135,6 @@
const activeTabKey = ref<ProductionWorkOrderTabKey>(
parseProductionWorkOrderTabKey(route.query.tab as string)
)
const activeTabComponent = computed(() => getProductionWorkOrderTabComponent(activeTabKey.value))
async function refreshActiveTab() {
activeTabRef.value?.bumpTaskRefresh()
@@ -88,6 +88,7 @@ export function useTaskStepEditor() {
const stepList = ref<Record<string, any>[]>([])
const selectedStepId = ref('')
const stepListLoading = ref(false)
const processLibraryLoading = ref(false)
const activeSubTab = ref(PROCESS_ROUTE_DEFAULT_SUB_TAB)
const processSearchKeyword = ref('')
const configSuppressSave = ref(false)
@@ -674,39 +675,47 @@ export function useTaskStepEditor() {
})
async function loadConfigReferenceData() {
const [processes, categories, sops, outputTypes, equipments] = await Promise.all([
fetchAllProcesses(),
fetchAllProcessCategories(),
fetchAllSopDocuments(),
fetchAllOutputTypes(),
fetchAllEquipments()
])
categoryOptions.value = categories || []
sopOptions.value = sops || []
outputTypeOptions.value = outputTypes || []
equipmentOptions.value = equipments || []
processLibraryLoading.value = true
try {
const [processes, categories, sops, outputTypes, equipments] = await Promise.all([
fetchAllProcesses(),
fetchAllProcessCategories(),
fetchAllSopDocuments(),
fetchAllOutputTypes(),
fetchAllEquipments()
])
categoryOptions.value = categories || []
sopOptions.value = sops || []
outputTypeOptions.value = outputTypes || []
equipmentOptions.value = equipments || []
const catMap: Record<
string,
{ categoryId: string; categoryName: string; processes: Record<string, any>[] }
> = {}
const uKey = '__uncategorized__'
catMap[uKey] = { categoryId: uKey, categoryName: '未分类', processes: [] }
;(categories || []).forEach((cat: Record<string, any>) => {
catMap[cat.id] = {
categoryId: cat.id,
categoryName: cat.categoryName || cat.name || '未命名分类',
processes: []
}
})
;(processes || []).forEach((proc: Record<string, any>) => {
const catId = proc.categoryId || uKey
if (!catMap[catId])
catMap[catId] = { categoryId: catId, categoryName: '未知分类', processes: [] }
catMap[catId].processes.push(proc)
})
expandedCategories.value = Object.keys(catMap)
processLibrary.value = Object.values(catMap)
const catMap: Record<
string,
{ categoryId: string; categoryName: string; processes: Record<string, any>[] }
> = {}
const uKey = '__uncategorized__'
catMap[uKey] = { categoryId: uKey, categoryName: '未分类', processes: [] }
;(categories || []).forEach((cat: Record<string, any>) => {
catMap[cat.id] = {
categoryId: cat.id,
categoryName: cat.categoryName || cat.name || '未命名分类',
processes: []
}
})
;(processes || []).forEach((proc: Record<string, any>) => {
const catId = proc.categoryId || uKey
if (!catMap[catId])
catMap[catId] = { categoryId: catId, categoryName: '未知分类', processes: [] }
catMap[catId].processes.push(proc)
})
expandedCategories.value = Object.keys(catMap)
processLibrary.value = Object.values(catMap)
} catch {
processLibrary.value = []
expandedCategories.value = []
} finally {
processLibraryLoading.value = false
}
await Promise.all([
searchItemUnitOptions(''),
searchReagentOptions(''),
@@ -1653,6 +1662,7 @@ export function useTaskStepEditor() {
selectedStepId,
selectedStep,
stepListLoading,
processLibraryLoading,
activeSubTab,
processSearchKeyword,
configSuppressSave,
@@ -25,7 +25,7 @@
</template>
<template #product-table>
<div v-if="detail?.products?.length" class="view-subtable-wrap">
<el-table :data="detail?.products || []" stripe size="small" class="list-table view-subtable">
<el-table :data="detail?.products || []" stripe class="list-table view-subtable">
<el-table-column type="index" label="序号" min-width="60" align="center" />
<el-table-column
prop="projectCode"
@@ -24,6 +24,21 @@
<span class="qc-protocol-payment__compact-item">
待收 {{ formatAmount(summary.pendingAmount) }}
</span>
<template v-if="isLumpSum && primaryDetail">
<span
v-if="primaryDetail.installmentDueDate"
class="qc-protocol-payment__compact-item"
>
应缴 {{ primaryDetail.installmentDueDate }}
</span>
<el-tag
v-if="hideOverallPayStatus && primaryDetail.payStatus"
:type="payStatusTagType(primaryDetail.payStatus)"
size="small"
>
{{ primaryDetail.payStatusLabel || payStatusLabel(primaryDetail.payStatus || '') }}
</el-tag>
</template>
<span v-if="summary.statusHint" class="qc-protocol-payment__compact-hint">
{{ summary.statusHint }}
</span>
@@ -36,12 +51,15 @@
{{ summary.overallPayStatusLabel }}
</el-tag>
</div>
<div v-if="detailRows.length" class="qc-protocol-payment__compact-details">
<div v-if="showCompactDetailChips" class="qc-protocol-payment__compact-details">
<div
v-for="(row, index) in detailRows"
:key="row.installmentNo ?? index"
class="qc-protocol-payment__detail-chip"
:class="{ 'qc-protocol-payment__detail-chip--head-only': hideInstallmentDetailAmounts && isInstallment }"
:class="{
'qc-protocol-payment__detail-chip--head-only': hideInstallmentDetailAmounts && isInstallment,
'qc-protocol-payment__detail-chip--inline': isLumpSum
}"
>
<div class="qc-protocol-payment__detail-chip-head">
<span v-if="isInstallment && row.installmentLabel" class="qc-protocol-payment__detail-chip-label">
@@ -113,42 +131,39 @@
<div class="qc-protocol-payment__table-title">
{{ isInstallment ? `缴费明细(共 ${detailRows.length} 期)` : '缴费明细' }}
</div>
<el-table
:data="detailRows"
stripe
size="small"
class="list-table qc-protocol-payment__table"
>
<el-table-column
v-if="isInstallment"
prop="installmentLabel"
label="期次"
min-width="88"
align="center"
/>
<el-table-column
v-if="isInstallment"
prop="installmentDueDate"
label="应缴日期"
min-width="110"
align="center"
>
<template #default="{ row }">{{ row.installmentDueDate || '—' }}</template>
</el-table-column>
<el-table-column label="应缴金额" min-width="110" align="center">
<template #default="{ row }">{{ formatAmount(row.dueAmount) }}</template>
</el-table-column>
<el-table-column label="实收金额" min-width="110" align="center">
<template #default="{ row }">{{ formatAmount(row.transferAmount) }}</template>
</el-table-column>
<el-table-column label="缴费状态" min-width="108" align="center">
<template #default="{ row }">
<el-tag :type="payStatusTagType(row.payStatus)" size="small">
{{ row.payStatusLabel || payStatusLabel(row.payStatus || '') }}
</el-tag>
</template>
</el-table-column>
</el-table>
<div class="view-subtable-wrap">
<el-table :data="detailRows" stripe class="list-table view-subtable">
<el-table-column
v-if="isInstallment"
prop="installmentLabel"
label="期次"
min-width="88"
align="center"
/>
<el-table-column
v-if="isInstallment"
prop="installmentDueDate"
label="应缴日期"
min-width="110"
align="center"
>
<template #default="{ row }">{{ row.installmentDueDate || '—' }}</template>
</el-table-column>
<el-table-column label="应缴金额" min-width="110" align="center">
<template #default="{ row }">{{ formatAmount(row.dueAmount) }}</template>
</el-table-column>
<el-table-column label="实收金额" min-width="110" align="center">
<template #default="{ row }">{{ formatAmount(row.transferAmount) }}</template>
</el-table-column>
<el-table-column label="缴费状态" min-width="108" align="center">
<template #default="{ row }">
<el-tag :type="payStatusTagType(row.payStatus)" size="small">
{{ row.payStatusLabel || payStatusLabel(row.payStatus || '') }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
</div>
<p v-else class="qc-protocol-payment__empty">暂无缴费明细</p>
</template>
@@ -184,8 +199,19 @@
const isInstallment = computed(() => props.summary?.payMode === 'fq')
const isLumpSum = computed(() => props.summary?.payMode === 'dj')
const detailRows = computed(() => props.summary?.paymentDetails || [])
const primaryDetail = computed(() => detailRows.value[0] ?? null)
/** 趸交仅 1 条明细时信息已并入摘要行,不再占满整行的明细区 */
const showCompactDetailChips = computed(() => {
if (!detailRows.value.length) return false
if (isLumpSum.value) return detailRows.value.length > 1
return true
})
const payModeTagType = computed((): TagType =>
payModeTagTypeGetter(props.summary?.payMode || '')
)
@@ -259,10 +285,6 @@
min-width: 0;
}
.qc-protocol-payment__table {
width: 100%;
}
.qc-protocol-payment--embedded {
padding: 0;
background: transparent;
@@ -319,6 +341,12 @@
border: 1px solid #bbf7d0;
}
.qc-protocol-payment__detail-chip--inline {
flex: 0 1 auto;
width: auto;
max-width: none;
}
.qc-protocol-payment__detail-chip-head {
display: flex;
flex-wrap: wrap;
@@ -22,7 +22,11 @@
<template v-for="field in visibleMetaFields" :key="field.label">
<div
class="qc-work-order-card__meta-item"
:class="{ 'qc-work-order-card__meta-item--wide': field.wide }"
:class="{
'qc-work-order-card__meta-item--wide': field.wide,
'qc-work-order-card__meta-item--span-2': field.colSpan === 2,
'qc-work-order-card__meta-item--span-3': field.colSpan === 3
}"
>
<dt>{{ field.label }}</dt>
<dd>
@@ -294,6 +298,14 @@
&--wide {
grid-column: 1 / -1;
}
&--span-2 {
grid-column: span 2;
}
&--span-3 {
grid-column: span 3;
}
}
:deep(.qc-work-order-card__fee-in-head) {
@@ -0,0 +1,430 @@
<!-- 质检工单查看弹窗 — 实验室 / 第三方项目卡片面板 -->
<template>
<div class="qc-wo-task-result-panel">
<template v-if="!tasks.length && configuredItems.length">
<div class="qc-wo-configured-list">
<article
v-for="(item, idx) in configuredItems"
:key="`${item.primary}-${idx}`"
class="qc-wo-configured-item"
>
<span class="qc-wo-configured-item__name">{{ item.primary }}</span>
<span v-if="item.secondary" class="qc-wo-configured-item__sub">{{ item.secondary }}</span>
<span v-if="item.meta" class="qc-wo-configured-item__meta">{{ item.meta }}</span>
</article>
</div>
<p class="qc-wo-task-result-panel__hint">已配置检测项目,任务待派发</p>
</template>
<div v-else-if="tasks.length" class="qc-wo-task-cards">
<article v-for="(task, idx) in tasks" :key="task.id || idx" class="qc-wo-task-card">
<header class="qc-wo-task-card__head">
<div class="qc-wo-task-card__title">
<span v-if="tasks.length > 1" class="qc-wo-task-card__index">{{ idx + 1 }}</span>
<span class="qc-wo-task-card__name">{{ task.itemName || '—' }}</span>
<span v-if="task.itemCode" class="qc-wo-task-card__code">{{ task.itemCode }}</span>
</div>
<div class="qc-wo-task-card__tags">
<el-tag :type="taskStatusTagType(task.taskStatus)" size="small">
{{ taskStatusLabel(task.taskStatus || '') }}
</el-tag>
<el-tag
v-if="task.conclusion === 'tg' || task.conclusion === 'btg'"
:type="conclusionTagType(task.conclusion)"
size="small"
>
{{ task.conclusionLabel || qcTaskConclusionLabel(task.conclusion) }}
</el-tag>
<el-tag v-else type="info" size="small">待结论</el-tag>
</div>
</header>
<div class="qc-wo-task-card__body">
<dl class="qc-wo-task-card__meta">
<div class="qc-wo-task-card__meta-item">
<dt>任务号</dt>
<dd>{{ task.taskNo || '—' }}</dd>
</div>
<div v-if="taskType === 'dsfjc'" class="qc-wo-task-card__meta-item">
<dt>第三方公司</dt>
<dd>{{ task.companyName || '—' }}</dd>
</div>
<div v-if="taskType === 'dsfjc' && task.feeAmount != null" class="qc-wo-task-card__meta-item">
<dt>费用(元)</dt>
<dd>¥{{ formatMoneyDisplay(task.feeAmount) }}</dd>
</div>
<div class="qc-wo-task-card__meta-item">
<dt>完成时间</dt>
<dd>{{ task.completedTime || '—' }}</dd>
</div>
</dl>
<div class="qc-wo-task-card__block">
<div class="qc-wo-task-card__block-label">检测参数</div>
<dl v-if="task.results?.length" class="qc-wo-task-card__params">
<div v-for="(param, pi) in task.results" :key="pi" class="qc-wo-task-card__param">
<dt>
{{ param.paramName
}}<span v-if="param.paramUnit" class="qc-wo-task-card__param-unit"
>({{ param.paramUnit }})</span
>
</dt>
<dd>{{ param.paramValue || '—' }}</dd>
</div>
</dl>
<p v-else class="qc-wo-task-result-panel__empty">—</p>
</div>
<div v-if="task.remark" class="qc-wo-task-card__block">
<div class="qc-wo-task-card__block-label">备注</div>
<p class="qc-wo-task-card__remark">{{ task.remark }}</p>
</div>
<div v-if="taskType === 'sysz' && showMaterialUsage" class="qc-wo-task-card__block">
<div class="qc-wo-task-card__block-label">物料使用</div>
<div v-if="task.materialUsages?.length" class="view-subtable-wrap">
<el-table :data="task.materialUsages" stripe class="list-table view-subtable">
<el-table-column
type="index"
label="序号"
min-width="56"
align="center"
header-align="center"
:index="(index) => index + 1"
/>
<el-table-column
prop="stockInNo"
label="入库编号"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="materialName"
label="物料名称"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column label="批号" min-width="100" align="center" header-align="center">
<template #default="{ row }">{{ row.batchNo || '—' }}</template>
</el-table-column>
<el-table-column label="领用人" min-width="100" align="center" header-align="center">
<template #default="{ row }">{{ row.receiverName || '—' }}</template>
</el-table-column>
<el-table-column label="用量" min-width="100" align="center" header-align="center">
<template #default="{ row }">
{{ row.qty != null && row.qty !== '' ? row.qty : '—' }}
</template>
</el-table-column>
<el-table-column label="单位" min-width="72" align="center" header-align="center">
<template #default="{ row }">{{ row.unit || '—' }}</template>
</el-table-column>
<el-table-column label="备注" min-width="140" align="center" header-align="center">
<template #default="{ row }">{{ row.remark || '—' }}</template>
</el-table-column>
</el-table>
</div>
<p v-else class="qc-wo-task-result-panel__empty">暂无物料使用记录</p>
</div>
<div class="qc-wo-task-card__block">
<div class="qc-wo-task-card__block-label">{{ attachmentLabel }}</div>
<ProtocolAttachmentReadonly
v-if="task.attachmentFiles?.length"
:files="task.attachmentFiles"
@preview="onAttachmentPreview"
@download="onAttachmentDownload"
/>
<p v-else class="qc-wo-task-result-panel__empty">—</p>
</div>
</div>
</article>
</div>
<p v-else class="qc-wo-task-result-panel__empty">—</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { formatMoneyDisplay } from '@/utils/money-format'
import type { QcWorkOrderDetail } from '@/api/qc-work-order'
import { taskStatusLabel, qcTaskConclusionLabel } from '@/utils/qc-labels'
import { SHOW_QC_TASK_MATERIAL_USAGE } from '@/utils/qc-material-ui'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import type { QcWorkOrderTaskView } from './composables/useQcWorkOrderTaskViewSections'
import { qcPreviewAttachment, qcDownloadAttachment } from './composables/useQcViewAttachmentHelpers'
defineOptions({ name: 'QcWorkOrderTaskResultPanel' })
type ConfiguredItem = {
primary: string
secondary?: string
meta?: string
}
const props = defineProps<{
taskType: 'sysz' | 'dsfjc'
tasks: QcWorkOrderTaskView[]
labItems?: QcWorkOrderDetail['labItems']
thirdPartyItems?: QcWorkOrderDetail['thirdPartyItems']
}>()
const showMaterialUsage = SHOW_QC_TASK_MATERIAL_USAGE
const attachmentLabel = computed(() => (props.taskType === 'dsfjc' ? '检测材料' : '检测附件'))
const configuredItems = computed<ConfiguredItem[]>(() => {
if (props.taskType === 'sysz') {
return (props.labItems || []).map((li) => ({
primary: li.itemName || '—',
secondary: li.itemCode ? `编码 ${li.itemCode}` : undefined
}))
}
return (props.thirdPartyItems || []).map((li) => ({
primary: li.itemName || '—',
secondary: [li.companyName, li.itemCode ? `编码 ${li.itemCode}` : '']
.filter(Boolean)
.join(' · '),
meta: li.feeAmount != null ? `¥${formatMoneyDisplay(li.feeAmount)}` : undefined
}))
})
function conclusionTagType(conclusion?: string) {
if (conclusion === 'tg') return 'success' as const
if (conclusion === 'btg') return 'danger' as const
return 'info' as const
}
function taskStatusTagType(status?: string) {
if (status === 'ywc') return 'success' as const
if (status === 'jxz') return 'warning' as const
return 'info' as const
}
const onAttachmentPreview = qcPreviewAttachment
const onAttachmentDownload = qcDownloadAttachment
</script>
<style scoped lang="scss">
.qc-wo-task-result-panel {
width: 100%;
}
.qc-wo-task-result-panel__hint,
.qc-wo-task-result-panel__empty {
margin: 0;
font-size: 13px;
color: var(--mint-text-muted, #334155);
line-height: 1.5;
}
.qc-wo-task-result-panel__hint {
margin-top: 10px;
padding-left: 2px;
font-size: 12px;
}
.qc-wo-configured-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.qc-wo-configured-item {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
}
.qc-wo-configured-item__name {
font-size: 13px;
font-weight: 600;
color: #1e293b;
}
.qc-wo-configured-item__sub {
font-size: 12px;
color: var(--mint-text-muted, #334155);
}
.qc-wo-configured-item__meta {
margin-left: auto;
font-size: 12px;
font-weight: 600;
color: #047857;
}
.qc-wo-task-cards {
display: flex;
flex-direction: column;
gap: 12px;
}
.qc-wo-task-card {
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
overflow: hidden;
}
.qc-wo-task-card__head {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 8px 12px;
padding: 12px 14px;
background: linear-gradient(180deg, #f8fafc 0%, #fff 100%);
border-bottom: 1px solid #e2e8f0;
}
.qc-wo-task-card__title {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px 10px;
min-width: 0;
flex: 1;
}
.qc-wo-task-card__index {
flex-shrink: 0;
font-size: 11px;
font-weight: 600;
color: #047857;
background: #ecfdf5;
border: 1px solid #a7f3d0;
border-radius: 4px;
padding: 1px 6px;
line-height: 1.4;
}
.qc-wo-task-card__name {
font-size: 14px;
font-weight: 600;
color: #1e293b;
word-break: break-word;
}
.qc-wo-task-card__code {
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
color: var(--mint-text-muted, #334155);
}
.qc-wo-task-card__tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
flex-shrink: 0;
}
.qc-wo-task-card__body {
padding: 12px 14px 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
.qc-wo-task-card__meta {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 10px 16px;
margin: 0;
}
.qc-wo-task-card__meta-item {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
dt {
margin: 0;
font-size: 11px;
line-height: 1.35;
color: var(--mint-text-muted, #334155);
}
dd {
margin: 0;
font-size: 13px;
line-height: 1.4;
color: #1e293b;
word-break: break-word;
}
}
.qc-wo-task-card__block {
padding-top: 10px;
border-top: 1px dashed #e2e8f0;
}
.qc-wo-task-card__block-label {
margin-bottom: 8px;
font-size: 12px;
font-weight: 600;
color: #334155;
line-height: 1.4;
}
.qc-wo-task-card__params {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px 12px;
margin: 0;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
}
.qc-wo-task-card__param {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
dt {
margin: 0;
font-size: 11px;
line-height: 1.35;
color: var(--mint-text-muted, #334155);
}
dd {
margin: 0;
font-size: 13px;
font-weight: 500;
line-height: 1.4;
color: #1e293b;
word-break: break-word;
}
}
.qc-wo-task-card__param-unit {
font-weight: 400;
color: var(--mint-text-muted, #334155);
}
.qc-wo-task-card__remark {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: #1e293b;
word-break: break-word;
}
.view-subtable-wrap {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
}
</style>
@@ -353,7 +353,6 @@
<el-table
:data="viewOutboundDetail?.products || []"
stripe
size="small"
class="list-table view-subtable"
>
<el-table-column type="index" label="序号" min-width="60" align="center" />
@@ -516,6 +515,7 @@
import { SHOW_QC_TASK_MATERIAL_USAGE } from '@/utils/qc-material-ui'
import {
taskTypeLabel,
taskTypeTagType,
taskStatusLabel,
qcBizTypeLabel,
qcBizTypeTagType,
@@ -755,10 +755,6 @@
return 'info' as const
}
function taskTypeTagType(type: string) {
return type === 'sysz' ? ('success' as const) : ('info' as const)
}
function conclusionTagType(conclusion: string) {
if (conclusion === 'tg') return 'success' as const
if (conclusion === 'btg') return 'danger' as const
@@ -1128,7 +1124,7 @@
{
label: '检测类型',
value: taskTypeLabel(d.taskType),
tagType: d.taskType === 'sysz' ? 'success' : 'info'
tagType: taskTypeTagType(d.taskType)
},
{ label: '检测项目', value: d.itemName },
{ label: '项目编码', value: d.itemCode || '—' },
@@ -3,9 +3,6 @@ import { fetchQcWorkOrderDetail, type QcWorkOrderDetail } from '@/api/qc-work-or
import type { ViewSection } from '@/components/business/system-view-dialog/index.vue'
import {
loadEnrichedQcWorkOrderTasks,
useQcWorkOrderTaskSlotHelpers,
taskAttachmentSlotName,
taskMaterialSlotName,
type QcWorkOrderTaskView
} from './useQcWorkOrderTaskViewSections'
import {
@@ -26,8 +23,6 @@ export function useInboundQcViewDialog(
return buildSections(detail.value, viewTasks.value)
})
const { viewTasksWithAttachments, viewLabTasks } = useQcWorkOrderTaskSlotHelpers(viewTasks)
const viewReceptionAttachmentFiles = computed(() => {
const r = detail.value?.reception
if (!r) return []
@@ -55,15 +50,9 @@ export function useInboundQcViewDialog(
detail,
sections,
viewTasks,
viewTasksWithAttachments,
viewLabTasks,
viewReceptionAttachmentFiles,
taskAttachmentSlotName,
taskMaterialSlotName,
handleReceptionAttachmentPreview: qcPreviewAttachment,
handleReceptionAttachmentDownload: qcDownloadAttachment,
handleTaskAttachmentPreview: qcPreviewAttachment,
handleTaskAttachmentDownload: qcDownloadAttachment,
load
}
}
@@ -14,11 +14,8 @@ import {
} from '@/utils/outbound-qc-pilot-view-sections'
import type { ViewField, ViewSection } from '@/components/business/system-view-dialog/index.vue'
import {
buildTaskTypeSections,
appendTaskResultSections,
loadEnrichedQcWorkOrderTasks,
useQcWorkOrderTaskSlotHelpers,
taskAttachmentSlotName,
taskMaterialSlotName,
type QcWorkOrderTaskView
} from './useQcWorkOrderTaskViewSections'
import {
@@ -35,8 +32,6 @@ export function useOutboundQcViewDialog(options: {
const detail = ref<OutboundQcDetail | null>(null)
const viewTasks = ref<QcWorkOrderTaskView[]>([])
const { viewTasksWithAttachments, viewLabTasks } = useQcWorkOrderTaskSlotHelpers(viewTasks)
const diagnosisAttachmentFiles = computed(() => {
const urls = detail.value?.patient?.diagnosisAttachments || ''
if (!urls.trim()) return []
@@ -131,8 +126,7 @@ export function useOutboundQcViewDialog(options: {
})
}
result.push(...buildTaskTypeSections(viewTasks.value, 'sysz', '实验室项目'))
result.push(...buildTaskTypeSections(viewTasks.value, 'dsfjc', '第三方项目'))
result.push(...appendTaskResultSections(wo, viewTasks.value))
return result
})
@@ -155,15 +149,10 @@ export function useOutboundQcViewDialog(options: {
loading,
detail,
sections,
viewTasksWithAttachments,
viewLabTasks,
viewTasks,
diagnosisAttachmentFiles,
taskAttachmentSlotName,
taskMaterialSlotName,
handlePreview: qcPreviewAttachment,
handleDownload: qcDownloadAttachment,
handleTaskPreview: qcPreviewAttachment,
handleTaskDownload: qcDownloadAttachment,
load
}
}
@@ -1,5 +1,4 @@
import { computed, type Ref } from 'vue'
import { formatMoneyDisplay } from '@/utils/money-format'
import type { QcWorkOrderDetail } from '@/api/qc-work-order'
import type { QcTask } from '@/api/qc-task'
import {
@@ -9,8 +8,7 @@ import {
type QcTaskMaterialUsageItem
} from '@/api/qc-task-result'
import { SHOW_QC_TASK_MATERIAL_USAGE } from '@/utils/qc-material-ui'
import { taskStatusLabel, qcTaskConclusionLabel } from '@/utils/qc-labels'
import type { ViewField, ViewSection } from '@/components/business/system-view-dialog/index.vue'
import type { ViewSection } from '@/components/business/system-view-dialog/index.vue'
export type ViewMaterialUsageRow = {
stockInNo: string
@@ -35,6 +33,14 @@ export function taskMaterialSlotName(taskId: string) {
return `task-mat-${taskId}`
}
export const LAB_TASKS_PANEL_SLOT = 'lab-tasks-panel'
export const THIRD_TASKS_PANEL_SLOT = 'third-tasks-panel'
export type QcTaskPanelContext = Pick<
QcWorkOrderDetail,
'hasLab' | 'hasThirdParty' | 'labItems' | 'thirdPartyItems'
>
function materialRowsFromApi(items: QcTaskMaterialUsageItem[]): ViewMaterialUsageRow[] {
return (items || [])
.filter((u) => (u.requisitionUsageId || u.parentUsageId || '').trim())
@@ -73,174 +79,36 @@ export async function loadEnrichedQcWorkOrderTasks(
)
}
function conclusionTagType(conclusion?: string) {
if (conclusion === 'tg') return 'success' as const
if (conclusion === 'btg') return 'danger' as const
return 'info' as const
}
function taskStatusTagType(status?: string) {
if (status === 'ywc') return 'success' as const
if (status === 'jxz') return 'warning' as const
return 'info' as const
}
function formatConfiguredLabItems(d: QcWorkOrderDetail) {
return d.labItems?.map((li) => `${li.itemName || ''}(${li.itemCode || ''})`).join('、') || '无'
}
function formatConfiguredThirdItems(d: QcWorkOrderDetail) {
return (
d.thirdPartyItems
?.map(
(li) =>
`${li.companyName || ''} — ${li.itemName || ''}(${li.feeAmount != null ? '¥' + formatMoneyDisplay(li.feeAmount) : '—'})`
)
.join('、') || '无'
)
}
function buildConfiguredOnlySection(
title: string,
configuredLabel: string,
configuredValue: string,
attachmentLabel: string
slot: string
): ViewSection {
return {
title,
fields: [
{ label: configuredLabel, value: configuredValue, span: true },
{ label: '检测结论', value: '—' },
{ label: '检测参数', value: '—' },
{ label: '备注', value: '—' },
{ label: attachmentLabel, value: '—' }
]
fields: [],
slot
}
}
export function buildTaskTypeSections(
tasks: QcWorkOrderTaskView[],
taskType: 'sysz' | 'dsfjc',
sectionTitle: string
): ViewSection[] {
const filtered = tasks.filter((t) => t.taskType === taskType)
if (!filtered.length) return []
return filtered.flatMap((task, index) => {
const title = filtered.length > 1 ? `${sectionTitle}(${index + 1})` : sectionTitle
const taskInfoFields: ViewField[] = [
{ label: '检测项目', value: task.itemName || '—' },
{ label: '项目编码', value: task.itemCode || '—' },
{
label: '任务状态',
value: taskStatusLabel(task.taskStatus || ''),
tagType: taskStatusTagType(task.taskStatus)
},
{
label: '检测结论',
value: task.conclusionLabel || qcTaskConclusionLabel(task.conclusion || '') || '—',
tagType: conclusionTagType(task.conclusion)
},
{ label: '任务号', value: task.taskNo || '—' },
{ label: '完成时间', value: task.completedTime || '—' }
]
if (taskType === 'dsfjc') {
taskInfoFields.splice(2, 0, { label: '第三方公司', value: task.companyName || '—' })
if (task.feeAmount != null) {
taskInfoFields.push({ label: '费用(元)', value: String(task.feeAmount) })
}
}
const paramFields: ViewField[] = (task.results || []).map((item) => ({
label: item.paramName + (item.paramUnit ? `(${item.paramUnit})` : ''),
value: item.paramValue || '—'
}))
const remarkField: ViewField = { label: '备注', value: task.remark || '—', span: true }
const section: ViewSection =
taskType === 'sysz'
? {
title,
fields: [],
fieldGroups: [
{ fields: taskInfoFields },
{
title: '检测参数',
titleStyle: 'field',
fields: paramFields,
emptyHint: '—'
},
{ fields: [remarkField] }
]
}
: {
title,
fields: [
...taskInfoFields,
...(paramFields.length ? paramFields : [{ label: '检测参数', value: '—' as const }]),
remarkField
]
}
if (task.attachmentFiles?.length) {
section.slot = taskAttachmentSlotName(task.id)
section.slotTitle = taskType === 'dsfjc' ? '检测材料' : '检测附件'
section.slotTitleStyle = 'field'
} else if (taskType === 'dsfjc') {
section.fields!.push({ label: '检测材料', value: '—' })
} else if (taskType === 'sysz') {
section.fieldGroups?.push({ fields: [{ label: '检测附件', value: '—' }] })
} else {
section.fields!.push({ label: '检测附件', value: '—' })
}
const sections: ViewSection[] = [section]
if (SHOW_QC_TASK_MATERIAL_USAGE && taskType === 'sysz') {
sections.push({
title: '',
fields: [],
slotTitle: '物料使用',
slotTitleStyle: 'field',
slot: taskMaterialSlotName(task.id)
})
}
return sections
})
}
export function appendTaskResultSections(
detail: QcWorkOrderDetail,
detail: QcTaskPanelContext,
tasks: QcWorkOrderTaskView[]
): ViewSection[] {
const result: ViewSection[] = []
if (tasks.some((t) => t.taskType === 'sysz')) {
result.push(...buildTaskTypeSections(tasks, 'sysz', '实验室项目'))
} else if (detail.hasLab) {
result.push(
buildConfiguredOnlySection(
'实验室项目',
'已配置项目',
formatConfiguredLabItems(detail),
'检测附件'
)
)
const hasLabTasks = tasks.some((t) => t.taskType === 'sysz')
const hasThirdTasks = tasks.some((t) => t.taskType === 'dsfjc')
if (hasLabTasks || detail.hasLab) {
result.push(buildConfiguredOnlySection('实验室项目', LAB_TASKS_PANEL_SLOT))
}
if (tasks.some((t) => t.taskType === 'dsfjc')) {
result.push(...buildTaskTypeSections(tasks, 'dsfjc', '第三方项目'))
} else if (detail.hasThirdParty) {
result.push(
buildConfiguredOnlySection(
'第三方项目',
'已配置项目',
formatConfiguredThirdItems(detail),
'检测材料'
)
)
if (hasThirdTasks || detail.hasThirdParty) {
result.push(buildConfiguredOnlySection('第三方项目', THIRD_TASKS_PANEL_SLOT))
}
return result
}
export function useQcWorkOrderTaskSlotHelpers(viewTasks: Ref<QcWorkOrderTaskView[]>) {
const viewTasksWithAttachments = computed(() =>
viewTasks.value.filter((t) => (t.attachmentFiles?.length || 0) > 0)
)
const viewLabTasks = computed(() => viewTasks.value.filter((t) => t.taskType === 'sysz'))
return { viewTasksWithAttachments, viewLabTasks }
const labTasks = computed(() => viewTasks.value.filter((t) => t.taskType === 'sysz'))
const thirdPartyTasks = computed(() => viewTasks.value.filter((t) => t.taskType === 'dsfjc'))
return { labTasks, thirdPartyTasks }
}
@@ -20,10 +20,10 @@
</div>
</div>
<component
:is="activeTabComponent"
<QcWorkOrderTabPanel
ref="activeTabRef"
:key="activeTabKey"
:tab-key="activeTabKey"
page-mode="manage"
@configure="handleConfigure"
@command="handleDropdownCommand"
@@ -305,7 +305,7 @@
import { fetchCellProductList } from '@/api/cell-product'
import { fetchUserOptions } from '@/api/admin'
import WorkOrderRemarkDialog from '@/components/business/work-order-remark-dialog/index.vue'
import { getQcWorkOrderTabComponent } from './tabs/tab-registry'
import QcWorkOrderTabPanel from './tabs/QcWorkOrderTabPanel.vue'
import {
QC_WORK_ORDER_VISIBLE_TABS,
DEFAULT_QC_WORK_ORDER_TAB,
@@ -327,7 +327,6 @@
const activeTabRef = ref<QcWorkOrderTabPanelExpose | null>(null)
const activeTabKey = ref<QcWorkOrderTabKey>(parseQcWorkOrderTabKey(route.query.tab as string))
const activeTabComponent = computed(() => getQcWorkOrderTabComponent(activeTabKey.value))
async function refreshActiveTab() {
activeTabRef.value?.bumpTaskRefresh()
@@ -12,8 +12,8 @@
:meta="detail?.workOrderNo || ''"
:sections="sections"
modal-class="qc-work-order-view-dialog-mask"
:tasks-with-attachments="viewTasksWithAttachments"
:lab-tasks="viewLabTasks"
:detail="detail"
:view-tasks="viewTasks"
>
<template #protocol-payment-details>
<QcWorkOrderProtocolPaymentPanel :summary="detail?.paymentSummary" embedded />
@@ -64,8 +64,7 @@
loading,
detail,
sections,
viewTasksWithAttachments,
viewLabTasks,
viewTasks,
viewReceptionAttachmentFiles,
handleReceptionAttachmentPreview,
handleReceptionAttachmentDownload,
@@ -12,8 +12,8 @@
:meta="detail?.workOrderNo || ''"
:sections="sections"
modal-class="qc-work-order-view-dialog-mask"
:tasks-with-attachments="viewTasksWithAttachments"
:lab-tasks="viewLabTasks"
:detail="detail"
:view-tasks="viewTasks"
>
<template #reception-extra>
<div class="view-reception-attachments">
@@ -60,8 +60,7 @@
loading,
detail,
sections,
viewTasksWithAttachments,
viewLabTasks,
viewTasks,
viewReceptionAttachmentFiles,
handleReceptionAttachmentPreview,
handleReceptionAttachmentDownload,
@@ -11,8 +11,8 @@
:icon="Document"
:meta="detail?.workOrder?.workOrderNo || ''"
:sections="sections"
:tasks-with-attachments="viewTasksWithAttachments"
:lab-tasks="viewLabTasks"
:detail="detail?.workOrder"
:view-tasks="viewTasks"
>
<template #product-table>
<OutboundQcProductTable :products="detail?.products" />
@@ -42,7 +42,7 @@
set: (v) => emit('update:visible', v)
})
const { Document, loading, detail, sections, viewTasksWithAttachments, viewLabTasks, load } =
const { Document, loading, detail, sections, viewTasks, load } =
useOutboundQcViewDialog({ variant: 'basic_experiment', showFeeSection: true })
watch(
@@ -11,8 +11,8 @@
:icon="Document"
:meta="detail?.workOrder?.workOrderNo || ''"
:sections="sections"
:tasks-with-attachments="viewTasksWithAttachments"
:lab-tasks="viewLabTasks"
:detail="detail?.workOrder"
:view-tasks="viewTasks"
>
<template #diagnosis-attachments>
<OutboundQcDiagnosisAttachments :files="diagnosisAttachmentFiles" />
@@ -51,8 +51,7 @@
loading,
detail,
sections,
viewTasksWithAttachments,
viewLabTasks,
viewTasks,
diagnosisAttachmentFiles,
load
} = useOutboundQcViewDialog({ variant: 'clinical_application', showFeeSection: true })
@@ -11,8 +11,8 @@
:icon="Document"
:meta="detail?.workOrder?.workOrderNo || ''"
:sections="sections"
:tasks-with-attachments="viewTasksWithAttachments"
:lab-tasks="viewLabTasks"
:detail="detail?.workOrder"
:view-tasks="viewTasks"
>
<template #diagnosis-attachments>
<OutboundQcDiagnosisAttachments :files="diagnosisAttachmentFiles" />
@@ -51,8 +51,7 @@
loading,
detail,
sections,
viewTasksWithAttachments,
viewLabTasks,
viewTasks,
diagnosisAttachmentFiles,
load
} = useOutboundQcViewDialog({ variant: 'pilot_service', showFeeSection: false })
@@ -1,11 +1,6 @@
<template>
<div v-if="products?.length" class="view-subtable-wrap">
<el-table
:data="products"
stripe
size="small"
class="list-table view-subtable outbound-product-table"
>
<el-table :data="products" stripe class="list-table view-subtable">
<el-table-column type="index" label="序号" min-width="60" align="center" />
<el-table-column
prop="projectCode"
@@ -60,28 +55,6 @@
</script>
<style scoped lang="scss">
.outbound-product-table {
margin-top: 0;
:deep(.el-table__header th.el-table__cell),
:deep(.el-table__body td.el-table__cell) {
font-size: 12px;
padding: 6px 0;
}
:deep(.el-table__header th.el-table__cell .cell),
:deep(.el-table__body td.el-table__cell .cell) {
font-size: 12px;
line-height: 1.35;
}
:deep(.el-tag) {
--el-tag-font-size: 11px;
height: 20px;
padding: 0 6px;
}
}
.view-subtable-wrap {
width: 100%;
overflow-x: auto;
@@ -1,93 +1,44 @@
<!-- SystemViewDialog 包装:转发业务 slot + 内置任务附件/物料 slot -->
<!-- SystemViewDialog 包装:任务结果面板 + 业务 slot 转发 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<SystemViewDialog v-model:visible="dialogVisible" v-bind="attrs">
<template v-for="name in extraSlotNames" #[name]>
<slot :name="name" />
</template>
<template
v-for="task in tasksWithAttachments"
:key="task.id"
#[taskAttachmentSlotName(task.id)]
>
<div class="qc-work-order-task-attachments">
<ProtocolAttachmentReadonly
:files="task.attachmentFiles || []"
@preview="onTaskPreview"
@download="onTaskDownload"
/>
</div>
<template #[LAB_TASKS_PANEL_SLOT]>
<QcWorkOrderTaskResultPanel
task-type="sysz"
:tasks="labTasks"
:lab-items="detail?.labItems"
/>
</template>
<template v-for="task in materialLabTasks" :key="task.id" #[taskMaterialSlotName(task.id)]>
<div v-if="task.materialUsages?.length" class="view-subtable-wrap">
<el-table :data="task.materialUsages" stripe class="list-table view-subtable">
<el-table-column
type="index"
label="序号"
min-width="56"
align="center"
header-align="center"
:index="(index) => index + 1"
/>
<el-table-column
prop="stockInNo"
label="入库编号"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="materialName"
label="物料名称"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column label="批号" min-width="100" align="center" header-align="center">
<template #default="{ row }">{{ row.batchNo || '—' }}</template>
</el-table-column>
<el-table-column label="领用人" min-width="100" align="center" header-align="center">
<template #default="{ row }">{{ row.receiverName || '—' }}</template>
</el-table-column>
<el-table-column label="用量" min-width="100" align="center" header-align="center">
<template #default="{ row }">
{{ row.qty != null && row.qty !== '' ? row.qty : '—' }}
</template>
</el-table-column>
<el-table-column label="单位" min-width="72" align="center" header-align="center">
<template #default="{ row }">{{ row.unit || '—' }}</template>
</el-table-column>
<el-table-column label="备注" min-width="140" align="center" header-align="center">
<template #default="{ row }">{{ row.remark || '—' }}</template>
</el-table-column>
</el-table>
</div>
<p v-else class="view-empty-hint">暂无物料使用记录</p>
<template #[THIRD_TASKS_PANEL_SLOT]>
<QcWorkOrderTaskResultPanel
task-type="dsfjc"
:tasks="thirdPartyTasks"
:third-party-items="detail?.thirdPartyItems"
/>
</template>
</SystemViewDialog>
</template>
<script setup lang="ts">
import { computed, useAttrs, useSlots } from 'vue'
import type { QcWorkOrderDetail } from '@/api/qc-work-order'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import { SHOW_QC_TASK_MATERIAL_USAGE } from '@/utils/qc-material-ui'
import QcWorkOrderTaskResultPanel from '../QcWorkOrderTaskResultPanel.vue'
import {
taskAttachmentSlotName,
taskMaterialSlotName,
LAB_TASKS_PANEL_SLOT,
THIRD_TASKS_PANEL_SLOT,
type QcWorkOrderTaskView
} from '../composables/useQcWorkOrderTaskViewSections'
import {
qcPreviewAttachment,
qcDownloadAttachment
} from '../composables/useQcViewAttachmentHelpers'
defineOptions({ name: 'QcWorkOrderSystemViewDialog', inheritAttrs: false })
const props = defineProps<{
visible: boolean
tasksWithAttachments: QcWorkOrderTaskView[]
labTasks: QcWorkOrderTaskView[]
detail?: Partial<QcWorkOrderDetail> | null
viewTasks?: QcWorkOrderTaskView[]
}>()
const emit = defineEmits<{
@@ -96,28 +47,21 @@
const attrs = useAttrs()
const slots = useSlots()
const extraSlotNames = computed(() => Object.keys(slots))
const extraSlotNames = computed(() =>
Object.keys(slots).filter(
(name) => name !== LAB_TASKS_PANEL_SLOT && name !== THIRD_TASKS_PANEL_SLOT
)
)
const dialogVisible = computed({
get: () => props.visible,
set: (v) => emit('update:visible', v)
})
const materialLabTasks = computed(() => (SHOW_QC_TASK_MATERIAL_USAGE ? props.labTasks : []))
const onTaskPreview = qcPreviewAttachment
const onTaskDownload = qcDownloadAttachment
const labTasks = computed(() =>
(props.viewTasks || []).filter((t) => t.taskType === 'sysz')
)
const thirdPartyTasks = computed(() =>
(props.viewTasks || []).filter((t) => t.taskType === 'dsfjc')
)
</script>
<style scoped lang="scss">
.view-subtable-wrap {
width: 100%;
overflow-x: auto;
}
.view-empty-hint {
margin: 0;
font-size: 13px;
color: var(--mint-text-muted, #334155);
}
</style>
@@ -12,8 +12,8 @@
:meta="detail?.workOrderNo || ''"
:sections="sections"
modal-class="qc-work-order-view-dialog-mask"
:tasks-with-attachments="viewTasksWithAttachments"
:lab-tasks="viewLabTasks"
:detail="detail"
:view-tasks="viewTasks"
/>
</template>
@@ -40,7 +40,7 @@
set: (v) => emit('update:visible', v)
})
const { loading, detail, sections, viewTasksWithAttachments, viewLabTasks, load } =
const { loading, detail, sections, viewTasks, load } =
useInboundQcViewDialog(buildTemporaryViewSections)
watch(
@@ -116,220 +116,176 @@
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="donationStatus"
label="状态"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
:type="donationStatusTagType(String(row.donationStatus || ''))"
size="small"
>{{ donationStatusLabel(String(row.donationStatus || '')) }}</el-tag
>
</template>
</el-table-column>
<el-table-column
prop="expectedDeliveryDate"
label="预产期"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="sampleCollectSummary"
label="是否已采集"
min-width="160"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="donationDate"
label="捐赠日期"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="motherName"
label="母亲姓名"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="motherPhone"
label="母亲电话"
min-width="130"
align="center"
header-align="center"
/>
<el-table-column
prop="motherIdCard"
label="母亲身份证号"
min-width="190"
align="center"
header-align="center"
/>
<el-table-column
prop="fatherName"
label="父亲姓名"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="newbornSex"
label="新生儿性别"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">{{
newbornSexLabel(String(row.newbornSex || ''))
}}</template>
</el-table-column>
<el-table-column
prop="deliveryMode"
label="分娩方式"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">{{
deliveryModeLabel(String(row.deliveryMode || ''))
}}</template>
</el-table-column>
<el-table-column
prop="newbornWeightGram"
label="新生儿体重(克)"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="sampleTypeNames"
label="样本类型"
min-width="140"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="sampleCodes"
label="样本编码"
min-width="160"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="labStorageCodes"
label="实验室入库编码"
min-width="160"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.labStorageCodes || '—' }}</template>
</el-table-column>
<el-table-column
prop="regionName"
label="采集地区"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="hospitalName"
label="采集医院"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="collectorUserName"
label="采集专员"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="salesUserName"
label="销售人员"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="sampleCollectTime"
label="样本采集时间"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column
prop="createTime"
label="创建时间"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
min-width="160"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item" @click="openAuditDialog(row)">
<el-icon><CircleCheck /></el-icon>审核
</el-button>
<el-dropdown trigger="hover" @command="(cmd: string) => handleDropdownCommand(cmd, row)">
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="view">
<el-icon><View /></el-icon>查看
</el-dropdown-item>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<div v-loading="loading" class="protocol-card-section">
<div v-if="tableData.length" class="protocol-card-list">
<article
v-for="(row, index) in tableData"
:key="String(row.id)"
class="protocol-card"
:class="{ 'is-selected': isRowSelected(String(row.id)) }"
@dblclick="handleView(row)"
>
<div class="protocol-card__select" @click.stop>
<el-checkbox
:model-value="isRowSelected(String(row.id))"
@change="(val) => toggleRowSelection(row, !!val)"
/>
</div>
</template>
</el-table-column>
</el-table>
<div class="protocol-card__aside">
<div class="protocol-card__name-row">
<span class="protocol-card__index">{{
(pagination.page - 1) * pagination.pageSize + index + 1
}}</span>
<span class="protocol-card__name">{{ row.motherName || '—' }}</span>
</div>
<div class="protocol-card__contact">
<div class="protocol-card__sub">{{ row.motherPhone || '—' }}</div>
<div class="protocol-card__sub protocol-card__sub--id">
{{ row.motherIdCard ? maskIdCard(String(row.motherIdCard)) : '—' }}
</div>
</div>
<el-tag
:type="donationStatusTagType(String(row.donationStatus || ''))"
size="small"
class="protocol-card__status"
>{{ donationStatusLabel(String(row.donationStatus || '')) }}</el-tag
>
<div v-if="row.createTime" class="protocol-card__create-time">
创建 {{ row.createTime }}
</div>
</div>
<div class="protocol-card__sections">
<section class="protocol-card__block protocol-card__block--basic">
<div class="protocol-card__block-title">基础信息</div>
<div class="protocol-card__kv-grid">
<div class="protocol-card__kv">
<label>捐赠单号</label><span>{{ row.donationNo || '—' }}</span>
</div>
<div class="protocol-card__kv">
<label>捐赠日期</label><span>{{ row.donationDate || '—' }}</span>
</div>
<div class="protocol-card__kv">
<label>采集地区</label><span>{{ row.regionName || '—' }}</span>
</div>
<div class="protocol-card__kv">
<label>采集医院</label
><span :title="String(row.hospitalName || '')">{{
row.hospitalName || '—'
}}</span>
</div>
<div class="protocol-card__kv">
<label>采集专员</label><span>{{ row.collectorUserName || '—' }}</span>
</div>
<div class="protocol-card__kv">
<label>销售人员</label><span>{{ row.salesUserName || '—' }}</span>
</div>
<div class="protocol-card__kv">
<label>样本采集</label><span>{{ row.sampleCollectSummary || '—' }}</span>
</div>
<div class="protocol-card__kv">
<label>采集时间</label><span>{{ row.sampleCollectTime || '—' }}</span>
</div>
</div>
</section>
<section class="protocol-card__block protocol-card__block--newborn">
<div class="protocol-card__block-title">新生儿</div>
<div class="protocol-card__kv-grid">
<div class="protocol-card__kv">
<label>姓名</label><span>{{ row.newbornName || '—' }}</span>
</div>
<div class="protocol-card__kv">
<label>父亲</label><span>{{ row.fatherName || '—' }}</span>
</div>
<div class="protocol-card__kv">
<label>性别</label
><span>{{ newbornSexLabel(String(row.newbornSex || '')) || '—' }}</span>
</div>
<div class="protocol-card__kv">
<label>预产期</label><span>{{ row.expectedDeliveryDate || '—' }}</span>
</div>
<div class="protocol-card__kv">
<label>分娩时间</label><span>{{ row.newbornDeliveryTime || '—' }}</span>
</div>
<div class="protocol-card__kv">
<label>分娩方式</label
><span>{{ deliveryModeLabel(String(row.deliveryMode || '')) || '—' }}</span>
</div>
<div class="protocol-card__kv">
<label>体重(克)</label
><span>{{
row.newbornWeightGram != null && row.newbornWeightGram !== ''
? row.newbornWeightGram
: '—'
}}</span>
</div>
</div>
</section>
<section class="protocol-card__block protocol-card__block--samples">
<div class="protocol-card__block-title">
样本信息
<span v-if="getSampleItems(row).length" class="protocol-card__block-count">
{{ getSampleItems(row).length }} 项
</span>
</div>
<div v-if="getSampleItems(row).length" class="protocol-card__sample-list">
<div
v-for="(item, sIdx) in getSampleItems(row)"
:key="sIdx"
class="protocol-card__sample-item"
>
<span class="protocol-card__sample-seq">{{ sIdx + 1 }}</span>
<div class="protocol-card__sample-main">
<div class="protocol-card__sample-line">
<span class="protocol-card__sample-label">类型</span>
<span class="protocol-card__sample-value">{{
item.sampleTypeName || '—'
}}</span>
</div>
<div class="protocol-card__sample-line">
<span class="protocol-card__sample-label">编码</span>
<span class="protocol-card__sample-value protocol-card__sample-value--code">
{{ item.sampleCode || '—' }}
</span>
</div>
<div class="protocol-card__sample-line">
<span class="protocol-card__sample-label">入库</span>
<span class="protocol-card__sample-value protocol-card__sample-value--code">
{{ item.labStorageCode || '—' }}
</span>
</div>
</div>
</div>
</div>
<div v-else class="protocol-card__empty">暂无样本</div>
</section>
</div>
<div class="protocol-card__foot">
<div class="protocol-card__actions">
<el-button class="btn-op-item" @click.stop="openAuditDialog(row)">
<el-icon><CircleCheck /></el-icon>审核
</el-button>
<el-button class="btn-op-item" @click.stop="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button class="btn-op-item" @click.stop="handleEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button class="btn-op-item" @click.stop="handleDelete(row)">
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
</div>
</article>
</div>
<el-empty
v-else-if="!loading"
class="protocol-card-empty"
description="暂无捐赠协议单"
:image-size="80"
/>
</div>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
@@ -386,8 +342,7 @@
Delete,
Search,
RefreshLeft,
CircleCheck,
More
CircleCheck
} from '@element-plus/icons-vue'
import DonationFormDialog from '../list/DonationFormDialog.vue'
import DonationViewDialog from '../list/DonationViewDialog.vue'
@@ -412,9 +367,37 @@
formatProtocolDocDeleteConfirm,
formatProtocolDocBatchDeleteConfirm
} from '@/utils/protocol-doc-label'
import { maskIdCard } from '@/utils/mask-sensitive'
defineOptions({ name: 'ResearchDonationAudit' })
type DonationSampleItem = {
sampleTypeName: string
sampleCode: string
labStorageCode: string
}
function getSampleItems(row: ResearchDonationRow): DonationSampleItem[] {
const types = String(row.sampleTypeNames || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
const codes = String(row.sampleCodes || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
const labCodes = String(row.labStorageCodes || '')
.split(',')
.map((s) => s.trim())
const len = Math.max(types.length, codes.length)
if (!len) return []
return Array.from({ length: len }, (_, i) => ({
sampleTypeName: types[i] || '',
sampleCode: codes[i] || '',
labStorageCode: labCodes[i] || ''
}))
}
function formatLocalDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
@@ -441,8 +424,15 @@
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const handleSelectionChange = (rows: ResearchDonationRow[]) => {
selectedRows.value = rows
const isRowSelected = (id: string) => selectedRows.value.some((r) => String(r.id) === id)
const toggleRowSelection = (row: ResearchDonationRow, checked: boolean) => {
const id = String(row.id)
if (checked) {
if (!isRowSelected(id)) selectedRows.value = [...selectedRows.value, row]
return
}
selectedRows.value = selectedRows.value.filter((r) => String(r.id) !== id)
}
async function loadData() {
@@ -460,6 +450,8 @@
})
tableData.value = res.list || []
pagination.total = res.total ?? 0
const validIds = new Set(tableData.value.map((r) => String(r.id)))
selectedRows.value = selectedRows.value.filter((r) => validIds.has(String(r.id)))
} finally {
loading.value = false
}
@@ -502,7 +494,7 @@
}
function handleEdit(row: ResearchDonationRow) {
formDialogEditId.value = row.id
formDialogEditId.value = String(row.id)
formDialogRow.value = row
formDialogVisible.value = true
}
@@ -523,7 +515,7 @@
}
auditTargets.value = rows.map((r) => ({
id: String(r.id),
motherName: r.motherName
motherName: r.motherName != null ? String(r.motherName) : undefined
}))
auditDialogVisible.value = true
}
@@ -547,7 +539,7 @@
statusChangeAction.value = action
statusChangeTargets.value = rows.map((r) => ({
id: String(r.id),
motherName: r.motherName
motherName: r.motherName != null ? String(r.motherName) : undefined
}))
statusChangeDialogVisible.value = true
}
@@ -557,14 +549,6 @@
loadData()
}
function handleDropdownCommand(command: string, row: any) {
switch (command) {
case 'view': handleView(row); break
case 'edit': handleEdit(row); break
case 'delete': handleDelete(row); break
}
}
/* ====== 删除 ====== */
function handleDelete(row: ResearchDonationRow) {
ElMessageBox.confirm(formatProtocolDocDeleteConfirm('research_donation', row), '删除确认', {
@@ -614,3 +598,334 @@
loadData()
})
</script>
<style scoped lang="scss">
.protocol-card-section {
min-height: 120px;
}
.protocol-card-list {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px;
}
.protocol-card {
position: relative;
display: flex;
flex-direction: row;
align-items: stretch;
gap: 12px;
width: 100%;
box-sizing: border-box;
border: 1px solid var(--mint-border, #e2e8f0);
border-radius: 10px;
background: #fff;
padding: 12px 14px 12px 40px;
transition:
border-color 0.15s,
box-shadow 0.15s;
&:hover {
border-color: #c5e0d4;
box-shadow: 0 2px 8px rgb(15 23 42 / 5%);
}
&.is-selected {
border-color: var(--primary, #10b981);
box-shadow: 0 0 0 1px rgb(16 185 129 / 25%);
}
}
.protocol-card__select {
position: absolute;
top: 12px;
left: 12px;
z-index: 1;
}
.protocol-card__aside {
flex: 0 0 148px;
min-width: 132px;
display: flex;
flex-direction: column;
gap: 6px;
padding-right: 12px;
border-right: 1px solid #eef2f6;
}
.protocol-card__status {
align-self: flex-start;
}
.protocol-card__create-time {
margin-top: auto;
font-size: 11px;
color: var(--mint-text-muted, #64748b);
line-height: 1.35;
word-break: break-all;
}
.protocol-card__name-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.protocol-card__index {
flex-shrink: 0;
min-width: 22px;
height: 22px;
padding: 0 6px;
border-radius: 6px;
background: var(--primary-bg, #ecfdf5);
color: var(--primary-dark, #059669);
font-size: 12px;
font-weight: 600;
line-height: 22px;
text-align: center;
}
.protocol-card__name {
font-size: 15px;
font-weight: 600;
color: var(--text, #1e293b);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.protocol-card__contact {
display: flex;
flex-direction: column;
gap: 2px;
}
.protocol-card__sub {
font-size: 12px;
color: var(--mint-text-muted, #64748b);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&--id {
white-space: normal;
word-break: break-all;
}
}
.protocol-card__sections {
flex: 1;
display: flex;
flex-direction: row;
align-items: stretch;
gap: 10px;
min-width: 0;
overflow-x: auto;
}
.protocol-card__block {
flex: 1;
min-width: 0;
padding: 8px 10px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid #eef2f6;
&--basic {
flex: 1.15;
min-width: 180px;
}
&--newborn {
flex: 0.85;
min-width: 140px;
}
&--samples {
flex: 1.2;
min-width: 200px;
}
}
.protocol-card__block-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
font-size: 11px;
font-weight: 600;
color: var(--primary-dark, #059669);
line-height: 1.3;
}
.protocol-card__block-count {
font-size: 11px;
font-weight: 500;
color: var(--mint-text-muted, #64748b);
}
.protocol-card__kv-grid {
display: flex;
flex-direction: column;
gap: 4px;
}
.protocol-card__kv {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
font-size: 12px;
line-height: 1.35;
label {
flex-shrink: 0;
color: var(--mint-text-muted, #334155);
}
span {
min-width: 0;
color: var(--text, #1e293b);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.protocol-card__empty {
font-size: 12px;
color: var(--mint-text-muted, #334155);
line-height: 1.4;
}
.protocol-card__sample-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.protocol-card__sample-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
background: #fff;
border: 1px solid var(--mint-border, #e2e8f0);
}
.protocol-card__sample-seq {
flex-shrink: 0;
width: 18px;
height: 18px;
border-radius: 4px;
background: var(--primary-bg, #ecfdf5);
color: var(--primary-dark, #059669);
font-size: 10px;
font-weight: 600;
line-height: 18px;
text-align: center;
}
.protocol-card__sample-main {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.protocol-card__sample-line {
display: flex;
align-items: flex-start;
gap: 6px;
min-width: 0;
font-size: 12px;
line-height: 1.35;
}
.protocol-card__sample-label {
flex-shrink: 0;
width: 28px;
color: var(--mint-text-muted, #334155);
}
.protocol-card__sample-value {
min-width: 0;
flex: 1;
color: var(--text, #1e293b);
word-break: break-all;
&--code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
}
}
.protocol-card__foot {
flex: 0 0 auto;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
padding-left: 12px;
border-left: 1px solid #eef2f6;
}
.protocol-card__actions {
display: flex;
flex-direction: column;
flex-shrink: 0;
align-items: stretch;
gap: 6px;
min-width: 72px;
:deep(.el-button) {
width: 100%;
margin: 0;
justify-content: center;
}
}
.protocol-card-empty {
padding: 32px 16px;
}
@media (max-width: 1200px) {
.protocol-card {
flex-wrap: wrap;
}
.protocol-card__sections {
flex-basis: 100%;
order: 3;
}
.protocol-card__foot {
flex-direction: column;
align-items: stretch;
border-left: none;
border-top: 1px solid #eef2f6;
padding-left: 0;
padding-top: 10px;
margin-left: 0;
order: 4;
width: 100%;
}
}
@media (max-width: 768px) {
.protocol-card__sections {
flex-direction: column;
}
.protocol-card__block {
min-width: 0 !important;
}
}
</style>
@@ -89,12 +89,6 @@
><div class="data-label">捐赠日期</div
><div class="data-value">{{ formatYmd(donation?.donationDate) }}</div></div
>
<div class="data-item"
><div class="data-label">来源类型</div
><div class="data-value">{{
sourceTypeLabel(String(donation?.sourceType || ''))
}}</div></div
>
<div class="data-item"
><div class="data-label">采集专员</div
><div class="data-value">{{ collectorDisplay }}</div></div
@@ -243,7 +237,8 @@
<div class="data-item">
<div class="data-label">采集量</div>
<div class="data-value"
>{{ dash(row.collectAmount) }} {{ unitName(row.unitId) }}</div
>{{ dash(row.collectAmount) }}
{{ unitName(row.unitId, row.unitName) }}</div
>
</div>
<div class="data-item">
@@ -523,61 +518,63 @@
class="section-card section-card--white"
>
<div class="section-title section-title--banner"><h3>审核记录</h3></div>
<el-table
v-loading="auditLoading"
class="list-table sub-view-table"
:data="auditRecords"
stripe
empty-text="暂无审核记录"
>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
/>
<el-table-column
prop="createTime"
label="审核时间"
min-width="170"
align="center"
header-align="center"
/>
<el-table-column
prop="auditStatus"
label="审核状态"
min-width="120"
align="center"
header-align="center"
<div class="list-body list-wrap view-inner-table-wrap">
<el-table
v-loading="auditLoading"
class="list-table inner-table"
:data="auditRecords"
stripe
empty-text="暂无审核记录"
>
<template #default="{ row }">
<el-tag
:type="String(row.auditStatus) === 'shtg' ? 'success' : 'warning'"
size="small"
>
{{ auditStatusLabel(String(row.auditStatus || '')) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="auditRemark"
label="审核备注"
min-width="200"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="auditorUserName"
label="审核人"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">{{ auditorDisplay(row) }}</template>
</el-table-column>
</el-table>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
/>
<el-table-column
prop="createTime"
label="审核时间"
min-width="170"
align="center"
header-align="center"
/>
<el-table-column
prop="auditStatus"
label="审核状态"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag
:type="String(row.auditStatus) === 'shtg' ? 'success' : 'warning'"
size="small"
>
{{ auditStatusLabel(String(row.auditStatus || '')) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="auditRemark"
label="审核备注"
min-width="200"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="auditorUserName"
label="审核人"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">{{ auditorDisplay(row) }}</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</template>
@@ -642,7 +639,6 @@
import {
donationStatusLabel,
donationStatusTagType,
sourceTypeLabel,
auditStatusLabel,
auditStatusTagType,
newbornSexLabel,
@@ -824,8 +820,30 @@
return sampleTypeOptions.value.find((x) => x.id === id)?.typeName || dash(id)
}
function unitName(id: string) {
return unitOptions.value.find((x) => x.id === id)?.unitName || dash(id)
function unitName(id: string, fallbackName?: string) {
const fromOptions = unitOptions.value.find((x) => x.id === id)?.unitName
if (fromOptions) return fromOptions
const name = String(fallbackName || '').trim()
if (name) return name
return dash(id)
}
function ensureUnitsInOptions(samples: Array<Record<string, any>>) {
const merged = [...unitOptions.value]
for (const s of samples) {
const uid = String(s.unitId || '')
if (!uid) continue
const name = String(s.unitName || '').trim()
const existing = merged.find((x) => x.id === uid)
if (existing) {
if (name && (!existing.unitName || existing.unitName === existing.id)) {
existing.unitName = name
}
continue
}
merged.push({ id: uid, unitName: name || uid })
}
unitOptions.value = merged
}
function siteName(id: string) {
@@ -923,6 +941,7 @@
unitOptions.value = (uRes.list || []) as any
expressCompanyOptions.value = ecRes.list || []
siteOptions.value = siteRes.list || []
ensureUnitsInOptions((data.samples as Array<Record<string, any>>) || [])
const coll = data.collection as Record<string, any> | null
const don = data.donation as Record<string, any> | undefined
@@ -964,12 +983,10 @@
}
auditLoading.value = true
try {
const no =
String(donation.value?.donationNo || props.listRow?.donationNo || '').trim() || undefined
const res = await fetchResearchDonationAuditList({
page: 1,
pageSize: 100,
keyword: no
researchDonationId: props.donationId
})
auditRecords.value = res.list || []
} catch {
@@ -1337,9 +1354,38 @@
gap: 2px;
}
/* ---- Sub View Table ---- */
.sub-view-table {
margin-bottom: 8px;
/* ---- 审核记录内嵌列表(对齐 OpenSpec 嵌套列表 14px 标准) ---- */
.view-inner-table-wrap.list-body.list-wrap {
margin-top: 4px;
border: 1px solid var(--mint-border, #e2e8f0);
border-radius: var(--mint-radius, 10px);
overflow: hidden;
background: #fff;
:deep(.el-table) {
--el-table-header-bg-color: var(--mint-table-head, #f8fafc);
--el-table-border-color: var(--mint-border, #e2e8f0);
--el-table-row-hover-bg-color: var(--mint-row-hover, #f0fdf4);
--el-table-text-color: var(--mint-text, #0f172a);
--el-table-header-text-color: var(--mint-text-muted, #64748b);
font-size: 14px;
}
:deep(.el-table th.el-table__cell) {
font-size: 12px;
font-weight: 600;
color: var(--mint-text-muted, #64748b);
}
:deep(.el-table td.el-table__cell) {
font-size: 14px;
font-weight: 500;
color: var(--mint-text, #0f172a);
}
:deep(.el-empty) {
padding: 24px 0;
}
}
/* ---- Sample Cards ---- */