提交
This commit is contained in:
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"
|
||||
|
||||
+9
-1
@@ -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
|
||||
|
||||
+11
-3
@@ -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 {
|
||||
|
||||
+26
-14
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+136
-66
@@ -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: '产品列表',
|
||||
|
||||
+10
-1
@@ -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
|
||||
|
||||
+2
@@ -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"
|
||||
|
||||
+11
-70
@@ -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%;
|
||||
|
||||
@@ -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>
|
||||
+240
-444
@@ -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"
|
||||
>
|
||||
|
||||
+3
-2
@@ -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>
|
||||
|
||||
+7
-2
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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
Reference in new issue
Block a user