Files
zkxw/web-admin/src/views/production/process/ProcessRouteTab.vue
T
2026-06-16 15:17:32 +08:00

2761 lines
90 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 工艺库管理 · 工艺路线 Tab -->
<template>
<div class="process-route-tab">
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="路线名称">
<el-input
v-model="searchForm.routeName"
placeholder="模糊搜索"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="路线编码">
<el-input
v-model="searchForm.routeCode"
placeholder="精确搜索"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="产品类型">
<el-select
v-model="searchForm.productTypeId"
placeholder="输入名称检索"
clearable
filterable
remote
reserve-keyword
:loading="productTypeOptionsLoading"
:remote-method="remoteSearchProductTypes"
@visible-change="onProductTypeSelectVisible"
>
<el-option
v-for="pt in productTypeOptions"
:key="pt.id"
:label="pt.productTypeName"
:value="pt.id"
/>
</el-select>
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint">按路线名称、编码、产品类型筛选。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
>
<el-button class="btn-main" type="primary" @click="handleSearch"
><el-icon><Search /></el-icon>搜索</el-button
>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增路线
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
@click="handleBatchDelete"
>
批量删除
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index: number) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="routeName"
label="路线名称"
min-width="160"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="routeCode"
label="路线编码"
min-width="130"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column label="产品类型" min-width="130" align="center" header-align="center">
<template #default="{ row }">
{{ productTypeMap[row.productTypeId] || '-' }}
</template>
</el-table-column>
<el-table-column
prop="version"
label="版本号"
min-width="90"
align="center"
header-align="center"
/>
<el-table-column label="当前版本" min-width="90" align="center" header-align="center">
<template #default="{ row }">
<el-tag v-if="row.isCurrent" type="success" size="small">当前</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column
prop="remark"
label="备注"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="createTime"
label="创建时间"
min-width="170"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
min-width="200"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item primary" @click="handleOpenConfig(row)">
<el-icon><Rank /></el-icon>路线图配置
</el-button>
<el-button v-if="!row.isCurrent" class="btn-op-item" @click="handleSetCurrent(row)">
设为当前
</el-button>
<el-dropdown trigger="hover" @command="(cmd: string) => handleDropdownCommand(cmd, row)">
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="
(val: number) => ((pagination.page = 1), (pagination.pageSize = val), loadData())
"
@current-change="(val: number) => ((pagination.page = val), loadData())"
/>
</div>
</div>
<!-- 新增/编辑路线弹窗 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
class="route-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="路线编码" prop="routeCode">
<el-input v-model="formData.routeCode" placeholder="请输入路线编码" />
</el-form-item>
<el-form-item label="路线名称" prop="routeName">
<el-input v-model="formData.routeName" placeholder="请输入路线名称" />
</el-form-item>
<el-form-item label="产品类型">
<el-select
v-model="formData.productTypeId"
placeholder="输入名称检索"
clearable
filterable
remote
reserve-keyword
:loading="productTypeOptionsLoading"
:remote-method="remoteSearchProductTypes"
@visible-change="onProductTypeSelectVisible"
>
<el-option
v-for="pt in productTypeOptions"
:key="pt.id"
:label="pt.productTypeName"
:value="pt.id"
/>
</el-select>
</el-form-item>
<el-form-item label="版本号">
<el-input v-model="formData.version" placeholder="如 V1.0" />
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="formData.remark" placeholder="可选" type="textarea" :rows="3" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" :loading="submitLoading" @click="handleSubmit">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
<!-- 路线图配置弹窗 -->
<el-dialog
v-model="configDialogVisible"
:title="`路线图配置 — ${configRouteName}${configRouteVersion ? ' ' + configRouteVersion : ''}`"
width="90vw"
class="dialog-form-layout route-config-dialog"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
<div class="route-config-body">
<!-- 左侧:工艺库 -->
<div class="route-config-left" v-loading="processLibraryLoading">
<div class="route-config-left-header">
<h3>工艺库</h3>
<el-input
v-model="processSearchKeyword"
placeholder="搜索工序"
clearable
size="small"
class="route-config-search"
/>
</div>
<el-collapse v-model="expandedCategories" class="route-config-collapse">
<el-collapse-item
v-for="(cat, catIndex) in filteredProcessLibrary"
:key="cat.categoryId"
:name="cat.categoryId"
>
<template #title>
<span class="route-config-cat-bar"></span>
<span>{{ cat.categoryName }}</span>
</template>
<div
v-for="(proc, procIndex) in cat.processes"
:key="proc.id"
class="route-config-process-card"
draggable="true"
@dragstart="onDragStart($event, proc)"
>
<div class="route-config-process-info">
<div class="route-config-process-name">
<span class="route-config-process-seq">{{ procIndex + 1 }}</span
>{{ proc.processName }}
</div>
<div class="route-config-process-code">{{ proc.processCode }}</div>
</div>
<el-icon class="route-config-process-drag"><Rank /></el-icon>
</div>
<el-empty
v-if="cat.processes.length === 0"
description="该分类暂无工序"
:image-size="32"
/>
</el-collapse-item>
</el-collapse>
<el-empty
v-if="filteredProcessLibrary.length === 0 && !processLibraryLoading"
description="暂无工艺数据"
:image-size="48"
/>
</div>
<!-- 中间:步骤列表 -->
<div
class="route-config-center"
v-loading="stepListLoading"
@dragover.prevent
@drop="onDropToCenter"
>
<div class="route-config-center-header">
<h3>工序步骤</h3>
<span class="route-config-step-count">共 {{ stepList.length }} 步</span>
</div>
<div class="route-config-center-dropzone">
<draggable
v-model="stepList"
handle=".route-config-step-handle"
:animation="200"
@end="onStepDragEnd"
>
<div
v-for="(element, index) in stepList"
:key="element.id"
class="route-config-step-card"
:class="{ 'is-active': selectedStepId === element.id }"
@click="selectStep(element)"
>
<div class="route-config-step-handle">
<el-icon><Rank /></el-icon>
</div>
<div class="route-config-step-order">{{ index + 1 }}</div>
<div class="route-config-step-info">
<div class="route-config-step-name">{{
element.processName || '未命名步骤'
}}</div>
<div class="route-config-step-desc">{{ element.description || '' }}</div>
</div>
<el-button
link
type="primary"
size="small"
title="复制步骤"
@click.stop="handleCopyStep(element)"
>
<el-icon><CopyDocument /></el-icon>
</el-button>
<el-button
link
type="danger"
size="small"
@click.stop="handleDeleteStep(element, index)"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</draggable>
<div v-if="stepList.length === 0" class="route-config-center-empty">
<el-icon :size="32" color="#a8d4c4"><Plus /></el-icon>
<p>从左侧拖拽工序到此处,添加路线步骤</p>
</div>
</div>
</div>
<!-- 右侧:步骤详情 -->
<div class="route-config-right">
<template v-if="selectedStep">
<div class="route-config-right-header">
<h3>步骤详情</h3>
<el-button
class="btn-main"
type="primary"
size="small"
:loading="manualSaveLoading"
@click="handleManualSave"
>
<el-icon><Check /></el-icon>保存
</el-button>
</div>
<div class="route-config-right-body">
<el-form label-position="top" class="route-config-step-form">
<div class="form-grid-4">
<el-form-item label="工序名称">
<el-input v-model="selectedStep.processName" @change="saveStepBasic" />
</el-form-item>
<el-form-item label="工序分类">
<el-select
v-model="selectedStep.categoryId"
placeholder="选择分类"
clearable
@change="saveStepBasic"
>
<el-option
v-for="cat in categoryOptions"
:key="cat.id"
:label="cat.categoryName || cat.name"
:value="cat.id"
/>
</el-select>
</el-form-item>
<el-form-item label="设备名称">
<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
v-model="selectedStep.estimatedDuration"
placeholder="如 2h"
@change="saveStepBasic"
/>
</el-form-item>
<el-form-item label="描述" class="step-form-col-half">
<el-input
v-model="selectedStep.description"
type="textarea"
:rows="2"
placeholder="工序描述"
@change="saveStepBasic"
/>
</el-form-item>
<el-form-item label="关联SOP" class="step-form-col-half">
<el-select
v-model="sopIdsArr"
multiple
placeholder="选择SOP文档"
filterable
@change="onSopIdsChange"
>
<el-option
v-for="sop in sopOptions"
:key="sop.id"
:label="sop.sopName || sop.name"
:value="sop.id"
/>
</el-select>
</el-form-item>
<div v-if="sopIdsArr.length" class="step-sop-attachment-row step-form-col-full">
<ProtocolAttachmentReadonly
:files="linkedSopAttachmentFiles"
@preview="handleSopAttachmentPreview"
@download="handleSopAttachmentDownload"
/>
</div>
</div>
</el-form>
<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 class="route-config-tab-toolbar">
<el-button
class="btn-main"
type="primary"
size="small"
@click="addReagentRow"
>
<el-icon><Plus /></el-icon>
添加试剂
</el-button>
</div>
<el-table
:data="selectedStep.reagents"
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="110"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-input
v-model="row.reagentCode"
placeholder="编码"
class="inner-cell-input"
@change="saveReagents"
/>
</template>
</el-table-column>
<el-table-column
label="试剂名称"
min-width="160"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-select
v-model="row.reagentId"
placeholder="请选择试剂"
clearable
filterable
remote
reserve-keyword
class="inner-cell-select"
:loading="reagentOptionsLoading"
:remote-method="searchReagentOptions"
@visible-change="(visible) => visible && searchReagentOptions('')"
@change="(val) => onReagentRowSelect(row, val)"
>
<el-option
v-for="item in reagentOptions"
:key="item.id"
:label="item.label"
:value="item.id"
/>
</el-select>
</template>
</el-table-column>
<el-table-column
label="规格"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-input
v-model="row.spec"
placeholder="规格"
class="inner-cell-input"
@change="saveReagents"
/>
</template>
</el-table-column>
<el-table-column
label="标准用量"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-input
v-model="row.standardUsage"
placeholder="用量"
class="inner-cell-input"
@change="saveReagents"
/>
</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="saveReagents"
>
<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="90"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-switch v-model="row.isKeyMaterial" @change="saveReagents" />
</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="saveReagents"
/>
</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="removeReagentRow($index)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
<el-empty
v-if="!selectedStep.reagents || selectedStep.reagents.length === 0"
description="暂无试剂"
:image-size="48"
/>
</el-tab-pane>
<!-- 耗材 -->
<el-tab-pane
v-if="SHOW_PROCESS_REAGENT_CONSUMABLE"
label="耗材"
name="consumable"
>
<div class="route-config-tab-toolbar">
<el-button
class="btn-main"
type="primary"
size="small"
@click="addConsumableRow"
>
<el-icon><Plus /></el-icon>
添加耗材
</el-button>
</div>
<el-table
:data="selectedStep.consumables"
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="110"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-input
v-model="row.consumableCode"
placeholder="编码"
class="inner-cell-input"
@change="saveConsumables"
/>
</template>
</el-table-column>
<el-table-column
label="耗材名称"
min-width="160"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-select
v-model="row.consumableId"
placeholder="请选择耗材"
clearable
filterable
remote
reserve-keyword
class="inner-cell-select"
:loading="consumableOptionsLoading"
:remote-method="searchConsumableOptions"
@visible-change="(visible) => visible && searchConsumableOptions('')"
@change="(val) => onConsumableRowSelect(row, val)"
>
<el-option
v-for="item in consumableOptions"
:key="item.id"
:label="item.label"
:value="item.id"
/>
</el-select>
</template>
</el-table-column>
<el-table-column
label="规格型号"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-input
v-model="row.specModel"
placeholder="规格型号"
class="inner-cell-input"
@change="saveConsumables"
/>
</template>
</el-table-column>
<el-table-column
label="标准用量"
min-width="110"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-input
v-model="row.standardUsage"
placeholder="用量"
class="inner-cell-input"
@change="saveConsumables"
/>
</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="saveConsumables"
>
<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="80"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-switch v-model="row.isRequired" @change="saveConsumables" />
</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="saveConsumables"
/>
</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="removeConsumableRow($index)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
<el-empty
v-if="!selectedStep.consumables || selectedStep.consumables.length === 0"
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 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>
</section>
<!-- 数据采集 -->
<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>
</section>
</div>
</div>
</template>
<template v-else>
<div class="route-config-right-empty">
<el-icon :size="48" color="#d1d5db"><Rank /></el-icon>
<p>请选择左侧步骤查看详情</p>
</div>
</template>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
Plus,
Delete,
Edit,
More,
Rank,
Close,
Check,
CopyDocument,
RefreshLeft,
Search
} from '@element-plus/icons-vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import { downloadAttachmentFile } from '@/utils/download-attachment'
import { VueDraggable as draggable } from 'vue-draggable-plus'
import {
fetchProcessRouteList,
fetchProcessRouteDetail,
fetchCreateProcessRoute,
fetchUpdateProcessRoute,
fetchSetCurrentProcessRoute,
fetchDeleteProcessRoute,
fetchAddRouteStep,
fetchCopyRouteStep,
fetchUpdateRouteStep,
fetchReorderRouteSteps,
fetchDeleteRouteStep,
fetchUpdateStepReagents,
fetchUpdateStepConsumables,
fetchUpdateStepOutputs,
fetchUpdateStepDataCollections,
fetchReagentList,
fetchConsumableList,
fetchAllProcesses,
fetchAllProcessCategories,
fetchAllSopDocuments,
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' })
// ---------------------------------------------------------------------------
// 列表页
// ---------------------------------------------------------------------------
const searchForm = reactive({
routeName: '',
routeCode: '',
productTypeId: ''
})
const tableData = ref<Record<string, any>[]>([])
const selectedRows = ref<Record<string, any>[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const productTypeOptions = ref<Record<string, any>[]>([])
const productTypeOptionsLoading = ref(false)
const productTypeNameById = ref<Record<string, string>>({})
const productTypeMap = computed(() => {
const map: Record<string, string> = { ...productTypeNameById.value }
productTypeOptions.value.forEach((pt: Record<string, any>) => {
if (pt.id) map[pt.id] = pt.productTypeName || map[pt.id] || ''
})
return map
})
const handleSelectionChange = (rows: Record<string, any>[]) => {
selectedRows.value = rows
}
async function remoteSearchProductTypes(query: string) {
productTypeOptionsLoading.value = true
try {
const res = await fetchCellProductTypeList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
productTypeOptions.value = res.list || res || []
for (const pt of productTypeOptions.value) {
if (pt.id) productTypeNameById.value[pt.id] = pt.productTypeName || ''
}
} catch {
productTypeOptions.value = []
} finally {
productTypeOptionsLoading.value = false
}
}
function onProductTypeSelectVisible(visible: boolean) {
if (visible) void remoteSearchProductTypes('')
}
function ensureProductTypeInOptions(id: string) {
if (!id) return
if (productTypeOptions.value.some((pt) => pt.id === id)) return
const name = productTypeNameById.value[id] || id
productTypeOptions.value = [...productTypeOptions.value, { id, productTypeName: name }]
if (!productTypeNameById.value[id]) productTypeNameById.value[id] = name
}
async function loadData() {
loading.value = true
try {
const res = await fetchProcessRouteList({
page: pagination.page,
pageSize: pagination.pageSize,
routeName: searchForm.routeName || undefined,
routeCode: searchForm.routeCode || undefined,
productTypeId: searchForm.productTypeId || undefined
})
tableData.value = res.list || []
pagination.total = res.total ?? 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.routeName = ''
searchForm.routeCode = ''
searchForm.productTypeId = ''
pagination.page = 1
loadData()
}
// ---------------------------------------------------------------------------
// 新增 / 编辑弹窗
// ---------------------------------------------------------------------------
const dialogVisible = ref(false)
const dialogTitle = ref('新增路线')
const isEdit = ref(false)
const submitLoading = ref(false)
const formRef = ref()
const editingId = ref<string | null>(null)
const formData = reactive({
routeCode: '',
routeName: '',
productTypeId: '',
version: '',
remark: ''
})
const formRules = {
routeCode: [{ required: true, message: '请输入路线编码', trigger: 'blur' }],
routeName: [{ required: true, message: '请输入路线名称', trigger: 'blur' }]
}
function resetForm() {
formData.routeCode = ''
formData.routeName = ''
formData.productTypeId = ''
formData.version = ''
formData.remark = ''
editingId.value = null
}
function handleAdd() {
isEdit.value = false
dialogTitle.value = '新增路线'
resetForm()
dialogVisible.value = true
nextTick(() => formRef.value?.clearValidate?.())
}
function handleEdit(row: Record<string, any>) {
isEdit.value = true
dialogTitle.value = '编辑路线'
editingId.value = row.id
formData.routeCode = row.routeCode || ''
formData.routeName = row.routeName || ''
formData.productTypeId = row.productTypeId || ''
ensureProductTypeInOptions(row.productTypeId)
formData.version = row.version || ''
formData.remark = row.remark || ''
dialogVisible.value = true
nextTick(() => formRef.value?.clearValidate?.())
}
async function handleSubmit() {
await formRef.value?.validate?.()
submitLoading.value = true
try {
const payload = {
routeCode: formData.routeCode.trim(),
routeName: formData.routeName.trim(),
productTypeId: formData.productTypeId || undefined,
version: formData.version?.trim() || undefined,
remark: formData.remark?.trim() || undefined
}
if (isEdit.value && editingId.value) {
await fetchUpdateProcessRoute(editingId.value, payload)
} else {
await fetchCreateProcessRoute(payload)
}
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
dialogVisible.value = false
await loadData()
} catch {
/* 校验或接口错误已由全局处理 */
} finally {
submitLoading.value = false
}
}
async function handleSetCurrent(row: Record<string, any>) {
await ElMessageBox.confirm('确定将该路线设为当前版本吗?', '提示', { type: 'warning' })
await fetchSetCurrentProcessRoute(row.id)
ElMessage.success('设置成功')
await loadData()
}
function handleDropdownCommand(command: string, row: any) {
switch (command) {
case 'edit': handleEdit(row); break
case 'delete': handleDelete(row); break
}
}
async function handleDelete(row: Record<string, any>) {
await ElMessageBox.confirm('确定要删除该路线吗?删除后不可恢复。', '提示', { type: 'warning' })
await fetchDeleteProcessRoute(row.id)
ElMessage.success('删除成功')
await loadData()
}
async function handleBatchDelete() {
const ids = selectedRows.value.map((r) => r.id)
if (!ids.length) return
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 条路线吗?`, '提示', {
type: 'warning'
})
for (const id of ids) {
await fetchDeleteProcessRoute(id)
}
ElMessage.success('批量删除成功')
selectedRows.value = []
await loadData()
}
// ---------------------------------------------------------------------------
// 路线图配置弹窗
// ---------------------------------------------------------------------------
const configDialogVisible = ref(false)
const configRouteId = ref('')
const configRouteName = ref('')
const configRouteVersion = ref('')
/** 加载路线图详情期间禁止子表自动保存(避免 el-switch 挂载误触发 @change) */
const configSuppressSave = ref(false)
const stepList = ref<Record<string, any>[]>([])
const selectedStepId = ref('')
const stepListLoading = ref(false)
const activeSubTab = ref(PROCESS_ROUTE_DEFAULT_SUB_TAB)
const processSearchKeyword = ref('')
/** 接口 isKeyMaterial / isRequired 为 "0" | "1" 字符串 */
function toApiFlag(val: boolean | number | string | undefined): string {
if (val === true || val === 1 || val === '1') return '1'
return '0'
}
function fromApiFlag(val: boolean | number | string | undefined): boolean {
return val === true || val === 1 || val === '1'
}
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)
})),
consumables: (step.consumables || []).map((c: Record<string, any>) => ({
...c,
isRequired: fromApiFlag(c.isRequired)
})),
outputs: step.outputs || [],
dataCollections: step.dataCollections || []
}
}
// 工艺库数据
const processLibrary = ref<Record<string, any>[]>([])
const processLibraryLoading = ref(false)
const expandedCategories = ref<string[]>([])
const chineseOrdinals = [
'一',
'二',
'三',
'四',
'五',
'六',
'七',
'八',
'九',
'十',
'十一',
'十二',
'十三',
'十四',
'十五',
'十六',
'十七',
'十八',
'十九',
'二十'
]
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)
const reagentOptions = ref<{ id: string; label: string }[]>([])
const reagentOptionsLoading = ref(false)
const reagentMasterById = ref<Record<string, Record<string, any>>>({})
const consumableOptions = ref<{ id: string; label: string }[]>([])
const consumableOptionsLoading = ref(false)
const consumableMasterById = ref<Record<string, Record<string, any>>>({})
function mapItemUnitOption(u: { unitName?: string; unitCode?: string }) {
const name = (u.unitName || '').trim()
const code = (u.unitCode || '').trim()
return {
label: code ? `${name}(${code})` : name,
value: name
}
}
function ensureItemUnitOption(unitName: string) {
const v = unitName?.trim()
if (!v) return
if (!itemUnitOptions.value.some((o) => o.value === v)) {
itemUnitOptions.value = [{ label: v, value: v }, ...itemUnitOptions.value]
}
}
async function searchItemUnitOptions(query: string) {
itemUnitOptionsLoading.value = true
try {
const res = await fetchItemUnitList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
itemUnitOptions.value = (res.list || []).map(mapItemUnitOption).filter((o) => o.value)
} catch {
itemUnitOptions.value = []
} finally {
itemUnitOptionsLoading.value = false
}
}
function mapReagentOptionLabel(r: { reagentName?: string; reagentCode?: string }) {
const name = (r.reagentName || '').trim()
const code = (r.reagentCode || '').trim()
return code ? `${name}(${code})` : name
}
function cacheReagentMasters(list: Record<string, any>[]) {
const next = { ...reagentMasterById.value }
for (const r of list) {
if (r?.id) next[r.id] = r
}
reagentMasterById.value = next
}
/** 通过 reagentCode 在已缓存的主表数据中反查 masterId */
function resolveReagentMasterId(row: Record<string, any>): string | null {
const code = (row.reagentCode || '').trim()
if (!code) return null
for (const master of Object.values(reagentMasterById.value)) {
if ((master.reagentCode || '').trim() === code) return master.id
}
return null
}
async function searchReagentOptions(query: string) {
reagentOptionsLoading.value = true
try {
const res = await fetchReagentList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
const list = res.list || []
cacheReagentMasters(list)
const options = list
.filter((r) => (r.reagentName || '').trim() || (r.reagentCode || '').trim())
.map((r) => ({ id: r.id, label: mapReagentOptionLabel(r) }))
reagentOptions.value = options
ensureAllStepReagentOptions()
} catch {
reagentOptions.value = []
ensureAllStepReagentOptions()
} finally {
reagentOptionsLoading.value = false
}
}
function ensureReagentOption(row: Record<string, any>) {
if (row.reagentId && !String(row.reagentId).startsWith('legacy:')) {
if (!reagentOptions.value.some((o) => o.id === row.reagentId)) {
reagentOptions.value = [
{ id: row.reagentId, label: mapReagentOptionLabel(row) },
...reagentOptions.value
]
}
return
}
// reagentId 为空(后端不存储该字段)— 通过 reagentCode 反查主表真实 ID
const masterId = resolveReagentMasterId(row)
if (masterId) {
row.reagentId = masterId
// 主表选项已在 searchReagentOptions 中加载,无需重复添加
return
}
// 实在找不到主表记录才走 legacy 兜底
const code = (row.reagentCode || '').trim()
const name = (row.reagentName || '').trim()
if (!code && !name) return
const legacyId = `legacy:${code || name}`
row.reagentId = legacyId
if (!reagentOptions.value.some((o) => o.id === legacyId)) {
reagentOptions.value = [
{ id: legacyId, label: mapReagentOptionLabel(row) },
...reagentOptions.value
]
}
}
function ensureAllStepReagentOptions() {
for (const step of stepList.value) {
;(step.reagents || []).forEach((r: Record<string, any>) => ensureReagentOption(r))
}
}
function onReagentRowSelect(row: Record<string, any>, reagentId: string) {
if (!reagentId) {
row.reagentId = ''
saveReagents()
return
}
if (String(reagentId).startsWith('legacy:')) {
saveReagents()
return
}
// 重复校验:同一试剂 ID 不可出现多次
const reagents = selectedStep.value?.reagents || []
const duplicate = reagents.find(
(r: Record<string, any>) => r !== row && r.reagentId === reagentId
)
if (duplicate) {
ElMessage.warning('该试剂已添加,不可重复选择')
row.reagentId = ''
return
}
const master = reagentMasterById.value[reagentId]
if (!master) {
saveReagents()
return
}
row.reagentId = master.id
row.reagentCode = master.reagentCode || ''
row.reagentName = master.reagentName || ''
row.spec = master.spec || ''
row.standardUsage = master.standardUsage ?? ''
row.unit = master.unit || ''
row.remark = master.remark ?? master.description ?? ''
ensureItemUnitOption(row.unit)
ensureReagentOption(row)
saveReagents()
}
function mapConsumableOptionLabel(c: { consumableName?: string; consumableCode?: string }) {
const name = (c.consumableName || '').trim()
const code = (c.consumableCode || '').trim()
return code ? `${name}(${code})` : name
}
function cacheConsumableMasters(list: Record<string, any>[]) {
const next = { ...consumableMasterById.value }
for (const c of list) {
if (c?.id) next[c.id] = c
}
consumableMasterById.value = next
}
/** 通过 consumableCode 在已缓存的主表数据中反查 masterId */
function resolveConsumableMasterId(row: Record<string, any>): string | null {
const code = (row.consumableCode || '').trim()
if (!code) return null
for (const master of Object.values(consumableMasterById.value)) {
if ((master.consumableCode || '').trim() === code) return master.id
}
return null
}
async function searchConsumableOptions(query: string) {
consumableOptionsLoading.value = true
try {
const res = await fetchConsumableList({
page: 1,
pageSize: 100,
keyword: query?.trim() || undefined
})
const list = res.list || []
cacheConsumableMasters(list)
consumableOptions.value = list
.filter((c) => (c.consumableName || '').trim() || (c.consumableCode || '').trim())
.map((c) => ({ id: c.id, label: mapConsumableOptionLabel(c) }))
ensureAllStepConsumableOptions()
} catch {
consumableOptions.value = []
ensureAllStepConsumableOptions()
} finally {
consumableOptionsLoading.value = false
}
}
function ensureConsumableOption(row: Record<string, any>) {
if (row.consumableId && !String(row.consumableId).startsWith('legacy:')) {
if (!consumableOptions.value.some((o) => o.id === row.consumableId)) {
consumableOptions.value = [
{ id: row.consumableId, label: mapConsumableOptionLabel(row) },
...consumableOptions.value
]
}
return
}
// consumableId 为空(后端不存储该字段)— 通过 consumableCode 反查主表真实 ID
const masterId = resolveConsumableMasterId(row)
if (masterId) {
row.consumableId = masterId
// 主表选项已在 searchConsumableOptions 中加载,无需重复添加
return
}
// 实在找不到主表记录才走 legacy 兜底
const code = (row.consumableCode || '').trim()
const name = (row.consumableName || '').trim()
if (!code && !name) return
const legacyId = `legacy:${code || name}`
row.consumableId = legacyId
if (!consumableOptions.value.some((o) => o.id === legacyId)) {
consumableOptions.value = [
{ id: legacyId, label: mapConsumableOptionLabel(row) },
...consumableOptions.value
]
}
}
function ensureAllStepConsumableOptions() {
for (const step of stepList.value) {
;(step.consumables || []).forEach((c: Record<string, any>) => ensureConsumableOption(c))
}
}
function onConsumableRowSelect(row: Record<string, any>, consumableId: string) {
if (!consumableId) {
row.consumableId = ''
saveConsumables()
return
}
if (String(consumableId).startsWith('legacy:')) {
saveConsumables()
return
}
// 重复校验:同一耗材 ID 不可出现多次
const consumables = selectedStep.value?.consumables || []
const duplicate = consumables.find(
(c: Record<string, any>) => c !== row && c.consumableId === consumableId
)
if (duplicate) {
ElMessage.warning('该耗材已添加,不可重复选择')
row.consumableId = ''
return
}
const master = consumableMasterById.value[consumableId]
if (!master) {
saveConsumables()
return
}
row.consumableId = master.id
row.consumableCode = master.consumableCode || ''
row.consumableName = master.consumableName || ''
row.specModel = master.specModel || ''
row.standardUsage = master.standardUsage ?? ''
row.unit = master.unit || ''
row.remark = master.remark ?? master.description ?? ''
ensureItemUnitOption(row.unit)
ensureConsumableOption(row)
saveConsumables()
}
function ensureAllStepUnitOptions() {
for (const step of stepList.value) {
;(step.reagents || []).forEach((r: Record<string, any>) => ensureItemUnitOption(r.unit))
;(step.consumables || []).forEach((c: Record<string, any>) => ensureItemUnitOption(c.unit))
;(step.dataCollections || []).forEach((d: Record<string, any>) =>
ensureItemUnitOption(d.unit)
)
}
}
const selectedStep = computed(() => {
if (!selectedStepId.value) return null
return stepList.value.find((s) => s.id === selectedStepId.value) || null
})
const sopIdsArr = computed({
get() {
if (!selectedStep.value) return []
const raw = selectedStep.value.sopIds
if (!raw) return []
if (Array.isArray(raw)) return raw.filter(Boolean)
return String(raw)
.split(',')
.map((s: string) => s.trim())
.filter(Boolean)
},
set(val: string[]) {
if (!selectedStep.value) return
selectedStep.value.sopIds = val.join(',')
}
})
function onSopIdsChange(val: string[]) {
if (!selectedStep.value) return
selectedStep.value.sopIds = val.join(',')
saveStepBasic()
}
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
function sopAttachmentFullUrl(url: string) {
const u = String(url || '').trim()
if (!u) return ''
if (/^https?:\/\//i.test(u)) return u
return minioBaseUrl ? `${minioBaseUrl}/${u.replace(/^\//, '')}` : u
}
function attachmentsFromUrls(urls: string, sopId: string): Array<Record<string, unknown>> {
return urls
.split(',')
.map((u) => u.trim())
.filter(Boolean)
.map((url, i) => ({
id: `${sopId}_url_${i}`,
fileName: decodeURIComponent(url.split('/').pop() || `附件${i + 1}`),
fileUrl: url,
fileType: ''
}))
}
const linkedSopAttachmentFiles = computed(() => {
const ids = sopIdsArr.value
if (!ids?.length) return []
const idSet = new Set(ids)
const files: Array<Record<string, unknown>> = []
for (const sop of sopOptions.value) {
if (!idSet.has(sop.id)) continue
if (sop.attachmentFiles?.length) {
for (const f of sop.attachmentFiles) {
if (!f?.fileUrl) continue
files.push({
id: f.id || `${sop.id}_${f.fileUrl}`,
fileName:
f.fileName || decodeURIComponent(String(f.fileUrl).split('/').pop() || '文档'),
fileUrl: f.fileUrl,
fileType: f.fileType || ''
})
}
} else if (sop.sopAttachments) {
files.push(...attachmentsFromUrls(sop.sopAttachments, sop.id))
}
}
return files
})
function handleSopAttachmentPreview(file: Record<string, unknown>) {
const url = sopAttachmentFullUrl(String(file.fileUrl || ''))
if (url) window.open(url, '_blank')
}
function handleSopAttachmentDownload(file: Record<string, unknown>) {
downloadAttachmentFile(file, { fullFileUrl: sopAttachmentFullUrl })
}
// 按分类分组的工艺库(支持搜索过滤)
const filteredProcessLibrary = computed(() => {
const keyword = processSearchKeyword.value?.trim().toLowerCase() || ''
return processLibrary.value
.map((group) => {
const filtered = keyword
? group.processes.filter(
(p: Record<string, any>) =>
(p.processName || '').toLowerCase().includes(keyword) ||
(p.processCode || '').toLowerCase().includes(keyword)
)
: group.processes
return {
categoryId: group.categoryId,
categoryName: group.categoryName,
processes: filtered
}
})
.filter((group) => group.processes.length > 0)
})
async function loadConfigReferenceData() {
processLibraryLoading.value = true
try {
const [processes, categories, sops, outputTypes, equipments] = await Promise.all([
fetchAllProcesses(),
fetchAllProcessCategories(),
fetchAllSopDocuments(),
fetchAllOutputTypes(),
fetchAllEquipments()
])
categoryOptions.value = categories || []
sopOptions.value = sops || []
outputTypeOptions.value = outputTypes || []
equipmentOptions.value = equipments || []
// 按分类分组
const catMap: Record<
string,
{ categoryId: string; categoryName: string; processes: Record<string, any>[] }
> = {}
const uncategorizedKey = '__uncategorized__'
catMap[uncategorizedKey] = {
categoryId: uncategorizedKey,
categoryName: '未分类',
processes: []
}
;(categories || []).forEach((cat: Record<string, any>) => {
catMap[cat.id] = {
categoryId: cat.id,
categoryName: cat.categoryName || cat.name || '未命名分类',
processes: []
}
})
;(processes || []).forEach((proc: Record<string, any>) => {
const catId = proc.categoryId || uncategorizedKey
if (!catMap[catId]) {
catMap[catId] = { categoryId: catId, categoryName: '未知分类', processes: [] }
}
catMap[catId].processes.push(proc)
})
// 展开所有分类
expandedCategories.value = Object.keys(catMap)
processLibrary.value = Object.values(catMap)
} catch {
processLibrary.value = []
expandedCategories.value = []
} finally {
processLibraryLoading.value = false
}
try {
await Promise.all([
searchItemUnitOptions(''),
searchReagentOptions(''),
searchConsumableOptions('')
])
} catch {
/* ignore */
}
}
async function handleOpenConfig(row: Record<string, any>) {
configRouteId.value = row.id
configRouteName.value = row.routeName || ''
configRouteVersion.value = row.version || ''
selectedStepId.value = ''
activeSubTab.value = PROCESS_ROUTE_DEFAULT_SUB_TAB
processSearchKeyword.value = ''
configSuppressSave.value = true
stepList.value = []
stepListLoading.value = true
configDialogVisible.value = true
try {
await loadConfigReferenceData()
await loadRouteDetail()
} finally {
stepListLoading.value = false
await nextTick()
configSuppressSave.value = false
}
}
async function loadRouteDetail() {
try {
const detail = await fetchProcessRouteDetail(configRouteId.value)
stepList.value = (detail.steps || []).map((s: Record<string, any>) =>
normalizeStepSubTables(s)
)
ensureAllStepReagentOptions()
ensureAllStepConsumableOptions()
ensureAllStepUnitOptions()
if (stepList.value.length > 0 && !selectedStepId.value) {
selectedStepId.value = stepList.value[0].id
}
} catch {
stepList.value = []
}
}
// ---------------------------------------------------------------------------
// 拖拽:从工艺库拖入步骤列表
// ---------------------------------------------------------------------------
function onDragStart(event: DragEvent, proc: Record<string, any>) {
event.dataTransfer?.setData('processId', proc.id)
event.dataTransfer!.effectAllowed = 'copy'
}
async function onDropToCenter(event: DragEvent) {
const processId = event.dataTransfer?.getData('processId')
if (!processId || !configRouteId.value) return
stepListLoading.value = true
try {
const newStep = await fetchAddRouteStep({
routeId: configRouteId.value,
processId
})
await loadRouteDetail()
if (newStep?.id) {
selectedStepId.value = newStep.id
}
ElMessage.success('步骤添加成功')
} catch {
/* 接口错误已由全局处理 */
} finally {
stepListLoading.value = false
}
}
// ---------------------------------------------------------------------------
// 步骤排序
// ---------------------------------------------------------------------------
async function onStepDragEnd() {
if (!configRouteId.value) return
const stepIds = stepList.value.map((s) => s.id)
stepListLoading.value = true
try {
await fetchReorderRouteSteps({ routeId: configRouteId.value, stepIds })
} catch {
/* ignore */
} finally {
stepListLoading.value = false
}
}
function selectStep(step: Record<string, any>) {
selectedStepId.value = step.id
}
async function handleCopyStep(step: Record<string, any>) {
stepListLoading.value = true
try {
const newStep = await fetchCopyRouteStep({ stepId: step.id })
await loadRouteDetail()
if (newStep?.id) {
selectedStepId.value = newStep.id
}
ElMessage.success('步骤已复制')
} catch (e: any) {
ElMessage.error(e?.message || '复制失败')
} finally {
stepListLoading.value = false
}
}
async function handleDeleteStep(step: Record<string, any>, index: number) {
await ElMessageBox.confirm('确定删除该步骤吗?', '提示', { type: 'warning' })
stepListLoading.value = true
try {
await fetchDeleteRouteStep(step.id)
await loadRouteDetail()
if (selectedStepId.value === step.id) {
selectedStepId.value = stepList.value.length > 0 ? stepList.value[0].id : ''
}
ElMessage.success('步骤已删除')
} catch {
/* ignore */
} finally {
stepListLoading.value = false
}
}
// ---------------------------------------------------------------------------
// 步骤基本信息实时保存
// ---------------------------------------------------------------------------
async function saveStepBasic() {
if (!selectedStep.value) return
try {
await fetchUpdateRouteStep({
stepId: selectedStep.value.id,
processName: selectedStep.value.processName,
categoryId: selectedStep.value.categoryId,
description: selectedStep.value.description,
equipmentIds: selectedStep.value.equipmentIds?.length
? selectedStep.value.equipmentIds
: null,
estimatedDuration: selectedStep.value.estimatedDuration,
sopIds: selectedStep.value.sopIds
})
} catch {
/* ignore */
}
}
function onStepEquipmentChange(val: string[]) {
if (!selectedStep.value) return
selectedStep.value.equipmentIds = val || []
selectedStep.value.equipmentName = syncEquipmentNamesFromOptions(val, equipmentOptions.value)
saveStepBasic()
}
// ---------------------------------------------------------------------------
// 子表操作
// ---------------------------------------------------------------------------
// --- 试剂 ---
function addReagentRow() {
if (!selectedStep.value) return
if (!selectedStep.value.reagents) selectedStep.value.reagents = []
selectedStep.value.reagents.push({
reagentId: '',
reagentCode: '',
reagentName: '',
spec: '',
standardUsage: '',
unit: '',
isKeyMaterial: false,
remark: ''
})
saveReagents()
}
function removeReagentRow(index: number) {
selectedStep.value?.reagents?.splice(index, 1)
saveReagents()
}
async function saveReagents() {
if (!selectedStep.value || configSuppressSave.value) return
const reagents = selectedStep.value.reagents || []
// 按 reagentId(如有)或 reagentCode 检查重复
const seen = new Map<string, number>()
for (let i = 0; i < reagents.length; i++) {
const r = reagents[i]
const key = r.reagentId ? String(r.reagentId) : (r.reagentCode || '').trim()
if (!key) continue
if (seen.has(key)) {
ElMessage.warning(`试剂"${r.reagentName || r.reagentCode}"重复,请移除重复项`)
return
}
seen.set(key, i)
}
try {
await fetchUpdateStepReagents({
stepId: selectedStep.value.id,
items: reagents.map((r: Record<string, any>, idx: number) => ({
reagentCode: r.reagentCode,
reagentName: r.reagentName,
spec: r.spec,
standardUsage: r.standardUsage,
unit: r.unit,
isKeyMaterial: toApiFlag(r.isKeyMaterial),
orderNum: idx,
remark: r.remark || ''
}))
})
} catch {
/* ignore */
}
}
// --- 耗材 ---
function addConsumableRow() {
if (!selectedStep.value) return
if (!selectedStep.value.consumables) selectedStep.value.consumables = []
selectedStep.value.consumables.push({
consumableId: '',
consumableCode: '',
consumableName: '',
specModel: '',
standardUsage: '',
unit: '',
isRequired: false,
remark: ''
})
saveConsumables()
}
function removeConsumableRow(index: number) {
selectedStep.value?.consumables?.splice(index, 1)
saveConsumables()
}
async function saveConsumables() {
if (!selectedStep.value || configSuppressSave.value) return
const consumables = selectedStep.value.consumables || []
// 按 consumableId(如有)或 consumableCode 检查重复
const seen = new Map<string, number>()
for (let i = 0; i < consumables.length; i++) {
const c = consumables[i]
const key = c.consumableId ? String(c.consumableId) : (c.consumableCode || '').trim()
if (!key) continue
if (seen.has(key)) {
ElMessage.warning(`耗材"${c.consumableName || c.consumableCode}"重复,请移除重复项`)
return
}
seen.set(key, i)
}
try {
await fetchUpdateStepConsumables({
stepId: selectedStep.value.id,
items: consumables.map((c: Record<string, any>, idx: number) => ({
consumableCode: c.consumableCode,
consumableName: c.consumableName,
specModel: c.specModel,
standardUsage: c.standardUsage,
unit: c.unit,
isRequired: toApiFlag(c.isRequired),
orderNum: idx,
remark: c.remark || ''
}))
})
} catch {
/* ignore */
}
}
// --- 产出物 ---
function addOutputRow() {
if (!selectedStep.value) return
if (!selectedStep.value.outputs) selectedStep.value.outputs = []
selectedStep.value.outputs.push({
outputTypeId: '',
outputName: '',
outputBarcode: '',
remark: ''
})
saveOutputs()
}
function removeOutputRow(index: number) {
selectedStep.value?.outputs?.splice(index, 1)
saveOutputs()
}
async function saveOutputs() {
if (!selectedStep.value || configSuppressSave.value) return
try {
await fetchUpdateStepOutputs({
stepId: selectedStep.value.id,
items: (selectedStep.value.outputs || []).map((o: Record<string, any>, idx: number) => ({
outputTypeId: o.outputTypeId,
outputName: o.outputName,
outputBarcode: o.outputBarcode,
orderNum: idx,
remark: o.remark || ''
}))
})
} catch {
/* ignore */
}
}
// --- 数据采集 ---
function parseOrderNum(value: unknown): number {
const n = Number.parseInt(String(value ?? '').trim(), 10)
if (!Number.isFinite(n) || n < 0) return 0
return Math.min(n, 9999)
}
function addDataCollectionRow() {
if (!selectedStep.value) return
if (!selectedStep.value.dataCollections) selectedStep.value.dataCollections = []
selectedStep.value.dataCollections.push({
collectionName: '',
unit: '',
orderNum: 0,
remark: ''
})
saveDataCollections()
}
function removeDataCollectionRow(index: number) {
selectedStep.value?.dataCollections?.splice(index, 1)
saveDataCollections()
}
async function saveDataCollections() {
if (!selectedStep.value || configSuppressSave.value) return
try {
await fetchUpdateStepDataCollections({
stepId: selectedStep.value.id,
items: (selectedStep.value.dataCollections || []).map(
(d: Record<string, any>, idx: number) => ({
collectionName: d.collectionName,
unit: d.unit,
orderNum: idx,
remark: d.remark || ''
})
)
})
} catch {
/* ignore */
}
}
// ---------------------------------------------------------------------------
// 右侧区域手动保存
// ---------------------------------------------------------------------------
const manualSaveLoading = ref(false)
async function handleManualSave() {
if (!selectedStep.value) return
manualSaveLoading.value = true
try {
await Promise.all([
saveStepBasic(),
saveReagents(),
saveConsumables(),
saveOutputs(),
saveDataCollections()
])
ElMessage.success('保存成功')
} catch {
ElMessage.error('保存失败,请重试')
} finally {
manualSaveLoading.value = false
}
}
// ---------------------------------------------------------------------------
// 初始化
// ---------------------------------------------------------------------------
// 输入检索条件时自动搜索(防抖 300ms)
watchDebounced(
searchForm,
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
onMounted(() => {
loadData()
void remoteSearchProductTypes('')
})
</script>
<style scoped lang="scss">
.route-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
/* 路线图配置弹窗 */
.route-config-body {
display: flex;
gap: 16px;
height: calc(90vh - 120px);
min-height: 500px;
}
/* 左侧面板:工艺库 */
.route-config-left {
width: 280px;
flex-shrink: 0;
display: flex;
flex-direction: column;
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
}
.route-config-left-header {
padding: 12px;
border-bottom: 1px solid #e5e7eb;
flex-shrink: 0;
h3 {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
}
.route-config-search {
width: 100%;
}
.route-config-collapse {
flex: 1;
overflow-y: auto;
border: none;
:deep(.el-collapse-item__header) {
font-size: 13px;
font-weight: 600;
color: #374151;
background: #e8f4ee;
padding: 0 12px;
height: 36px;
line-height: 36px;
}
:deep(.el-collapse-item__wrap) {
border-bottom: none;
}
:deep(.el-collapse-item__content) {
padding: 4px 8px 8px;
}
}
.route-config-cat-bar {
display: inline-block;
width: 3px;
height: 14px;
background: #047857;
border-radius: 2px;
margin-right: 8px;
vertical-align: middle;
}
.route-config-process-card {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 8px 8px 10px;
margin-bottom: 4px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 6px;
cursor: grab;
transition: all 0.15s ease;
&:hover {
border-color: #047857;
box-shadow: 0 2px 4px rgba(4, 120, 87, 0.12);
}
&:active {
cursor: grabbing;
}
}
.route-config-process-name {
font-size: 13px;
font-weight: 500;
color: #1f2937;
margin-bottom: 2px;
}
.route-config-process-seq {
color: #1f2937;
font-size: 13px;
font-weight: 500;
margin-right: 2px;
}
.route-config-process-code {
font-size: 11px;
color: #9ca3af;
}
.route-config-process-info {
flex: 1;
min-width: 0;
}
.route-config-process-drag {
flex-shrink: 0;
color: #d1d5db;
font-size: 14px;
}
/* 中间面板:步骤列表 */
.route-config-center {
width: 480px;
flex-shrink: 0;
display: flex;
flex-direction: column;
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
}
.route-config-center-header {
padding: 12px 16px;
border-bottom: 1px solid #e5e7eb;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
}
.route-config-step-count {
font-size: 12px;
color: #9ca3af;
}
.route-config-center-dropzone {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.route-config-center-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #9ca3af;
p {
margin-top: 8px;
font-size: 13px;
}
}
.route-config-step-card {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
margin-bottom: 6px;
background: #fff;
border: 2px solid #e5e7eb;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
border-color: #6bb99a;
}
&.is-active {
border-color: #047857;
background: #e8f4ee;
}
}
.route-config-step-handle {
cursor: grab;
color: #9ca3af;
flex-shrink: 0;
font-size: 16px;
&:active {
cursor: grabbing;
}
}
.route-config-step-order {
width: 24px;
height: 24px;
border-radius: 50%;
background: #047857;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.route-config-step-info {
flex: 1;
min-width: 0;
}
.route-config-step-name {
font-size: 13px;
font-weight: 500;
color: #1f2937;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.route-config-step-desc {
font-size: 11px;
color: #9ca3af;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 右侧面板:步骤详情 */
.route-config-right {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
}
.route-config-right-header {
padding: 12px 16px;
border-bottom: 1px solid #e5e7eb;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
}
.route-config-right-body {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
}
.route-config-right-footer {
padding: 12px 16px;
border-top: 1px solid #e5e7eb;
display: flex;
justify-content: flex-end;
flex-shrink: 0;
}
.route-config-right-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #9ca3af;
p {
margin-top: 12px;
font-size: 13px;
}
}
.route-config-step-form {
margin-bottom: 12px;
:deep(.el-form-item) {
margin-bottom: 8px;
}
}
.route-config-tabs {
:deep(.el-tabs__header) {
margin-bottom: 8px;
}
}
.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%;
:deep(.inner-cell-input) {
width: 100%;
}
:deep(.inner-cell-select) {
width: 100%;
.el-select__wrapper {
min-height: 36px;
}
}
}
/* 路线图配置弹窗内的对话框滚动区域 */
.route-config-dialog {
:deep(.el-dialog__body) {
max-height: calc(90vh - 60px);
overflow: hidden;
padding: 16px;
}
}
.form-grid-4 .step-form-col-half {
grid-column: span 2;
}
.step-form-col-full {
grid-column: 1 / -1;
}
.step-sop-attachment-row {
margin-top: -4px;
:deep(.attachment-list) {
gap: 8px;
}
:deep(.attachment-item) {
width: 100px;
}
:deep(.attachment-preview) {
height: 68px;
}
:deep(.attachment-preview__icon) {
width: 36px;
height: 36px;
.el-icon {
font-size: 22px !important;
}
}
:deep(.attachment-footer) {
padding: 4px 6px;
}
:deep(.attachment-name) {
font-size: 10px;
min-height: calc(10px * 1.4 * 2);
}
:deep(.attachment-actions) {
margin-top: 2px;
gap: 6px;
}
:deep(.action-btn) {
width: 20px;
height: 20px;
font-size: 11px;
}
}
</style>