Files
zkxw/web-admin/src/views/production/process-route/index.vue
T
2026-05-19 23:12:56 +08:00

1584 lines
55 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.
<!-- 工艺路线管理 -->
<template>
<div class="page-container">
<header class="page-head">
<h1>工艺路线管理</h1>
<p class="page-desc">管理产品工艺路线,配置工序步骤及其关联的试剂、耗材与产出物。</p>
</header>
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">路线列表</h2>
</div>
<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
>
<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-button>
<el-button class="btn-main" type="primary" @click="handleSearch">搜索</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" width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
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="操作" width="340" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<el-button class="btn-op-item primary" @click="handleOpenConfig(row)">
<el-icon><Rank /></el-icon>路线图配置
</el-button>
<el-button class="btn-op-item" @click="handleEdit(row)">编辑</el-button>
<el-button
v-if="!row.isCurrent"
class="btn-op-item"
@click="handleSetCurrent(row)"
>
设为当前
</el-button>
<el-button class="btn-op-item" @click="handleDelete(row)">删除</el-button>
</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
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>
<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
destroy-on-close
:close-on-click-modal="false"
>
<div class="route-config-body">
<!-- 左侧:工艺库 -->
<div class="route-config-left">
<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"
description="暂无工艺数据"
:image-size="48"
/>
</div>
<!-- 中间:步骤列表 -->
<div
class="route-config-center"
@dragover.prevent
@drop="onDropToCenter"
v-loading="stepListLoading"
element-loading-text="加载中..."
>
<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="#a7f3d0"><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>
</div>
<div class="route-config-right-body">
<el-form label-position="top" class="route-config-step-form">
<div class="form-grid-2">
<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="描述" class="form-col-span-2">
<el-input
v-model="selectedStep.description"
type="textarea"
:rows="2"
placeholder="工序描述"
@change="saveStepBasic"
/>
</el-form-item>
<el-form-item label="设备名称">
<el-input v-model="selectedStep.equipmentName" @change="saveStepBasic" />
</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="关联SOP" class="form-col-span-2">
<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>
</el-form>
<el-tabs v-model="activeSubTab" class="route-config-tabs">
<!-- 试剂 -->
<el-tab-pane 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="序号"
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="120" align="center" header-align="center">
<template #default="{ row }">
<el-input v-model="row.reagentName" placeholder="名称" class="inner-cell-input" @change="saveReagents" />
</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="80" align="center" header-align="center">
<template #default="{ row }">
<el-input v-model="row.unit" placeholder="单位" class="inner-cell-input" @change="saveReagents" />
</template>
</el-table-column>
<el-table-column label="关键物料" 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="操作" 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 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="序号"
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="120" align="center" header-align="center">
<template #default="{ row }">
<el-input v-model="row.consumableName" 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.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="80" align="center" header-align="center">
<template #default="{ row }">
<el-input v-model="row.unit" placeholder="单位" class="inner-cell-input" @change="saveConsumables" />
</template>
</el-table-column>
<el-table-column label="必需" 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="操作" 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-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>
</div>
<el-table :data="selectedStep.outputs" stripe class="inner-table" max-height="300">
<el-table-column
type="index"
label="序号"
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="操作" 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>
<!-- 数据采集 -->
<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>
</div>
<el-table :data="selectedStep.dataCollections" stripe class="inner-table" max-height="300">
<el-table-column
type="index"
label="序号"
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-input v-model="row.unit" placeholder="如 °C、mL" class="inner-cell-input" @change="saveDataCollections" />
</template>
</el-table-column>
<el-table-column label="排序号" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-input-number
v-model="row.orderNum"
:min="0"
:max="9999"
controls-position="right"
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-input v-model="row.remark" placeholder="备注" class="inner-cell-input" @change="saveDataCollections" />
</template>
</el-table-column>
<el-table-column label="操作" 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>
</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>
</section>
</div>
</template>
<script setup lang="ts">
import { Plus, Delete, Rank, Close, Check, CopyDocument } from '@element-plus/icons-vue'
import { VueDraggable as draggable } from 'vue-draggable-plus'
import {
fetchProcessRouteList,
fetchProcessRouteDetail,
fetchCreateProcessRoute,
fetchUpdateProcessRoute,
fetchSetCurrentProcessRoute,
fetchDeleteProcessRoute,
fetchAddRouteStep,
fetchCopyRouteStep,
fetchUpdateRouteStep,
fetchReorderRouteSteps,
fetchDeleteRouteStep,
fetchUpdateStepReagents,
fetchUpdateStepConsumables,
fetchUpdateStepOutputs,
fetchUpdateStepDataCollections,
fetchAllProcesses,
fetchAllProcessCategories,
fetchAllSopDocuments,
fetchAllOutputTypes
} from '@/api/production'
import { fetchCellProductTypeList } from '@/api/admin'
defineOptions({ name: 'ProductionProcessRoute' })
// ---------------------------------------------------------------------------
// 列表页
// ---------------------------------------------------------------------------
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 productTypeMap = computed(() => {
const map: Record<string, string> = {}
productTypeOptions.value.forEach((pt: Record<string, any>) => {
map[pt.id] = pt.productTypeName || ''
})
return map
})
const handleSelectionChange = (rows: Record<string, any>[]) => {
selectedRows.value = rows
}
async function loadProductTypes() {
try {
const res = await fetchCellProductTypeList({ page: 1, pageSize: 100 })
productTypeOptions.value = res.list || res || []
} catch {
/* ignore */
}
}
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 || ''
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()
}
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('reagent')
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,
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 expandedCategories = ref<string[]>([])
const chineseOrdinals = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一', '十二', '十三', '十四', '十五', '十六', '十七', '十八', '十九', '二十']
const categoryOptions = ref<Record<string, any>[]>([])
const sopOptions = ref<Record<string, any>[]>([])
const outputTypeOptions = ref<Record<string, any>[]>([])
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 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() {
try {
const [processes, categories, sops, outputTypes] = await Promise.all([
fetchAllProcesses(),
fetchAllProcessCategories(),
fetchAllSopDocuments(),
fetchAllOutputTypes()
])
categoryOptions.value = categories || []
sopOptions.value = sops || []
outputTypeOptions.value = outputTypes || []
// 按分类分组
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 {
/* ignore */
}
}
async function handleOpenConfig(row: Record<string, any>) {
configRouteId.value = row.id
configRouteName.value = row.routeName || ''
configRouteVersion.value = row.version || ''
selectedStepId.value = ''
activeSubTab.value = 'reagent'
processSearchKeyword.value = ''
configSuppressSave.value = true
configDialogVisible.value = true
try {
await loadConfigReferenceData()
await loadRouteDetail()
} finally {
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))
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,
equipmentName: selectedStep.value.equipmentName,
estimatedDuration: selectedStep.value.estimatedDuration,
sopIds: selectedStep.value.sopIds
})
} catch {
/* ignore */
}
}
// ---------------------------------------------------------------------------
// 子表操作
// ---------------------------------------------------------------------------
// --- 试剂 ---
function addReagentRow() {
if (!selectedStep.value) return
if (!selectedStep.value.reagents) selectedStep.value.reagents = []
selectedStep.value.reagents.push({
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
try {
await fetchUpdateStepReagents({
stepId: selectedStep.value.id,
items: (selectedStep.value.reagents || []).map((r: Record<string, any>) => ({
reagentCode: r.reagentCode,
reagentName: r.reagentName,
spec: r.spec,
standardUsage: r.standardUsage,
unit: r.unit,
isKeyMaterial: toApiFlag(r.isKeyMaterial),
remark: r.remark || ''
}))
})
} catch {
/* ignore */
}
}
// --- 耗材 ---
function addConsumableRow() {
if (!selectedStep.value) return
if (!selectedStep.value.consumables) selectedStep.value.consumables = []
selectedStep.value.consumables.push({
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
try {
await fetchUpdateStepConsumables({
stepId: selectedStep.value.id,
items: (selectedStep.value.consumables || []).map((c: Record<string, any>) => ({
consumableCode: c.consumableCode,
consumableName: c.consumableName,
specModel: c.specModel,
standardUsage: c.standardUsage,
unit: c.unit,
isRequired: toApiFlag(c.isRequired),
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>) => ({
outputTypeId: o.outputTypeId,
outputName: o.outputName,
outputBarcode: o.outputBarcode,
remark: o.remark || ''
}))
})
} catch {
/* ignore */
}
}
// --- 数据采集 ---
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>) => ({
collectionName: d.collectionName,
unit: d.unit,
orderNum: d.orderNum ?? 0,
remark: d.remark || ''
}))
})
} catch {
/* ignore */
}
}
// ---------------------------------------------------------------------------
// 初始化
// ---------------------------------------------------------------------------
onMounted(() => {
loadData()
loadProductTypes()
})
</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: #ecfdf5;
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: #10b981;
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: #10b981;
box-shadow: 0 2px 4px rgba(16, 185, 129, 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: 640px;
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: #6ee7b7;
}
&.is-active {
border-color: #10b981;
background: #ecfdf5;
}
}
.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: #10b981;
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;
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-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-tab-toolbar {
margin-bottom: 8px;
display: flex;
justify-content: flex-end;
}
/* 路线图配置弹窗内的对话框滚动区域 */
.route-config-dialog {
:deep(.el-dialog__body) {
max-height: calc(90vh - 60px);
overflow: hidden;
padding: 16px;
}
}
</style>