2761 lines
90 KiB
Vue
2761 lines
90 KiB
Vue
<!-- 工艺库管理 · 工艺路线 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>
|