This commit is contained in:
zlwu committed 2026-06-16 07:38:27 +08:00
1 parent a1c2511eb7
commit eaf7d490ec
382 files changed
+44146 -34245

No files matched your search

@@ -236,6 +236,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -320,6 +322,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -380,6 +384,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -118,6 +118,8 @@
width="640px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -207,6 +207,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -284,6 +286,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -344,6 +348,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -161,6 +161,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -205,6 +207,8 @@
width="800px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@closed="onHospitalDialogClosed"
@@ -309,6 +313,7 @@
width="520px"
class="dialog-form-layout"
align-center
modal-class="protocol-dialog-mask"
append-to-body
destroy-on-close
:close-on-click-modal="false"
@@ -182,6 +182,8 @@
width="720px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -177,6 +177,8 @@
width="720px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -281,6 +283,8 @@
width="960px"
class="dialog-form-layout insurance-item-config-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@closed="onItemDialogClosed"
@@ -395,6 +399,7 @@
width="640px"
class="dialog-form-layout"
align-center
modal-class="protocol-dialog-mask"
append-to-body
destroy-on-close
:close-on-click-modal="false"
@@ -159,6 +159,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -196,6 +198,8 @@
width="1120px"
class="dialog-form-layout unused-split-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -432,6 +436,8 @@
width="1400px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
>
<div v-if="viewDetail" class="stock-check-view">
@@ -257,6 +257,8 @@
width="960px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -385,6 +387,8 @@
width="960px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -567,6 +571,8 @@
width="1080px"
class="dialog-form-layout material-batch-stock-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -749,6 +755,8 @@
width="1200px"
class="dialog-form-layout material-batch-stock-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -940,6 +948,8 @@
width="1200px"
class="dialog-form-layout material-batch-stock-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -221,6 +221,8 @@
width="960px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@closed="handleInsuranceDialogClosed"
@@ -428,6 +430,7 @@
v-model="previewVisible"
width="800px"
align-center
append-to-body
destroy-on-close
:show-close="true"
style="backdrop-filter: none"
@@ -199,6 +199,8 @@
width="640px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -189,6 +189,8 @@
width="720px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -307,6 +309,7 @@
title="文件预览"
width="800px"
align-center
append-to-body
destroy-on-close
>
<div v-if="previewType === 'image'" class="preview-container">
@@ -288,7 +288,7 @@
<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">
@@ -443,8 +443,9 @@
</template>
<div v-else class="sample-reception-empty">暂未接收</div>
</div>
<SampleQcInfoCards
:qc-info="row.qcInfo"
<SampleQcInfoGroupedPanel
:gate-qc-info="row.qcInfo"
:process-qc-info-list="row.processQcInfoList"
:sample-reception="row.sampleReception"
/>
<SampleProductionInfoCards :production-info="row.productionInfo" />
@@ -1146,7 +1147,7 @@
import { computed, nextTick, ref, watch } from 'vue'
import { Close } from '@element-plus/icons-vue'
import ProtocolAttachmentReadonly from './ProtocolAttachmentReadonly.vue'
import SampleQcInfoCards from '@/components/business/sample-qc-info-cards/index.vue'
import SampleQcInfoGroupedPanel from '@/components/business/sample-qc-info-grouped-panel/index.vue'
import SampleProductionInfoCards from '@/components/business/sample-production-info-cards/index.vue'
import SampleStorageReleaseInfo from '@/components/business/sample-storage-release-info/index.vue'
import {
@@ -1660,8 +1661,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 id = String(s.unitId || '')
if (!id) continue
const name = String(s.unitName || '').trim()
const existing = merged.find((x) => x.id === id)
if (existing) {
if (name && (!existing.unitName || existing.unitName === existing.id)) {
existing.unitName = name
}
continue
}
merged.push({ id, unitName: name || id })
}
unitOptions.value = merged
}
function siteName(id: string) {
@@ -1927,6 +1950,7 @@
lookupLoading.value = true
try {
await baseLookupsPromise
ensureUnitsInOptions((data.samples as Array<Record<string, any>>) || [])
await loadConditionalLookups(data)
} finally {
lookupLoading.value = false
@@ -138,6 +138,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -261,6 +263,8 @@
width="1200px"
class="dialog-form-layout clean-room-log-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -422,6 +426,7 @@
width="520px"
class="dialog-form-layout"
align-center
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
append-to-body
@@ -540,6 +545,8 @@
width="1200px"
class="dialog-form-layout clean-room-log-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -685,6 +692,7 @@
width="520px"
class="dialog-form-layout"
align-center
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
append-to-body
@@ -188,6 +188,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -181,6 +181,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@close="handleNodeDialogClose"
@@ -267,6 +269,8 @@
width="480px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@close="handleSlotDialogClose"
@@ -316,6 +320,8 @@
width="480px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@close="handleBatchDialogClose"
@@ -382,6 +388,8 @@
width="400px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@close="handleAddRowDialogClose"
@@ -425,6 +433,8 @@
top="5vh"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
>
<el-tabs v-model="occupancyActiveTab">
@@ -585,6 +595,8 @@
width="480px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -215,6 +215,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -186,6 +186,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -154,6 +154,8 @@
width="1100px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -286,6 +288,7 @@
width="860px"
class="dialog-form-layout"
align-center
modal-class="protocol-dialog-mask"
destroy-on-close
append-to-body
:close-on-click-modal="false"
@@ -141,6 +141,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -253,6 +253,7 @@
<!-- 确认操作弹窗 -->
<el-dialog
append-to-body
v-model="confirmVisible"
title="确认操作"
:width="txnTypeHasProducts(currentRow?.txnType) ? '960px' : '640px'"
@@ -500,6 +501,7 @@
title="文件预览"
width="800px"
align-center
append-to-body
destroy-on-close
>
<div v-if="previewType === 'image'" class="preview-container">
@@ -6,6 +6,7 @@
width="880px"
destroy-on-close
align-center
append-to-body
:close-on-click-modal="false"
class="service-order-txn-dialog"
style="backdrop-filter: none"
@@ -313,6 +314,7 @@
destroy-on-close
append-to-body
align-center
modal-class="protocol-dialog-mask"
:close-on-click-modal="false"
class="service-order-txn-edit-dialog dialog-form-layout"
style="backdrop-filter: none"
@@ -346,6 +346,7 @@
<!-- 新增/编辑弹窗 -->
<!-- ═══════════════════════════════════════════════════════════════════ -->
<el-dialog
append-to-body
v-model="formVisible"
:title="formMode === 'add' ? '新增订单' : '编辑订单'"
width="1080px"
@@ -460,6 +461,7 @@
<!-- 发起充值弹窗 -->
<el-dialog
append-to-body
v-model="rechargeVisible"
title="发起充值"
width="560px"
@@ -566,6 +568,7 @@
<!-- 申请退款弹窗 -->
<el-dialog
append-to-body
v-model="refundTxnVisible"
title="申请退款"
width="640px"
@@ -682,6 +685,7 @@
<!-- 购买产品弹窗 -->
<el-dialog
append-to-body
v-model="purchaseVisible"
title="购买产品"
width="880px"
@@ -776,6 +780,7 @@
<!-- 退订产品弹窗 -->
<el-dialog
append-to-body
v-model="unsubscribeVisible"
title="退订产品"
width="860px"
@@ -863,6 +868,7 @@
title="文件预览"
width="800px"
align-center
append-to-body
destroy-on-close
>
<div v-if="previewType === 'image'" class="preview-container">
@@ -7,6 +7,8 @@
width="1100px"
class="dialog-form-layout channel-view-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@@ -400,6 +402,7 @@
title="文件预览"
width="800px"
align-center
append-to-body
destroy-on-close
>
<div v-if="previewType === 'image'" class="preview-container">
@@ -174,6 +174,8 @@
width="1100px"
class="dialog-form-layout channel-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -461,6 +463,7 @@
width="560px"
class="dialog-form-layout"
align-center
modal-class="protocol-dialog-mask"
destroy-on-close
append-to-body
:close-on-click-modal="false"
@@ -518,6 +521,7 @@
width="560px"
class="dialog-form-layout"
align-center
modal-class="protocol-dialog-mask"
destroy-on-close
append-to-body
:close-on-click-modal="false"
@@ -581,6 +585,8 @@
width="1280px"
class="dialog-form-layout channel-agreement-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -864,6 +870,7 @@
width="560px"
class="dialog-form-layout"
align-center
modal-class="protocol-dialog-mask"
destroy-on-close
append-to-body
:close-on-click-modal="false"
@@ -974,6 +981,7 @@
width="960px"
class="dialog-form-layout price-selector-dialog"
align-center
modal-class="protocol-dialog-mask"
destroy-on-close
append-to-body
:close-on-click-modal="false"
@@ -1109,6 +1117,7 @@
title="文件预览"
width="800px"
align-center
append-to-body
destroy-on-close
>
<div v-if="previewType === 'image'" class="preview-container">
@@ -95,6 +95,7 @@
width="980px"
class="dialog-form-layout"
align-center
modal-class="protocol-dialog-mask"
append-to-body
destroy-on-close
:close-on-click-modal="false"
@@ -218,6 +219,7 @@
width="860px"
class="dialog-form-layout"
align-center
modal-class="protocol-dialog-mask"
append-to-body
destroy-on-close
:close-on-click-modal="false"
@@ -180,6 +180,8 @@
width="720px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -284,6 +286,8 @@
width="1120px"
class="dialog-form-layout checkup-item-config-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@closed="onItemDialogClosed"
@@ -413,6 +417,7 @@
width="640px"
class="dialog-form-layout"
align-center
modal-class="protocol-dialog-mask"
append-to-body
destroy-on-close
:close-on-click-modal="false"
@@ -6,6 +6,8 @@
width="1200px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@@ -193,7 +195,7 @@
</div>
</div>
</div>
<span class="upload-hint">支持图片、PDF、Word等文件</span>
<span class="upload-hint">支持图片、视频、PDF、Word、Excel 等常见文件格式</span>
</div>
</div>
@@ -6,6 +6,8 @@
width="1080px"
class="dialog-form-layout checkup-fill-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@@ -87,6 +89,7 @@
width="960px"
class="dialog-form-layout checkup-picker-dialog"
align-center
modal-class="protocol-dialog-mask"
append-to-body
destroy-on-close
:close-on-click-modal="false"
@@ -179,6 +182,7 @@
width="1080px"
class="dialog-form-layout checkup-picker-dialog"
align-center
modal-class="protocol-dialog-mask"
append-to-body
destroy-on-close
:close-on-click-modal="false"
@@ -299,6 +303,7 @@
width="900px"
class="dialog-form-layout"
align-center
modal-class="protocol-dialog-mask"
append-to-body
destroy-on-close
:close-on-click-modal="false"
@@ -6,6 +6,8 @@
width="1080px"
class="dialog-form-layout checkup-mark-checked-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@@ -103,7 +105,6 @@
:show-file-list="false"
:auto-upload="false"
:multiple="true"
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx"
@change="handleReportUpload"
>
<div class="upload-card">
@@ -143,7 +144,7 @@
</div>
</div>
</div>
<span class="upload-hint">支持图片、PDF、Word、Excel 等文件</span>
<span class="upload-hint">支持图片、视频、PDF、Word、Excel 等常见文件格式</span>
</div>
</div>
@@ -174,6 +175,7 @@
width="960px"
class="dialog-form-layout checkup-picker-dialog"
align-center
modal-class="protocol-dialog-mask"
append-to-body
destroy-on-close
:close-on-click-modal="false"
@@ -266,6 +268,7 @@
width="1080px"
class="dialog-form-layout checkup-picker-dialog"
align-center
modal-class="protocol-dialog-mask"
append-to-body
destroy-on-close
:close-on-click-modal="false"
@@ -386,6 +389,7 @@
width="900px"
class="dialog-form-layout"
align-center
modal-class="protocol-dialog-mask"
append-to-body
destroy-on-close
:close-on-click-modal="false"
@@ -6,6 +6,8 @@
width="1380px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@@ -243,6 +245,7 @@
width="960px"
class="dialog-form-layout checkup-picker-dialog"
align-center
modal-class="protocol-dialog-mask"
append-to-body
destroy-on-close
:close-on-click-modal="false"
@@ -329,6 +332,7 @@
width="1080px"
class="dialog-form-layout checkup-picker-dialog"
align-center
modal-class="protocol-dialog-mask"
append-to-body
destroy-on-close
:close-on-click-modal="false"
@@ -449,6 +453,7 @@
width="900px"
class="dialog-form-layout"
align-center
modal-class="protocol-dialog-mask"
append-to-body
destroy-on-close
:close-on-click-modal="false"
@@ -7,6 +7,8 @@
width="1120px"
class="dialog-form-layout outbound-handover-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
style="backdrop-filter: none"
@@ -23,7 +25,7 @@
<span class="material-summary-value">{{ scheduleSummary.patientName || '—' }}</span>
</div>
<div class="material-summary-row">
<span class="material-summary-label">输注时间</span>
<span class="material-summary-label">{{ pickupTimeLabel }}</span>
<span class="material-summary-value">{{ scheduleSummary.infusionTime || '—' }}</span>
</div>
</div>
@@ -233,6 +235,7 @@
import OutboundMaterialFormSections from '@/components/business/outbound-material-form-sections/index.vue'
import { useUserStore } from '@/store/modules/user'
import { downloadAttachmentFile } from '@/utils/download-attachment'
import { getPilotBizConfig, resolvePilotBizType } from '@/config/pilot-biz-type'
type AttachmentItem = {
id: string
@@ -248,8 +251,13 @@
modelValue: boolean
scheduleId: string
scheduleRow?: Record<string, any> | null
bizType?: string
}>()
const pickupTimeLabel = computed(
() => getPilotBizConfig(resolvePilotBizType(props.bizType)).pickupTimeLabel
)
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'saved'): void
@@ -2,10 +2,12 @@
<template>
<el-dialog
:model-value="modelValue"
:title="'取消输注计划 — ' + (patientName || '')"
:title="'取消' + scheduleLabel + ' — ' + (patientName || '')"
width="500px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@@ -41,18 +43,24 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref, watch, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { Close, CircleCheck } from '@element-plus/icons-vue'
import { updateInfusionScheduleStatus } from '@/api/admin'
import { getPilotBizConfig, resolvePilotBizType } from '@/config/pilot-biz-type'
const props = defineProps<{
modelValue: boolean
scheduleId: string
patientName?: string
bizType?: string
}>()
const emit = defineEmits(['update:modelValue', 'success'])
const scheduleLabel = computed(
() => getPilotBizConfig(resolvePilotBizType(props.bizType)).scheduleLabel
)
const cancelReason = ref('')
const submitLoading = ref(false)
@@ -68,7 +76,7 @@
infusionStatus: 'yqx',
cancelReason: cancelReason.value.trim()
})
ElMessage.success('已取消输注计划')
ElMessage.success(`已取消${scheduleLabel.value}`)
emit('update:modelValue', false)
emit('success')
} catch {
@@ -2,11 +2,13 @@
<template>
<el-dialog
:model-value="modelValue"
title="编辑输注计划"
:title="'编辑' + bizConfig.scheduleLabel"
width="960px"
class="dialog-form-layout schedule-edit-dialog"
label-position="top"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@@ -95,28 +97,31 @@
<div class="section-card section-card--white schedule-plan-section">
<div class="section-title section-title--mint-bar">
<h3>输注计划信息</h3>
<h3>{{ bizConfig.scheduleLabel }}信息</h3>
</div>
<div class="form-grid form-grid-3 schedule-edit-fields">
<el-form-item label="输注时间">
<el-form-item :label="bizConfig.pickupTimeLabel">
<el-date-picker
v-model="formData.infusionTime"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择输注时间"
:placeholder="'请选择' + bizConfig.pickupTimeLabel"
clearable
style="width: 100%"
/>
</el-form-item>
<el-form-item label="输注地点">
<el-input v-model="formData.infusionLocation" placeholder="请输入输注地点" />
<el-form-item v-if="!isBasicExperiment" :label="bizConfig.locationLabel">
<el-input
v-model="formData.infusionLocation"
:placeholder="'请输入' + bizConfig.locationLabel"
/>
</el-form-item>
<el-form-item label="输注方式">
<el-form-item v-if="!isBasicExperiment" :label="bizConfig.methodLabel">
<el-select
v-model="formData.infusionMethod"
clearable
filterable
placeholder="请选择输注方式"
:placeholder="'请选择' + bizConfig.methodLabel"
style="width: 100%"
>
<el-option
@@ -143,8 +148,8 @@
<span v-else class="data-value--muted">—</span>
</div>
</div>
<div class="data-item">
<div class="data-label">输注状态</div>
<div v-if="!isBasicExperiment" class="data-item">
<div class="data-label">{{ bizConfig.statusLabel }}</div>
<div class="data-value">
<el-tag
v-if="scheduleStatusFields.infusionStatus"
@@ -268,7 +273,7 @@
:image-size="48"
/>
<div v-if="isYfkOrder && formData.cellProducts.length" class="schedule-amount-summary">
本次输注计划金额:
本次{{ bizConfig.scheduleLabel }}金额:
<span class="schedule-amount-summary__value">
¥{{ formatOrderBalance(calcScheduleAmount()) }}
</span>
@@ -473,6 +478,7 @@
} from '@/utils/status-tag-registry'
import { formatMoneyDisplay } from '@/utils/money-format'
import { fetchAllPagedRecords } from '@/utils/fetch-paged-list'
import { getPilotBizConfig } from '@/config/pilot-biz-type'
const props = defineProps<{
modelValue: boolean
@@ -499,6 +505,7 @@
})
const scheduleContext = reactive({
bizType: '',
pilotApplicationId: '',
applicationNo: '',
applicationName: '',
@@ -520,6 +527,9 @@
const agreementOptions = ref<any[]>([])
const optionsLoadIssue = ref('')
const bizConfig = computed(() => getPilotBizConfig(scheduleContext.bizType))
const isBasicExperiment = computed(() => scheduleContext.bizType === 'basic_experiment')
const applicationContextLabel = computed(() => {
const no = scheduleContext.applicationNo?.trim()
const name = scheduleContext.applicationName?.trim()
@@ -587,6 +597,7 @@
const applyScheduleContextFields = (source: Record<string, any>) => {
if (!source) return
if (source.bizType) scheduleContext.bizType = source.bizType
if (source.pilotApplicationId) {
scheduleContext.pilotApplicationId = source.pilotApplicationId
}
@@ -994,6 +1005,7 @@
scheduleContext.applicationNo = app.applicationNo || scheduleContext.applicationNo
scheduleContext.applicationName = app.applicationName || scheduleContext.applicationName
scheduleContext.applicationStatus = app.applicationStatus || scheduleContext.applicationStatus
scheduleContext.bizType = app.bizType || scheduleContext.bizType
scheduleContext.channelId = app.channelId || scheduleContext.channelId
scheduleContext.channelName = app.channelName || scheduleContext.channelName
scheduleContext.channelServiceOrderId =
@@ -1397,7 +1409,7 @@
idx !== productIdx && projectKey && getCellProductProjectKey(cp) === projectKey
)
if (duplicated) {
ElMessage.warning('同一输注计划中不能重复添加相同细胞项目')
ElMessage.warning(`同一${bizConfig.value.scheduleLabel}中不能重复添加相同细胞项目`)
product.priceId = ''
product.projectCode = ''
product.projectName = ''
@@ -1442,11 +1454,11 @@
return
}
if (!formData.id) {
ElMessage.warning('输注计划信息未加载完成,请关闭弹窗后重试')
ElMessage.warning(`${bizConfig.value.scheduleLabel}信息未加载完成,请关闭弹窗后重试`)
return
}
if (!formData.infusionTime) {
ElMessage.warning('请选择输注时间')
ElMessage.warning(`请选择${bizConfig.value.pickupTimeLabel}`)
return
}
@@ -261,6 +261,7 @@
import { formatMoneyDisplay } from '@/utils/money-format'
import { maskIdCard } from '@/utils/mask-sensitive'
import { downloadAttachmentFile } from '@/utils/download-attachment'
import { getPilotBizConfig } from '@/config/pilot-biz-type'
defineOptions({ name: 'ScheduleViewDialog' })
@@ -292,7 +293,10 @@
const heroTitle = computed(() => {
const patient = detail.value?.patient || {}
return patient.patientName || detail.value?.patientName || '输注计划详情'
const cfg = getPilotBizConfig(
detail.value?.application?.bizType || detail.value?.bizType
)
return patient.patientName || detail.value?.patientName || `${cfg.scheduleLabel}详情`
})
const heroCode = computed(() => {
@@ -308,6 +312,16 @@
detail.value?.bizType === 'clinical_application'
)
const scheduleBizConfig = computed(() =>
getPilotBizConfig(detail.value?.application?.bizType || detail.value?.bizType)
)
const isBasicExperiment = computed(
() =>
detail.value?.application?.bizType === 'basic_experiment' ||
detail.value?.bizType === 'basic_experiment'
)
const outboundMaterial = computed(() => detail.value?.outboundMaterial || null)
const outboundMaterialCells = computed(() => outboundMaterial.value?.cells || [])
const outboundMaterialQcRows = computed(() => outboundMaterial.value?.qcMaterials || [])
@@ -413,7 +427,8 @@
? storageReleaseConclusionTagType(reviewConclusionValue)
: 'info'
const orderType = app.orderType || d.orderType || ''
const cfg = scheduleBizConfig.value
const basic = isBasicExperiment.value
const confirmFields: ViewSection['fields'] = hasInfusionConfirm(confirm)
? [
{ label: '确认人', value: confirm.confirmUserName || '—' },
@@ -430,90 +445,139 @@
},
{
label: '说明',
value: '已在输注计划管理中确认,暂无独立应用确认记录',
value: `已在${cfg.scheduleLabel}管理中确认,暂无独立应用确认记录`,
span: true
}
]
: [{ label: '确认状态', value: '未确认', tagType: 'info' }]
return [
{
title: '中试申请 / 渠道 / 服务订单',
fields: [
{ label: '申请单号', value: app.applicationNo || d.applicationNo || '—' },
{ label: '申请单名称', value: app.applicationName || d.applicationName || '—' },
const orderType = app.orderType || d.orderType || ''
const contextSections: ViewSection[] = basic
? [
{
label: '申请单状态',
value: applicationStatusLabel(app.applicationStatus || d.applicationStatus || ''),
tagType: applicationStatusTagType(app.applicationStatus || d.applicationStatus || '')
},
{ label: '渠道名称', value: app.channelName || d.channelName || '—' },
{
label: '服务订单',
value: app.channelServiceOrderName || d.channelServiceOrderName || '—'
},
{
label: '订单类型',
value: orderType ? channelServiceOrderTypeLabel(orderType) : '—',
tagType: orderType ? channelServiceOrderTypeTagType(orderType) : undefined
title: '基础实验申请信息',
fields: [
{ label: '申请单号', value: app.applicationNo || d.applicationNo || '—' },
{ label: '课题名称', value: app.projectTitle || d.projectTitle || '—' },
{ label: '客户名称', value: app.customerName || d.customerName || '—' },
{
label: '申请单状态',
value: applicationStatusLabel(app.applicationStatus || d.applicationStatus || ''),
tagType: applicationStatusTagType(app.applicationStatus || d.applicationStatus || '')
}
]
}
]
},
{
title: '患者信息',
fields: [
{ label: '患者姓名', value: patient.patientName || d.patientName || '—' },
{ label: '性别', value: sexLabel(patient.sex || d.patientSex) || '—' },
: [
{
label: '年龄',
title: '中试申请 / 渠道 / 服务订单',
fields: [
{ label: '申请单号', value: app.applicationNo || d.applicationNo || '—' },
{ label: '申请单名称', value: app.applicationName || d.applicationName || '—' },
{
label: '申请单状态',
value: applicationStatusLabel(app.applicationStatus || d.applicationStatus || ''),
tagType: applicationStatusTagType(app.applicationStatus || d.applicationStatus || '')
},
{ label: '渠道名称', value: app.channelName || d.channelName || '—' },
{
label: '服务订单',
value: app.channelServiceOrderName || d.channelServiceOrderName || '—'
},
{
label: '订单类型',
value: orderType ? channelServiceOrderTypeLabel(orderType) : '—',
tagType: orderType ? channelServiceOrderTypeTagType(orderType) : undefined
}
]
},
{
title: '患者信息',
fields: [
{ label: '患者姓名', value: patient.patientName || d.patientName || '—' },
{ label: '性别', value: sexLabel(patient.sex || d.patientSex) || '—' },
{
label: '年龄',
value:
patient.age != null && patient.age !== ''
? String(patient.age)
: d.patientAge != null
? String(d.patientAge)
: '—'
},
{ label: '联系电话', value: patient.phoneNo || '—' },
{
label: '身份证号',
value: patient.idCardNo ? maskIdCard(patient.idCardNo) : '—'
},
{ label: '紧急联系人', value: patient.emergencyContact || '—' },
{ label: '紧急联系电话', value: patient.emergencyPhone || '—' },
{ label: '是否需要体检', value: formatNeedCheckup(patient.needCheckup) },
{ label: '临床诊断', value: patient.clinicalDiagnosis || '—', span: true }
]
},
{
title: cfg.planLabel,
fields: [
{ label: '细胞类型', value: formatCellType(plan, schedule) },
{ label: '规格', value: plan.specText || schedule.specText || d.specText || '—' },
{
label: '总疗程数',
value:
plan.totalCourseCount != null
? String(plan.totalCourseCount)
: schedule.totalCourseCount != null
? String(schedule.totalCourseCount)
: d.totalCourseCount != null
? String(d.totalCourseCount)
: '—'
},
{ label: '疗程间隔', value: plan.courseInterval || '—' },
{ label: '输注开始日期', value: plan.infusionStartDate || '—' }
]
}
]
const scheduleFields: ViewSection['fields'] = basic
? [
{ label: '细胞类型', value: formatCellType(plan, schedule) },
{ label: '规格', value: schedule.specText || d.specText || '—' },
{ label: cfg.pickupTimeLabel, value: schedule.infusionTime || d.infusionTime || '—' },
{
label: '售卖金额',
value:
patient.age != null && patient.age !== ''
? String(patient.age)
: d.patientAge != null
? String(d.patientAge)
schedule.amount != null && schedule.amount !== ''
? `¥${Number(schedule.amount).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`
: d.amount != null && d.amount !== ''
? `¥${Number(d.amount).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`
: '—'
},
{ label: '联系电话', value: patient.phoneNo || '—' },
{
label: '身份证号',
value: patient.idCardNo ? maskIdCard(patient.idCardNo) : '—'
label: '生产状态',
value: scheduleStatusLabel(schedule.scheduleStatus || d.scheduleStatus || ''),
tagType: scheduleStatusTagType(schedule.scheduleStatus || d.scheduleStatus || '') as TagType
},
{ label: '紧急联系人', value: patient.emergencyContact || '—' },
{ label: '紧急联系电话', value: patient.emergencyPhone || '—' },
{ label: '是否需要体检', value: formatNeedCheckup(patient.needCheckup) },
{ label: '临床诊断', value: patient.clinicalDiagnosis || '—', span: true }
]
},
{
title: '输注方案',
fields: [
{ label: '细胞类型', value: formatCellType(plan, schedule) },
{ label: '规格', value: plan.specText || schedule.specText || d.specText || '—' },
{
label: '总疗程数',
value:
plan.totalCourseCount != null
? String(plan.totalCourseCount)
: schedule.totalCourseCount != null
? String(schedule.totalCourseCount)
: d.totalCourseCount != null
? String(d.totalCourseCount)
: '—'
label: '质检状态',
value: qcStatusLabel(schedule.qcStatus || d.qcStatus || ''),
tagType: qcStatusTagType(schedule.qcStatus || d.qcStatus || '') as TagType
},
{ label: '疗程间隔', value: plan.courseInterval || '—' },
{ label: '输注开始日期', value: plan.infusionStartDate || '—' }
{
label: '取消原因',
value: schedule.cancelReason || d.cancelReason || '—',
span: true
},
{ label: '备注', value: schedule.remark || d.remark || '—', span: true }
]
},
{
title: '本次输注计划',
fields: [
: [
{ label: '疗程进度', value: formatCourseNo(schedule) },
{ label: '细胞类型', value: formatCellType(plan, schedule) },
{ label: '规格', value: schedule.specText || d.specText || '—' },
{ label: '输注时间', value: schedule.infusionTime || d.infusionTime || '—' },
{ label: '输注地点', value: schedule.infusionLocation || d.infusionLocation || '—' },
{ label: cfg.pickupTimeLabel, value: schedule.infusionTime || d.infusionTime || '—' },
{ label: cfg.locationLabel, value: schedule.infusionLocation || d.infusionLocation || '—' },
{
label: '输注方式',
label: cfg.methodLabel,
value: infusionMethodLabel(schedule.infusionMethod || d.infusionMethod || '')
},
{
@@ -522,7 +586,7 @@
tagType: scheduleStatusTagType(schedule.scheduleStatus || d.scheduleStatus || '') as TagType
},
{
label: '输注状态',
label: cfg.statusLabel,
value: infusionStatusLabel(schedule.infusionStatus || d.infusionStatus || ''),
tagType: infusionStatusTagType(schedule.infusionStatus || d.infusionStatus || '') as TagType
},
@@ -538,6 +602,12 @@
},
{ label: '备注', value: schedule.remark || d.remark || '—', span: true }
]
return [
...contextSections,
{
title: basic ? `本次${cfg.scheduleLabel}` : '本次输注计划',
fields: scheduleFields
},
{
title: '产品列表',
@@ -8,6 +8,8 @@
width="1120px"
class="dialog-form-layout outbound-material-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
style="backdrop-filter: none"
@@ -24,7 +26,7 @@
<span class="material-summary-value">{{ scheduleSummary.patientName || '—' }}</span>
</div>
<div class="material-summary-row">
<span class="material-summary-label">输注时间</span>
<span class="material-summary-label">{{ pickupTimeLabel }}</span>
<span class="material-summary-value">{{ scheduleSummary.infusionTime || '—' }}</span>
</div>
</div>
@@ -347,6 +349,7 @@
width="1200px"
class="dialog-form-layout"
align-center
modal-class="protocol-dialog-mask"
destroy-on-close
append-to-body
:close-on-click-modal="false"
@@ -454,6 +457,7 @@
} from '@/api/outbound-release-review'
import { downloadAttachmentFile } from '@/utils/download-attachment'
import { cellProductStatusLabel, cellProductStatusTagType } from '@/utils/status-tag-registry'
import { getPilotBizConfig, resolvePilotBizType } from '@/config/pilot-biz-type'
type AttachmentLike = {
fileName?: string
@@ -493,6 +497,7 @@
const props = defineProps<{
modelValue: boolean
scheduleId: string
bizType?: string
scheduleSummary?: {
applicationNo?: string
patientName?: string
@@ -500,6 +505,10 @@
}
}>()
const pickupTimeLabel = computed(
() => getPilotBizConfig(resolvePilotBizType(props.bizType)).pickupTimeLabel
)
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'saved'): void
@@ -7,6 +7,8 @@
width="640px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
style="backdrop-filter: none"
@@ -231,26 +231,23 @@
type OutboundReleaseReviewDetail
} from '@/api/outbound-release-review'
import {
applicationStatusLabel,
applicationStatusTagType,
storageReleaseConclusionLabel,
storageReleaseConclusionTagType,
scheduleStatusLabel,
scheduleStatusTagType,
qcStatusLabel,
qcStatusTagType,
infusionMethodLabel,
cellProductStatusLabel,
cellProductStatusTagType
} from '@/utils/status-tag-registry'
import { maskIdCard } from '@/utils/mask-sensitive'
import { downloadAttachmentFile } from '@/utils/download-attachment'
import {
buildOutboundPilotContextSections,
resolveOutboundViewVariant
} from '@/utils/outbound-qc-pilot-view-sections'
defineOptions({ name: 'OutboundReleaseViewDialog' })
const props = defineProps<{
visible: boolean
scheduleId: string
bizType?: string
}>()
const emit = defineEmits<{
@@ -420,11 +417,10 @@
const d = detail.value
if (!d) return []
const ctx = resolvePilotDetail(d)
const app = ctx.application || {}
const patient = ctx.patient || {}
const plan = ctx.infusionPlan || {}
const schedule = ctx.infusionSchedule || {}
const review = ctx.reviewInfo || {}
const variant = resolveOutboundViewVariant(
ctx.application?.bizType || props.bizType
)
const reviewConclusionValue = review.reviewConclusion || ''
const reviewConclusionDisplay = reviewConclusionValue
@@ -434,65 +430,10 @@
? storageReleaseConclusionTagType(reviewConclusionValue)
: 'info'
const contextSections = buildOutboundPilotContextSections(ctx as any, variant)
return [
{
title: '中试服务申请单信息',
fields: [
{ label: '申请单号', value: app.applicationNo || '—' },
{ label: '申请单名称', value: app.applicationName || '—' },
{
label: '申请单状态',
value: applicationStatusLabel(app.applicationStatus || ''),
tagType: applicationStatusTagType(app.applicationStatus || '')
},
{ label: '渠道名称', value: app.channelName || '—' }
]
},
{
title: '患者信息',
fields: [
{ label: '患者姓名', value: patient.patientName || '—' },
{ label: '性别', value: patient.sex || '—' },
{ label: '年龄', value: patient.age || '—' },
{ label: '联系电话', value: patient.phoneNo || '—' },
{
label: '身份证号',
value: patient.idCardNo ? maskIdCard(patient.idCardNo) : '—'
},
{ label: '紧急联系人', value: patient.emergencyContact || '—' },
{ label: '紧急联系电话', value: patient.emergencyPhone || '—' },
{ label: '临床诊断', value: patient.clinicalDiagnosis || '—', span: true }
]
},
{
title: '输注方案信息',
fields: [
{
label: '总疗程数',
value: plan.totalCourseCount != null ? String(plan.totalCourseCount) : '—'
},
{ label: '疗程间隔', value: plan.courseInterval || '—' },
{ label: '输注开始日期', value: plan.infusionStartDate || '—' }
]
},
{
title: '本次输注计划',
fields: [
{ label: '输注时间', value: schedule.infusionTime || '—' },
{ label: '输注地点', value: schedule.infusionLocation || '—' },
{ label: '输注方式', value: infusionMethodLabel(schedule.infusionMethod || '') },
{
label: '计划状态',
value: scheduleStatusLabel(schedule.scheduleStatus || ''),
tagType: scheduleStatusTagType(schedule.scheduleStatus || '')
},
{
label: '质检状态',
value: qcStatusLabel(schedule.qcStatus || ''),
tagType: qcStatusTagType(schedule.qcStatus || '')
}
]
},
...contextSections,
{
title: '生产信息',
slot: 'production-info',
@@ -29,7 +29,7 @@
/>
</el-select>
</el-form-item>
<el-form-item label="输注时间">
<el-form-item :label="pickupTimeLabel">
<el-date-picker
v-model="searchForm.infusionTimeRange"
type="daterange"
@@ -43,7 +43,7 @@
</div>
<div class="filter-actions-row">
<span class="filter-hint"
>支持按申请单号、患者姓名搜索;输注时间默认今天前 15 天至后 15 天。</span
>支持按申请单号、患者姓名搜索;{{ pickupTimeLabel }}默认今天前 15 天至后 15 天。</span
>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset">
@@ -115,7 +115,7 @@
</el-table-column>
<el-table-column
prop="infusionTime"
label="输注时间"
:label="pickupTimeLabel"
min-width="170"
align="center"
header-align="center"
@@ -217,7 +217,11 @@
</div>
</div>
<OutboundReleaseViewDialog v-model:visible="viewVisible" :schedule-id="viewScheduleId" />
<OutboundReleaseViewDialog
v-model:visible="viewVisible"
:schedule-id="viewScheduleId"
:biz-type="effectiveBizType"
/>
<OutboundReleaseReviewDialog
v-model="reviewDialogVisible"
@@ -229,6 +233,7 @@
v-model="materialDialogVisible"
:schedule-id="materialScheduleId"
:schedule-summary="materialScheduleSummary"
:biz-type="effectiveBizType"
@saved="loadData"
/>
</section>
@@ -255,7 +260,7 @@
bizTypeLabel,
bizTypeTagType
} from '@/utils/status-tag-registry'
import { resolvePilotBizType } from '@/config/pilot-biz-type'
import { getPilotBizConfig, resolvePilotBizType } from '@/config/pilot-biz-type'
defineOptions({ name: 'PilotServiceOutboundReleaseReview' })
@@ -265,6 +270,8 @@
}>()
const effectiveBizType = computed(() => resolvePilotBizType(props.bizType))
const bizConfig = computed(() => getPilotBizConfig(effectiveBizType.value))
const pickupTimeLabel = computed(() => bizConfig.value.pickupTimeLabel)
const reviewConclusionOptions = [
{ value: 'tg', label: '通过' },
@@ -6,6 +6,8 @@
width="1600px"
class="dialog-form-layout application-detail-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -216,7 +218,7 @@
</div>
</div>
</div>
<span class="upload-hint">支持图片、PDF等文件,单文件不超过10MB</span>
<span class="upload-hint">支持图片、视频、PDF、Word、Excel 等常见文件格式</span>
</div>
</el-form-item>
</div>
@@ -491,7 +493,7 @@
</div>
</div>
</div>
<span class="upload-hint">支持图片、PDF等文件,单文件不超过10MB</span>
<span class="upload-hint">支持图片、视频、PDF、Word、Excel 等常见文件格式</span>
</div>
</el-form-item>
</div>
@@ -1120,6 +1122,7 @@
<!-- ═══ 体检信息编辑弹窗 ═══ -->
<el-dialog
append-to-body
v-model="checkupDialogVisible"
:title="checkupData ? '编辑体检信息' : '新增体检信息'"
width="640px"
@@ -1219,7 +1222,7 @@
</div>
</div>
</div>
<span class="upload-hint">支持图片、PDF等文件,单文件不超过10MB</span>
<span class="upload-hint">支持图片、视频、PDF、Word、Excel 等常见文件格式</span>
</div>
</el-form-item>
</div>
@@ -1237,6 +1240,7 @@
<!-- ═══ 预约项目添加弹窗 ═══ -->
<el-dialog
append-to-body
v-model="reserveItemDialogVisible"
title="添加预约项目"
width="420px"
@@ -1271,6 +1275,7 @@
<!-- ═══ 实际项目添加弹窗 ═══ -->
<el-dialog
append-to-body
v-model="actualItemDialogVisible"
title="添加实际项目"
width="420px"
@@ -1305,6 +1310,7 @@
<!-- ═══ 交付详情弹窗 ═══ -->
<el-dialog
append-to-body
v-model="deliveryDetailDialogVisible"
title="交付详情"
width="720px"
@@ -1481,6 +1487,7 @@
<!-- ═══ 物料添加弹窗 ═══ -->
<el-dialog
append-to-body
v-model="addMaterialDialogVisible"
title="添加物料"
width="420px"
@@ -1520,6 +1527,7 @@
<!-- ═══ 客户选择弹窗 ═══ -->
<el-dialog
append-to-body
v-model="customerDialogVisible"
title="选择客户"
width="800px"
@@ -1590,6 +1598,7 @@
<!-- ═══ 输注方案弹窗 ═══ -->
<el-dialog
append-to-body
v-model="planDialogVisible"
:title="planDialogMode === 'add' ? '新增输注方案' : '编辑输注方案'"
width="640px"
@@ -1651,6 +1660,7 @@
<!-- ═══ 输注计划编辑弹窗 ═══ -->
<el-dialog
append-to-body
v-model="scheduleDialogVisible"
:title="scheduleDialogMode === 'add' ? '添加输注计划' : '编辑输注计划'"
width="500px"
@@ -1717,6 +1727,7 @@
<!-- ═══ 创建模式-本地输注方案弹窗 ═══ -->
<el-dialog
append-to-body
v-model="localPlanDialogVisible"
:title="localPlanEditIndex >= 0 ? '编辑输注方案' : '新增输注方案'"
width="640px"
@@ -1776,6 +1787,7 @@
<!-- ═══ 新增客户弹窗 ═══ -->
<el-dialog
append-to-body
v-model="newCustomerDialogVisible"
title="新增客户"
width="500px"
@@ -3,15 +3,20 @@
<el-dialog
:model-value="modelValue"
:title="isEdit ? '编辑申请单' : '新增申请单'"
width="1380px"
:width="isBasicExperiment ? '1600px' : '1380px'"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@open="handleOpen"
@update:model-value="emit('update:modelValue', $event)"
>
<DialogFormBody :loading="formLoading">
<DialogFormBody
:loading="formLoading"
body-class="protocol-dialog-body min-h-[200px]"
>
<div class="detail-tabs">
<div
v-for="tab in formTabs"
@@ -30,6 +35,7 @@
:rules="formRules"
label-position="top"
scroll-to-error
class="protocol-form"
>
<!-- ═══ Tab 0: 申请单信息 ═══ -->
<div id="section-application" class="form-tab-pane">
@@ -150,7 +156,6 @@
:show-file-list="false"
:auto-upload="false"
:multiple="true"
accept="image/*,.pdf"
@change="handleConsentFileChange"
>
<div class="upload-card">
@@ -200,7 +205,7 @@
</div>
</div>
</div>
<span class="upload-hint">支持图片和 PDF 格式,可稍后补充</span>
<span class="upload-hint">支持图片、视频、PDF、Word、Excel 等常见文件格式,可稍后补充</span>
</div>
</div>
@@ -215,7 +220,6 @@
:show-file-list="false"
:auto-upload="false"
:multiple="true"
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx"
@change="handleOtherFileChange"
>
<div class="upload-card">
@@ -265,14 +269,16 @@
</div>
</div>
</div>
<span class="upload-hint">支持图片、PDF、Word、Excel 等文件</span>
<span class="upload-hint">支持图片、视频、PDF、Word、Excel 等常见文件格式</span>
</div>
</div>
<div v-if="bizConfig.showStandardAttachments" class="form-col-full">
<div class="section-subtitle" style="margin-top: 8px; margin-bottom: 12px"
>标准附件</div
>
</div>
<div
v-if="isBasicExperiment && bizConfig.showStandardAttachments"
class="form-grid form-grid--attachment"
>
<el-form-item label="标准附件">
<div class="attachment-upload">
<div class="attachment-list">
<el-upload
@@ -280,7 +286,6 @@
:show-file-list="false"
:auto-upload="false"
:multiple="true"
accept="image/*,.pdf"
@change="handleOtherFileChange"
>
<div class="upload-card">
@@ -322,9 +327,9 @@
</div>
</div>
</div>
<span class="upload-hint">支持图片和 PDF 格式</span>
<span class="upload-hint">支持图片、视频、PDF、Word、Excel 等常见文件格式</span>
</div>
</div>
</el-form-item>
</div>
</div>
</div>
@@ -493,10 +498,10 @@
<div class="section-title-left section-title-left--with-hint">
<h3>{{ bizConfig.planLabel }}</h3>
<p v-if="isEdit" class="section-title-hint">
编辑模式下仅更新申请单信息;新增售卖计划请在创建时维护,或于「售卖计划管理」Tab 查看。
编辑模式下仅更新申请单信息;新增交付计划请在创建时维护,或于「交付计划管理」Tab 查看。
</p>
<p v-else class="section-title-hint">
每行细胞产品将独立生成一条售卖计划(含提货时间与金额)。
每行细胞产品将独立生成一条交付计划(含交付时间与金额)。
</p>
</div>
</div>
@@ -569,7 +574,7 @@
v-model="row.pickupTime"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择提货时间"
placeholder="请选择交付时间"
style="width: 100%"
/>
</template>
@@ -940,17 +945,20 @@
</DialogFormBody>
<template #footer>
<el-button @click="emit('update:modelValue', false)">
<el-icon><Close /></el-icon>取消
<el-button class="btn-ghost" @click="emit('update:modelValue', false)">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">
<el-icon><Check /></el-icon>保存
<el-button class="btn-main" type="primary" :loading="submitting" @click="handleSubmit">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
<!-- ═══ 新增客户子弹窗 ═══ -->
<el-dialog
append-to-body
v-model="newCustomerDialogVisible"
title="新增客户"
width="500px"
@@ -2854,6 +2862,17 @@
</script>
<style scoped lang="scss">
.protocol-dialog-body {
overflow: visible;
background: #f5f7fa;
}
.protocol-form {
:deep(.el-form-item) {
margin-bottom: 4px;
}
}
/* ---- Sticky Tab 导航 ---- */
.detail-tabs {
position: sticky;
@@ -2903,6 +2922,11 @@
grid-template-columns: repeat(4, minmax(0, 1fr));
}
&--attachment {
margin-top: 16px;
grid-template-columns: 1fr;
}
.form-col-full {
grid-column: 1 / -1;
}
@@ -1,12 +1,14 @@
<!-- 中试服务申请单 — 只读查看(样式对齐采集协议单 ProtocolViewDialog) -->
<template>
<div class="application-view-dialog-root">
<div>
<el-dialog
:model-value="modelValue"
:title="dialogTitle"
width="1600px"
class="dialog-form-layout protocol-view-dialog application-view-dialog"
class="dialog-form-layout protocol-view-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@@ -34,7 +36,7 @@
</div>
<template v-if="applicationDetail.id">
<div class="kpi-strip">
<div class="kpi-strip" :class="{ 'kpi-strip--cols5': isBasicExperiment }">
<div class="kpi-card">
<div class="kpi-card__icon" aria-hidden="true">
<el-icon><Document /></el-icon>
@@ -103,16 +105,12 @@
<!-- 申请单信息 -->
<div id="view-section-application" class="section-card section-card--white">
<div class="section-title"><h3>申请单信息</h3></div>
<div class="data-grid data-grid--4">
<div class="data-item">
<div class="data-label">申请单名称</div>
<div class="data-value">{{ dash(applicationDetail.applicationName) }}</div>
</div>
<div class="data-item">
<div class="data-label">申请单号</div>
<div class="data-value">{{ dash(applicationDetail.applicationNo) }}</div>
</div>
<template v-if="isBasicExperiment">
<template v-if="isBasicExperiment">
<div class="data-grid data-grid--5">
<div class="data-item">
<div class="data-label">申请单名称</div>
<div class="data-value">{{ dash(applicationDetail.applicationName) }}</div>
</div>
<div class="data-item">
<div class="data-label">课题名称</div>
<div class="data-value">{{ dash(applicationDetail.projectTitle) }}</div>
@@ -129,28 +127,40 @@
<div class="data-label">客户联系方式</div>
<div class="data-value">{{ dash(applicationDetail.customerPhone) }}</div>
</div>
</template>
<template v-else>
<div class="data-item">
<div class="data-label">渠道名称</div>
<div class="data-value">{{ dash(applicationDetail.channelName) }}</div>
</div>
<div class="data-item">
<div class="data-label">服务订单名称</div>
<div class="data-value">
<div class="field-with-action">
<span class="field-with-action__main">{{ dash(serviceOrderDisplayName) }}</span>
<el-button
class="field-with-action__btn"
:disabled="!applicationDetail.channelServiceOrderId"
@click="openServiceOrderView"
>
<el-icon><View /></el-icon>查看
</el-button>
</div>
</div>
<div v-if="applicationDetail.remark" class="data-item data-item--remark">
<div class="data-label">备注</div>
<div class="data-value data-value--muted">{{ dash(applicationDetail.remark) }}</div>
</div>
</template>
<div v-else class="data-grid data-grid--4">
<div class="data-item">
<div class="data-label">申请单名称</div>
<div class="data-value">{{ dash(applicationDetail.applicationName) }}</div>
</div>
<div class="data-item">
<div class="data-label">申请单号</div>
<div class="data-value">{{ dash(applicationDetail.applicationNo) }}</div>
</div>
<div class="data-item">
<div class="data-label">渠道名称</div>
<div class="data-value">{{ dash(applicationDetail.channelName) }}</div>
</div>
<div class="data-item">
<div class="data-label">服务订单名称</div>
<div class="data-value">
<div class="field-with-action">
<span class="field-with-action__main">{{ dash(serviceOrderDisplayName) }}</span>
<el-button
class="field-with-action__btn"
:disabled="!applicationDetail.channelServiceOrderId"
@click="openServiceOrderView"
>
<el-icon><View /></el-icon>查看
</el-button>
</div>
</div>
</template>
</div>
<div class="data-item">
<div class="data-label">状态</div>
<div class="data-value">
@@ -421,10 +431,12 @@
<el-empty v-else description="暂无体检登记信息" :image-size="64" />
</div>
<!-- 输注方案 -->
<!-- 输注方案 / 交付计划 -->
<div id="view-section-infusion" class="section-card section-card--white">
<div class="section-title"><h3>输注方案</h3></div>
<div class="data-grid data-grid--3 infusion-order-meta">
<div class="section-title">
<h3>{{ viewBizConfig.planLabel }}</h3>
</div>
<div v-if="!isBasicExperiment" class="data-grid data-grid--3 infusion-order-meta">
<div class="data-item">
<div class="data-label">渠道协议</div>
<div class="data-value">
@@ -461,7 +473,7 @@
</div>
</div>
<div class="data-grid data-grid--4">
<div v-if="!isBasicExperiment" class="data-grid data-grid--4">
<div class="data-item">
<div class="data-label">疗程数</div>
<div class="data-value">{{ dash(infusionPlan.totalCourseCount) }}</div>
@@ -486,8 +498,8 @@
</div>
</div>
<div class="schedule-block">
<div class="section-subtitle">输注计划</div>
<div class="schedule-block" :class="{ 'schedule-block--standalone': isBasicExperiment }">
<div v-if="!isBasicExperiment" class="section-subtitle">输注计划</div>
<div v-if="infusionSchedules.length" class="schedule-details">
<div
v-for="(schedule, sIdx) in infusionSchedules"
@@ -497,21 +509,23 @@
>
<div class="schedule-header" @click="toggleScheduleExpand(sIdx)">
<span class="schedule-chevron">▶</span>
<span class="schedule-title">输注计划 #{{ sIdx + 1 }}</span>
<span class="schedule-title"
>{{ viewBizConfig.scheduleLabel }} #{{ sIdx + 1 }}</span
>
</div>
<div v-show="schedule._expanded" class="schedule-body">
<div class="schedule-body-inner">
<div class="data-grid data-grid--3">
<div class="data-item">
<div class="data-label">输注时间</div>
<div class="data-label">{{ viewBizConfig.pickupTimeLabel }}</div>
<div class="data-value">{{ dash(schedule.infusionTime) }}</div>
</div>
<div class="data-item">
<div class="data-label">输注地点</div>
<div v-if="!isBasicExperiment" class="data-item">
<div class="data-label">{{ viewBizConfig.locationLabel }}</div>
<div class="data-value">{{ dash(schedule.infusionLocation) }}</div>
</div>
<div class="data-item">
<div class="data-label">输注方式</div>
<div v-if="!isBasicExperiment" class="data-item">
<div class="data-label">{{ viewBizConfig.methodLabel }}</div>
<div class="data-value">{{
formatInfusionMethod(schedule.infusionMethod)
}}</div>
@@ -531,8 +545,8 @@
<span v-else>-</span>
</div>
</div>
<div class="data-item">
<div class="data-label">输注状态</div>
<div v-if="!isBasicExperiment" class="data-item">
<div class="data-label">{{ viewBizConfig.statusLabel }}</div>
<div class="data-value">
<el-tag
v-if="schedule.infusionStatus"
@@ -628,14 +642,14 @@
v-if="isYfkOrder && schedule.cellProducts?.length"
class="schedule-amount-summary"
>
本次输注计划金额:
本次{{ viewBizConfig.scheduleLabel }}金额:
<span class="schedule-amount-summary__value">
¥{{ formatOrderBalance(calcScheduleAmount(schedule)) }}
</span>
</div>
</div>
<div v-if="isClinicalApplication" class="schedule-payment-block">
<div v-if="viewBizConfig.showPayment" class="schedule-payment-block">
<div class="section-subtitle">缴费明细</div>
<ClinicalSchedulePaymentDetailView
v-if="schedule.payment"
@@ -655,7 +669,11 @@
</div>
</div>
</div>
<el-empty v-else description="暂无输注计划" :image-size="48" />
<el-empty
v-else
:description="'暂无' + viewBizConfig.scheduleLabel"
:image-size="48"
/>
</div>
</div>
</template>
@@ -816,13 +834,14 @@
</template>
</SystemViewDialog>
<el-dialog
v-model="previewVisible"
title="文件预览"
width="800px"
align-center
destroy-on-close
>
<el-dialog
v-model="previewVisible"
title="文件预览"
width="800px"
align-center
append-to-body
destroy-on-close
>
<div v-if="previewType === 'image'" class="preview-container">
<img
:src="previewUrl"
@@ -912,7 +931,10 @@
if (showCheckupInfo.value) {
tabs.push({ key: 'checkup', label: '体检信息' })
}
tabs.push({ key: 'infusion', label: cfg.planLabel })
tabs.push({
key: 'infusion',
label: viewBizConfig.value.planLabel
})
return tabs
})
@@ -956,9 +978,6 @@
const isYfkOrder = computed(() => serviceOrderDetail.value?.orderType === 'yfk')
const isGmcpOrder = computed(() => serviceOrderDetail.value?.orderType === 'gmcp')
const isClinicalApplication = computed(
() => applicationDetail.value?.bizType === 'clinical_application'
)
const isBasicExperiment = computed(
() => applicationDetail.value?.bizType === 'basic_experiment'
)
@@ -1542,6 +1561,10 @@
border: 1px solid #a8d4c4;
border-radius: 12px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.85);
&--cols5 {
grid-template-columns: repeat(5, 1fr);
}
}
.kpi-card {
@@ -1646,6 +1669,10 @@
&--4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
&--5 {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}
.data-item {
@@ -1654,6 +1681,10 @@
&--full {
grid-column: 1 / -1;
}
&--remark {
margin-top: 14px;
}
}
.data-label {
File diff suppressed because it is too large. Load diff
@@ -352,6 +352,7 @@
v-model="cancelDialogVisible"
:schedule-id="cancelScheduleId"
:patient-name="cancelPatientName"
:biz-type="props.bizType"
@success="loadData"
/>
@@ -360,6 +361,7 @@
v-model="handoverDialogVisible"
:schedule-id="handoverScheduleId"
:schedule-row="handoverRow"
:biz-type="props.bizType"
@saved="loadData"
/>
</div>
@@ -596,13 +598,23 @@
}
const buildCompleteConfirmMessage = (row: any) => {
const scheduleLabel = bizConfig.value.scheduleLabel
if (isBasicExperiment.value) {
const subject = row.projectTitle
? `课题「${row.projectTitle}」`
: row.customerName
? `客户「${row.customerName}」`
: '该'
return `${subject}${scheduleLabel}将标记为已完成。是否继续?`
}
const patient = row.patientName ? `患者「${row.patientName}」` : '该患者'
const pendingConfirmHint =
getInfusionStatus(row) === 'dtz' ? '当前为待输注,系统将先记录用户确认再标记完成。' : ''
if (isClinical.value) {
const paymentHint = '临床应用不在此扣减渠道服务订单费用,缴费请在「缴费信息」中单独处理。'
return `${patient}的输注计划将标记为已完成。${pendingConfirmHint}${paymentHint}是否继续?`
return `${patient}的${scheduleLabel}将标记为已完成。${pendingConfirmHint}${paymentHint}是否继续?`
}
const orderTypeLabel = channelServiceOrderTypeLabel(row.orderType)
@@ -614,12 +626,15 @@
} else if (row.orderType === 'gmcp') {
settlementHint = `关联服务订单类型为「${orderTypeLabel}」,系统将按份数扣减服务订单中的细胞产品持仓。`
}
return `${patient}的输注计划将标记为已完成。${statusHint}${settlementHint}是否继续?`
return `${patient}的${scheduleLabel}将标记为已完成。${statusHint}${settlementHint}是否继续?`
}
const handleConfirm = async (row: any) => {
try {
await ElMessageBox.confirm('确认该输注计划已由用户确认?', '用户已确认', {
await ElMessageBox.confirm(
`确认该${bizConfig.value.scheduleLabel}已由用户确认?`,
'用户已确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
@@ -639,7 +654,10 @@
const handleComplete = async (row: any) => {
try {
await ElMessageBox.confirm(buildCompleteConfirmMessage(row), '完成输注计划', {
await ElMessageBox.confirm(
buildCompleteConfirmMessage(row),
`完成${bizConfig.value.scheduleLabel}`,
{
confirmButtonText: '确定完成',
cancelButtonText: '取消',
type: 'warning'
@@ -655,7 +673,11 @@
scheduleId,
infusionStatus: 'ywc'
})
ElMessage.success(isClinical.value ? '输注计划已完成' : '输注计划已完成,结算扣减已处理')
ElMessage.success(
isBasicExperiment.value || isClinical.value
? `${bizConfig.value.scheduleLabel}已完成`
: `${bizConfig.value.scheduleLabel}已完成,结算扣减已处理`
)
loadData()
} catch (e: any) {
if (e !== 'cancel') {
@@ -2,26 +2,30 @@
<template>
<div class="page-container">
<section class="panel">
<div class="panel-entity-switch" role="tablist" aria-label="视图类型">
<button
v-for="card in tabCards"
:key="card.key"
type="button"
class="entity-switch-card"
:class="{ 'is-active': activeTab === card.key }"
role="tab"
:aria-selected="activeTab === card.key"
@click="activeTab = card.key"
>
<span class="entity-switch-card__icon"
><el-icon><component :is="card.icon" /></el-icon
></span>
<span class="entity-switch-card__main">
<span class="entity-switch-card__title">{{ card.title }}</span>
<span class="entity-switch-card__desc">{{ card.desc }}</span>
</span>
</button>
</div>
<nav class="pill-tab-nav" role="tablist" aria-label="视图类型">
<div class="pill-tab-nav__rail">
<div class="pill-tab-nav__progress" :style="pillProgressStyle" />
<button
v-for="(item, index) in tabCards"
:key="item.key"
type="button"
class="pill-tab-nav__step"
:class="{
'is-active': activeTab === item.key,
'is-done': index < activeTabIndex
}"
role="tab"
:aria-selected="activeTab === item.key"
@click="activeTab = item.key"
>
<span class="pill-tab-nav__index">{{ index + 1 }}</span>
<span class="pill-tab-nav__text">
<span class="pill-tab-nav__title">{{ item.title }}</span>
<span class="pill-tab-nav__desc">{{ item.desc }}</span>
</span>
</button>
</div>
</nav>
<PilotApplicationTab v-if="activeTab === 'application'" :biz-type="effectiveBizType" />
<CheckupManagementTab v-else-if="activeTab === 'checkup'" :biz-type="effectiveBizType" />
@@ -32,7 +36,6 @@
</template>
<script setup lang="ts">
import { Document, Calendar, Operation, Wallet } from '@element-plus/icons-vue'
import PilotApplicationTab from './PilotApplicationTab.vue'
import CheckupManagementTab from './CheckupManagementTab.vue'
import InfusionScheduleTab from './InfusionScheduleTab.vue'
@@ -49,47 +52,56 @@
const bizConfig = computed(() => getPilotBizConfig(props.bizType))
const effectiveBizType = computed<PilotBizType>(() => resolvePilotBizType(props.bizType))
type TabKey = 'application' | 'checkup' | 'infusion' | 'payment'
const tabCards = computed(() => {
const cfg = bizConfig.value
const cards = [
const cards: Array<{ key: TabKey; title: string; desc: string }> = [
{
key: 'application',
title: effectiveBizType.value === 'basic_experiment' ? '申请单' : `${cfg.label}申请单`,
desc: '申请单录入、审核与跟踪',
icon: Document
desc: '申请单录入、审核与跟踪'
}
]
if (cfg.showCheckup) {
cards.push({
key: 'checkup',
title: '体检管理',
desc: '体检预约、登记与结果',
icon: Calendar
desc: '体检预约、登记与结果'
})
}
cards.push({
key: 'infusion',
title: `${cfg.scheduleLabel}管理`,
desc: `${cfg.scheduleLabel}安排与执行`,
icon: Operation
desc: `${cfg.scheduleLabel}安排与执行`
})
if (cfg.showPayment) {
cards.push({
key: 'payment',
title: '缴费信息',
desc: '缴费提交与状态跟踪',
icon: Wallet
desc: '缴费提交与状态跟踪'
})
}
return cards
})
type TabKey = 'application' | 'checkup' | 'infusion' | 'payment'
const validTabKeys = computed(() => tabCards.value.map((c) => c.key as TabKey))
const validTabKeys = computed(() => tabCards.value.map((c) => c.key))
const route = useRoute()
const activeTab = ref<TabKey>('application')
const activeTabIndex = computed(() =>
Math.max(
0,
tabCards.value.findIndex((item) => item.key === activeTab.value)
)
)
const pillProgressStyle = computed(() => ({
width: `calc(${100 / tabCards.value.length}% - 3px)`,
transform: `translateX(${activeTabIndex.value * 100}%)`
}))
onMounted(() => {
const tab = String(route.query.tab || '')
if (validTabKeys.value.includes(tab as TabKey)) {
@@ -99,82 +111,139 @@
</script>
<style scoped lang="scss">
.panel-entity-switch {
display: flex;
justify-content: center;
gap: 12px;
padding: 16px 16px 12px;
}
.entity-switch-card {
display: flex;
align-items: center;
gap: 12px;
min-width: 220px;
max-width: 280px;
flex: 1;
padding: 14px 18px;
border: 1px solid var(--mint-border, #c5e0d4);
border-radius: 10px;
background: var(--surface, #fff);
cursor: pointer;
text-align: left;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
box-shadow 0.2s ease;
&:hover:not(.is-active) {
border-color: #a8d4c4;
background: #f8fafc;
}
&.is-active {
border-color: var(--primary, #047857);
background: var(--primary-bg, #e8f4ee);
box-shadow: 0 0 0 1px rgb(4 120 87 / 12%);
}
}
.entity-switch-card__icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: 8px;
background: #f1f5f9;
color: #64748b;
font-size: 20px;
.entity-switch-card.is-active & {
background: #fff;
color: var(--primary-dark, #065f46);
}
}
.entity-switch-card__main {
.pill-tab-nav {
padding: 16px 20px 0;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
align-items: center;
}
.entity-switch-card__title {
font-size: 14px;
font-weight: 600;
color: var(--text, #1e293b);
line-height: 1.4;
.pill-tab-nav__rail {
display: flex;
align-items: center;
width: 70%;
max-width: 100%;
margin-inline: auto;
background: var(--surface, #fff);
border: 1px solid var(--border, #e2e8f0);
border-radius: 10px;
padding: 6px;
position: relative;
overflow: hidden;
}
.entity-switch-card.is-active & {
color: var(--primary-dark, #065f46);
.pill-tab-nav__progress {
position: absolute;
top: 6px;
bottom: 6px;
left: 6px;
background: var(--primary, #10b981);
border: 1px solid var(--primary-dark, #059669);
border-radius: 8px;
box-shadow: 0 1px 3px rgb(16 185 129 / 28%);
transition:
transform 0.25s ease,
width 0.25s ease;
z-index: 0;
}
.pill-tab-nav__step {
flex: 1;
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 0;
min-height: 44px;
padding: 8px 10px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 8px;
transition: color 0.2s ease;
}
.pill-tab-nav__index {
width: 22px;
height: 22px;
border-radius: 50%;
background: #e2e8f0;
color: #475569;
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.2s ease;
}
.pill-tab-nav__text {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
text-align: left;
}
.pill-tab-nav__title {
font-size: 13px;
font-weight: 600;
color: var(--mint-text-muted, #64748b);
white-space: nowrap;
}
.pill-tab-nav__desc {
font-size: 11px;
color: #94a3b8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.pill-tab-nav__step.is-active .pill-tab-nav__index {
background: #fff;
color: var(--primary-dark, #059669);
}
.pill-tab-nav__step.is-active .pill-tab-nav__title,
.pill-tab-nav__step.is-active .pill-tab-nav__desc {
color: #fff;
}
.pill-tab-nav__step.is-done .pill-tab-nav__index {
background: var(--primary-bg, #ecfdf5);
color: var(--primary-dark, #059669);
border: 1px solid var(--mint-border, #c5e0d4);
}
.pill-tab-nav__step.is-done .pill-tab-nav__title {
color: var(--text, #1e293b);
}
@media (width <= 960px) {
.pill-tab-nav__desc {
display: none;
}
.pill-tab-nav__text {
align-items: center;
}
}
.entity-switch-card__desc {
font-size: 12px;
color: var(--mint-text-muted, #64748b);
line-height: 1.4;
@media (width <= 720px) {
.pill-tab-nav {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
align-items: stretch;
}
.pill-tab-nav__rail {
width: 100%;
min-width: 680px;
}
}
</style>
@@ -226,6 +226,8 @@
width="1080px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -534,6 +536,7 @@
title="图片预览"
width="800px"
align-center
append-to-body
destroy-on-close
>
<div class="preview-container">
@@ -148,6 +148,8 @@
width="720px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -204,6 +206,8 @@
width="1000px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -261,6 +265,8 @@
width="1000px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -326,6 +332,8 @@
width="960px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
>
<div v-if="washLogGarment?.garmentStatus === 'zc'" style="margin-bottom: 12px">
@@ -372,6 +380,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -408,6 +418,8 @@
width="960px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
>
<div v-if="sterLogGarment?.garmentStatus === 'zc'" style="margin-bottom: 12px">
@@ -453,6 +465,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -488,6 +502,8 @@
width="1000px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
>
<div class="view-dialog-content">
@@ -242,6 +242,8 @@
width="960px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -431,6 +431,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -527,6 +529,8 @@
width="480px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -576,6 +580,8 @@
width="480px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -649,6 +655,8 @@
width="920px"
class="dialog-form-layout consumable-batch-stock-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -809,6 +817,8 @@
width="920px"
class="dialog-form-layout consumable-batch-stock-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -999,6 +1009,8 @@
width="1000px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
>
<div class="list-body list-wrap" style="max-height: 480px; overflow-y: auto">
@@ -182,6 +182,8 @@
width="720px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -179,6 +179,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -116,6 +116,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -198,6 +198,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -420,7 +422,31 @@
</el-select>
</el-form-item>
<el-form-item label="设备名称">
<el-input v-model="selectedStep.equipmentName" @change="saveStepBasic" />
<el-select
v-model="selectedStep.equipmentIds"
placeholder="请选择设备"
clearable
filterable
multiple
collapse-tags
collapse-tags-tooltip
style="width: 100%"
@change="onStepEquipmentChange"
>
<el-option
v-for="eq in equipmentOptions"
:key="eq.id"
:label="equipmentOptionLabel(eq)"
:value="eq.id"
:disabled="eq.status === '0'"
/>
</el-select>
<p
v-if="!(selectedStep.equipmentIds || []).length && selectedStep.equipmentName"
class="form-tip"
>
历史文本:{{ selectedStep.equipmentName }},请重新选择设备
</p>
</el-form-item>
<el-form-item label="预计时长(min)">
<el-input
@@ -464,9 +490,17 @@
</div>
</el-form>
<el-tabs v-model="activeSubTab" class="route-config-tabs">
<el-tabs
v-if="SHOW_PROCESS_REAGENT_CONSUMABLE"
v-model="activeSubTab"
class="route-config-tabs"
>
<!-- 试剂 -->
<el-tab-pane label="试剂" name="reagent">
<el-tab-pane
v-if="SHOW_PROCESS_REAGENT_CONSUMABLE"
label="试剂"
name="reagent"
>
<div class="route-config-tab-toolbar">
<el-button
class="btn-main"
@@ -645,7 +679,11 @@
</el-tab-pane>
<!-- 耗材 -->
<el-tab-pane label="耗材" name="consumable">
<el-tab-pane
v-if="SHOW_PROCESS_REAGENT_CONSUMABLE"
label="耗材"
name="consumable"
>
<div class="route-config-tab-toolbar">
<el-button
class="btn-main"
@@ -822,233 +860,237 @@
:image-size="48"
/>
</el-tab-pane>
</el-tabs>
<div class="route-config-step-sections">
<!-- 产出物 -->
<el-tab-pane label="产出物" name="output">
<div class="route-config-tab-toolbar">
<el-button class="btn-main" type="primary" size="small" @click="addOutputRow">
<el-icon><Plus /></el-icon>
添加产出物
</el-button>
<section class="route-config-step-section">
<h3 class="route-config-step-section-title">产出物</h3>
<div class="route-config-tab-list">
<div class="list-toolbar">
<div class="list-toolbar-row">
<el-button class="btn-main" type="primary" @click="addOutputRow">
<el-icon><Plus /></el-icon>
添加产出物
</el-button>
</div>
</div>
<div class="list-body list-wrap inner-table-wrap">
<el-table
v-if="selectedStep.outputs?.length"
:data="selectedStep.outputs"
stripe
class="list-table inner-table"
max-height="240"
>
<el-table-column
type="index"
label="序号"
min-width="56"
align="center"
header-align="center"
:index="(i: number) => i + 1"
/>
<el-table-column
label="产出类型"
min-width="150"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<el-select
v-model="row.outputTypeId"
placeholder="选择类型"
filterable
class="inner-cell-select"
@change="saveOutputs"
>
<el-option
v-for="ot in outputTypeOptions"
:key="ot.id"
:label="ot.typeName || ot.name"
:value="ot.id"
/>
</el-select>
</template>
</el-table-column>
<el-table-column
label="名称"
min-width="150"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<el-input
v-model="row.outputName"
placeholder="名称"
class="inner-cell-input"
@change="saveOutputs"
/>
</template>
</el-table-column>
<el-table-column
label="条码"
min-width="150"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<el-input
v-model="row.outputBarcode"
placeholder="条码"
class="inner-cell-input"
@change="saveOutputs"
/>
</template>
</el-table-column>
<el-table-column
label="备注"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<el-input
v-model="row.remark"
placeholder="备注"
class="inner-cell-input"
@change="saveOutputs"
/>
</template>
</el-table-column>
<el-table-column
label="操作"
min-width="80"
align="center"
header-align="center"
>
<template #default="{ $index }">
<el-button link type="danger" @click="removeOutputRow($index)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无产出物" :image-size="48" />
</div>
</div>
<el-table
:data="selectedStep.outputs"
stripe
class="inner-table"
max-height="300"
>
<el-table-column
type="index"
label="序号"
min-width="56"
align="center"
header-align="center"
:index="(i: number) => i + 1"
/>
<el-table-column
label="产出类型"
min-width="150"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-select
v-model="row.outputTypeId"
placeholder="选择类型"
filterable
class="inner-cell-select"
@change="saveOutputs"
>
<el-option
v-for="ot in outputTypeOptions"
:key="ot.id"
:label="ot.typeName || ot.name"
:value="ot.id"
/>
</el-select>
</template>
</el-table-column>
<el-table-column
label="名称"
min-width="150"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-input
v-model="row.outputName"
placeholder="名称"
class="inner-cell-input"
@change="saveOutputs"
/>
</template>
</el-table-column>
<el-table-column
label="条码"
min-width="150"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-input
v-model="row.outputBarcode"
placeholder="条码"
class="inner-cell-input"
@change="saveOutputs"
/>
</template>
</el-table-column>
<el-table-column
label="备注"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-input
v-model="row.remark"
placeholder="备注"
class="inner-cell-input"
@change="saveOutputs"
/>
</template>
</el-table-column>
<el-table-column
label="操作"
min-width="80"
align="center"
header-align="center"
>
<template #default="{ $index }">
<el-button
link
type="danger"
size="small"
@click="removeOutputRow($index)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
<el-empty
v-if="!selectedStep.outputs || selectedStep.outputs.length === 0"
description="暂无产出物"
:image-size="48"
/>
</el-tab-pane>
</section>
<!-- 数据采集 -->
<el-tab-pane label="数据采集" name="dataCollection">
<div class="route-config-tab-toolbar">
<el-button
class="btn-main"
type="primary"
size="small"
@click="addDataCollectionRow"
>
<el-icon><Plus /></el-icon>
添加采集项
</el-button>
<section class="route-config-step-section">
<h3 class="route-config-step-section-title">数据采集</h3>
<div class="route-config-tab-list">
<div class="list-toolbar">
<div class="list-toolbar-row">
<el-button class="btn-main" type="primary" @click="addDataCollectionRow">
<el-icon><Plus /></el-icon>
添加采集项
</el-button>
</div>
</div>
<div class="list-body list-wrap inner-table-wrap">
<el-table
v-if="selectedStep.dataCollections?.length"
:data="selectedStep.dataCollections"
stripe
class="list-table inner-table"
max-height="240"
>
<el-table-column
type="index"
label="序号"
min-width="56"
align="center"
header-align="center"
:index="(i: number) => i + 1"
/>
<el-table-column
label="采集名称"
min-width="180"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<el-input
v-model="row.collectionName"
placeholder="采集项名称"
class="inner-cell-input"
@change="saveDataCollections"
/>
</template>
</el-table-column>
<el-table-column
label="单位"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<el-select
v-model="row.unit"
placeholder="请选择单位"
clearable
filterable
remote
reserve-keyword
class="inner-cell-select"
:loading="itemUnitOptionsLoading"
:remote-method="searchItemUnitOptions"
@visible-change="(visible) => visible && searchItemUnitOptions('')"
@change="saveDataCollections"
>
<el-option
v-for="item in itemUnitOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</template>
</el-table-column>
<el-table-column
label="备注"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<el-input
v-model="row.remark"
placeholder="备注"
class="inner-cell-input"
@change="saveDataCollections"
/>
</template>
</el-table-column>
<el-table-column
label="操作"
min-width="80"
align="center"
header-align="center"
>
<template #default="{ $index }">
<el-button link type="danger" @click="removeDataCollectionRow($index)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无采集项" :image-size="48" />
</div>
</div>
<el-table
:data="selectedStep.dataCollections"
stripe
class="inner-table"
max-height="300"
>
<el-table-column
type="index"
label="序号"
min-width="56"
align="center"
header-align="center"
:index="(i: number) => i + 1"
/>
<el-table-column
label="采集名称"
min-width="180"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-input
v-model="row.collectionName"
placeholder="采集项名称"
class="inner-cell-input"
@change="saveDataCollections"
/>
</template>
</el-table-column>
<el-table-column
label="单位"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-select
v-model="row.unit"
placeholder="请选择单位"
clearable
filterable
remote
reserve-keyword
class="inner-cell-select"
:loading="itemUnitOptionsLoading"
:remote-method="searchItemUnitOptions"
@visible-change="(visible) => visible && searchItemUnitOptions('')"
@change="saveDataCollections"
>
<el-option
v-for="item in itemUnitOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</template>
</el-table-column>
<el-table-column
label="备注"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-input
v-model="row.remark"
placeholder="备注"
class="inner-cell-input"
@change="saveDataCollections"
/>
</template>
</el-table-column>
<el-table-column
label="操作"
min-width="80"
align="center"
header-align="center"
>
<template #default="{ $index }">
<el-button
link
type="danger"
size="small"
@click="removeDataCollectionRow($index)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
<el-empty
v-if="
!selectedStep.dataCollections || selectedStep.dataCollections.length === 0
"
description="暂无采集项"
:image-size="48"
/>
</el-tab-pane>
</el-tabs>
</section>
</div>
</div>
</template>
<template v-else>
@@ -1101,9 +1143,15 @@
fetchAllProcesses,
fetchAllProcessCategories,
fetchAllSopDocuments,
fetchAllOutputTypes
fetchAllOutputTypes,
fetchAllEquipments
} from '@/api/production'
import { fetchCellProductTypeList, fetchItemUnitList } from '@/api/admin'
import { equipmentOptionLabel, parseEquipmentIds, syncEquipmentNamesFromOptions } from '@/utils/equipment-select'
import {
SHOW_PROCESS_REAGENT_CONSUMABLE,
PROCESS_ROUTE_DEFAULT_SUB_TAB
} from '@/utils/process-material-ui'
defineOptions({ name: 'ProcessRouteTab' })
@@ -1323,7 +1371,7 @@
const stepList = ref<Record<string, any>[]>([])
const selectedStepId = ref('')
const stepListLoading = ref(false)
const activeSubTab = ref('reagent')
const activeSubTab = ref(PROCESS_ROUTE_DEFAULT_SUB_TAB)
const processSearchKeyword = ref('')
/** 接口 isKeyMaterial / isRequired 为 "0" | "1" 字符串 */
@@ -1339,6 +1387,7 @@
function normalizeStepSubTables(step: Record<string, any>) {
return {
...step,
equipmentIds: parseEquipmentIds(step.equipmentIds || step.equipmentName),
reagents: (step.reagents || []).map((r: Record<string, any>) => ({
...r,
isKeyMaterial: fromApiFlag(r.isKeyMaterial)
@@ -1379,6 +1428,7 @@
]
const categoryOptions = ref<Record<string, any>[]>([])
const sopOptions = ref<Record<string, any>[]>([])
const equipmentOptions = ref<Record<string, any>[]>([])
const outputTypeOptions = ref<Record<string, any>[]>([])
const itemUnitOptions = ref<{ label: string; value: string }[]>([])
const itemUnitOptionsLoading = ref(false)
@@ -1780,15 +1830,17 @@
async function loadConfigReferenceData() {
try {
const [processes, categories, sops, outputTypes] = await Promise.all([
const [processes, categories, sops, outputTypes, equipments] = await Promise.all([
fetchAllProcesses(),
fetchAllProcessCategories(),
fetchAllSopDocuments(),
fetchAllOutputTypes()
fetchAllOutputTypes(),
fetchAllEquipments()
])
categoryOptions.value = categories || []
sopOptions.value = sops || []
outputTypeOptions.value = outputTypes || []
equipmentOptions.value = equipments || []
// 按分类分组
const catMap: Record<
@@ -1834,7 +1886,7 @@
configRouteName.value = row.routeName || ''
configRouteVersion.value = row.version || ''
selectedStepId.value = ''
activeSubTab.value = 'reagent'
activeSubTab.value = PROCESS_ROUTE_DEFAULT_SUB_TAB
processSearchKeyword.value = ''
configSuppressSave.value = true
stepList.value = []
@@ -1961,7 +2013,9 @@
processName: selectedStep.value.processName,
categoryId: selectedStep.value.categoryId,
description: selectedStep.value.description,
equipmentName: selectedStep.value.equipmentName,
equipmentIds: selectedStep.value.equipmentIds?.length
? selectedStep.value.equipmentIds
: null,
estimatedDuration: selectedStep.value.estimatedDuration,
sopIds: selectedStep.value.sopIds
})
@@ -1970,6 +2024,13 @@
}
}
function onStepEquipmentChange(val: string[]) {
if (!selectedStep.value) return
selectedStep.value.equipmentIds = val || []
selectedStep.value.equipmentName = syncEquipmentNamesFromOptions(val, equipmentOptions.value)
saveStepBasic()
}
// ---------------------------------------------------------------------------
// 子表操作
// ---------------------------------------------------------------------------
@@ -2526,12 +2587,89 @@
}
}
.route-config-tabs + .route-config-step-sections {
margin-top: 12px;
}
.route-config-step-sections {
display: flex;
flex-direction: column;
gap: 16px;
}
.route-config-step-section-title {
margin: 0 0 8px;
padding-left: 10px;
border-left: 3px solid #10b981;
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
.route-config-tab-toolbar {
margin-bottom: 8px;
display: flex;
justify-content: flex-end;
}
/* 步骤详情 Tab 内嵌列表:弹窗在 body 下,须本地复刻 page-container 列表视觉 */
.route-config-tab-list {
border: 1px solid var(--mint-border, #e2e8f0);
border-radius: var(--mint-radius, 10px);
overflow: hidden;
background: var(--mint-surface, #fff);
.list-toolbar {
padding: 0;
background: var(--mint-table-head, #f8fafc);
border-bottom: none;
}
.list-toolbar-row {
padding: 8px 16px;
gap: 8px;
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-start;
:deep(.el-button + .el-button) {
margin-left: 0 !important;
}
}
.list-body.list-wrap.inner-table-wrap {
border-top: 1px solid var(--mint-border, #e2e8f0);
border-radius: 0;
overflow: hidden;
: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;
}
}
}
/* 步骤详情内嵌表格:单元格控件随列宽收缩 */
.inner-table {
width: 100%;
+329 -207
View File
@@ -205,7 +205,25 @@
</el-select>
</el-form-item>
<el-form-item label="设备名称">
<el-input v-model="formData.equipmentName" placeholder="请输入设备名称" />
<el-select
v-model="formData.equipmentIds"
placeholder="请选择设备"
clearable
filterable
multiple
collapse-tags
collapse-tags-tooltip
style="width: 100%"
>
<el-option
v-for="eq in equipmentOptions"
:key="eq.id"
:label="equipmentOptionLabel(eq)"
:value="eq.id"
:disabled="eq.status === '0'"
/>
</el-select>
<p v-if="legacyEquipmentHint" class="form-tip">{{ legacyEquipmentHint }}</p>
</el-form-item>
<el-form-item>
<template #label>预估耗时<span class="form-label-tip">(min)</span></template>
@@ -248,7 +266,11 @@
</div>
</div>
<div id="form-section-reagents" class="form-tab-pane">
<div
v-if="SHOW_PROCESS_REAGENT_CONSUMABLE"
id="form-section-reagents"
class="form-tab-pane"
>
<div class="section-card">
<div class="section-title">
<div class="section-title-left"><h3>试剂关联</h3></div>
@@ -353,7 +375,11 @@
</div>
</div>
<div id="form-section-consumables" class="form-tab-pane">
<div
v-if="SHOW_PROCESS_REAGENT_CONSUMABLE"
id="form-section-consumables"
class="form-tab-pane"
>
<div class="section-card">
<div class="section-title">
<div class="section-title-left"><h3>耗材关联</h3></div>
@@ -459,89 +485,103 @@
<div class="section-card">
<div class="section-title">
<div class="section-title-left"><h3>产出物</h3></div>
<el-button class="btn-main" type="primary" size="small" @click="addOutputRow">
<el-icon><Plus /></el-icon>
添加产出物
</el-button>
</div>
<el-table :data="outputList" stripe class="inner-table">
<el-table-column
label="产出物类型"
min-width="150"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-select
v-model="row.outputTypeId"
placeholder="选择类型"
filterable
class="inner-cell-select"
<div class="route-config-tab-list">
<div class="list-toolbar">
<div class="list-toolbar-row">
<el-button class="btn-main" type="primary" @click="addOutputRow">
<el-icon><Plus /></el-icon>
添加产出物
</el-button>
</div>
</div>
<div class="list-body list-wrap inner-table-wrap">
<el-table
v-if="outputList.length"
:data="outputList"
stripe
class="list-table inner-table"
>
<el-table-column
label="产出物类型"
min-width="150"
align="center"
header-align="center"
show-overflow-tooltip
>
<el-option
v-for="ot in outputTypeOptions"
:key="ot.id"
:label="ot.typeName || ot.name"
:value="ot.id"
/>
</el-select>
</template>
</el-table-column>
<el-table-column
label="产出物名称"
min-width="150"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-input
v-model="row.outputName"
placeholder="名称"
class="inner-cell-input"
/>
</template>
</el-table-column>
<el-table-column
label="产出物条码"
min-width="150"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-input
v-model="row.outputBarcode"
placeholder="条码"
class="inner-cell-input"
/>
</template>
</el-table-column>
<el-table-column
label="备注"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-input v-model="row.remark" placeholder="备注" class="inner-cell-input" />
</template>
</el-table-column>
<el-table-column label="操作" min-width="80" align="center" header-align="center">
<template #default="{ $index }">
<el-button
link
type="danger"
size="small"
@click="outputList.splice($index, 1)"
>删除</el-button
<template #default="{ row }">
<el-select
v-model="row.outputTypeId"
placeholder="选择类型"
filterable
class="inner-cell-select"
>
<el-option
v-for="ot in outputTypeOptions"
:key="ot.id"
:label="ot.typeName || ot.name"
:value="ot.id"
/>
</el-select>
</template>
</el-table-column>
<el-table-column
label="产出物名称"
min-width="150"
align="center"
header-align="center"
show-overflow-tooltip
>
</template>
</el-table-column>
</el-table>
<el-empty
v-if="outputList.length === 0"
description="暂无产出物,点击上方按钮添加"
:image-size="48"
/>
<template #default="{ row }">
<el-input
v-model="row.outputName"
placeholder="名称"
class="inner-cell-input"
/>
</template>
</el-table-column>
<el-table-column
label="产出物条码"
min-width="150"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<el-input
v-model="row.outputBarcode"
placeholder="条码"
class="inner-cell-input"
/>
</template>
</el-table-column>
<el-table-column
label="备注"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<el-input v-model="row.remark" placeholder="备注" class="inner-cell-input" />
</template>
</el-table-column>
<el-table-column label="操作" min-width="80" align="center" header-align="center">
<template #default="{ $index }">
<el-button link type="danger" @click="outputList.splice($index, 1)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty
v-else
description="暂无产出物,点击上方按钮添加"
:image-size="48"
/>
</div>
</div>
</div>
</div>
@@ -550,125 +590,129 @@
<div class="section-title">
<div class="section-title-left">
<h3>数据采集</h3>
<span class="inner-table-sort-tip">拖拽左侧手柄可调整排序</span>
</div>
<el-button
class="btn-main"
type="primary"
size="small"
@click="addDataCollectionRow"
>
<el-icon><Plus /></el-icon>
添加采集项
</el-button>
</div>
<el-table
ref="dataCollectionTableRef"
:data="dataCollectionList"
row-key="_clientKey"
stripe
class="inner-table data-collection-sort-table"
>
<el-table-column
label=""
min-width="52"
align="center"
header-align="center"
class-name="drag-col"
>
<template #default>
<el-icon class="data-collection-drag-handle" title="拖拽排序"
><Rank
/></el-icon>
</template>
</el-table-column>
<el-table-column
type="index"
label="序号"
min-width="56"
align="center"
header-align="center"
:index="(index) => index + 1"
/>
<el-table-column
label="采集项名称"
min-width="180"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-input
v-model="row.collectionName"
placeholder="请输入采集项名称"
class="inner-cell-input"
<div class="route-config-tab-list">
<div class="list-toolbar">
<div class="list-toolbar-row">
<el-button class="btn-main" type="primary" @click="addDataCollectionRow">
<el-icon><Plus /></el-icon>
添加采集项
</el-button>
<span class="inner-table-sort-tip">拖拽左侧手柄可调整排序</span>
</div>
</div>
<div class="list-body list-wrap inner-table-wrap">
<el-table
v-if="dataCollectionList.length"
ref="dataCollectionTableRef"
:data="dataCollectionList"
row-key="_clientKey"
stripe
class="list-table inner-table data-collection-sort-table"
>
<el-table-column
label=""
min-width="52"
align="center"
header-align="center"
class-name="drag-col"
>
<template #default>
<el-icon class="data-collection-drag-handle" title="拖拽排序"
><Rank
/></el-icon>
</template>
</el-table-column>
<el-table-column
type="index"
label="序号"
min-width="56"
align="center"
header-align="center"
:index="(index) => index + 1"
/>
</template>
</el-table-column>
<el-table-column
label="单位"
min-width="150"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-select
v-model="row.unit"
placeholder="请选择单位"
clearable
filterable
remote
reserve-keyword
class="inner-cell-select"
:loading="itemUnitOptionsLoading"
:remote-method="searchItemUnitOptions"
@visible-change="(visible) => visible && searchItemUnitOptions('')"
<el-table-column
label="采集项名称"
min-width="180"
align="center"
header-align="center"
show-overflow-tooltip
>
<el-option
v-for="item in itemUnitOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</template>
</el-table-column>
<el-table-column
label="排序号"
min-width="88"
align="center"
header-align="center"
>
<template #default="{ $index }">
<span class="data-collection-order-num">{{ $index + 1 }}</span>
</template>
</el-table-column>
<el-table-column
label="备注"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-input v-model="row.remark" placeholder="备注" class="inner-cell-input" />
</template>
</el-table-column>
<el-table-column label="操作" min-width="80" align="center" header-align="center">
<template #default="{ $index }">
<el-button
link
type="danger"
size="small"
@click="removeDataCollectionRow($index)"
>删除</el-button
<template #default="{ row }">
<el-input
v-model="row.collectionName"
placeholder="请输入采集项名称"
class="inner-cell-input"
/>
</template>
</el-table-column>
<el-table-column
label="单位"
min-width="150"
align="center"
header-align="center"
show-overflow-tooltip
>
</template>
</el-table-column>
</el-table>
<el-empty
v-if="dataCollectionList.length === 0"
description="暂无采集项,点击上方按钮添加"
:image-size="48"
/>
<template #default="{ row }">
<el-select
v-model="row.unit"
placeholder="请选择单位"
clearable
filterable
remote
reserve-keyword
class="inner-cell-select"
:loading="itemUnitOptionsLoading"
:remote-method="searchItemUnitOptions"
@visible-change="(visible) => visible && searchItemUnitOptions('')"
>
<el-option
v-for="item in itemUnitOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</template>
</el-table-column>
<el-table-column
label="排序号"
min-width="88"
align="center"
header-align="center"
>
<template #default="{ $index }">
<span class="data-collection-order-num">{{ $index + 1 }}</span>
</template>
</el-table-column>
<el-table-column
label="备注"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<el-input v-model="row.remark" placeholder="备注" class="inner-cell-input" />
</template>
</el-table-column>
<el-table-column label="操作" min-width="80" align="center" header-align="center">
<template #default="{ $index }">
<el-button link type="danger" @click="removeDataCollectionRow($index)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty
v-else
description="暂无采集项,点击上方按钮添加"
:image-size="48"
/>
</div>
</div>
</div>
</div>
</el-form>
@@ -688,6 +732,7 @@
<!-- 选择试剂内弹窗 -->
<el-dialog
v-if="SHOW_PROCESS_REAGENT_CONSUMABLE"
v-model="selectReagentVisible"
title="选择试剂"
width="1000px"
@@ -791,6 +836,7 @@
<!-- 选择耗材内弹窗 -->
<el-dialog
v-if="SHOW_PROCESS_REAGENT_CONSUMABLE"
v-model="selectConsumableVisible"
title="选择耗材"
width="1000px"
@@ -894,6 +940,7 @@
<!-- 选择耗材包导入 -->
<el-dialog
v-if="SHOW_PROCESS_REAGENT_CONSUMABLE"
v-model="importPackVisible"
title="从耗材包导入"
width="960px"
@@ -991,7 +1038,7 @@
</template>
<script setup lang="ts">
import { Plus, Close, Check, Rank, RefreshLeft, Search } from '@element-plus/icons-vue'
import { Plus, Close, Check, Rank, RefreshLeft, Search, Delete } from '@element-plus/icons-vue'
import Sortable from 'sortablejs'
import type { SortableEvent } from 'sortablejs'
import {
@@ -1006,9 +1053,12 @@
fetchAllConsumables,
fetchAllOutputTypes,
fetchAllConsumablePacks,
fetchConsumablePackDetail
fetchConsumablePackDetail,
fetchAllEquipments
} from '@/api/production'
import { fetchItemUnitList } from '@/api/admin'
import { equipmentOptionLabel, parseEquipmentIds } from '@/utils/equipment-select'
import { SHOW_PROCESS_REAGENT_CONSUMABLE } from '@/utils/process-material-ui'
import { packTypeLabel, packTypeTagType } from '@/utils/status-tag-registry'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import DialogFormBody from '@/components/business/dialog-form-body/index.vue'
@@ -1038,8 +1088,12 @@
const formTabs = [
{ key: 'basic', label: '基本信息' },
{ key: 'reagents', label: '试剂关联' },
{ key: 'consumables', label: '耗材关联' },
...(SHOW_PROCESS_REAGENT_CONSUMABLE
? [
{ key: 'reagents', label: '试剂关联' },
{ key: 'consumables', label: '耗材关联' }
]
: []),
{ key: 'outputs', label: '产出物' },
{ key: 'data-collections', label: '数据采集' }
] as const
@@ -1055,6 +1109,8 @@
// 下拉选项
const categoryOptions = ref<any[]>([])
const sopOptions = ref<any[]>([])
const equipmentOptions = ref<any[]>([])
const legacyEquipmentHint = ref('')
const outputTypeOptions = ref<any[]>([])
const itemUnitOptions = ref<{ label: string; value: string }[]>([])
const itemUnitOptionsLoading = ref(false)
@@ -1077,7 +1133,7 @@
processName: '',
categoryId: '',
description: '',
equipmentName: '',
equipmentIds: [] as string[],
estimatedDuration: '',
sopIds: [] as string[],
remark: ''
@@ -1224,13 +1280,15 @@
// 加载所有下拉参考数据
async function loadReferenceData() {
try {
const [cats, sops, reagents, consumables, outputTypes, packs] = await Promise.all([
const [cats, sops, reagents, consumables, outputTypes, packs, equipments] =
await Promise.all([
fetchAllProcessCategories(),
fetchAllSopDocuments(),
fetchAllReagents(),
fetchAllConsumables(),
fetchAllOutputTypes(),
fetchAllConsumablePacks()
fetchAllConsumablePacks(),
fetchAllEquipments()
])
categoryOptions.value = cats || []
sopOptions.value = sops || []
@@ -1238,6 +1296,7 @@
allConsumableOptions.value = consumables || []
outputTypeOptions.value = outputTypes || []
packOptions.value = packs || []
equipmentOptions.value = equipments || []
} catch {
/* ignore */
}
@@ -1259,7 +1318,8 @@
formData.processName = ''
formData.categoryId = ''
formData.description = ''
formData.equipmentName = ''
formData.equipmentIds = []
legacyEquipmentHint.value = ''
formData.estimatedDuration = ''
formData.sopIds = []
formData.remark = ''
@@ -1376,7 +1436,11 @@
formData.processName = detail.processName || ''
formData.categoryId = detail.categoryId || ''
formData.description = detail.description || ''
formData.equipmentName = detail.equipmentName || ''
formData.equipmentIds = parseEquipmentIds(detail.equipmentIds || detail.equipmentName)
legacyEquipmentHint.value =
formData.equipmentIds.length === 0 && detail.equipmentName
? `历史文本:${detail.equipmentName},请重新选择设备`
: ''
formData.estimatedDuration = detail.estimatedDuration || ''
formData.sopIds = parseSopIds(detail.sopIds)
formData.remark = detail.remark || ''
@@ -1419,7 +1483,7 @@
processName: formData.processName.trim(),
categoryId: formData.categoryId || undefined,
description: formData.description?.trim() || undefined,
equipmentName: formData.equipmentName?.trim() || undefined,
equipmentIds: formData.equipmentIds.length ? formData.equipmentIds : null,
estimatedDuration: formData.estimatedDuration?.trim() || undefined,
sopIds: serializeSopIds(formData.sopIds),
remark: formData.remark?.trim() || undefined,
@@ -1849,6 +1913,64 @@
color: var(--mint-text-muted, #64748b);
}
/* 弹窗内嵌列表:append-to-body 不在 page-container,须本地复刻标准列表视觉 */
.route-config-tab-list {
border: 1px solid var(--mint-border, #e2e8f0);
border-radius: var(--mint-radius, 10px);
overflow: hidden;
background: var(--mint-surface, #fff);
.list-toolbar {
padding: 0;
background: var(--mint-table-head, #f8fafc);
border-bottom: none;
}
.list-toolbar-row {
padding: 8px 16px;
gap: 8px;
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-start;
:deep(.el-button + .el-button) {
margin-left: 0 !important;
}
}
.list-body.list-wrap.inner-table-wrap {
border-top: 1px solid var(--mint-border, #e2e8f0);
border-radius: 0;
overflow: hidden;
: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;
}
}
}
.data-collection-drag-handle {
cursor: move;
color: #909399;
@@ -6,6 +6,7 @@
width="520px"
class="dialog-form-layout"
align-center
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
:append-to-body="true"
@@ -6,6 +6,7 @@
width="560px"
class="dialog-form-layout"
align-center
modal-class="protocol-dialog-mask"
append-to-body
destroy-on-close
:close-on-click-modal="false"
@@ -8,6 +8,7 @@
:width="phase === 'form' ? '560px' : '90vw'"
:class="phase === 'edit' ? 'dialog-form-layout dispatch-route-dialog' : 'dispatch-dialog'"
align-center
modal-class="protocol-dialog-mask"
append-to-body
destroy-on-close
:close-on-click-modal="false"
@@ -113,16 +114,16 @@
<div class="route-config-left-header">
<h3>工艺库</h3>
<el-input
v-model="editor.processSearchKeyword.value"
v-model="processSearchKeyword"
placeholder="搜索工序"
clearable
size="small"
class="route-config-search"
/>
</div>
<el-collapse v-model="editor.expandedCategories.value" class="route-config-collapse">
<el-collapse v-model="expandedCategories" class="route-config-collapse">
<el-collapse-item
v-for="cat in editor.filteredProcessLibrary.value"
v-for="cat in filteredProcessLibrary"
:key="cat.categoryId"
:name="cat.categoryId"
>
@@ -150,7 +151,7 @@
</el-collapse-item>
</el-collapse>
<el-empty
v-if="!editor.filteredProcessLibrary.value.length"
v-if="!filteredProcessLibrary.length"
description="暂无工艺数据"
:image-size="48"
/>
@@ -158,26 +159,26 @@
<div
class="route-config-center"
v-loading="editor.stepListLoading.value"
v-loading="stepListLoading"
@dragover.prevent
@drop="editor.onDropToCenter"
>
<div class="route-config-center-header">
<h3>工序步骤</h3>
<span class="route-config-step-count">共 {{ editor.stepList.value.length }} 步</span>
<span class="route-config-step-count">共 {{ stepList.length }} 步</span>
</div>
<div class="route-config-center-dropzone">
<draggable
v-model="editor.stepList.value"
v-model="stepList"
handle=".route-config-step-handle"
:animation="200"
@end="editor.onStepDragEnd"
>
<div
v-for="(element, index) in editor.stepList.value"
v-for="(element, index) in stepList"
:key="element.id"
class="route-config-step-card"
:class="{ 'is-active': editor.selectedStepId.value === element.id }"
:class="{ 'is-active': selectedStepId === element.id }"
@click="editor.selectStep(element)"
>
<div class="route-config-step-handle"
@@ -207,7 +208,7 @@
</el-button>
</div>
</draggable>
<div v-if="!editor.stepList.value.length" class="route-config-center-empty">
<div v-if="!stepList.length && !stepListLoading" class="route-config-center-empty">
<el-icon :size="32" color="#a8d4c4"><Plus /></el-icon>
<p>从左侧拖拽工序到此处,添加任务步骤</p>
</div>
@@ -273,6 +274,25 @@
}
const editor = useTaskStepEditor()
const {
stepList,
selectedStepId,
stepListLoading,
processSearchKeyword,
expandedCategories,
filteredProcessLibrary
} = editor
function resolveCreatedTaskId(task: Record<string, any> | null | undefined) {
const raw = task?.id ?? task?.taskId ?? task?.data?.id
return String(raw || '').trim()
}
async function enterEditPhase(taskId: string, options?: { expectSteps?: boolean }) {
phase.value = 'edit'
await nextTick()
return editor.initEditor(taskId, options)
}
async function remoteSearchUsers(query: string) {
userLoading.value = true
@@ -343,10 +363,19 @@
description: form.description || undefined,
remark: form.remark || undefined
})
const taskId = resolveCreatedTaskId(task)
if (!taskId) {
ElMessage.error('任务已创建,但未获取到任务 ID,请刷新后从任务列表编辑工艺')
return
}
createdTaskName.value = form.taskName
phase.value = 'edit'
await editor.initEditor(task.id)
ElMessage.success('任务已创建,可编辑步骤')
const expectSteps = Boolean(form.routeId)
const stepCount = await enterEditPhase(taskId, { expectSteps })
if (expectSteps && stepCount === 0) {
ElMessage.warning('任务已创建,但工艺步骤未加载,请关闭后从「编辑工艺」重试')
} else {
ElMessage.success('任务已创建,可编辑步骤')
}
emit('success')
} catch {
/* global error handling */
@@ -362,10 +391,9 @@
if (props.editTaskId) {
// 编辑已有任务:跳过阶段一,直接进入阶段二
createdTaskName.value = props.editTaskName || ''
phase.value = 'edit'
await editor.initEditor(props.editTaskId)
} else {
// 新建任务:走阶段一表单
await enterEditPhase(props.editTaskId, { expectSteps: true })
} else if (phase.value !== 'edit') {
// 新建任务:走阶段一表单(已在编辑阶段时不重置,避免生成任务后被 watch 打回表单)
phase.value = 'form'
form.taskName = ''
form.assigneeUserId = ''
@@ -6,6 +6,7 @@
width="520px"
class="dialog-form-layout"
align-center
modal-class="protocol-dialog-mask"
append-to-body
destroy-on-close
:close-on-click-modal="false"
@@ -3,7 +3,7 @@
<el-dialog
:model-value="modelValue"
:title="dialogTitle"
width="920px"
width="1600px"
class="dialog-form-layout sample-reception-dialog"
align-center
append-to-body
@@ -13,7 +13,7 @@
@update:model-value="(v) => emit('update:modelValue', v)"
@closed="handleClosed"
>
<div v-loading="loading" class="sample-reception-dialog-body">
<DialogFormBody :loading="loading" body-class="protocol-dialog-body min-h-[200px]">
<div v-if="!currentRow && !loading" class="empty-hint">
暂无关联的样本信息,无法填写接收记录。
</div>
@@ -39,7 +39,7 @@
class="protocol-form"
>
<div id="form-section-sample" class="form-tab-pane">
<div class="section-card">
<div v-if="currentRow" class="section-card">
<div class="section-title">
<div class="section-title-left"><h3>样本信息</h3></div>
</div>
@@ -88,7 +88,7 @@
</div>
<div id="form-section-transport" class="form-tab-pane">
<div class="section-card">
<div v-if="currentRow" class="section-card">
<div class="section-title">
<div class="section-title-left"><h3>运输信息</h3></div>
</div>
@@ -239,7 +239,7 @@
</div>
</el-form>
</template>
</div>
</DialogFormBody>
<template #footer>
<el-button class="btn-ghost" @click="emit('update:modelValue', false)">
<el-icon><Close /></el-icon>取消
@@ -691,128 +691,185 @@
</script>
<style scoped lang="scss">
.sample-reception-dialog-body {
max-height: calc(100vh - 180px);
overflow-y: auto;
padding-right: 4px;
}
.empty-hint {
padding: 32px;
text-align: center;
color: var(--mint-text-muted, #64748b);
font-size: 14px;
}
.detail-tabs {
display: flex;
gap: 4px;
margin-bottom: 16px;
border-bottom: 1px solid var(--mint-border, #e2e8f0);
padding-bottom: 8px;
}
.detail-tab {
padding: 6px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: var(--mint-text-muted, #64748b);
transition: all 0.15s;
&:hover {
color: var(--primary, #10b981);
background: var(--primary-bg, #ecfdf5);
.sample-reception-dialog {
.protocol-dialog-body {
overflow: visible;
background: #f5f7fa;
}
&.active {
color: var(--primary, #10b981);
background: var(--primary-bg, #ecfdf5);
.empty-hint {
padding: 32px;
text-align: center;
color: var(--mint-text-muted, #64748b);
font-size: 14px;
}
.protocol-form {
:deep(.el-form-item) {
margin-bottom: 4px;
}
}
.detail-tabs {
position: sticky;
top: 0;
z-index: 1000;
display: flex;
gap: 4px;
background: #f0fdf4;
padding: 4px;
border-radius: 8px;
margin: 0 28px 16px;
width: fit-content;
isolation: isolate;
box-shadow: 0 1px 0 #f0fdf4;
}
.detail-tab {
padding: 10px 20px;
cursor: pointer;
border-radius: 6px;
font-size: 14px;
color: #606266;
transition: all 0.3s;
&.active {
background: #fff;
color: #065f46;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
&:hover:not(.active) {
color: #065f46;
}
}
.form-tab-pane {
padding: 0 28px 4px;
& + .form-tab-pane {
margin-top: 0;
padding-top: 0;
}
}
.section-card {
border-radius: 12px;
padding: 22px 24px;
margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
transition:
transform 0.2s,
box-shadow 0.2s;
position: relative;
background: #fff;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
}
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
padding-bottom: 12px;
border-bottom: 1px solid #e2e8f0;
}
.section-title-left {
display: flex;
align-items: center;
&::before {
content: '';
width: 3px;
height: 18px;
background: #047857;
border-radius: 2px;
margin-right: 10px;
flex-shrink: 0;
}
}
.section-title h3 {
font-size: 15px;
font-weight: 600;
color: #1e293b;
}
}
.form-tab-pane {
margin-bottom: 16px;
}
.data-grid {
display: grid;
gap: 14px 20px;
.section-card {
border: 1px solid var(--mint-border, #e2e8f0);
border-radius: 8px;
overflow: hidden;
margin-bottom: 12px;
}
.section-title {
display: flex;
align-items: center;
padding: 10px 16px;
background: #f8fafc;
border-bottom: 1px solid var(--mint-border, #e2e8f0);
h3 {
margin: 0;
font-size: 13px;
font-weight: 600;
color: #334155;
&--4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
}
.data-grid {
display: grid;
gap: 1px;
background: #f1f5f9;
.data-item {
min-width: 0;
&.data-grid--4 {
grid-template-columns: repeat(4, 1fr);
}
}
.data-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 16px;
background: #fff;
&.data-item--full {
grid-column: 1 / -1;
&--full {
grid-column: 1 / -1;
}
}
.data-label {
font-size: 11px;
color: #64748b;
line-height: 1.4;
font-size: 12px;
color: #94a3b8;
margin-bottom: 3px;
}
.data-value {
font-size: 13px;
font-size: 14px;
color: #1e293b;
line-height: 1.5;
word-break: break-all;
font-weight: 500;
word-break: break-word;
}
.form-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px 20px;
&--col4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
&--attachment {
margin-top: 16px;
grid-template-columns: 1fr;
}
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.form-col-span-2 {
grid-column: 1 / -1;
}
.check-list {
display: flex;
flex-wrap: wrap;
gap: 12px 20px;
}
.check-empty-hint {
margin: 0;
font-size: 13px;
color: var(--mint-text-muted, #64748b);
}
.form-label-tip {
margin-left: 6px;
font-size: 11px;
color: var(--mint-text-muted, #64748b);
font-weight: 400;
}
}
.check-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.check-empty-hint {
margin: 0;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
}
.form-label-tip {
margin-left: 6px;
font-size: 11px;
color: var(--mint-text-muted, #64748b);
font-weight: 400;
}
.form-col-span-2 {
grid-column: 1 / -1;
}
</style>
@@ -1,4 +1,4 @@
<!-- 步骤详情右侧面板:表单 + 试剂/耗材/产出物/数据采集 4 个 Tab -->
<!-- 步骤详情右侧面板:表单 + 产出物/数据采集上下区块(试剂/耗材 Tab 按开关展示) -->
<template>
<div class="route-config-right">
<template v-if="editor.selectedStep.value">
@@ -66,7 +66,32 @@
</el-select>
</el-form-item>
<el-form-item label="设备名称">
<el-input v-model="editor.selectedStep.value.equipmentName" :disabled="readonly" />
<el-select
v-model="editor.selectedStep.value.equipmentIds"
placeholder="请选择设备"
clearable
filterable
multiple
collapse-tags
collapse-tags-tooltip
:disabled="readonly"
style="width: 100%"
@change="editor.onStepEquipmentChange"
>
<el-option
v-for="eq in editor.equipmentOptions.value"
:key="eq.id"
:label="editor.equipmentOptionLabel(eq)"
:value="eq.id"
:disabled="eq.status === '0'"
/>
</el-select>
<p
v-if="!(editor.selectedStep.value.equipmentIds || []).length && editor.selectedStep.value.equipmentName"
class="form-tip"
>
历史文本:{{ editor.selectedStep.value.equipmentName }},请重新选择设备
</p>
</el-form-item>
<el-form-item label="预计时长(min)">
<el-input
@@ -114,8 +139,16 @@
</div>
</el-form>
<el-tabs v-model="editor.activeSubTab.value" class="route-config-tabs">
<el-tab-pane label="试剂" name="reagent">
<el-tabs
v-if="SHOW_PROCESS_REAGENT_CONSUMABLE"
v-model="activeSubTab"
class="route-config-tabs"
>
<el-tab-pane
v-if="SHOW_PROCESS_REAGENT_CONSUMABLE"
label="试剂"
name="reagent"
>
<div v-if="!readonly" class="route-config-tab-toolbar">
<el-button class="btn-main" type="primary" size="small" @click="editor.addReagentRow">
<el-icon><Plus /></el-icon>添加试剂
@@ -319,7 +352,11 @@
/>
</el-tab-pane>
<el-tab-pane label="耗材" name="consumable">
<el-tab-pane
v-if="SHOW_PROCESS_REAGENT_CONSUMABLE"
label="耗材"
name="consumable"
>
<div v-if="!readonly" class="route-config-tab-toolbar">
<el-button
class="btn-main"
@@ -532,409 +569,271 @@
:image-size="48"
/>
</el-tab-pane>
<el-tab-pane label="配置试剂" name="configuredReagent">
<div v-if="!readonly" class="route-config-tab-toolbar">
<el-button
class="btn-main"
type="primary"
size="small"
@click="editor.addConfiguredReagentRow"
>
<el-icon><Plus /></el-icon>添加配置试剂
</el-button>
</div>
<el-table
:data="editor.selectedStep.value.configuredReagents"
stripe
class="list-table inner-table"
max-height="400"
>
<el-table-column
type="index"
label="序号"
min-width="56"
align="center"
:index="(i: number) => i + 1"
/>
<el-table-column label="编码" min-width="110" align="center" show-overflow-tooltip>
<template #default="{ row }">
<el-input
v-if="!readonly"
v-model="row.configuredReagentCode"
placeholder="编码"
class="inner-cell-input"
/>
<span v-else>{{ row.configuredReagentCode || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="名称" min-width="160" align="center" show-overflow-tooltip>
<template #default="{ row }">
<el-select
v-if="!readonly"
v-model="row.configuredReagentId"
placeholder="请选择配置试剂"
clearable
filterable
remote
reserve-keyword
class="inner-cell-select"
:loading="editor.configuredReagentOptionsLoading.value"
:remote-method="editor.searchConfiguredReagentOptions"
@visible-change="(vis: boolean) => vis && editor.searchConfiguredReagentOptions('')"
@change="(val: string) => editor.onConfiguredReagentRowSelect(row, val)"
>
<el-option
v-for="item in editor.configuredReagentOptions.value"
:key="item.id"
:label="item.label"
:value="item.id"
/>
</el-select>
<span v-else>{{ row.configuredReagentName || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="规格" min-width="110" align="center" show-overflow-tooltip>
<template #default="{ row }">
<el-input
v-if="!readonly"
v-model="row.spec"
placeholder="规格"
class="inner-cell-input"
/>
<span v-else>{{ row.spec || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="标准用量" min-width="110" align="center">
<template #default="{ row }">
<el-input
v-if="!readonly"
v-model="row.standardUsage"
placeholder="用量"
class="inner-cell-input"
/>
<span v-else>{{ row.standardUsage ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column
v-if="editor.mode.value === 'execution'"
label="领用记录"
min-width="220"
align="center"
>
<template #default="{ row, $index }">
<el-select
v-if="!readonly"
v-model="row.requisitionUsageId"
filterable
remote
clearable
placeholder="选择领用记录"
class="inner-cell-select"
:loading="editor.configuredReagentRequisitionOptionsLoading.value"
:remote-method="editor.searchConfiguredReagentRequisitionOptions"
@visible-change="(vis: boolean) => vis && editor.searchConfiguredReagentRequisitionOptions('')"
@change="(val: string) => editor.onConfiguredReagentRequisitionSelect(row, val)"
>
<el-option
v-for="opt in editor.configuredReagentRequisitionOptions.value"
:key="opt.id"
:label="opt.label"
:value="opt.id"
:disabled="editor.isConfiguredReagentRequisitionDisabled(opt.id, $index)"
/>
</el-select>
<span v-else>{{
editor.configuredReagentRequisitionOptions.value.find(
(o) => o.id === row.requisitionUsageId
)?.label ||
row.requisitionUsageId ||
'—'
}}</span>
</template>
</el-table-column>
<el-table-column
v-if="editor.mode.value === 'execution'"
label="实际用量"
min-width="110"
align="center"
>
<template #default="{ row }">
<el-input
v-if="!readonly"
v-model="row.actualUsage"
placeholder="实际用量"
class="inner-cell-input"
@change="editor.saveConfiguredReagents"
/>
<span v-else>{{ row.actualUsage ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="单位" min-width="120" align="center" show-overflow-tooltip>
<template #default="{ row }">
<el-select
v-if="!readonly"
v-model="row.unit"
placeholder="请选择单位"
clearable
filterable
remote
reserve-keyword
class="inner-cell-select"
:loading="editor.itemUnitOptionsLoading.value"
:remote-method="editor.searchItemUnitOptions"
@visible-change="(vis: boolean) => vis && editor.searchItemUnitOptions('')"
>
<el-option
v-for="item in editor.itemUnitOptions.value"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<span v-else>{{ row.unit || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="备注" min-width="120" align="center" show-overflow-tooltip>
<template #default="{ row }">
<el-input
v-if="!readonly"
v-model="row.remark"
placeholder="备注"
class="inner-cell-input"
/>
<span v-else>{{ row.remark || '—' }}</span>
</template>
</el-table-column>
<el-table-column v-if="!readonly" label="操作" min-width="80" align="center">
<template #default="{ $index }">
<el-button
link
type="danger"
size="small"
@click="editor.removeConfiguredReagentRow($index)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
<el-empty
v-if="!editor.selectedStep.value.configuredReagents?.length"
description="暂无配置试剂"
:image-size="48"
/>
</el-tab-pane>
<el-tab-pane label="产出物" name="output">
<div v-if="!readonly" class="route-config-tab-toolbar">
<el-button class="btn-main" type="primary" size="small" @click="editor.addOutputRow">
<el-icon><Plus /></el-icon>添加产出物
</el-button>
</div>
<el-table
:data="editor.selectedStep.value.outputs"
stripe
class="list-table inner-table"
max-height="400"
>
<el-table-column
type="index"
label="序号"
min-width="56"
align="center"
:index="(i: number) => i + 1"
/>
<el-table-column
label="产出类型"
min-width="150"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<el-select
v-if="!readonly"
v-model="row.outputTypeId"
placeholder="选择类型"
filterable
class="inner-cell-select"
@change="editor.saveOutputs"
>
<el-option
v-for="ot in editor.outputTypeOptions.value"
:key="ot.id"
:label="ot.typeName || ot.name"
:value="ot.id"
/>
</el-select>
<span v-else>{{ outputTypeLabel(row.outputTypeId) }}</span>
</template>
</el-table-column>
<el-table-column label="名称" min-width="150" align="center" show-overflow-tooltip>
<template #default="{ row }">
<el-input
v-if="!readonly"
v-model="row.outputName"
placeholder="名称"
class="inner-cell-input"
@change="editor.saveOutputs"
/>
<span v-else>{{ row.outputName || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="条码" min-width="150" align="center" show-overflow-tooltip>
<template #default="{ row }">
<el-input
v-if="!readonly"
v-model="row.outputBarcode"
placeholder="条码"
class="inner-cell-input"
@change="editor.saveOutputs"
/>
<span v-else>{{ row.outputBarcode || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="备注" min-width="120" align="center" show-overflow-tooltip>
<template #default="{ row }">
<el-input
v-if="!readonly"
v-model="row.remark"
placeholder="备注"
class="inner-cell-input"
@change="editor.saveOutputs"
/>
<span v-else>{{ row.remark || '—' }}</span>
</template>
</el-table-column>
<el-table-column v-if="!readonly" label="操作" min-width="80" align="center">
<template #default="{ $index }">
<el-button link type="danger" size="small" @click="editor.removeOutputRow($index)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
<el-empty
v-if="!editor.selectedStep.value.outputs?.length"
description="暂无产出物"
:image-size="48"
/>
</el-tab-pane>
<el-tab-pane label="数据采集" name="dataCollection">
<div v-if="!readonly" class="route-config-tab-toolbar">
<el-button
class="btn-main"
type="primary"
size="small"
@click="editor.addDataCollectionRow"
>
<el-icon><Plus /></el-icon>添加采集项
</el-button>
</div>
<el-table
:data="editor.selectedStep.value.dataCollections"
stripe
class="list-table inner-table"
max-height="400"
>
<el-table-column
type="index"
label="序号"
min-width="56"
align="center"
:index="(i: number) => i + 1"
/>
<el-table-column
label="采集名称"
min-width="180"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<el-input
v-if="!readonly"
v-model="row.collectionName"
placeholder="采集项名称"
class="inner-cell-input"
@change="editor.saveDataCollections"
/>
<span v-else>{{ row.collectionName || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="单位" min-width="120" align="center" show-overflow-tooltip>
<template #default="{ row }">
<el-select
v-if="!readonly"
v-model="row.unit"
placeholder="请选择单位"
clearable
filterable
remote
reserve-keyword
class="inner-cell-select"
:loading="editor.itemUnitOptionsLoading.value"
:remote-method="editor.searchItemUnitOptions"
@visible-change="(vis: boolean) => vis && editor.searchItemUnitOptions('')"
@change="editor.saveDataCollections"
>
<el-option
v-for="item in editor.itemUnitOptions.value"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<span v-else>{{ row.unit || '—' }}</span>
</template>
</el-table-column>
<el-table-column
v-if="editor.mode.value === 'execution'"
label="采集值"
min-width="140"
align="center"
>
<template #default="{ row }">
<el-input
v-if="!readonly"
v-model="row.collectionValue"
placeholder="输入采集值"
class="inner-cell-input"
@change="editor.saveDataCollections"
/>
<span v-else>{{ row.collectionValue ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="备注" min-width="120" align="center" show-overflow-tooltip>
<template #default="{ row }">
<el-input
v-if="!readonly"
v-model="row.remark"
placeholder="备注"
class="inner-cell-input"
@change="editor.saveDataCollections"
/>
<span v-else>{{ row.remark || '—' }}</span>
</template>
</el-table-column>
<el-table-column v-if="!readonly" label="操作" min-width="80" align="center">
<template #default="{ $index }">
<el-button
link
type="danger"
size="small"
@click="editor.removeDataCollectionRow($index)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
<el-empty
v-if="!editor.selectedStep.value.dataCollections?.length"
description="暂无采集项"
:image-size="48"
/>
</el-tab-pane>
</el-tabs>
<div class="route-config-step-sections">
<!-- 产出物 -->
<section class="route-config-step-section">
<h3 class="route-config-step-section-title">产出物</h3>
<div class="route-config-tab-list">
<div v-if="!readonly" class="list-toolbar">
<div class="list-toolbar-row">
<el-button class="btn-main" type="primary" @click="editor.addOutputRow">
<el-icon><Plus /></el-icon>
添加产出物
</el-button>
</div>
</div>
<div class="list-body list-wrap inner-table-wrap">
<el-table
v-if="editor.selectedStep.value.outputs?.length"
:data="editor.selectedStep.value.outputs"
stripe
class="list-table inner-table"
max-height="240"
>
<el-table-column
type="index"
label="序号"
min-width="56"
align="center"
header-align="center"
:index="(i: number) => i + 1"
/>
<el-table-column
label="产出类型"
min-width="150"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<el-select
v-if="!readonly"
v-model="row.outputTypeId"
placeholder="选择类型"
filterable
class="inner-cell-select"
@change="editor.saveOutputs"
>
<el-option
v-for="ot in editor.outputTypeOptions.value"
:key="ot.id"
:label="ot.typeName || ot.name"
:value="ot.id"
/>
</el-select>
<span v-else>{{ outputTypeLabel(row.outputTypeId) }}</span>
</template>
</el-table-column>
<el-table-column
label="名称"
min-width="150"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<el-input
v-if="!readonly"
v-model="row.outputName"
placeholder="名称"
class="inner-cell-input"
@change="editor.saveOutputs"
/>
<span v-else>{{ row.outputName || '—' }}</span>
</template>
</el-table-column>
<el-table-column
label="条码"
min-width="150"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<el-input
v-if="!readonly"
v-model="row.outputBarcode"
placeholder="条码"
class="inner-cell-input"
@change="editor.saveOutputs"
/>
<span v-else>{{ row.outputBarcode || '—' }}</span>
</template>
</el-table-column>
<el-table-column
label="备注"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<el-input
v-if="!readonly"
v-model="row.remark"
placeholder="备注"
class="inner-cell-input"
@change="editor.saveOutputs"
/>
<span v-else>{{ row.remark || '—' }}</span>
</template>
</el-table-column>
<el-table-column
v-if="!readonly"
label="操作"
min-width="80"
align="center"
header-align="center"
>
<template #default="{ $index }">
<el-button link type="danger" @click="editor.removeOutputRow($index)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无产出物" :image-size="48" />
</div>
</div>
</section>
<!-- 数据采集 -->
<section class="route-config-step-section">
<h3 class="route-config-step-section-title">数据采集</h3>
<div class="route-config-tab-list">
<div v-if="!readonly" class="list-toolbar">
<div class="list-toolbar-row">
<el-button class="btn-main" type="primary" @click="editor.addDataCollectionRow">
<el-icon><Plus /></el-icon>
添加采集项
</el-button>
</div>
</div>
<div class="list-body list-wrap inner-table-wrap">
<el-table
v-if="editor.selectedStep.value.dataCollections?.length"
:data="editor.selectedStep.value.dataCollections"
stripe
class="list-table inner-table"
max-height="240"
>
<el-table-column
type="index"
label="序号"
min-width="56"
align="center"
header-align="center"
:index="(i: number) => i + 1"
/>
<el-table-column
label="采集名称"
min-width="180"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<el-input
v-if="!readonly"
v-model="row.collectionName"
placeholder="采集项名称"
class="inner-cell-input"
@change="editor.saveDataCollections"
/>
<span v-else>{{ row.collectionName || '—' }}</span>
</template>
</el-table-column>
<el-table-column
label="单位"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<el-select
v-if="!readonly"
v-model="row.unit"
placeholder="请选择单位"
clearable
filterable
remote
reserve-keyword
class="inner-cell-select"
:loading="editor.itemUnitOptionsLoading.value"
:remote-method="editor.searchItemUnitOptions"
@visible-change="(vis: boolean) => vis && editor.searchItemUnitOptions('')"
@change="editor.saveDataCollections"
>
<el-option
v-for="item in editor.itemUnitOptions.value"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<span v-else>{{ row.unit || '—' }}</span>
</template>
</el-table-column>
<el-table-column
v-if="editor.mode.value === 'execution'"
label="采集值"
min-width="140"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-input
v-if="!readonly"
v-model="row.collectionValue"
placeholder="输入采集值"
class="inner-cell-input"
@change="editor.saveDataCollections"
/>
<span v-else>{{ row.collectionValue ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column
label="备注"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<el-input
v-if="!readonly"
v-model="row.remark"
placeholder="备注"
class="inner-cell-input"
@change="editor.saveDataCollections"
/>
<span v-else>{{ row.remark || '—' }}</span>
</template>
</el-table-column>
<el-table-column
v-if="!readonly"
label="操作"
min-width="80"
align="center"
header-align="center"
>
<template #default="{ $index }">
<el-button link type="danger" @click="editor.removeDataCollectionRow($index)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无采集项" :image-size="48" />
</div>
</div>
</section>
</div>
</div>
</template>
<template v-else>
@@ -948,9 +847,10 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Check, Plus, Rank, VideoPlay } from '@element-plus/icons-vue'
import { Check, Delete, Plus, Rank, VideoPlay } from '@element-plus/icons-vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import { downloadAttachmentFile } from '@/utils/download-attachment'
import { SHOW_PROCESS_REAGENT_CONSUMABLE } from '@/utils/process-material-ui'
import { useTaskStepEditor } from './useTaskStepEditor'
const props = defineProps<{
@@ -958,6 +858,13 @@
readonly?: boolean
}>()
const activeSubTab = computed({
get: () => props.editor.activeSubTab.value,
set: (val: string) => {
props.editor.activeSubTab.value = val
}
})
function outputTypeLabel(outputTypeId: string) {
const item = props.editor.outputTypeOptions.value.find((ot) => ot.id === outputTypeId)
return item?.typeName || item?.name || outputTypeId || '—'
@@ -6,6 +6,7 @@
width="96vw"
class="dialog-form-layout execution-dialog"
align-center
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
:append-to-body="appendToBody"
@@ -192,6 +193,25 @@
<el-tag v-else type="info" size="small">暂无</el-tag>
</div>
<!-- 虚拟步骤:线下纸质文档 -->
<div
class="virtual-step-card"
:class="{ 'is-active': editor.selectedVirtualStep.value === 'paper' }"
@click="editor.selectVirtualStep('paper')"
>
<div class="virtual-step-icon virtual-step-icon--paper">📄</div>
<div class="virtual-step-info">
<div class="virtual-step-name">线下纸质文档</div>
</div>
<el-tag
v-if="editor.virtualPaperStatus.value === 'filled'"
type="success"
size="small"
>已上传 {{ editor.virtualPaperCount.value }} 份</el-tag
>
<el-tag v-else type="info" size="small">暂无</el-tag>
</div>
<!-- 分割线 -->
<div class="step-divider"><span>工艺步骤</span></div>
@@ -266,23 +286,15 @@
<template v-if="editor.selectedVirtualStep.value === 'input'">
<div class="execution-detail-header">
<h3>📥 原材料(样本/细胞产品)</h3>
<div v-if="!readonly" class="execution-detail-actions">
<el-button
class="btn-main task-exec-toolbar-btn"
type="primary"
:loading="editor.inputSaving.value"
@click="editor.saveTaskInputs"
>
<el-icon><Check /></el-icon>保存
</el-button>
</div>
</div>
<div class="execution-detail-body">
<TaskInputEnhancedTable
:items="editor.taskInputs.value"
:loading="false"
:disabled="readonly"
:saving="editor.inputSaving.value"
:sample-code="editor.taskDetail.value?.sampleCode || ''"
:lab-storage-code="editor.taskDetail.value?.labStorageCode || ''"
:cell-product-options="editor.cellProductOptions.value"
:cell-product-options-loading="editor.cellProductOptionsLoading.value"
:sample-code-options="editor.sampleCodeOptions.value"
@@ -299,24 +311,15 @@
<template v-else-if="editor.selectedVirtualStep.value === 'output'">
<div class="execution-detail-header">
<h3>📤 产出(细胞产品)</h3>
<div v-if="!readonly" class="execution-detail-actions">
<el-button
class="btn-main task-exec-toolbar-btn"
type="primary"
:loading="editor.outputSaving.value"
@click="editor.saveTaskOutputs"
>
<el-icon><Check /></el-icon>保存
</el-button>
</div>
</div>
<div class="execution-detail-body">
<TaskOutputList
:items="editor.taskOutputs.value"
:loading="false"
:disabled="readonly"
:saving="editor.outputSaving.value"
:unit-options="editor.itemUnitOptions.value"
:unit-options-loading="editor.itemUnitOptionsLoading.value"
:disabled="readonly"
@update:items="(items: Record<string, any>[]) => (editor.taskOutputs.value = items)"
@search-unit="(q: string) => editor.searchItemUnitOptions(q)"
@save="editor.saveTaskOutputs"
@@ -324,6 +327,22 @@
</div>
</template>
<!-- 线下纸质文档 -->
<template v-else-if="editor.selectedVirtualStep.value === 'paper'">
<div class="execution-detail-header">
<h3>📄 线下纸质文档</h3>
</div>
<div class="execution-detail-body">
<TaskPaperAttachmentsPanel
:attachments="editor.taskAttachments.value"
:disabled="readonly"
:saving="editor.attachmentSaving.value"
@update:attachments="(items) => (editor.taskAttachments.value = items)"
@save="editor.saveTaskAttachments"
/>
</div>
</template>
<!-- 步骤详情 -->
<template v-else>
<StepDetailPanel :editor="editor" :readonly="readonly" />
@@ -358,6 +377,7 @@
import StepDetailPanel from './StepDetailPanel.vue'
import TaskInputEnhancedTable from './TaskInputEnhancedTable.vue'
import TaskOutputList from './TaskOutputList.vue'
import TaskPaperAttachmentsPanel from './TaskPaperAttachmentsPanel.vue'
import CompleteProductionTaskDialog from './CompleteProductionTaskDialog.vue'
import ForwardTaskDialog from './ForwardTaskDialog.vue'
import type { ProductionTaskOperateScope } from '@/api/production-task'
@@ -451,17 +471,6 @@
<style scoped lang="scss">
@use '../../../styles/route-config' as *;
.execution-dialog {
:deep(.el-dialog) {
margin: 2vh auto !important;
}
:deep(.el-dialog__body) {
max-height: calc(96vh - 54px);
overflow-y: auto;
padding: 10px 14px;
}
}
/* 顶部信息 */
.execution-header {
padding: 6px 16px;
@@ -469,6 +478,7 @@
border-radius: 6px;
margin-bottom: 8px;
border: 1px solid #d1fae5;
flex-shrink: 0;
}
.execution-header-row {
display: flex;
@@ -494,23 +504,29 @@
color: #64748b;
}
/* 主体三区域 */
/* 主体三区域:占满弹窗剩余高度,各栏内部独立滚动 */
.execution-main {
display: flex;
gap: 0;
height: calc(96vh - 110px);
min-height: 400px;
flex: 1;
min-height: 0;
}
/* 工艺库折叠侧栏 */
.execution-lib-panel {
width: 260px;
flex-shrink: 0;
min-height: 0;
display: flex;
flex-direction: column;
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
.route-config-collapse {
flex: 1;
min-height: 0;
}
}
.execution-lib-header {
padding: 10px 12px;
@@ -568,6 +584,7 @@
.execution-step-list {
width: 340px;
flex-shrink: 0;
min-height: 0;
display: flex;
flex-direction: column;
border: 1px solid #e5e7eb;
@@ -598,6 +615,7 @@
}
.execution-step-list-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 8px;
}
@@ -644,7 +662,8 @@
font-size: 14px;
flex-shrink: 0;
&--input,
&--output {
&--output,
&--paper {
background: #d1fae5;
}
}
@@ -697,11 +716,19 @@
.execution-detail-panel {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
:deep(.route-config-right) {
flex: 1;
min-height: 0;
border: none;
border-radius: 0;
}
}
.execution-detail-header {
padding: 10px 16px;
@@ -723,6 +750,7 @@
}
.execution-detail-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px;
}
@@ -737,8 +765,29 @@
}
</style>
<!-- 弹窗 teleport 到 body,工具栏按钮样式需挂 execution-dialog 全局类 -->
<!-- 弹窗 teleport 到 body,布局与工具栏样式需挂 execution-dialog 全局类 -->
<style lang="scss">
.el-dialog.execution-dialog.dialog-form-layout {
display: flex;
flex-direction: column;
max-height: calc(100vh - 32px);
overflow: hidden;
margin: 16px auto !important;
.el-dialog__header {
flex-shrink: 0;
}
.el-dialog__body {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 10px 14px;
}
}
.execution-dialog {
.task-exec-toolbar-btn.el-button {
height: 36px;
@@ -1,116 +1,174 @@
<template>
<div class="task-input-table">
<!-- 工具栏 -->
<div class="panel-top">
<h2 class="title-bar-heading">原材料(样本/细胞产品)</h2>
<div v-if="!disabled" class="panel-top-actions">
<el-button class="btn-main task-exec-toolbar-btn" type="primary" @click="handleAddSample">
<el-icon><Plus /></el-icon>
添加样本
</el-button>
<el-button class="btn-ghost task-exec-toolbar-btn" @click="handleAddCellProduct">
<el-icon><Plus /></el-icon>
添加细胞产品
</el-button>
<div class="route-config-tab-list">
<div v-if="!disabled" class="list-toolbar">
<div class="list-toolbar-row">
<el-button class="btn-main" type="primary" @click="handleAddSample">
<el-icon><Plus /></el-icon>
添加样本
</el-button>
<el-button class="btn-ghost" @click="handleAddCellProduct">
<el-icon><Plus /></el-icon>
添加细胞产品
</el-button>
</div>
</div>
<div class="list-body list-wrap task-input-cards-body">
<div v-if="items.length" v-loading="loading" class="task-input-cards-wrap">
<article
v-for="(row, index) in items"
:key="inputCardKey(row, index)"
class="task-input-card"
>
<header class="task-input-card__head">
<div class="task-input-card__title-row">
<span class="task-input-card__index">{{ index + 1 }}</span>
<el-tag :type="inputTypeTagType(row.inputType)" size="small">
{{ row.inputType === 'cell_product' ? '细胞产品' : '样本' }}
</el-tag>
</div>
<el-button
v-if="!disabled"
type="danger"
link
class="task-input-card__delete"
@click="handleDelete(index)"
>
<el-icon><Delete /></el-icon>
删除
</el-button>
</header>
<dl class="task-input-card__meta">
<template v-if="row.inputType === 'sample'">
<div class="task-input-card__meta-item">
<dt>样本编码</dt>
<dd>
<template v-if="!disabled">
<el-select
v-model="row.inputCode"
placeholder="选择或搜索"
clearable
filterable
remote
reserve-keyword
class="inner-cell-select"
:loading="sampleCodeOptionsLoading"
:remote-method="handleSearchSampleCode"
@visible-change="(visible) => visible && handleSampleSelectVisible(row)"
@change="(val) => onSampleCodeChange(row, val)"
>
<el-option
v-for="sc in sampleCodeOptions"
:key="sc.id"
:label="sampleCodeOptionLabel(sc)"
:value="sc.sampleCode"
:disabled="isSampleCodeOptionDisabled(sc, row)"
/>
</el-select>
</template>
<template v-else>
<span :title="row.inputCode || undefined">{{ row.inputCode || '—' }}</span>
</template>
</dd>
</div>
<div class="task-input-card__meta-item">
<dt>入库编码</dt>
<dd :title="resolveLabStorageCode(row) || undefined">
{{ resolveLabStorageCode(row) || '—' }}
</dd>
</div>
<div class="task-input-card__meta-item">
<dt>名称</dt>
<dd>
<template v-if="!disabled">
<el-input
v-model="row.inputName"
placeholder="请输入名称"
class="inner-cell-input"
/>
</template>
<template v-else>
<span :title="row.inputName || undefined">{{ row.inputName || '—' }}</span>
</template>
</dd>
</div>
<div class="task-input-card__meta-item">
<dt>备注</dt>
<dd>
<template v-if="!disabled">
<el-input v-model="row.remark" placeholder="备注" class="inner-cell-input" />
</template>
<template v-else>{{ row.remark || '—' }}</template>
</dd>
</div>
</template>
<template v-else>
<div class="task-input-card__meta-item">
<dt>产品编码</dt>
<dd :title="displayText(row.inputCode || row.productCode)">
{{ displayText(row.inputCode || row.productCode) }}
</dd>
</div>
<div class="task-input-card__meta-item">
<dt>产品名称</dt>
<dd :title="displayText(row.inputName || row.productName)">
{{ displayText(row.inputName || row.productName) }}
</dd>
</div>
<div class="task-input-card__meta-item">
<dt>产品类型</dt>
<dd :title="displayText(row.productTypeName)">
{{ displayText(row.productTypeName) }}
</dd>
</div>
<div class="task-input-card__meta-item">
<dt>代次</dt>
<dd :title="displayText(row.passage)">{{ displayText(row.passage) }}</dd>
</div>
<div class="task-input-card__meta-item">
<dt>储物格位置</dt>
<dd :title="displayText(row.storageSlotName)">
{{ displayText(row.storageSlotName) }}
</dd>
</div>
<div class="task-input-card__meta-item">
<dt>状态</dt>
<dd>
<el-tag :type="inputStatusTagType(row.productStatus)" size="small">
{{ inputStatusLabel(row.productStatus) }}
</el-tag>
</dd>
</div>
<div class="task-input-card__meta-item">
<dt>备注</dt>
<dd>
<template v-if="!disabled">
<el-input v-model="row.remark" placeholder="备注" class="inner-cell-input" />
</template>
<template v-else>{{ row.remark || '—' }}</template>
</dd>
</div>
</template>
</dl>
</article>
</div>
<el-empty v-else description="暂无原材料" :image-size="48" />
</div>
</div>
<!-- 列表 -->
<el-table
:data="items"
class="list-table"
stripe
border
height="420"
v-loading="loading"
empty-text="暂无原材料"
>
<el-table-column type="index" label="序号" min-width="56" align="center" />
<el-table-column label="类型" min-width="100" align="center">
<template #default="{ row }">
<el-tag :type="inputTypeTagType(row.inputType)" size="small">
{{ row.inputType === 'cell_product' ? '细胞产品' : '样本' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="编码" min-width="180" align="center" show-overflow-tooltip>
<template #default="{ row }">
<template v-if="row.inputType === 'sample' && !disabled">
<el-select
v-model="row.inputCode"
placeholder="选择或搜索样本编码"
clearable
filterable
remote
reserve-keyword
class="inner-cell-select"
:loading="sampleCodeOptionsLoading"
:remote-method="handleSearchSampleCode"
@visible-change="(visible) => visible && handleSearchSampleCode('')"
@change="(val) => onSampleCodeChange(row, val)"
>
<el-option
v-for="sc in sampleCodeOptions"
:key="sc.id"
:label="sampleCodeOptionLabel(sc)"
:value="sc.sampleCode"
:disabled="isSampleCodeOptionDisabled(sc, row)"
/>
</el-select>
</template>
<template v-else>{{ row.inputCode || row.productCode || '—' }}</template>
</template>
</el-table-column>
<el-table-column label="名称" min-width="120" align="center" show-overflow-tooltip>
<template #default="{ row }">
<template v-if="row.inputType === 'sample' && !disabled">
<el-input v-model="row.inputName" placeholder="请输入名称" />
</template>
<template v-else>{{ row.inputName || row.productName || '—' }}</template>
</template>
</el-table-column>
<!-- 细胞产品特有字段 -->
<el-table-column label="产品类型" min-width="90" show-overflow-tooltip>
<template #default="{ row }">
{{ row.productTypeName || '—' }}
</template>
</el-table-column>
<el-table-column label="代次" min-width="70" align="center">
<template #default="{ row }">
{{ row.passage || '—' }}
</template>
</el-table-column>
<el-table-column label="储物格位置" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
{{ row.storageSlotName || '—' }}
</template>
</el-table-column>
<el-table-column label="状态" min-width="90" align="center">
<template #default="{ row }">
<template v-if="row.inputType === 'cell_product'">
<el-tag :type="inputStatusTagType(row.productStatus)" size="small">
{{ inputStatusLabel(row.productStatus) }}
</el-tag>
</template>
<template v-else>—</template>
</template>
</el-table-column>
<el-table-column label="备注" min-width="120" show-overflow-tooltip>
<template #default="{ row }">
<template v-if="!disabled">
<el-input v-model="row.remark" placeholder="备注" />
</template>
<template v-else>{{ row.remark || '—' }}</template>
</template>
</el-table-column>
<el-table-column v-if="!disabled" label="操作" min-width="80" fixed="right" align="center">
<template #default="{ $index }">
<el-button type="danger" link size="small" @click="handleDelete($index)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="!disabled" class="list-save-footer">
<el-button
class="btn-main task-exec-toolbar-btn"
type="primary"
:loading="saving"
@click="emit('save')"
>
<el-icon><Check /></el-icon>
保存
</el-button>
</div>
<!-- 细胞产品选择弹窗 -->
<el-dialog
@@ -119,6 +177,7 @@
width="1400px"
class="dialog-form-layout"
align-center
modal-class="protocol-dialog-mask"
append-to-body
destroy-on-close
:close-on-click-modal="false"
@@ -137,17 +196,18 @@
</template>
</el-input>
<!-- 可选列表 -->
<div class="list-body list-wrap">
<el-table
ref="selectTableRef"
class="list-table"
:data="filteredCellProducts"
stripe
row-key="id"
height="380"
v-loading="cellProductOptionsLoading"
@selection-change="handleSelectionChange"
>
<div class="route-config-tab-list">
<div class="list-body list-wrap inner-table-wrap">
<el-table
ref="selectTableRef"
class="list-table inner-table"
:data="filteredCellProducts"
stripe
row-key="id"
max-height="380"
v-loading="cellProductOptionsLoading"
@selection-change="handleSelectionChange"
>
<el-table-column
type="selection"
min-width="45"
@@ -204,6 +264,7 @@
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
<template #footer>
@@ -217,9 +278,9 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search } from '@element-plus/icons-vue'
import { Plus, Search, Check, Delete } from '@element-plus/icons-vue'
defineOptions({ name: 'TaskInputEnhancedTable' })
@@ -229,6 +290,7 @@
loading: boolean
disabled?: boolean
sampleCode?: string
labStorageCode?: string
cellProductOptions: {
id: string
label: string
@@ -246,8 +308,16 @@
cellProductOptionsLoading: boolean
sampleCodeOptions: Api.BizSampleCode.SampleCodeItem[]
sampleCodeOptionsLoading: boolean
saving?: boolean
}>(),
{ disabled: false, sampleCode: '', sampleCodeOptions: () => [], sampleCodeOptionsLoading: false }
{
disabled: false,
sampleCode: '',
labStorageCode: '',
sampleCodeOptions: () => [],
sampleCodeOptionsLoading: false,
saving: false
}
)
const emit = defineEmits<{
@@ -291,6 +361,32 @@
return map[status] || status || '—'
}
function inputCardKey(row: Record<string, any>, index: number) {
return row.id || `${row.inputType || 'item'}_${row.inputId || row.inputCode || index}`
}
function displayText(val: unknown) {
const s = val == null ? '' : String(val).trim()
return s || '—'
}
function lookupLabStorageCodeBySampleCode(sampleCode: string) {
const code = (sampleCode || '').trim()
if (!code) return ''
if (code === (props.sampleCode || '').trim() && (props.labStorageCode || '').trim()) {
return props.labStorageCode.trim()
}
const sc = props.sampleCodeOptions.find((x) => (x.sampleCode || '').trim() === code)
return (sc?.labStorageCode || '').trim()
}
function resolveLabStorageCode(row: Record<string, any>) {
if (row.inputType !== 'sample') return ''
const stored = (row.labStorageCode || '').trim()
if (stored) return stored
return lookupLabStorageCodeBySampleCode(row.inputCode || '')
}
const filteredCellProducts = computed(() => {
const keyword = searchKeyword.value?.trim().toLowerCase() || ''
if (!keyword) return props.cellProductOptions
@@ -317,6 +413,31 @@
emit('searchSampleCode', query)
}
function handleSampleSelectVisible(row: Record<string, any>) {
const code = (row.inputCode || '').trim()
emit('searchSampleCode', code || '')
}
function syncLabStorageCodesToItems() {
let changed = false
const newItems = props.items.map((row) => {
if (row.inputType !== 'sample') return row
const code = (row.inputCode || '').trim()
if (!code) return row
const resolved = lookupLabStorageCodeBySampleCode(code)
if (!resolved || resolved === (row.labStorageCode || '').trim()) return row
changed = true
return { ...row, labStorageCode: resolved }
})
if (changed) emit('update:items', newItems)
}
watch(
() => [props.items, props.sampleCodeOptions, props.labStorageCode, props.sampleCode] as const,
() => syncLabStorageCodesToItems(),
{ deep: true, immediate: true }
)
function sampleCodeOptionLabel(sc: Api.BizSampleCode.SampleCodeItem) {
if (sc.usedInProtocol) return `${sc.sampleCode}(已占用)`
if (sc.issued) return `${sc.sampleCode}(已下发)`
@@ -338,16 +459,21 @@
if (!sampleCode) {
row.inputId = ''
row.inputCode = ''
row.labStorageCode = ''
return
}
const sc = props.sampleCodeOptions.find((x) => x.sampleCode === sampleCode)
const sc = props.sampleCodeOptions.find((x) => (x.sampleCode || '').trim() === sampleCode.trim())
row.inputCode = sampleCode
row.inputId = sc?.id || ''
const fromOption = (sc?.labStorageCode || '').trim()
row.labStorageCode = fromOption || lookupLabStorageCodeBySampleCode(sampleCode)
if (!row.labStorageCode) {
emit('searchSampleCode', sampleCode)
}
}
function handleAddSample() {
const newItems = [...props.items]
// 自动填入样本编码:如果工单关联了样本编码,且尚未添加过,则自动填入
let autoCode = ''
const code = (props.sampleCode || '').trim()
if (code) {
@@ -363,6 +489,7 @@
inputId: '',
inputCode: autoCode,
inputName: '',
labStorageCode: autoCode ? lookupLabStorageCodeBySampleCode(autoCode) : '',
remark: ''
})
emit('update:items', newItems)
@@ -421,32 +548,132 @@
</script>
<style lang="scss" scoped>
.task-input-table {
.panel-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
@use '../../../styles/route-config' as *;
.panel-top-actions {
display: flex;
gap: 10px;
}
.task-input-table {
.route-config-tab-list {
overflow: visible;
}
.list-save-footer {
display: flex;
justify-content: center;
margin-top: 16px;
}
.select-dialog-body {
padding: 0 4px;
}
}
// 样本行输入框 / 下拉高度适配
:deep(.el-table__cell) {
.el-input,
.el-select {
.task-input-cards-body {
border-top: none;
overflow: visible;
}
.task-input-cards-wrap {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
overflow: visible;
}
.task-input-card {
flex-shrink: 0;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
overflow: hidden;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
&:hover {
border-color: #a8d4c4;
box-shadow: 0 2px 8px rgba(4, 120, 87, 0.08);
}
}
.task-input-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
border-radius: 9px 9px 0 0;
background: #f8fafc;
}
.task-input-card__title-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.task-input-card__index {
flex-shrink: 0;
font-size: 12px;
color: #94a3b8;
min-width: 20px;
}
.task-input-card__delete {
flex-shrink: 0;
}
.task-input-card__meta {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: start;
gap: 12px 14px;
margin: 0;
padding: 14px 16px;
min-height: 0;
}
.task-input-card__meta-item {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
min-height: 0;
dt {
margin: 0;
font-size: 11px;
line-height: 1.35;
color: #94a3b8;
white-space: nowrap;
}
dd {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: #334155;
word-break: break-word;
overflow-wrap: anywhere;
white-space: normal;
min-width: 0;
min-height: 22px;
:deep(.el-input),
:deep(.el-select) {
width: 100%;
}
.el-input__wrapper {
min-height: 32px;
}
:deep(.el-input__wrapper),
:deep(.el-select__wrapper) {
min-height: 36px;
}
:deep(.el-select__selected-item),
:deep(.el-input__inner) {
white-space: normal;
word-break: break-word;
}
}
}
@@ -1,108 +1,123 @@
<template>
<div class="task-output-list">
<!-- 工具栏 -->
<div class="panel-top">
<h2 class="title-bar-heading">产出(细胞产品)</h2>
<el-button
v-if="!disabled"
class="btn-main task-exec-toolbar-btn"
type="primary"
@click="handleAdd"
>
<el-icon><Plus /></el-icon>
新增产出
</el-button>
<div class="route-config-tab-list">
<div v-if="!disabled" class="list-toolbar">
<div class="list-toolbar-row">
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增产出
</el-button>
</div>
</div>
<div class="list-body list-wrap task-output-cards-body">
<div v-if="items.length" v-loading="loading" class="task-output-cards-wrap">
<article
v-for="(row, index) in items"
:key="outputCardKey(row, index)"
class="task-output-card"
>
<header class="task-output-card__head">
<div class="task-output-card__title-row">
<span class="task-output-card__index">{{ index + 1 }}</span>
<span class="task-output-card__code" :title="displayText(row.productCode)">
{{ displayText(row.productCode) }}
</span>
<el-tag :type="statusTagType(row.productStatus)" size="small">
{{ statusLabel(row.productStatus) }}
</el-tag>
</div>
<div v-if="!disabled" class="task-output-card__ops">
<el-button type="primary" link @click="handleEdit(row, index)">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button type="danger" link @click="handleDelete(index)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</div>
</header>
<dl class="task-output-card__meta">
<div class="task-output-card__meta-item">
<dt>产品名称</dt>
<dd :title="displayText(row.productName)">{{ displayText(row.productName) }}</dd>
</div>
<div class="task-output-card__meta-item">
<dt>产品类型</dt>
<dd :title="displayText(row.productTypeName)">
{{ displayText(row.productTypeName) }}
</dd>
</div>
<div class="task-output-card__meta-item">
<dt>规格</dt>
<dd :title="displayText(row.specName)">{{ displayText(row.specName) }}</dd>
</div>
<div class="task-output-card__meta-item">
<dt>代次</dt>
<dd :title="displayText(row.passage)">{{ displayText(row.passage) }}</dd>
</div>
<div class="task-output-card__meta-item">
<dt>批次号</dt>
<dd :title="displayText(row.batchNo)">{{ displayText(row.batchNo) }}</dd>
</div>
<div class="task-output-card__meta-item">
<dt>储物格位置</dt>
<dd :title="displayText(row.storageSlotName)">
{{ displayText(row.storageSlotName) }}
</dd>
</div>
<div class="task-output-card__meta-item">
<dt>预计细胞数量(亿)</dt>
<dd>{{ row.expectedCellCount ?? '—' }}</dd>
</div>
<div class="task-output-card__meta-item">
<dt>活率(%)</dt>
<dd>{{ row.viability != null ? row.viability : '—' }}</dd>
</div>
<div class="task-output-card__meta-item">
<dt>冻存日期</dt>
<dd>{{ formatDateOnly(row.freezeDate) }}</dd>
</div>
<div class="task-output-card__meta-item">
<dt>有效期</dt>
<dd>{{ formatDateOnly(row.expiryDate) }}</dd>
</div>
<div class="task-output-card__meta-item">
<dt>数量</dt>
<dd>{{ row.quantity ?? '—' }}</dd>
</div>
</dl>
</article>
</div>
<el-empty v-else description="暂无产出产品" :image-size="48" />
</div>
</div>
<!-- 列表 -->
<el-table
:data="items"
class="list-table"
stripe
border
height="420"
v-loading="loading"
empty-text="暂无产出产品"
>
<el-table-column type="index" label="序号" min-width="56" align="center" />
<el-table-column label="产品编码" prop="productCode" min-width="140" show-overflow-tooltip />
<el-table-column label="产品名称" prop="productName" min-width="120" show-overflow-tooltip />
<el-table-column label="产品类型" min-width="100" show-overflow-tooltip>
<template #default="{ row }">
{{ row.productTypeName || '—' }}
</template>
</el-table-column>
<el-table-column label="规格" min-width="80" show-overflow-tooltip>
<template #default="{ row }">
{{ row.specName || '—' }}
</template>
</el-table-column>
<el-table-column label="代次" prop="passage" min-width="70" align="center" />
<el-table-column label="批次号" prop="batchNo" min-width="110" show-overflow-tooltip />
<el-table-column label="储物格位置" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
{{ row.storageSlotName || '—' }}
</template>
</el-table-column>
<el-table-column label="预计细胞数量(亿)" min-width="130" align="center">
<template #default="{ row }">
{{ row.expectedCellCount ?? '—' }}
</template>
</el-table-column>
<el-table-column label="活性" min-width="80" align="center">
<template #default="{ row }">
{{ row.viability != null ? row.viability : '—' }}
</template>
</el-table-column>
<el-table-column label="冻存日期" min-width="110" align="center">
<template #default="{ row }">
{{ formatDateOnly(row.freezeDate) }}
</template>
</el-table-column>
<el-table-column label="有效期" min-width="110" align="center">
<template #default="{ row }">
{{ formatDateOnly(row.expiryDate) }}
</template>
</el-table-column>
<el-table-column label="数量" min-width="80" align="center">
<template #default="{ row }">
{{ row.quantity ?? '—' }}
</template>
</el-table-column>
<el-table-column label="单位" prop="unit" min-width="70" align="center" />
<el-table-column label="状态" min-width="90" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.productStatus)" size="small">
{{ statusLabel(row.productStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column v-if="!disabled" label="操作" min-width="140" fixed="right" align="center">
<template #default="{ row, $index }">
<el-button type="primary" link size="small" @click="handleEdit(row, $index)">
编辑
</el-button>
<el-button type="danger" link size="small" @click="handleDelete($index)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="!disabled" class="list-save-footer">
<el-button
class="btn-main task-exec-toolbar-btn"
type="primary"
:loading="saving"
@click="emit('save')"
>
<el-icon><Check /></el-icon>
保存
</el-button>
</div>
<!-- 新增/编辑弹窗 -->
<TaskOutputProductDialog
v-model="dialogVisible"
:edit-item="editingItem"
:unit-options="unitOptions"
:unit-options-loading="unitOptionsLoading"
:occupied-slot-ids="dialogOccupiedSlotIds"
@confirm="handleDialogConfirm"
@search-unit="(q: string) => emit('searchUnit', q)"
/>
</div>
</template>
<script setup lang="ts">
import { Plus } from '@element-plus/icons-vue'
import { Plus, Check, Edit, Delete } from '@element-plus/icons-vue'
import TaskOutputProductDialog from './TaskOutputProductDialog.vue'
defineOptions({ name: 'TaskOutputList' })
@@ -114,8 +129,9 @@
unitOptions: { label: string; value: string }[]
unitOptionsLoading: boolean
disabled?: boolean
saving?: boolean
}>(),
{ disabled: false }
{ disabled: false, saving: false }
)
const emit = defineEmits<{
@@ -128,6 +144,25 @@
const editingItem = ref<Record<string, any> | null>(null)
const editingIndex = ref(-1)
/** 本任务已添加产出占用的储物格(编辑时排除当前条) */
const dialogOccupiedSlotIds = computed(() =>
props.items
.map((item, idx) => {
if (editingIndex.value >= 0 && idx === editingIndex.value) return ''
return String(item.storageSlotId || '').trim()
})
.filter(Boolean)
)
function outputCardKey(row: Record<string, any>, index: number) {
return row.id || `output_${row.productCode || index}`
}
function displayText(val: unknown) {
const s = val == null ? '' : String(val).trim()
return s || '—'
}
function statusTagType(status: string) {
const map: Record<string, string> = {
frozen: 'success',
@@ -208,12 +243,123 @@
</script>
<style lang="scss" scoped>
@use '../../../styles/route-config' as *;
.task-output-list {
.panel-top {
.route-config-tab-list {
overflow: visible;
}
.list-save-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
justify-content: center;
margin-top: 16px;
}
}
.task-output-cards-body {
border-top: none;
overflow: visible;
}
.task-output-cards-wrap {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
overflow: visible;
}
.task-output-card {
flex-shrink: 0;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
overflow: hidden;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
&:hover {
border-color: #a8d4c4;
box-shadow: 0 2px 8px rgba(4, 120, 87, 0.08);
}
}
.task-output-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
border-radius: 9px 9px 0 0;
background: #f8fafc;
}
.task-output-card__title-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
}
.task-output-card__index {
flex-shrink: 0;
font-size: 12px;
color: #94a3b8;
min-width: 20px;
}
.task-output-card__code {
font-size: 14px;
font-weight: 600;
color: #1e293b;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
word-break: break-all;
}
.task-output-card__ops {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.task-output-card__meta {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: start;
gap: 12px 14px;
margin: 0;
padding: 14px 16px;
}
.task-output-card__meta-item {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
dt {
margin: 0;
font-size: 11px;
line-height: 1.35;
color: #94a3b8;
white-space: nowrap;
}
dd {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: #334155;
word-break: break-word;
overflow-wrap: anywhere;
white-space: normal;
min-width: 0;
min-height: 22px;
}
}
</style>
@@ -2,9 +2,10 @@
<el-dialog
:model-value="modelValue"
:title="isEdit ? '编辑产出产品' : '新增产出产品'"
width="1080px"
width="1280px"
class="dialog-form-layout task-output-product-dialog"
align-center
modal-class="protocol-dialog-mask"
append-to-body
destroy-on-close
:close-on-click-modal="false"
@@ -20,7 +21,7 @@
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-3">
<div class="form-grid-4">
<!-- 产品编码 -->
<el-form-item label="产品编码" prop="productCode">
<el-input
@@ -56,8 +57,8 @@
/>
</el-select>
</el-form-item>
<!-- 规格 -->
<el-form-item label="规格">
<!-- 规格(编辑单条) -->
<el-form-item v-if="isEdit" label="规格">
<el-select
v-model="formData.specId"
placeholder="输入名称检索"
@@ -78,8 +79,8 @@
/>
</el-select>
</el-form-item>
<!-- 代次 -->
<el-form-item label="代次">
<!-- 代次(编辑单条) -->
<el-form-item v-if="isEdit" label="代次">
<el-select
v-model="formData.passage"
placeholder="请选择代次"
@@ -108,15 +109,15 @@
@visible-change="onStorageSlotSelectVisible"
>
<el-option
v-for="item in storageSlotSelectOptions"
v-for="item in editStorageSlotSelectOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<!-- 预计细胞数量 -->
<el-form-item label="预计细胞数量(亿)">
<!-- 预计细胞数量(编辑单条) -->
<el-form-item v-if="isEdit" label="预计细胞数量(亿)">
<el-input-number
v-model="formData.expectedCellCount"
:min="0"
@@ -125,8 +126,8 @@
style="width: 100%"
/>
</el-form-item>
<!-- 活性 -->
<el-form-item label="活性(%)">
<!-- 活率(编辑单条) -->
<el-form-item v-if="isEdit" label="活率(%)">
<el-input-number
v-model="formData.viability"
:min="0"
@@ -144,6 +145,18 @@
placeholder="请选择冻存日期"
value-format="YYYY-MM-DD"
style="width: 100%"
@change="onFreezeDateChange"
/>
</el-form-item>
<!-- 有效期(冻存日期 + 20 年) -->
<el-form-item label="有效期">
<el-date-picker
v-model="formData.expiryDate"
type="date"
placeholder="请先选择冻存日期"
value-format="YYYY-MM-DD"
style="width: 100%"
disabled
/>
</el-form-item>
<!-- 入库份数 / 数量 -->
@@ -158,38 +171,6 @@
@change="onCreateCountChange"
/>
</el-form-item>
<!-- 单位 -->
<el-form-item label="单位">
<el-select
v-model="formData.unit"
placeholder="请选择单位"
clearable
filterable
remote
reserve-keyword
style="width: 100%"
:loading="unitOptionsLoading"
:remote-method="(q: string) => emit('searchUnit', q)"
@visible-change="(visible: boolean) => visible && emit('searchUnit', '')"
>
<el-option
v-for="item in unitOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<!-- 有效期 -->
<el-form-item label="有效期">
<el-date-picker
v-model="formData.expiryDate"
type="date"
placeholder="请选择有效期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<!-- 状态(编辑单条,对齐细胞产品库) -->
<el-form-item v-if="isEdit" label="状态">
<el-select
@@ -205,91 +186,94 @@
/>
</el-select>
</el-form-item>
<!-- 产品图片 (full width) -->
<el-form-item label="产品图片" class="form-col-span-2">
<div class="attachment-upload">
<div class="attachment-list">
<el-upload
ref="productImageUploadRef"
:auto-upload="false"
:show-file-list="false"
:multiple="true"
accept="image/*"
@change="handleProductImageChange"
>
<div class="upload-card">
<el-icon :size="28" color="#047857"><Plus /></el-icon>
<span class="upload-card-text">上传图片</span>
</div>
</el-upload>
<div
v-for="(file, index) in productImageFiles"
:key="file.id || index"
class="attachment-item"
:class="{ 'is-uploading': file._uploading }"
>
<div
class="attachment-preview"
@click="
file.fileUrl && !file._uploading ? handlePreviewProductImage(file) : undefined
"
>
<template v-if="file.fileUrl">
<img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
</template>
<template v-else>
<el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
</template>
</div>
<div class="attachment-footer">
<span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
<div class="attachment-actions">
<el-icon
class="action-btn"
title="下载"
@click="handleDownloadProductImage(file)"
>
<Download />
</el-icon>
<el-icon
class="action-btn action-btn--danger"
title="删除"
@click="removeProductImage(index)"
>
<Delete />
</el-icon>
</div>
</div>
</div>
</div>
<span class="upload-hint">支持图片上传</span>
</div>
</el-form-item>
<!-- 备注 (full width) -->
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="可选" />
<el-input v-model="formData.remark" type="textarea" :rows="2" placeholder="可选" />
</el-form-item>
<!-- 入库明细(新增多份) -->
<div v-if="!isEdit" class="product-lines-section">
<div class="product-lines-head">
<span class="product-lines-title">入库明细</span>
<span class="form-label-tip"
>每份填写编码与储物格;多份时编码为「产品编码-01、-02…」</span
>请先选代次,编码自动生成「产品编码-代次-01、-02…」;同代次多份序号递增</span
>
</div>
<el-table :data="productLines" size="small" class="product-lines-table">
<el-table-column type="index" label="序号" min-width="64" align="center" />
<el-table-column label="产品编码" min-width="200">
<el-table
:data="productLines"
class="list-table product-lines-table"
max-height="240"
>
<el-table-column type="index" label="序号" min-width="56" align="center" />
<el-table-column label="产品编码" min-width="180">
<template #default="{ row }">
<el-input
v-model="row.productCode"
placeholder="产品编码"
@input="onLineProductCodeInput"
placeholder="请先选择代次"
readonly
/>
</template>
</el-table-column>
<el-table-column label="储物格位置" min-width="280">
<el-table-column label="代次" min-width="100">
<template #default="{ row }">
<el-select
v-model="row.passage"
placeholder="代次"
clearable
style="width: 100%"
@change="syncLineCodesFromBase"
>
<el-option v-for="p in passageOptions" :key="p" :label="p" :value="p" />
</el-select>
</template>
</el-table-column>
<el-table-column label="规格" min-width="140">
<template #default="{ row }">
<el-select
v-model="row.specId"
placeholder="规格"
clearable
filterable
remote
reserve-keyword
style="width: 100%"
:loading="specOptionsLoading"
:remote-method="remoteSearchSpecs"
@visible-change="onSpecSelectVisible"
>
<el-option
v-for="item in specOptions"
:key="item.id"
:label="item.specName"
:value="item.id"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="预计细胞数量(亿)" min-width="150">
<template #default="{ row }">
<el-input-number
v-model="row.expectedCellCount"
:min="0"
:precision="4"
controls-position="right"
style="width: 100%"
/>
</template>
</el-table-column>
<el-table-column label="活率(%)" min-width="120">
<template #default="{ row }">
<el-input-number
v-model="row.viability"
:min="0"
:max="100"
:precision="1"
controls-position="right"
style="width: 100%"
/>
</template>
</el-table-column>
<el-table-column label="储物格位置" min-width="200">
<template #default="{ row, $index }">
<el-select
v-model="row.storageSlotId"
placeholder="输入编号检索"
@@ -303,7 +287,7 @@
@visible-change="onStorageSlotSelectVisible"
>
<el-option
v-for="item in storageSlotSelectOptions"
v-for="item in getLineStorageSlotOptions($index)"
:key="item.id"
:label="item.name"
:value="item.id"
@@ -326,62 +310,40 @@
</el-button>
</template>
</el-dialog>
<!-- 图片预览弹窗 -->
<el-dialog
v-model="previewVisible"
title="图片预览"
width="800px"
align-center
append-to-body
destroy-on-close
>
<div class="preview-container">
<img
:src="previewUrl"
alt="预览"
style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
/>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { Plus, Close, Check, Delete, Download, Loading } from '@element-plus/icons-vue'
import { Close, Check } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { fetchUploadAttachment } from '@/api/business'
import { fetchNextCellProductCode } from '@/api/cell-product'
import { fetchCellProductTypeList, fetchCellProductSpecList } from '@/api/admin'
import {
remoteSearchStorageSlotSelectOptions,
ensureStorageSlotsInOptions,
filterStorageSlotSelectOptions,
validateUniqueStorageSlotIds
} from '@/utils/storage-slot-options'
import { downloadAttachmentFile } from '@/utils/download-attachment'
defineOptions({ name: 'TaskOutputProductDialog' })
type ProductLine = { productCode: string; storageSlotId: string }
type ProductImageFile = {
id?: string
fileName: string
fileUrl: string
fileType?: string
_uploading?: boolean
type ProductLine = {
productCode: string
storageSlotId: string
passage: string
specId: string
expectedCellCount: number | undefined
viability: number | undefined
}
const props = defineProps<{
modelValue: boolean
editItem?: Record<string, any> | null
unitOptions: { label: string; value: string }[]
unitOptionsLoading: boolean
/** 本任务其他产出已占用的储物格(编辑时父级已排除当前条) */
occupiedSlotIds?: string[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
confirm: [data: Record<string, any> | Record<string, any>[]]
searchUnit: [query: string]
}>()
const passageOptions = ['P1', 'P2', 'P3', 'P4', 'P5', 'P6', 'P7', 'P8']
@@ -395,14 +357,6 @@
{ label: '已复苏', value: 'thawed' }
]
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
function fullFileUrl(suffix: string) {
if (!suffix) return ''
if (suffix.startsWith('http://') || suffix.startsWith('https://') || suffix.startsWith('/'))
return suffix
return `${minioBaseUrl}/${suffix}`
}
const isEdit = computed(() => !!props.editItem)
const productTypeOptions = ref<Array<{ id: string; productTypeName: string }>>([])
@@ -494,13 +448,31 @@
return [{ id: formData.storageSlotId, name: String(label) }, ...base]
})
const editStorageSlotSelectOptions = computed(() =>
filterStorageSlotSelectOptions(
storageSlotSelectOptions.value,
props.occupiedSlotIds || [],
formData.storageSlotId
)
)
function getOtherLineStorageSlotIds(lineIndex: number) {
return productLines.value
.map((line, idx) => (idx === lineIndex ? '' : line.storageSlotId))
.filter(Boolean)
}
function getLineStorageSlotOptions(lineIndex: number) {
return filterStorageSlotSelectOptions(
storageSlotSelectOptions.value,
[...(props.occupiedSlotIds || []), ...getOtherLineStorageSlotIds(lineIndex)],
productLines.value[lineIndex]?.storageSlotId
)
}
const formRef = ref()
const submitLoading = ref(false)
const productImageUploadRef = ref()
const previewVisible = ref(false)
const previewUrl = ref('')
const productImageFiles = ref<ProductImageFile[]>([])
const productLines = ref<ProductLine[]>([{ productCode: '', storageSlotId: '' }])
const productLines = ref<ProductLine[]>([createEmptyProductLine()])
const formData = reactive({
cellProductId: '',
@@ -515,10 +487,8 @@
viability: undefined as number | undefined,
freezeDate: '',
quantity: 1 as number,
unit: '',
expiryDate: '',
productStatus: 'frozen',
productImages: '',
remark: ''
})
@@ -534,6 +504,36 @@
return s.length >= 10 ? s.slice(0, 10) : s
}
function calcExpiryFromFreeze(freezeDate: string): string {
if (!freezeDate) return ''
const d = new Date(`${freezeDate}T00:00:00`)
if (Number.isNaN(d.getTime())) return ''
d.setFullYear(d.getFullYear() + 20)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function onFreezeDateChange(val: string | null) {
formData.expiryDate = calcExpiryFromFreeze(val || '')
}
function createEmptyProductLine(): ProductLine {
return {
productCode: '',
storageSlotId: '',
passage: '',
specId: '',
expectedCellCount: undefined,
viability: undefined
}
}
function resolveSpecName(specId: string) {
return specOptions.value.find((o) => o.id === specId)?.specName || ''
}
function populateFormFromEditItem(item: Record<string, any>) {
formData.cellProductId = item.cellProductId || ''
formData.productCode = item.productCode || item.outputBarcode || ''
@@ -547,19 +547,16 @@
formData.viability = item.viability ?? undefined
formData.freezeDate = normalizeDateValue(item.freezeDate)
formData.quantity = item.quantity ?? 1
formData.unit = item.unit || ''
formData.expiryDate = normalizeDateValue(item.expiryDate)
formData.expiryDate =
normalizeDateValue(item.expiryDate) || calcExpiryFromFreeze(formData.freezeDate)
formData.productStatus = item.productStatus || 'frozen'
formData.productImages = item.productImages || ''
formData.remark = item.remark || ''
syncFormToImages(item.productImages || '')
}
async function initAddForm() {
resetForm()
formData.quantity = 1
storageSlotSearchOpts.value = {}
emit('searchUnit', '')
await Promise.all([
remoteSearchProductTypes(''),
remoteSearchSpecs(''),
@@ -567,18 +564,10 @@
])
await nextTick()
formRef.value?.clearValidate?.()
try {
const res = await fetchNextCellProductCode()
if (res?.code) {
formData.productCode = res.code
}
} catch {
/* 自动生成失败时用户可手动输入 */
}
applyProductCodeToLines()
}
/** 按入库份数将主表单产品编码同步到入库明细(每份均带 -01、-02…,单份时为 -01) */
/** 按入库份数同步明细行,并按「编码-代次-序号」重算产品编码 */
function applyProductCodeToLines() {
syncProductLines()
syncLineCodesFromBase()
@@ -588,7 +577,6 @@
if (props.editItem) {
populateFormFromEditItem(props.editItem)
void hydrateLookupOptionsFromEdit(props.editItem)
emit('searchUnit', props.editItem.unit || '')
nextTick(() => formRef.value?.clearValidate?.())
} else {
void initAddForm()
@@ -601,22 +589,18 @@
return Math.min(n, 50)
}
function stripProductCodeSuffix(code: string) {
const c = (code || '').trim()
const m = c.match(/^(.+)-(\d{2})$/)
return m ? m[1].trim() : c
}
function buildLineProductCode(base: string, index: number) {
const b = stripProductCodeSuffix(base)
if (!b) return ''
return `${b}-${String(index + 1).padStart(2, '0')}`
}
function syncLineCodesFromBase() {
const base = formData.productCode
productLines.value.forEach((line, index) => {
line.productCode = buildLineProductCode(base, index)
const base = formData.productCode.trim()
const passageCounters = new Map<string, number>()
productLines.value.forEach((line) => {
const passage = (line.passage || '').trim()
if (!base || !passage) {
line.productCode = ''
return
}
const seq = (passageCounters.get(passage) || 0) + 1
passageCounters.set(passage, seq)
line.productCode = `${base}-${passage}-${String(seq).padStart(2, '0')}`
})
}
@@ -626,11 +610,15 @@
const lines: ProductLine[] = []
for (let i = 0; i < count; i++) {
lines.push({
productCode: buildLineProductCode(formData.productCode, i),
storageSlotId: prev[i]?.storageSlotId || ''
productCode: prev[i]?.productCode || '',
storageSlotId: prev[i]?.storageSlotId || '',
passage: prev[i]?.passage || '',
specId: prev[i]?.specId || '',
expectedCellCount: prev[i]?.expectedCellCount,
viability: prev[i]?.viability
})
}
productLines.value = lines.length ? lines : [{ productCode: '', storageSlotId: '' }]
productLines.value = lines.length ? lines : [createEmptyProductLine()]
}
function onCreateCountChange() {
@@ -654,21 +642,45 @@
}
)
function onLineProductCodeInput() {
if (!isEdit.value && createQuantityValue() <= 1) {
formData.productCode = stripProductCodeSuffix(productLines.value[0]?.productCode || '')
}
}
/** 其他行选中某格后,若本行已选同一格则清空 */
watch(
() => productLines.value.map((line) => line.storageSlotId),
(slotIds) => {
if (isEdit.value) return
const seen = new Set<string>()
slotIds.forEach((slotId, idx) => {
const id = String(slotId || '').trim()
if (!id) return
if (seen.has(id)) {
productLines.value[idx].storageSlotId = ''
} else {
seen.add(id)
}
})
},
{ deep: true }
)
async function validateProductLines(): Promise<boolean> {
if (isEdit.value) return true
const count = createQuantityValue()
const codeSeen = new Set<string>()
for (let i = 0; i < productLines.value.length; i++) {
const line = productLines.value[i]
if (!line.productCode?.trim()) {
ElMessage.warning(`请填写第 ${i + 1} 份产品编码`)
if (!line.passage?.trim()) {
ElMessage.warning(`请选择第 ${i + 1} 份产品的代次`)
return false
}
if (!line.productCode?.trim()) {
ElMessage.warning(`第 ${i + 1} 份产品编码未生成,请确认已填写产品编码并选择代次`)
return false
}
const code = line.productCode.trim()
if (codeSeen.has(code)) {
ElMessage.warning(`产品编码 ${code} 重复,请调整代次或入库份数`)
return false
}
codeSeen.add(code)
if (!line.storageSlotId) {
ElMessage.warning(`请选择第 ${i + 1} 份产品的储物格位置`)
return false
@@ -689,24 +701,25 @@
function buildSharedResult(): Record<string, any> {
const productType = productTypeOptions.value.find((o) => o.id === formData.productTypeId)
const spec = specOptions.value.find((o) => o.id === formData.specId)
return {
const shared: Record<string, any> = {
productName: formData.productName.trim(),
productTypeId: formData.productTypeId || undefined,
productTypeName: productType?.productTypeName || props.editItem?.productTypeName || '',
specId: formData.specId || undefined,
specName: spec?.specName || props.editItem?.specName || '',
passage: formData.passage || undefined,
batchNo: formData.batchNo.trim(),
expectedCellCount: formData.expectedCellCount ?? undefined,
viability: formData.viability ?? undefined,
freezeDate: formData.freezeDate || undefined,
unit: formData.unit?.trim() || undefined,
expiryDate: formData.expiryDate || undefined,
productImages: formData.productImages?.trim() || undefined,
productStatus: formData.productStatus || 'frozen',
remark: formData.remark?.trim() || undefined
}
if (isEdit.value) {
const spec = specOptions.value.find((o) => o.id === formData.specId)
shared.specId = formData.specId || undefined
shared.specName = spec?.specName || props.editItem?.specName || ''
shared.passage = formData.passage || undefined
shared.expectedCellCount = formData.expectedCellCount ?? undefined
shared.viability = formData.viability ?? undefined
}
return shared
}
function resolveSlotName(slotId: string) {
@@ -742,100 +755,15 @@
formData.viability = undefined
formData.freezeDate = ''
formData.quantity = 1
formData.unit = ''
formData.expiryDate = ''
formData.productStatus = 'frozen'
formData.productImages = ''
formData.remark = ''
productImageFiles.value = []
productLines.value = [{ productCode: '', storageSlotId: '' }]
}
function fileNameFromUrl(url: string, index: number) {
const segment = url.split('/').pop() || ''
const base = decodeURIComponent(segment.split('?')[0] || '')
return base || `图片${index + 1}`
}
function syncImagesToForm() {
formData.productImages = productImageFiles.value
.filter((f) => f.fileUrl && !f._uploading)
.map((f) => f.fileUrl)
.join(',')
}
function syncFormToImages(imagesStr: string) {
productImageFiles.value = imagesStr
? imagesStr
.split(',')
.map((s, i) => {
const url = s.trim()
if (!url) return null
return {
id: `existing_${i}`,
fileName: fileNameFromUrl(url, i),
fileUrl: url,
fileType: 'image/'
} as ProductImageFile
})
.filter((f): f is ProductImageFile => Boolean(f))
: []
}
async function handleProductImageChange(uploadFile: any) {
const file = uploadFile.raw || uploadFile
if (!file) return
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
productImageFiles.value.push({
id: tempId,
fileName: file.name,
fileUrl: '',
fileType: file.type || 'image/',
_uploading: true
})
const idx = productImageFiles.value.length - 1
try {
const fd = new FormData()
fd.append('folder', 'cell-product')
fd.append('file', file)
const res = await fetchUploadAttachment(fd)
productImageFiles.value[idx] = {
...productImageFiles.value[idx],
id: res.id || tempId,
fileName: file.name || res.fileName || fileNameFromUrl(res.fileUrl, idx),
fileUrl: res.fileUrl,
fileType: res.fileType || file.type || 'image/',
_uploading: false
}
syncImagesToForm()
} catch {
ElMessage.error('图片上传失败')
productImageFiles.value.splice(idx, 1)
} finally {
nextTick(() => productImageUploadRef.value?.clearFiles())
}
}
function removeProductImage(index: number) {
productImageFiles.value.splice(index, 1)
syncImagesToForm()
}
function handlePreviewProductImage(file: ProductImageFile) {
if (file._uploading || !file.fileUrl) return
previewUrl.value = fullFileUrl(file.fileUrl)
previewVisible.value = true
}
function handleDownloadProductImage(file: ProductImageFile) {
downloadAttachmentFile(file, { fullFileUrl })
productLines.value = [createEmptyProductLine()]
}
async function handleSubmit() {
if (!(await validateProductLines())) return
await formRef.value?.validate?.()
syncImagesToForm()
submitLoading.value = true
try {
const shared = buildSharedResult()
@@ -857,6 +785,11 @@
productCode: line.productCode.trim(),
storageSlotId: line.storageSlotId,
storageSlotName: resolveSlotName(line.storageSlotId),
passage: line.passage || undefined,
specId: line.specId || undefined,
specName: resolveSpecName(line.specId),
expectedCellCount: line.expectedCellCount ?? undefined,
viability: line.viability ?? undefined,
quantity: 1
}))
emit('confirm', results)
@@ -869,162 +802,19 @@
</script>
<style scoped lang="scss">
.task-output-product-dialog {
.task-output-product-dialog.dialog-form-layout {
:deep(.el-dialog__body) {
max-height: calc(100vh - 200px);
overflow-y: auto;
padding-top: 14px;
padding-bottom: 14px;
}
}
.attachment-upload {
width: 100%;
}
.upload-card {
width: 140px;
height: 96px;
border: 1px dashed #a8d4c4;
border-radius: 8px;
background: #f0fdf4;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
transition:
border-color 0.15s,
background 0.15s;
&:hover {
border-color: #047857;
border-style: solid;
background: #e8f4ee;
.task-output-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.upload-card-text {
font-size: 12px;
color: #047857;
font-weight: 500;
line-height: 1;
}
.upload-hint {
display: block;
font-size: 11px;
color: #94a3b8;
line-height: 1;
margin-top: 8px;
}
.attachment-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.attachment-item {
width: 140px;
display: flex;
flex-direction: column;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
overflow: hidden;
transition:
border-color 0.15s,
box-shadow 0.15s;
&:hover {
border-color: #047857;
box-shadow: 0 2px 8px rgba(4, 120, 87, 0.1);
}
&.is-uploading {
opacity: 0.6;
pointer-events: none;
}
}
.attachment-preview {
width: 100%;
height: 96px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: #f8fafc;
overflow: hidden;
cursor: pointer;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.attachment-footer {
display: flex;
flex-direction: column;
flex: 1;
padding: 6px 8px;
}
.attachment-name {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
font-size: 11px;
color: #334155;
line-height: 1.4;
word-break: break-all;
min-height: calc(11px * 1.4 * 2);
}
.attachment-actions {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
margin-top: 4px;
flex-shrink: 0;
}
.action-btn {
width: 22px;
height: 22px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #64748b;
cursor: pointer;
background: #f1f5f9;
transition:
background 0.15s,
color 0.15s;
&:hover {
background: #047857;
color: #fff;
}
&--danger:hover {
background: #ef4444;
color: #fff;
}
}
.preview-container {
text-align: center;
}
.product-lines-section {
grid-column: 1 / -1;
margin-top: 4px;
@@ -1048,3 +838,9 @@
width: 100%;
}
</style>
<style lang="scss">
.el-dialog.task-output-product-dialog.dialog-form-layout {
max-height: min(760px, calc(100vh - 120px));
}
</style>
@@ -0,0 +1,95 @@
<template>
<div class="task-paper-attachments">
<template v-if="disabled">
<ProtocolAttachmentReadonly
:files="readonlyFiles"
@preview="handlePreview"
@download="handleDownload"
/>
</template>
<template v-else>
<ProtocolFinanceConfirmFields
finance-remark=""
:attachments="attachments"
:show-remark="false"
attachment-label="线下纸质文档"
upload-folder="production-task-paper"
@update:attachments="emit('update:attachments', $event)"
/>
</template>
<div v-if="!disabled" class="list-save-footer">
<el-button
class="btn-main task-exec-toolbar-btn"
type="primary"
:loading="saving"
@click="emit('save')"
>
<el-icon><Check /></el-icon>
保存
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { Check } from '@element-plus/icons-vue'
import ProtocolFinanceConfirmFields from '@/components/business/protocol-finance-confirm-fields/index.vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import { downloadAttachmentFile } from '@/utils/download-attachment'
import type { FinanceAttachmentItem } from '@/components/business/protocol-finance-confirm-fields/finance-attachments'
defineOptions({ name: 'TaskPaperAttachmentsPanel' })
const props = withDefaults(
defineProps<{
attachments: FinanceAttachmentItem[]
disabled?: boolean
saving?: boolean
}>(),
{ disabled: false, saving: false }
)
const emit = defineEmits<{
'update:attachments': [items: FinanceAttachmentItem[]]
save: []
}>()
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
function fullFileUrl(url: string) {
if (!url) return ''
if (/^https?:\/\//i.test(url)) return url
return minioBaseUrl ? `${minioBaseUrl}/${url.replace(/^\//, '')}` : url
}
const readonlyFiles = computed(() =>
props.attachments
.filter((f) => f.fileUrl)
.map((f) => ({
id: f.id,
fileName: f.fileName,
fileUrl: f.fileUrl,
fileType: f.fileType
}))
)
function handlePreview(file: Record<string, any>) {
if (!file.fileUrl) return
window.open(fullFileUrl(String(file.fileUrl)), '_blank')
}
function handleDownload(file: Record<string, any>) {
downloadAttachmentFile(file, { fullFileUrl })
}
</script>
<style lang="scss" scoped>
.task-paper-attachments {
.list-save-footer {
display: flex;
justify-content: center;
margin-top: 16px;
}
}
</style>
@@ -273,7 +273,7 @@
}
function isCurrentAssignee(row: Record<string, any>) {
const uid = String(userStore.info?.userId || '')
const uid = String(userStore.info?.id || userStore.info?.userId || '').trim()
return uid && String(row.currentAssigneeUserId || '') === uid
}
@@ -204,6 +204,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -277,7 +279,6 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
@@ -392,8 +393,6 @@
defineOptions({ name: 'ProductionProductionWorkOrder' })
const router = useRouter()
type WorkOrderTypeTab = 'inbound' | 'temporary' | 'outbound'
const WORK_ORDER_TYPE_TABS = [
@@ -970,23 +969,9 @@
loadData()
}
function handleDispatchQcSuccess(payload: { sampleCode?: string }) {
function handleDispatchQcSuccess() {
taskSubListRefreshToken.value += 1
loadData()
if (payload.sampleCode) {
ElMessageBox.confirm('是否前往质检工单列表查看?', '提示', {
type: 'info',
confirmButtonText: '前往',
cancelButtonText: '留在此页'
})
.then(() => {
router.push({
path: '/quality-manage/qc-work-order',
query: { sampleCode: payload.sampleCode }
})
})
.catch(() => {})
}
}
async function handleCompleteSingle(row: ProductionWorkOrderItem) {
@@ -11,6 +11,7 @@ import {
fetchReplaceStepDataCollection,
fetchReplaceStepOutputs,
fetchUpdateProductionTaskStatus,
fetchUpdateProductionTask,
fetchSetTaskInputs,
fetchSetTaskOutputs
} from '@/api/production-task'
@@ -19,18 +20,35 @@ import {
fetchAllProcessCategories,
fetchAllSopDocuments,
fetchAllOutputTypes,
fetchAllEquipments,
fetchReagentList,
fetchConsumableList
} from '@/api/production'
import { fetchItemUnitList, fetchSampleCodeList } from '@/api/admin'
import { fetchProductionWorkOrderDetail } from '@/api/production-work-order'
import { fetchSampleReceptionReceivedList } from '@/api/sample-reception'
import { equipmentOptionLabel, parseEquipmentIds, syncEquipmentNamesFromOptions } from '@/utils/equipment-select'
import { PROCESS_ROUTE_DEFAULT_SUB_TAB } from '@/utils/process-material-ui'
import { fetchCellProductList } from '@/api/cell-product'
import {
fetchProductionMaterialRequisitionOptions,
fetchProductionMaterialOptions,
type ProductionMaterialRequisitionOption
} from '@/api/production-material'
import type { FinanceAttachmentItem } from '@/components/business/protocol-finance-confirm-fields/finance-attachments'
import { financeAttachmentsToUrlString } from '@/components/business/protocol-finance-confirm-fields/finance-attachments'
// ── helpers ──────────────────────────────────────────────────────────
function parseTaskAttachmentsFromUrls(urls: string): FinanceAttachmentItem[] {
return String(urls || '')
.split(',')
.map((u) => u.trim())
.filter(Boolean)
.map((url, i) => {
const name = url.includes('/') ? url.split('/').pop() || `文档${i + 1}` : url
return { id: `url_${i}`, fileUrl: url, fileName: name }
})
}
function toApiFlag(val: boolean | number | string | undefined): string {
if (val === true || val === 1 || val === '1') return '1'
return '0'
@@ -41,6 +59,7 @@ function fromApiFlag(val: boolean | number | string | undefined): boolean {
function normalizeStepSubTables(step: Record<string, any>) {
return {
...step,
equipmentIds: parseEquipmentIds(step.equipmentIds || step.equipmentName),
reagents: (step.reagents || []).map((r: Record<string, any>) => ({
...r,
isKeyMaterial: fromApiFlag(r.isKeyMaterial)
@@ -69,7 +88,7 @@ export function useTaskStepEditor() {
const stepList = ref<Record<string, any>[]>([])
const selectedStepId = ref('')
const stepListLoading = ref(false)
const activeSubTab = ref('reagent')
const activeSubTab = ref(PROCESS_ROUTE_DEFAULT_SUB_TAB)
const processSearchKeyword = ref('')
const configSuppressSave = ref(false)
@@ -80,13 +99,16 @@ export function useTaskStepEditor() {
const taskOutputs = ref<Record<string, any>[]>([])
const inputSaving = ref(false)
const outputSaving = ref(false)
const selectedVirtualStep = ref<'input' | 'output' | null>(null)
const attachmentSaving = ref(false)
const taskAttachments = ref<FinanceAttachmentItem[]>([])
const selectedVirtualStep = ref<'input' | 'output' | 'paper' | null>(null)
const processLibraryExpanded = ref(false)
const processLibrary = ref<Record<string, any>[]>([])
const expandedCategories = ref<string[]>([])
const categoryOptions = ref<Record<string, any>[]>([])
const sopOptions = ref<Record<string, any>[]>([])
const equipmentOptions = ref<Record<string, any>[]>([])
const outputTypeOptions = ref<Record<string, any>[]>([])
const itemUnitOptions = ref<{ label: string; value: string }[]>([])
@@ -442,14 +464,26 @@ export function useTaskStepEditor() {
}
}
function ensureSampleCodesInOptions(samples: Array<{ inputId?: string; inputCode?: string }>) {
function mergeSampleCodeOption(item: Api.BizSampleCode.SampleCodeItem) {
const code = (item.sampleCode || '').trim()
if (!code) return
const merged = [...sampleCodeOptions.value]
const idx = merged.findIndex((x) => x.sampleCode === code || (item.id && x.id === item.id))
if (idx >= 0) {
merged[idx] = { ...merged[idx], ...item, sampleCode: code }
} else {
merged.push(item)
}
sampleCodeOptions.value = merged
}
function ensureSampleCodesInOptions(samples: Array<{ inputId?: string; inputCode?: string }>) {
for (const s of samples) {
const code = (s.inputCode || '').trim()
const id = (s.inputId || '').trim()
if (!code) continue
if (merged.some((x) => x.sampleCode === code || (id && x.id === id))) continue
merged.push({
if (sampleCodeOptions.value.some((x) => x.sampleCode === code || (id && x.id === id))) continue
mergeSampleCodeOption({
id: id || code,
sampleCode: code,
sampleCodeType: '',
@@ -465,7 +499,85 @@ export function useTaskStepEditor() {
updateTime: ''
})
}
sampleCodeOptions.value = merged
}
function resolveLabStorageCodeFromOptions(sampleCode: string) {
const code = (sampleCode || '').trim()
if (!code) return ''
const taskSampleCode = (taskDetail.value?.sampleCode || '').trim()
if (code === taskSampleCode && (taskDetail.value?.labStorageCode || '').trim()) {
return String(taskDetail.value.labStorageCode).trim()
}
const sc = sampleCodeOptions.value.find((x) => (x.sampleCode || '').trim() === code)
return (sc?.labStorageCode || '').trim()
}
async function fetchLabStorageCodeBySampleCode(sampleCode: string) {
const code = (sampleCode || '').trim()
if (!code) return ''
try {
const res = await fetchSampleCodeList({ page: 1, pageSize: 20, keyword: code })
for (const item of (res.list || []) as Api.BizSampleCode.SampleCodeItem[]) {
mergeSampleCodeOption(item)
const sc = (item.sampleCode || '').trim()
const lc = (item.labStorageCode || '').trim()
if (sc === code && lc) return lc
}
} catch {
/* 样本编码池查询失败时继续尝试接收记录 */
}
try {
const recRes = await fetchSampleReceptionReceivedList({ page: 1, pageSize: 10, keyword: code })
for (const item of recRes.list || []) {
const sc = String(item.sampleCode || '').trim()
const lc = String(item.labStorageCode || '').trim()
if (sc === code && lc) return lc
}
} catch {
/* 接收记录查询失败不阻断 */
}
return ''
}
async function hydrateSampleLabStorageCodes() {
const sampleRows = taskInputs.value.filter(
(r) => r.inputType === 'sample' && (r.inputCode || '').trim()
)
const codesToFetch = new Set<string>()
for (const row of sampleRows) {
const code = (row.inputCode || '').trim()
if (!code) continue
const resolved = (row.labStorageCode || '').trim() || resolveLabStorageCodeFromOptions(code)
if (resolved) {
if (!(row.labStorageCode || '').trim()) row.labStorageCode = resolved
continue
}
codesToFetch.add(code)
}
const taskSampleCode = (taskDetail.value?.sampleCode || '').trim()
if (taskSampleCode && !(taskDetail.value?.labStorageCode || '').trim()) {
const fromOpts = resolveLabStorageCodeFromOptions(taskSampleCode)
if (!fromOpts) codesToFetch.add(taskSampleCode)
}
if (!codesToFetch.size) return
const codeMap: Record<string, string> = {}
await Promise.all(
[...codesToFetch].map(async (code) => {
const lc = await fetchLabStorageCodeBySampleCode(code)
if (lc) codeMap[code] = lc
})
)
if (taskSampleCode && codeMap[taskSampleCode] && taskDetail.value) {
taskDetail.value = { ...taskDetail.value, labStorageCode: codeMap[taskSampleCode] }
}
for (const row of sampleRows) {
const code = (row.inputCode || '').trim()
if (!code || (row.labStorageCode || '').trim()) continue
const resolved = codeMap[code] || resolveLabStorageCodeFromOptions(code)
if (resolved) row.labStorageCode = resolved
}
}
async function searchSampleCodeOptions(query: string) {
@@ -478,9 +590,11 @@ export function useTaskStepEditor() {
})
sampleCodeOptions.value = (res.list || []) as Api.BizSampleCode.SampleCodeItem[]
ensureSampleCodesInOptions(taskInputs.value.filter((r) => r.inputType === 'sample'))
await hydrateSampleLabStorageCodes()
} catch {
sampleCodeOptions.value = []
ensureSampleCodesInOptions(taskInputs.value.filter((r) => r.inputType === 'sample'))
await hydrateSampleLabStorageCodes()
} finally {
sampleCodeOptionsLoading.value = false
}
@@ -514,6 +628,12 @@ export function useTaskStepEditor() {
})
const virtualInputStatus = computed(() => (taskInputs.value.length > 0 ? 'filled' : 'empty'))
const virtualOutputStatus = computed(() => (taskOutputs.value.length > 0 ? 'filled' : 'empty'))
const virtualPaperStatus = computed(() =>
taskAttachments.value.some((f) => f.fileUrl && !f._uploading) ? 'filled' : 'empty'
)
const virtualPaperCount = computed(
() => taskAttachments.value.filter((f) => f.fileUrl && !f._uploading).length
)
const completedStepCount = computed(
() => stepList.value.filter((s) => s.stepStatus === 'ywc').length
)
@@ -554,15 +674,17 @@ export function useTaskStepEditor() {
})
async function loadConfigReferenceData() {
const [processes, categories, sops, outputTypes] = await Promise.all([
const [processes, categories, sops, outputTypes, equipments] = await Promise.all([
fetchAllProcesses(),
fetchAllProcessCategories(),
fetchAllSopDocuments(),
fetchAllOutputTypes()
fetchAllOutputTypes(),
fetchAllEquipments()
])
categoryOptions.value = categories || []
sopOptions.value = sops || []
outputTypeOptions.value = outputTypes || []
equipmentOptions.value = equipments || []
const catMap: Record<
string,
@@ -609,26 +731,64 @@ export function useTaskStepEditor() {
}
}
async function loadTaskDetail(overrideId?: string) {
const id = overrideId || taskId.value
if (!id) return
function extractTaskSteps(detail: Record<string, any> | null | undefined) {
if (!detail || typeof detail !== 'object') return []
const steps = detail.steps ?? detail.taskSteps ?? detail.stepList
return Array.isArray(steps) ? steps : []
}
function applyTaskDetailSteps(detail: Record<string, any>) {
stepList.value = extractTaskSteps(detail).map(normalizeStepSubTables)
try {
const detail = await fetchProductionTaskDetail(id)
stepList.value = (detail.steps || []).map(normalizeStepSubTables)
ensureAllStepReagentOptions()
ensureAllStepConsumableOptions()
ensureAllStepConfiguredReagentOptions()
ensureAllStepUnitOptions()
if (stepList.value.length > 0 && !selectedStepId.value) {
const firstPending = stepList.value.find((s) => s.stepStatus !== 'ywc')
selectedStepId.value = firstPending ? firstPending.id : stepList.value[0].id
} catch {
/* 步骤主数据已写入,选项补全失败不阻断展示 */
}
if (stepList.value.length > 0 && !selectedStepId.value) {
const firstPending = stepList.value.find((s) => s.stepStatus !== 'ywc')
selectedStepId.value = firstPending ? firstPending.id : stepList.value[0].id
}
}
async function loadTaskDetail(overrideId?: string, options?: { expectSteps?: boolean }) {
const id = overrideId || taskId.value
if (!id) return 0
try {
let detail = await fetchProductionTaskDetail(id)
let steps = extractTaskSteps(detail)
if (options?.expectSteps && steps.length === 0) {
for (const delayMs of [200, 400, 800]) {
await new Promise((resolve) => setTimeout(resolve, delayMs))
detail = await fetchProductionTaskDetail(id)
steps = extractTaskSteps(detail)
if (steps.length > 0) break
}
}
applyTaskDetailSteps(detail || {})
if (mode.value === 'execution') {
taskDetail.value = detail
taskInputs.value = (detail.inputs || []).map((i: Record<string, any>) => ({ ...i }))
let executionDetail = detail
if (!(executionDetail.labStorageCode || '').trim() && executionDetail.workOrderId) {
try {
const wo = await fetchProductionWorkOrderDetail(String(executionDetail.workOrderId))
const lc = String(
wo?.labStorageCode || (wo?.reception as Record<string, any> | undefined)?.labStorageCode || ''
).trim()
if (lc) {
executionDetail = { ...executionDetail, labStorageCode: lc }
}
} catch {
/* 工单接收信息查询失败不阻断任务执行 */
}
}
taskDetail.value = executionDetail
taskInputs.value = (executionDetail.inputs || []).map((i: Record<string, any>) => ({ ...i }))
taskOutputs.value = (detail.outputs || []).map((o: Record<string, any>) =>
enrichTaskOutputRow(o)
)
taskAttachments.value = parseTaskAttachmentsFromUrls(executionDetail.attachments || '')
await Promise.all([
searchReagentRequisitionOptions(''),
searchConsumableRequisitionOptions('')
@@ -646,9 +806,12 @@ export function useTaskStepEditor() {
}
}
ensureSampleCodesInOptions(taskInputs.value.filter((r) => r.inputType === 'sample'))
await hydrateSampleLabStorageCodes()
}
return stepList.value.length
} catch {
stepList.value = []
return 0
}
}
@@ -693,7 +856,7 @@ export function useTaskStepEditor() {
selectedStepId.value = step.id
selectedVirtualStep.value = null
}
function selectVirtualStep(type: 'input' | 'output') {
function selectVirtualStep(type: 'input' | 'output' | 'paper') {
selectedVirtualStep.value = type
selectedStepId.value = ''
}
@@ -739,7 +902,9 @@ export function useTaskStepEditor() {
process_name: selectedStep.value.processName,
category_id: selectedStep.value.categoryId,
description: selectedStep.value.description,
equipment_name: selectedStep.value.equipmentName,
equipment_ids: selectedStep.value.equipmentIds?.length
? selectedStep.value.equipmentIds
: null,
estimated_duration: selectedStep.value.estimatedDuration,
sop_ids: selectedStep.value.sopIds
},
@@ -749,6 +914,13 @@ export function useTaskStepEditor() {
/* ignore */
}
}
function onStepEquipmentChange(val: string[]) {
if (!selectedStep.value) return
selectedStep.value.equipmentIds = val || []
selectedStep.value.equipmentName = syncEquipmentNamesFromOptions(val, equipmentOptions.value)
saveStepBasic()
}
function onSopIdsChange(val: string[]) {
if (!selectedStep.value) return
selectedStep.value.sopIds = val.join(',')
@@ -1407,6 +1579,26 @@ export function useTaskStepEditor() {
}
}
async function saveTaskAttachments() {
if (!taskDetail.value?.id) return
if (taskAttachments.value.some((f) => f._uploading)) {
ElMessage.warning('文档正在上传,请稍候再保存')
return
}
attachmentSaving.value = true
try {
await fetchUpdateProductionTask(taskDetail.value.id, {
attachments: financeAttachmentsToUrlString(taskAttachments.value) || undefined
})
ElMessage.success('纸质文档已保存')
await loadTaskDetail()
} catch {
/* global error handling */
} finally {
attachmentSaving.value = false
}
}
function taskStatusTag(status: string) {
const map: Record<string, { label: string; type: string }> = {
dpz: { label: '待配置', type: 'info' },
@@ -1429,25 +1621,30 @@ export function useTaskStepEditor() {
options?: {
mode?: 'edit' | 'execution'
operateScope?: import('@/api/production-task').ProductionTaskOperateScope
expectSteps?: boolean
}
) {
): Promise<number> {
operateScope.value = options?.operateScope
mode.value = options?.mode || 'edit'
taskId.value = id
selectedStepId.value = ''
activeSubTab.value = 'reagent'
activeSubTab.value = PROCESS_ROUTE_DEFAULT_SUB_TAB
processSearchKeyword.value = ''
configSuppressSave.value = true
stepList.value = []
stepListLoading.value = true
let stepCount = 0
try {
await loadConfigReferenceData()
await loadTaskDetail()
// 先拉任务步骤,避免参考数据加载失败/过慢导致工序列表一直为空
stepCount = await loadTaskDetail(id, { expectSteps: options?.expectSteps })
void loadConfigReferenceData().catch(() => {
/* 工艺库参考数据失败不阻断步骤展示 */
})
} finally {
stepListLoading.value = false
await nextTick()
configSuppressSave.value = false
}
return stepCount
}
return {
@@ -1465,16 +1662,22 @@ export function useTaskStepEditor() {
taskOutputs,
inputSaving,
outputSaving,
attachmentSaving,
taskAttachments,
selectedVirtualStep,
processLibraryExpanded,
virtualInputStatus,
virtualOutputStatus,
virtualPaperStatus,
virtualPaperCount,
completedStepCount,
processLibrary,
filteredProcessLibrary,
expandedCategories,
categoryOptions,
sopOptions,
equipmentOptions,
equipmentOptionLabel,
outputTypeOptions,
itemUnitOptions,
itemUnitOptionsLoading,
@@ -1513,9 +1716,11 @@ export function useTaskStepEditor() {
addTaskOutputRow,
removeTaskOutputRow,
saveTaskOutputs,
saveTaskAttachments,
taskStatusTag,
stepStatusTag,
saveStepBasic,
onStepEquipmentChange,
onSopIdsChange,
searchItemUnitOptions,
searchReagentOptions,
@@ -444,6 +444,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -535,6 +537,8 @@
width="480px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -584,6 +588,8 @@
width="480px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -657,6 +663,8 @@
width="920px"
class="dialog-form-layout reagent-batch-stock-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -817,6 +825,8 @@
width="920px"
class="dialog-form-layout reagent-batch-stock-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -1007,6 +1017,8 @@
width="960px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -158,6 +158,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -273,6 +275,7 @@
title="文件预览"
width="800px"
align-center
append-to-body
destroy-on-close
>
<div v-if="previewType === 'image'" class="preview-container">
@@ -158,6 +158,8 @@
width="640px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -201,6 +201,8 @@
width="720px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -323,7 +325,9 @@
</template>
</div>
<div class="attachment-footer">
<span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
<span class="attachment-name" :title="file.fileName">{{
file.fileName
}}</span>
<div class="attachment-actions">
<el-icon
class="action-btn"
@@ -377,7 +381,14 @@
</template>
</SystemViewDialog>
<el-dialog v-model="previewVisible" title="文件预览" width="800px" align-center destroy-on-close>
<el-dialog
v-model="previewVisible"
title="文件预览"
width="800px"
align-center
destroy-on-close
append-to-body
>
<div v-if="previewType === 'image'" class="preview-container">
<img
:src="previewUrl"
@@ -107,6 +107,7 @@
</div>
<el-dialog
append-to-body
v-model="dialogVisible"
:title="dialogTitle"
width="840px"
@@ -159,6 +159,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -196,6 +198,8 @@
width="1120px"
class="dialog-form-layout unused-split-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -432,6 +436,8 @@
width="1400px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
>
<div v-if="viewDetail" class="stock-check-view">
@@ -36,7 +36,7 @@
<el-select v-model="searchForm.changeType" placeholder="全部" clearable>
<el-option v-if="!mineOnly" label="入库" value="in" />
<el-option label="出库" value="out" />
<el-option label="任务使用" value="use" />
<el-option v-if="SHOW_QC_TASK_MATERIAL_USAGE" label="任务使用" value="use" />
<el-option label="废弃" value="discard" />
</el-select>
</el-form-item>
@@ -74,7 +74,7 @@
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
:data="displayTableData"
v-loading="loading"
stripe
row-key="id"
@@ -188,7 +188,7 @@
header-align="center"
/>
<el-table-column
v-if="!mineOnly"
v-if="SHOW_QC_TASK_MATERIAL_USAGE && !mineOnly"
prop="qcTaskNo"
label="关联任务号"
min-width="160"
@@ -378,6 +378,7 @@
import { fetchQcMaterialList } from '@/api/qc-material'
import type { QcMaterialOption } from '@/api/qc-material'
import { formatMoneyDisplay } from '@/utils/money-format'
import { SHOW_QC_TASK_MATERIAL_USAGE } from '@/utils/qc-material-ui'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
defineOptions({ name: 'QcMaterialUsageTab' })
@@ -405,6 +406,11 @@
const dateRange = ref<[string, string] | null>(null)
const tableData = ref<QcMaterialUsage[]>([])
const displayTableData = computed(() =>
SHOW_QC_TASK_MATERIAL_USAGE
? tableData.value
: tableData.value.filter((row) => row.sourceType !== 'task_complete')
)
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const materialOptions = ref<QcMaterialOption[]>([])
@@ -643,7 +649,9 @@
},
{ label: '变动前库存', value: String(displayStockBefore(row)) },
{ label: '变动后库存', value: String(row.stockAfter ?? 0) },
{ label: '关联任务号', value: displayQcTaskNo(row) },
...(SHOW_QC_TASK_MATERIAL_USAGE
? [{ label: '关联任务号', value: displayQcTaskNo(row) }]
: []),
{
label: '来源类型',
value: getSourceLabel(row.sourceType),
@@ -667,7 +675,7 @@
]
}
]
if (isManualOutRow(row)) {
if (SHOW_QC_TASK_MATERIAL_USAGE && isManualOutRow(row)) {
sections.push({
title: '质检任务消耗',
fields: [],
@@ -681,7 +689,7 @@
viewRow.value = row
viewTaskUsages.value = []
viewVisible.value = true
if (!isManualOutRow(row)) return
if (!SHOW_QC_TASK_MATERIAL_USAGE || !isManualOutRow(row)) return
viewLoading.value = true
try {
viewTaskUsages.value = (await fetchQcMaterialLinkedTaskUsages(row.id)) || []
@@ -244,6 +244,8 @@
width="960px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -362,6 +364,8 @@
width="960px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -534,6 +538,8 @@
width="1080px"
class="dialog-form-layout material-batch-stock-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -716,6 +722,8 @@
width="1200px"
class="dialog-form-layout material-batch-stock-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -907,6 +915,8 @@
width="1200px"
class="dialog-form-layout material-batch-stock-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -1106,7 +1116,7 @@
<el-table-column label="用途" min-width="120" align="center" header-align="center" show-overflow-tooltip>
<template #default="{ row }">{{ row.purpose || row.remark || '—' }}</template>
</el-table-column>
<el-table-column label="质检任务" min-width="120" align="center" header-align="center">
<el-table-column label="质检任务" min-width="120" align="center" header-align="center" v-if="SHOW_QC_TASK_MATERIAL_USAGE">
<template #default="{ row }">{{ row.qcTaskNo || '—' }}</template>
</el-table-column>
<el-table-column label="时间" min-width="150" align="center" header-align="center">
@@ -1150,6 +1160,7 @@
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import AmountInput from '@/components/common/amount-input/index.vue'
import { formatMoneyDisplay } from '@/utils/money-format'
import { SHOW_QC_TASK_MATERIAL_USAGE } from '@/utils/qc-material-ui'
import { fetchItemUnitList, fetchUserList } from '@/api/admin'
import {
fetchQcMaterialBatchList,
@@ -1748,7 +1759,10 @@
stockInNo: row.stockInNo || undefined
})
viewUsageRecords.value = (res.list || []).filter(
(u) => (u.changeType === 'use' || u.changeType === 'discard') && u.stockInNo === row.stockInNo
(u) =>
(u.changeType === 'use' || u.changeType === 'discard') &&
u.stockInNo === row.stockInNo &&
(SHOW_QC_TASK_MATERIAL_USAGE || u.sourceType !== 'task_complete')
)
} catch {
viewUsageRecords.value = []
@@ -77,7 +77,7 @@
/>
</div>
</template>
<template v-for="task in labTasksWithMaterials" #[taskMaterialSlotName(task.id)]>
<template v-if="SHOW_QC_TASK_MATERIAL_USAGE" v-for="task in labTasksWithMaterials" #[taskMaterialSlotName(task.id)]>
<div v-if="task.materialUsages?.length" :key="task.id" class="view-subtable-wrap">
<el-table :data="task.materialUsages" stripe class="list-table view-subtable">
<el-table-column
@@ -157,6 +157,7 @@
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import { downloadAttachmentFile } from '@/utils/download-attachment'
import { formatMoneyDisplay } from '@/utils/money-format'
import { SHOW_QC_TASK_MATERIAL_USAGE } from '@/utils/qc-material-ui'
defineOptions({ name: 'OutboundQcViewDialog' })
@@ -245,7 +246,7 @@
} catch {
base.results = []
}
if (task.taskType === 'sysz') {
if (SHOW_QC_TASK_MATERIAL_USAGE && task.taskType === 'sysz') {
try {
const savedUsages = await fetchQcTaskMaterialUsages(task.id)
base.materialUsages = materialRowsFromApi(savedUsages || [])
@@ -429,7 +430,7 @@
section.fields.push({ label: '检测附件', value: '—' })
}
const sections: ViewSection[] = [section]
if (taskType === 'sysz') {
if (SHOW_QC_TASK_MATERIAL_USAGE && taskType === 'sysz') {
sections.push({
title: '',
fields: [],
@@ -11,28 +11,42 @@
</div>
<div v-if="group.workOrders.length > 1" class="qc-wo-tabs">
<button
v-for="wo in group.workOrders"
:key="wo.id"
type="button"
class="qc-wo-tab"
:class="{ active: wo.id === activeWorkOrderId }"
@click="activeWorkOrderId = wo.id"
>
<span
class="qc-wo-tab__dot"
:class="
(wo.triggerSource || 'reception') === 'production'
? 'qc-wo-tab__dot--production'
: 'qc-wo-tab__dot--reception'
"
></span>
<span class="qc-wo-tab__no">{{ wo.workOrderNo }}</span>
· {{ triggerSourceTabLabel(wo) }}
<el-tag :type="workOrderStatusTagType(wo.workOrderStatus)" size="small" class="qc-wo-tab__status">
{{ workOrderStatusLabel(wo.workOrderStatus) }}
</el-tag>
</button>
<div class="qc-wo-tabs__head">
<el-icon class="qc-wo-tabs__hint-icon"><Switch /></el-icon>
<span class="qc-wo-tabs__hint">该样本有多条质检工单,点击下方切换查看</span>
</div>
<div class="qc-wo-tabs__track" role="tablist" aria-label="同样本质检工单">
<button
v-for="wo in group.workOrders"
:key="wo.id"
type="button"
role="tab"
class="qc-wo-tab"
:class="{ 'is-active': wo.id === activeWorkOrderId }"
:aria-selected="wo.id === activeWorkOrderId"
@click="activeWorkOrderId = wo.id"
>
<span
class="qc-wo-tab__source"
:class="
(wo.triggerSource || 'reception') === 'production'
? 'qc-wo-tab__source--production'
: 'qc-wo-tab__source--reception'
"
>
<span class="qc-wo-tab__dot"></span>
{{ triggerSourceTabLabel(wo) }}
</span>
<span class="qc-wo-tab__no">{{ wo.workOrderNo }}</span>
<el-tag
:type="workOrderStatusTagType(wo.workOrderStatus)"
size="small"
class="qc-wo-tab__status"
>
{{ workOrderStatusLabel(wo.workOrderStatus) }}
</el-tag>
</button>
</div>
</div>
<QcWorkOrderSingleCard
@@ -55,6 +69,7 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Switch } from '@element-plus/icons-vue'
import type { QcWorkOrder } from '@/api/qc-work-order'
import { workOrderStatusLabel } from '@/utils/qc-labels'
import {
@@ -217,37 +232,100 @@
.qc-wo-tabs {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 16px 12px;
border-bottom: 1px solid #e2e8f0;
background: #fafafa;
background: #f8fafc;
}
.qc-wo-tabs__head {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.qc-wo-tabs__hint-icon {
flex-shrink: 0;
font-size: 14px;
color: #64748b;
}
.qc-wo-tabs__hint {
font-size: 12px;
line-height: 1.4;
color: #64748b;
}
.qc-wo-tabs__track {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 4px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f1f5f9;
overflow-x: auto;
}
.qc-wo-tab {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 10px 16px;
gap: 6px;
min-height: 34px;
padding: 6px 12px;
font-size: 12px;
color: #64748b;
border: none;
border-bottom: 2px solid transparent;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
cursor: pointer;
white-space: nowrap;
transition:
color 0.12s ease,
background 0.12s ease;
color 0.15s ease,
background 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
&:hover {
&:hover:not(.is-active) {
color: #334155;
background: #f8fafc;
background: rgba(255, 255, 255, 0.72);
border-color: #dbeafe;
}
&.active {
&:focus-visible {
outline: 2px solid #10b981;
outline-offset: 1px;
}
&.is-active {
color: #065f46;
font-weight: 600;
border-bottom-color: #047857;
background: #fff;
border-color: #a7f3d0;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
}
}
.qc-wo-tab__source {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
line-height: 1.4;
&--reception {
color: #1d4ed8;
background: #eff6ff;
}
&--production {
color: #047857;
background: #ecfdf5;
}
}
@@ -261,18 +339,11 @@
height: 6px;
border-radius: 50%;
flex-shrink: 0;
&--reception {
background: #3b82f6;
}
&--production {
background: #047857;
}
background: currentColor;
}
.qc-wo-tab__status {
margin-left: 4px;
margin-left: 2px;
}
.qc-sample-row :deep(.qc-work-order-card) {
@@ -140,6 +140,7 @@
:width="resultDialogWidth"
class="dialog-form-layout qc-task-result-dialog"
align-center
modal-class="protocol-dialog-mask"
append-to-body
destroy-on-close
:close-on-click-modal="false"
@@ -177,7 +178,7 @@
</div>
</div>
<div
v-if="resultMaterialUsageSectionVisible && resultTaskType !== 'dsfjc'"
v-if="SHOW_QC_TASK_MATERIAL_USAGE && resultTaskType !== 'dsfjc'"
class="form-section material-usage-section"
>
<div class="section-title material-usage-head">
@@ -512,6 +513,7 @@
type QcMaterialRequisitionOption
} from '@/api/qc-material-usage'
import { fetchItemUnitList } from '@/api/admin'
import { SHOW_QC_TASK_MATERIAL_USAGE } from '@/utils/qc-material-ui'
import {
taskTypeLabel,
taskStatusLabel,
@@ -618,8 +620,6 @@
return `mu_${Date.now()}_${materialUsageRowSeq}`
}
const resultMaterialUsageSectionVisible = true
const requisitionOptions = ref<QcMaterialRequisitionOption[]>([])
const requisitionOptionsLoading = ref(false)
const usageUnitOptions = ref<{ label: string; value: string }[]>([])
@@ -1094,7 +1094,7 @@
]
: []
const tailSections: ViewSection[] = []
if (d.taskType !== 'dsfjc') {
if (SHOW_QC_TASK_MATERIAL_USAGE && d.taskType !== 'dsfjc') {
tailSections.push({ title: '物料使用', fields: [], slot: 'material-usages' })
}
if (d.attachmentFiles?.length) {
@@ -1213,7 +1213,7 @@
} catch {
;(detail as any).results = []
}
if (row.taskType !== 'dsfjc') {
if (SHOW_QC_TASK_MATERIAL_USAGE && row.taskType !== 'dsfjc') {
try {
const savedUsages = await fetchQcTaskMaterialUsages(row.id).catch(
() => [] as QcTaskMaterialUsageItem[]
@@ -1263,7 +1263,7 @@
resultAttachments.value = apiFilesToFinanceItems(detail.attachmentFiles)
const results = await fetchQcTaskResults(row.id)
resultParams.value = normalizeResultParams(results || [])
if (resultMaterialUsageSectionVisible && row.taskType !== 'dsfjc') {
if (SHOW_QC_TASK_MATERIAL_USAGE && row.taskType !== 'dsfjc') {
const savedUsages = await fetchQcTaskMaterialUsages(row.id).catch(
() => [] as QcTaskMaterialUsageItem[]
)
@@ -1297,7 +1297,7 @@
ElMessage.warning('请上传第三方检测材料')
return
}
if (resultMaterialUsageSectionVisible && resultTaskType.value !== 'dsfjc') {
if (SHOW_QC_TASK_MATERIAL_USAGE && resultTaskType.value !== 'dsfjc') {
const seenRequisitionIds = new Set<string>()
for (const mu of materialUsages.value) {
if (!mu.requisitionUsageId?.trim()) {
@@ -1352,7 +1352,7 @@
}))
}
data.thirdPartyAttachments = financeAttachmentsToUrlString(resultAttachments.value)
if (resultMaterialUsageSectionVisible && resultTaskType.value !== 'dsfjc') {
if (SHOW_QC_TASK_MATERIAL_USAGE && resultTaskType.value !== 'dsfjc') {
data.materialUsages = validMaterialUsages.map((mu) => ({
requisitionUsageId: mu.requisitionUsageId,
qty: mu.qtyNum!,
@@ -39,7 +39,7 @@
/>
</div>
</template>
<template v-for="task in viewLabTasks" #[taskMaterialSlotName(task.id)]>
<template v-if="SHOW_QC_TASK_MATERIAL_USAGE" v-for="task in viewLabTasks" #[taskMaterialSlotName(task.id)]>
<div v-if="task.materialUsages?.length" :key="task.id" class="view-subtable-wrap">
<el-table :data="task.materialUsages" stripe class="list-table view-subtable">
<el-table-column
@@ -98,6 +98,7 @@
import OutboundQcViewDialog from './OutboundQcViewDialog.vue'
import QcWorkOrderProtocolPaymentPanel from './QcWorkOrderProtocolPaymentPanel.vue'
import { useQcWorkOrderViewDialog } from './useQcWorkOrderViewDialog'
import { SHOW_QC_TASK_MATERIAL_USAGE } from '@/utils/qc-material-ui'
defineOptions({ name: 'QcWorkOrderViewDialogHost' })
@@ -124,6 +124,7 @@
width="1080px"
class="dialog-form-layout qc-configure-dialog"
align-center
modal-class="protocol-dialog-mask"
append-to-body
destroy-on-close
:close-on-click-modal="false"
@@ -308,7 +309,7 @@
/>
</div>
</template>
<template v-for="task in viewLabTasks" #[taskMaterialSlotName(task.id)]>
<template v-if="SHOW_QC_TASK_MATERIAL_USAGE" v-for="task in viewLabTasks" #[taskMaterialSlotName(task.id)]>
<div v-if="task.materialUsages?.length" :key="task.id" class="view-subtable-wrap">
<el-table :data="task.materialUsages" stripe class="list-table view-subtable">
<el-table-column
@@ -362,6 +363,8 @@
width="720px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -493,6 +496,7 @@
type QcTaskResultItem,
type QcTaskMaterialUsageItem
} from '@/api/qc-task-result'
import { SHOW_QC_TASK_MATERIAL_USAGE } from '@/utils/qc-material-ui'
import type { QcTask } from '@/api/qc-task'
import {
workOrderStatusLabel,
@@ -1079,7 +1083,7 @@
section.fields.push({ label: '检测附件', value: '—' })
}
const sections: ViewSection[] = [section]
if (taskType === 'sysz') {
if (SHOW_QC_TASK_MATERIAL_USAGE && taskType === 'sysz') {
sections.push({
title: '',
fields: [],
@@ -1466,7 +1470,7 @@
} catch {
base.results = []
}
if (task.taskType === 'sysz') {
if (SHOW_QC_TASK_MATERIAL_USAGE && task.taskType === 'sysz') {
try {
const savedUsages = await fetchQcTaskMaterialUsages(task.id)
base.materialUsages = materialRowsFromApi(savedUsages || [])
@@ -8,6 +8,7 @@ import {
type QcTaskResultItem,
type QcTaskMaterialUsageItem
} from '@/api/qc-task-result'
import { SHOW_QC_TASK_MATERIAL_USAGE } from '@/utils/qc-material-ui'
import {
workOrderStatusLabel,
receptionSourceLabel,
@@ -249,7 +250,7 @@ export function useQcWorkOrderViewDialog() {
section.fields!.push({ label: '检测附件', value: '—' })
}
const sections: ViewSection[] = [section]
if (taskType === 'sysz') {
if (SHOW_QC_TASK_MATERIAL_USAGE && taskType === 'sysz') {
sections.push({
title: '',
fields: [],
@@ -452,7 +453,7 @@ export function useQcWorkOrderViewDialog() {
} catch {
base.results = []
}
if (task.taskType === 'sysz') {
if (SHOW_QC_TASK_MATERIAL_USAGE && task.taskType === 'sysz') {
try {
base.materialUsages = materialRowsFromApi(await fetchQcTaskMaterialUsages(task.id))
} catch {
@@ -149,6 +149,8 @@
width="640px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -366,6 +366,8 @@
width="920px"
class="dialog-form-layout sample-reception-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
>
<template v-if="viewRow">
@@ -499,6 +501,8 @@
width="960px"
class="dialog-form-layout sample-reception-dialog batch-receive-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -7,6 +7,8 @@
width="640px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
style="backdrop-filter: none"
@@ -59,7 +61,6 @@
:auto-upload="false"
:show-file-list="false"
:multiple="true"
accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.zip"
@change="handleAttachmentChange"
>
<div class="upload-card">
@@ -93,7 +94,7 @@
</div>
</div>
</div>
<p class="upload-hint">支持图片、文档等。</p>
<p class="upload-hint">支持图片、视频、PDF、Word、Excel 等常见文件格式</p>
</div>
</el-form-item>
</el-form>
@@ -30,7 +30,12 @@
</template>
<template #qc-info>
<SampleQcInfoCards :qc-info="detail?.qcInfo" :sample-reception="viewReceptionBrief" />
<SampleQcInfoGroupedPanel
:gate-qc-info="detail?.qcInfo"
:process-qc-info-list="detail?.processQcInfoList"
:sample-reception="viewReceptionBrief"
:show-section-label="false"
/>
</template>
<template #production-info>
@@ -54,7 +59,7 @@
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import type { ViewSection } from '@/components/business/system-view-dialog/index.vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import SampleQcInfoCards from '@/components/business/sample-qc-info-cards/index.vue'
import SampleQcInfoGroupedPanel from '@/components/business/sample-qc-info-grouped-panel/index.vue'
import SampleProductionInfoCards from '@/components/business/sample-production-info-cards/index.vue'
import type { QcWorkOrderReceptionDetail } from '@/api/qc-work-order'
import {
@@ -6,6 +6,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@@ -3,9 +3,11 @@
<el-dialog
:model-value="modelValue"
:title="dialogTitle"
width="1380px"
width="1600px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -82,11 +84,10 @@
/>
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="formData.remark" type="textarea" :rows="2" placeholder="可选" />
</el-form-item>
</div>
<el-form-item label="备注">
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="可选" />
</el-form-item>
</div>
</div>
@@ -420,141 +421,143 @@
</div>
<!-- 知情同意书 -->
<div class="section-subtitle" style="margin-bottom: 12px">知情同意书</div>
<div class="attachment-upload">
<div class="attachment-list">
<el-upload
ref="consentUploadRef"
:auto-upload="false"
:show-file-list="false"
:multiple="true"
accept="image/*,.pdf"
@change="handleConsentAttachmentChange"
>
<div class="upload-card">
<el-icon :size="28" color="#047857"><Plus /></el-icon>
<span class="upload-card-text">上传附件</span>
</div>
</el-upload>
<div
v-for="(file, fIdx) in consentAttachments"
:key="file.id || fIdx"
class="attachment-item"
:class="{ 'is-uploading': file._uploading }"
>
<div
class="attachment-preview"
@click="
isImageFile(file) && file.fileUrl ? handlePreviewAttachment(file) : undefined
"
>
<template v-if="isImageFile(file) && file.fileUrl">
<img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
</template>
<template v-else-if="isImageFile(file) && !file.fileUrl">
<el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
</template>
<template v-else-if="isVideoFile(file)">
<div class="attachment-preview__icon attachment-preview__icon--video">
<el-icon :size="28"><VideoPlay /></el-icon>
<div class="form-grid form-grid--attachment">
<el-form-item label="知情同意书">
<div class="attachment-upload">
<div class="attachment-list">
<el-upload
ref="consentUploadRef"
:auto-upload="false"
:show-file-list="false"
:multiple="true"
@change="handleConsentAttachmentChange"
>
<div class="upload-card">
<el-icon :size="28" color="#047857"><Plus /></el-icon>
<span class="upload-card-text">上传附件</span>
</div>
</template>
<template v-else>
<div class="attachment-preview__icon attachment-preview__icon--doc">
<el-icon :size="28"><Document /></el-icon>
</el-upload>
<div
v-for="(file, fIdx) in consentAttachments"
:key="file.id || fIdx"
class="attachment-item"
:class="{ 'is-uploading': file._uploading }"
>
<div
class="attachment-preview"
@click="
isImageFile(file) && file.fileUrl ? handlePreviewAttachment(file) : undefined
"
>
<template v-if="isImageFile(file) && file.fileUrl">
<img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
</template>
<template v-else-if="isImageFile(file) && !file.fileUrl">
<el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
</template>
<template v-else-if="isVideoFile(file)">
<div class="attachment-preview__icon attachment-preview__icon--video">
<el-icon :size="28"><VideoPlay /></el-icon>
</div>
</template>
<template v-else>
<div class="attachment-preview__icon attachment-preview__icon--doc">
<el-icon :size="28"><Document /></el-icon>
</div>
</template>
</div>
<div class="attachment-footer">
<span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
<div class="attachment-actions">
<el-icon
class="action-btn"
title="下载"
@click="handleDownloadAttachment(file)"
><Download
/></el-icon>
<el-icon
class="action-btn action-btn--danger"
title="删除"
@click="removeConsentAttachment(fIdx)"
><Delete
/></el-icon>
</div>
</div>
</template>
</div>
<div class="attachment-footer">
<span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
<div class="attachment-actions">
<el-icon
class="action-btn"
title="下载"
@click="handleDownloadAttachment(file)"
><Download
/></el-icon>
<el-icon
class="action-btn action-btn--danger"
title="删除"
@click="removeConsentAttachment(fIdx)"
><Delete
/></el-icon>
</div>
</div>
<span class="upload-hint">支持图片、视频、PDF、Word、Excel 等常见文件格式,可稍后补充</span>
</div>
</div>
<span class="upload-hint">支持图片和 PDF 格式,可稍后补充</span>
</el-form-item>
</div>
<!-- 其他附件 -->
<div class="section-subtitle" style="margin-top: 24px; margin-bottom: 12px"
>其他附件</div
>
<div class="attachment-upload">
<div class="attachment-list">
<el-upload
ref="otherUploadRef"
:auto-upload="false"
:show-file-list="false"
:multiple="true"
accept="image/*,.pdf,.doc,.docx"
@change="handleOtherAttachmentChange"
>
<div class="upload-card">
<el-icon :size="28" color="#047857"><Plus /></el-icon>
<span class="upload-card-text">上传附件</span>
</div>
</el-upload>
<div
v-for="(file, fIdx) in otherAttachments"
:key="file.id || fIdx"
class="attachment-item"
:class="{ 'is-uploading': file._uploading }"
>
<div
class="attachment-preview"
@click="
isImageFile(file) && file.fileUrl ? handlePreviewAttachment(file) : undefined
"
>
<template v-if="isImageFile(file) && file.fileUrl">
<img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
</template>
<template v-else-if="isImageFile(file) && !file.fileUrl">
<el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
</template>
<template v-else-if="isVideoFile(file)">
<div class="attachment-preview__icon attachment-preview__icon--video">
<el-icon :size="28"><VideoPlay /></el-icon>
<div class="form-grid form-grid--attachment">
<el-form-item label="其他附件">
<div class="attachment-upload">
<div class="attachment-list">
<el-upload
ref="otherUploadRef"
:auto-upload="false"
:show-file-list="false"
:multiple="true"
@change="handleOtherAttachmentChange"
>
<div class="upload-card">
<el-icon :size="28" color="#047857"><Plus /></el-icon>
<span class="upload-card-text">上传附件</span>
</div>
</template>
<template v-else>
<div class="attachment-preview__icon attachment-preview__icon--doc">
<el-icon :size="28"><Document /></el-icon>
</el-upload>
<div
v-for="(file, fIdx) in otherAttachments"
:key="file.id || fIdx"
class="attachment-item"
:class="{ 'is-uploading': file._uploading }"
>
<div
class="attachment-preview"
@click="
isImageFile(file) && file.fileUrl ? handlePreviewAttachment(file) : undefined
"
>
<template v-if="isImageFile(file) && file.fileUrl">
<img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
</template>
<template v-else-if="isImageFile(file) && !file.fileUrl">
<el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
</template>
<template v-else-if="isVideoFile(file)">
<div class="attachment-preview__icon attachment-preview__icon--video">
<el-icon :size="28"><VideoPlay /></el-icon>
</div>
</template>
<template v-else>
<div class="attachment-preview__icon attachment-preview__icon--doc">
<el-icon :size="28"><Document /></el-icon>
</div>
</template>
</div>
<div class="attachment-footer">
<span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
<div class="attachment-actions">
<el-icon
class="action-btn"
title="下载"
@click="handleDownloadAttachment(file)"
><Download
/></el-icon>
<el-icon
class="action-btn action-btn--danger"
title="删除"
@click="removeOtherAttachment(fIdx)"
><Delete
/></el-icon>
</div>
</div>
</template>
</div>
<div class="attachment-footer">
<span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
<div class="attachment-actions">
<el-icon
class="action-btn"
title="下载"
@click="handleDownloadAttachment(file)"
><Download
/></el-icon>
<el-icon
class="action-btn action-btn--danger"
title="删除"
@click="removeOtherAttachment(fIdx)"
><Delete
/></el-icon>
</div>
</div>
<span class="upload-hint">支持图片、视频、PDF、Word、Excel 等常见文件格式</span>
</div>
</div>
<span class="upload-hint">支持图片、PDF、Word 格式</span>
</el-form-item>
</div>
</div>
</div>
@@ -574,7 +577,14 @@
</el-dialog>
<!-- ========== 文件预览弹窗 ========== -->
<el-dialog v-model="previewVisible" title="文件预览" width="800px" align-center destroy-on-close>
<el-dialog
v-model="previewVisible"
title="文件预览"
width="800px"
align-center
append-to-body
destroy-on-close
>
<div v-if="previewType === 'image'" class="preview-container">
<img
:src="previewUrl"
@@ -2019,6 +2029,11 @@
grid-template-columns: repeat(4, minmax(0, 1fr));
}
&--attachment {
margin-top: 16px;
grid-template-columns: 1fr;
}
:deep(.el-form-item) {
margin-bottom: 0;
}
@@ -6,6 +6,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@@ -6,6 +6,8 @@
width="1600px"
class="dialog-form-layout donation-view-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@@ -378,8 +380,9 @@
</template>
<div v-else class="sample-reception-empty">暂未接收</div>
</div>
<SampleQcInfoCards
:qc-info="row.qcInfo"
<SampleQcInfoGroupedPanel
:gate-qc-info="row.qcInfo"
:process-qc-info-list="row.processQcInfoList"
:sample-reception="row.sampleReception"
/>
<SampleProductionInfoCards :production-info="row.productionInfo" />
@@ -588,7 +591,14 @@
</template>
</el-dialog>
<el-dialog v-model="previewVisible" title="文件预览" width="800px" align-center destroy-on-close>
<el-dialog
v-model="previewVisible"
title="文件预览"
width="800px"
align-center
destroy-on-close
append-to-body
>
<div v-if="previewType === 'image'" class="preview-container">
<img
:src="previewUrl"
@@ -611,7 +621,7 @@
import { formatMoneyDisplay } from '@/utils/money-format'
import { Calendar, CircleCheck, Close, User } from '@element-plus/icons-vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import SampleQcInfoCards from '@/components/business/sample-qc-info-cards/index.vue'
import SampleQcInfoGroupedPanel from '@/components/business/sample-qc-info-grouped-panel/index.vue'
import SampleProductionInfoCards from '@/components/business/sample-production-info-cards/index.vue'
import SampleStorageReleaseInfo from '@/components/business/sample-storage-release-info/index.vue'
import {
+15 -5
View File
@@ -186,7 +186,10 @@
>
新增子分类
</el-button>
<el-dropdown trigger="hover" @command="(cmd: string) => handleDropdownCommand(cmd, row)">
<el-dropdown
trigger="hover"
@command="(cmd: string) => handleDropdownCommand(cmd, row)"
>
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
@@ -216,6 +219,7 @@
<!-- 新增/编辑弹窗 -->
<el-dialog
append-to-body
v-model="dialogVisible"
:title="dialogTitle"
width="900px"
@@ -438,7 +442,7 @@
</el-dialog>
<!-- 选择参数弹窗 -->
<el-dialog v-model="selectParameterVisible" title="选择参数" width="900px">
<el-dialog v-model="selectParameterVisible" title="选择参数" width="900px" append-to-body>
<div class="parameter-select-wrapper">
<div class="parameter-select-filter">
<el-input
@@ -1294,9 +1298,15 @@
function handleDropdownCommand(command: string, row: any) {
switch (command) {
case 'view': handleView(row); break
case 'edit': handleEdit(row); break
case 'delete': handleDelete(row); break
case 'view':
handleView(row)
break
case 'edit':
handleEdit(row)
break
case 'delete':
handleDelete(row)
break
}
}
@@ -144,6 +144,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@close="handleDialogClose"
@@ -147,6 +147,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -245,6 +247,8 @@
width="900px"
class="dialog-form-layout dict-data-config-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -339,6 +343,7 @@
width="480px"
class="dialog-form-layout"
align-center
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
append-to-body
@@ -115,6 +115,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -157,6 +157,8 @@
width="600px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -177,6 +177,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@close="handleDialogClose"
+6 -2
View File
@@ -336,6 +336,7 @@
<!-- 订单详情:普通模式用 Dialog;嵌入模式下直接渲染内容,避免父层弹窗+子层弹窗叠加 -->
<el-dialog
append-to-body
v-if="!embeddedMode"
v-model="viewVisible"
title="订单详情"
@@ -1262,6 +1263,7 @@
<!-- 分配 / 更换配送员 -->
<el-dialog
append-to-body
v-model="assignVisible"
:title="assignReassignMode ? '更换配送员' : '分配配送员'"
width="560px"
@@ -1327,7 +1329,7 @@
</el-dialog>
<!-- 审核取消弹窗 -->
<el-dialog v-model="cancelVisible" title="审核取消" width="500px">
<el-dialog v-model="cancelVisible" title="审核取消" width="500px" append-to-body>
<el-form :model="cancelForm" label-width="100px">
<el-form-item label="审核结果">
<el-radio-group v-model="cancelForm.approved">
@@ -1355,7 +1357,7 @@
</el-dialog>
<!-- 取消订单弹窗 -->
<el-dialog v-model="cancelDialogVisible" title="取消订单" width="500px">
<el-dialog v-model="cancelDialogVisible" title="取消订单" width="500px" append-to-body>
<el-alert type="warning" :closable="false" class="mb-4">
取消订单后,订单将被标记为已取消状态,已支付的款项将原路退回。
</el-alert>
@@ -1404,6 +1406,7 @@
<!-- 调整订单弹窗 -->
<el-dialog
append-to-body
v-model="adjustDialogVisible"
title="订单调整"
width="1100px"
@@ -1543,6 +1546,7 @@
<!-- 订单所用价格版本明细(只读,对齐询价「选中供应商报价」信息结构) -->
<el-dialog
append-to-body
v-model="orderPriceVersionVisible"
title="价格版本明细"
width="1100px"
@@ -155,6 +155,8 @@
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -208,6 +210,8 @@
width="1120px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
+29 -7
View File
@@ -193,7 +193,10 @@
<el-button class="btn-op-item" @click="handleCopy(row)">
<el-icon><CopyDocument /></el-icon>复制
</el-button>
<el-dropdown trigger="hover" @command="(cmd: string) => handleDropdownCommand(cmd, row)">
<el-dropdown
trigger="hover"
@command="(cmd: string) => handleDropdownCommand(cmd, row)"
>
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
@@ -234,6 +237,7 @@
<!-- 新增/编辑/查看弹窗(同一套表单结构) -->
<el-dialog
append-to-body
v-model="dialogVisible"
:title="dialogTitle"
width="900px"
@@ -791,7 +795,7 @@
<ProductViewDialog v-model="viewDialogVisible" :product-id="viewProductId" />
<!-- 选择参数弹窗 -->
<el-dialog v-model="selectParameterVisible" title="添加参数" width="900px">
<el-dialog v-model="selectParameterVisible" title="添加参数" width="900px" append-to-body>
<div class="parameter-select-wrapper">
<div class="parameter-select-filter">
<el-input
@@ -863,14 +867,26 @@
</el-dialog>
<!-- 图片预览弹窗 -->
<el-dialog v-model="previewVisible" title="图片预览" width="800px" class="preview-dialog">
<el-dialog
v-model="previewVisible"
title="图片预览"
width="800px"
class="preview-dialog"
append-to-body
>
<div class="preview-container">
<el-image :src="currentPreview.fileUrl" fit="contain" style="width: 100%; height: 500px" />
</div>
</el-dialog>
<!-- 视频播放弹窗 -->
<el-dialog v-model="videoVisible" title="视频播放" width="800px" class="video-dialog">
<el-dialog
v-model="videoVisible"
title="视频播放"
width="800px"
class="video-dialog"
append-to-body
>
<div class="video-container">
<video
v-if="currentPreview.fileUrl"
@@ -1717,9 +1733,15 @@
// 删除
function handleDropdownCommand(command: string, row: any) {
switch (command) {
case 'view': handleView(row); break
case 'edit': handleEdit(row); break
case 'delete': handleDelete(row); break
case 'view':
handleView(row)
break
case 'edit':
handleEdit(row)
break
case 'delete':
handleDelete(row)
break
}
}
@@ -164,6 +164,8 @@
width="720px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -210,6 +212,8 @@
width="700px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -249,6 +253,8 @@
width="1120px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
@@ -132,6 +132,8 @@
width="720px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
@close="handleDialogClose"
Loaded 100 of 382 files, more files were not shown because too many files have changed in this diff. Show more