2077 lines
65 KiB
Vue
2077 lines
65 KiB
Vue
<!-- 工艺库管理 -->
|
||
<template>
|
||
<div class="page-container">
|
||
<section class="panel">
|
||
<el-tabs v-model="activeTab" class="panel-pill-tabs panel-pill-tabs--lg">
|
||
<el-tab-pane label="工艺列表" name="list">
|
||
<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.keyword"
|
||
placeholder="工序代码/名称"
|
||
clearable
|
||
@keyup.enter="handleSearch"
|
||
/>
|
||
</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) => (pagination.page - 1) * pagination.pageSize + index + 1"
|
||
/>
|
||
<el-table-column
|
||
prop="processCode"
|
||
label="工序代码"
|
||
min-width="130"
|
||
align="center"
|
||
header-align="center"
|
||
/>
|
||
<el-table-column
|
||
prop="processName"
|
||
label="工序名称"
|
||
min-width="140"
|
||
align="center"
|
||
header-align="center"
|
||
/>
|
||
<el-table-column label="工序分类" min-width="130" align="center" header-align="center">
|
||
<template #default="{ row }">
|
||
{{ categoryMap[row.categoryId] || '-' }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
prop="description"
|
||
label="描述"
|
||
min-width="160"
|
||
align="center"
|
||
header-align="center"
|
||
show-overflow-tooltip
|
||
/>
|
||
<el-table-column
|
||
prop="equipmentName"
|
||
label="设备名称"
|
||
min-width="120"
|
||
align="center"
|
||
header-align="center"
|
||
/>
|
||
<el-table-column
|
||
prop="estimatedDuration"
|
||
label="预估耗时(min)"
|
||
min-width="100"
|
||
align="center"
|
||
header-align="center"
|
||
/>
|
||
<el-table-column
|
||
prop="createTime"
|
||
label="创建时间"
|
||
min-width="180"
|
||
align="center"
|
||
header-align="center"
|
||
/>
|
||
<el-table-column
|
||
label="操作"
|
||
min-width="260"
|
||
fixed="right"
|
||
align="center"
|
||
header-align="center"
|
||
>
|
||
<template #default="{ row }">
|
||
<el-button class="btn-op-item primary" @click="handleEdit(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) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
|
||
@current-change="(val) => ((pagination.page = val), loadData())"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 新增/编辑弹窗 -->
|
||
<el-dialog
|
||
v-model="dialogVisible"
|
||
:title="dialogTitle"
|
||
width="1200px"
|
||
class="dialog-form-layout"
|
||
align-center
|
||
append-to-body
|
||
modal-class="protocol-dialog-mask"
|
||
destroy-on-close
|
||
:close-on-click-modal="false"
|
||
>
|
||
<DialogFormBody :loading="dialogLoading" body-class="process-dialog-body min-h-[200px]">
|
||
<div class="detail-tabs">
|
||
<div
|
||
v-for="tab in formTabs"
|
||
:key="tab.key"
|
||
class="detail-tab"
|
||
:class="{ active: activeFormTab === tab.key }"
|
||
@click="scrollToSection(tab.key)"
|
||
>
|
||
{{ tab.label }}
|
||
</div>
|
||
</div>
|
||
|
||
<el-form
|
||
ref="formRef"
|
||
:model="formData"
|
||
:rules="formRules"
|
||
label-position="top"
|
||
class="process-form"
|
||
scroll-to-error
|
||
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
|
||
>
|
||
<div id="form-section-basic" class="form-tab-pane">
|
||
<div class="section-card">
|
||
<div class="section-title">
|
||
<div class="section-title-left"><h3>基本信息</h3></div>
|
||
</div>
|
||
<div class="form-grid">
|
||
<el-form-item label="工序代码" prop="processCode">
|
||
<el-input v-model="formData.processCode" placeholder="请输入工序代码" />
|
||
</el-form-item>
|
||
<el-form-item label="工序名称" prop="processName">
|
||
<el-input v-model="formData.processName" placeholder="请输入工序名称" />
|
||
</el-form-item>
|
||
<el-form-item label="工序分类">
|
||
<el-select
|
||
v-model="formData.categoryId"
|
||
placeholder="请选择分类"
|
||
clearable
|
||
filterable
|
||
style="width: 100%"
|
||
>
|
||
<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="formData.equipmentIds"
|
||
placeholder="请选择设备"
|
||
clearable
|
||
filterable
|
||
multiple
|
||
collapse-tags
|
||
collapse-tags-tooltip
|
||
style="width: 100%"
|
||
>
|
||
<el-option
|
||
v-for="eq in equipmentOptions"
|
||
:key="eq.id"
|
||
:label="equipmentOptionLabel(eq)"
|
||
:value="eq.id"
|
||
:disabled="eq.status === '0'"
|
||
/>
|
||
</el-select>
|
||
<p v-if="legacyEquipmentHint" class="form-tip">{{ legacyEquipmentHint }}</p>
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<template #label>预估耗时<span class="form-label-tip">(min)</span></template>
|
||
<el-input v-model="formData.estimatedDuration" placeholder="如 30" />
|
||
</el-form-item>
|
||
<el-form-item label="关联SOP">
|
||
<el-select
|
||
v-model="formData.sopIds"
|
||
placeholder="请选择SOP文档"
|
||
multiple
|
||
clearable
|
||
filterable
|
||
style="width: 100%"
|
||
>
|
||
<el-option
|
||
v-for="sop in sopOptions"
|
||
:key="sop.id"
|
||
:label="sop.sopName || sop.name"
|
||
:value="sop.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="描述">
|
||
<el-input
|
||
v-model="formData.description"
|
||
type="textarea"
|
||
:rows="2"
|
||
placeholder="请输入描述"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="备注">
|
||
<el-input
|
||
v-model="formData.remark"
|
||
type="textarea"
|
||
:rows="2"
|
||
placeholder="可选"
|
||
/>
|
||
</el-form-item>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-if="SHOW_PROCESS_REAGENT_CONSUMABLE"
|
||
id="form-section-reagents"
|
||
class="form-tab-pane"
|
||
>
|
||
<div class="section-card">
|
||
<div class="section-title">
|
||
<div class="section-title-left"><h3>试剂关联</h3></div>
|
||
<el-button
|
||
class="btn-main"
|
||
type="primary"
|
||
size="small"
|
||
@click="openAddReagentDialog"
|
||
>
|
||
<el-icon><Plus /></el-icon>
|
||
添加试剂
|
||
</el-button>
|
||
</div>
|
||
<el-table :data="reagentList" stripe class="inner-table">
|
||
<el-table-column
|
||
type="index"
|
||
label="序号"
|
||
min-width="56"
|
||
align="center"
|
||
header-align="center"
|
||
:index="(index) => index + 1"
|
||
/>
|
||
<el-table-column
|
||
prop="reagentCode"
|
||
label="试剂编码"
|
||
min-width="110"
|
||
align="center"
|
||
header-align="center"
|
||
/>
|
||
<el-table-column
|
||
prop="reagentName"
|
||
label="试剂名称"
|
||
min-width="120"
|
||
align="center"
|
||
header-align="center"
|
||
/>
|
||
<el-table-column
|
||
prop="spec"
|
||
label="规格"
|
||
min-width="100"
|
||
align="center"
|
||
header-align="center"
|
||
/>
|
||
<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 inner-cell-input--usage"
|
||
/>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
prop="unit"
|
||
label="单位"
|
||
min-width="80"
|
||
align="center"
|
||
header-align="center"
|
||
/>
|
||
<el-table-column
|
||
label="关键物料"
|
||
min-width="90"
|
||
align="center"
|
||
header-align="center"
|
||
>
|
||
<template #default="{ row }">
|
||
<el-switch v-model="row.isKeyMaterial" />
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
label="备注"
|
||
min-width="120"
|
||
align="center"
|
||
header-align="center"
|
||
>
|
||
<template #default="{ row }">
|
||
<el-input v-model="row.remark" placeholder="备注" class="inner-cell-input" />
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" min-width="80" align="center" header-align="center">
|
||
<template #default="{ $index }">
|
||
<el-button
|
||
link
|
||
type="danger"
|
||
size="small"
|
||
@click="reagentList.splice($index, 1)"
|
||
>删除</el-button
|
||
>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<el-empty
|
||
v-if="reagentList.length === 0"
|
||
description="暂无试剂,点击上方按钮添加"
|
||
:image-size="48"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-if="SHOW_PROCESS_REAGENT_CONSUMABLE"
|
||
id="form-section-consumables"
|
||
class="form-tab-pane"
|
||
>
|
||
<div class="section-card">
|
||
<div class="section-title">
|
||
<div class="section-title-left"><h3>耗材关联</h3></div>
|
||
<div class="section-title-actions">
|
||
<el-button
|
||
class="btn-main"
|
||
type="primary"
|
||
size="small"
|
||
@click="openAddConsumableDialog"
|
||
>
|
||
<el-icon><Plus /></el-icon>
|
||
添加耗材
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
<el-table :data="consumableList" stripe class="inner-table">
|
||
<el-table-column
|
||
type="index"
|
||
label="序号"
|
||
min-width="56"
|
||
align="center"
|
||
header-align="center"
|
||
:index="(index) => index + 1"
|
||
/>
|
||
<el-table-column
|
||
prop="consumableCode"
|
||
label="耗材编码"
|
||
min-width="110"
|
||
align="center"
|
||
header-align="center"
|
||
/>
|
||
<el-table-column
|
||
prop="consumableName"
|
||
label="耗材名称"
|
||
min-width="120"
|
||
align="center"
|
||
header-align="center"
|
||
/>
|
||
<el-table-column
|
||
prop="specModel"
|
||
label="规格型号"
|
||
min-width="110"
|
||
align="center"
|
||
header-align="center"
|
||
/>
|
||
<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 inner-cell-input--usage"
|
||
/>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
prop="unit"
|
||
label="单位"
|
||
min-width="80"
|
||
align="center"
|
||
header-align="center"
|
||
/>
|
||
<el-table-column label="必填" min-width="80" align="center" header-align="center">
|
||
<template #default="{ row }">
|
||
<el-switch v-model="row.isRequired" />
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
label="备注"
|
||
min-width="120"
|
||
align="center"
|
||
header-align="center"
|
||
>
|
||
<template #default="{ row }">
|
||
<el-input v-model="row.remark" placeholder="备注" class="inner-cell-input" />
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" min-width="80" align="center" header-align="center">
|
||
<template #default="{ $index }">
|
||
<el-button
|
||
link
|
||
type="danger"
|
||
size="small"
|
||
@click="consumableList.splice($index, 1)"
|
||
>删除</el-button
|
||
>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<el-empty
|
||
v-if="consumableList.length === 0"
|
||
description="暂无耗材,点击上方按钮添加"
|
||
:image-size="48"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="form-section-outputs" class="form-tab-pane">
|
||
<div class="section-card">
|
||
<div class="section-title">
|
||
<div class="section-title-left"><h3>产出物</h3></div>
|
||
</div>
|
||
<div class="route-config-tab-list">
|
||
<div class="list-toolbar">
|
||
<div class="list-toolbar-row">
|
||
<el-button class="btn-main" type="primary" @click="addOutputRow">
|
||
<el-icon><Plus /></el-icon>
|
||
添加产出物
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
<div class="list-body list-wrap inner-table-wrap">
|
||
<el-table
|
||
v-if="outputList.length"
|
||
:data="outputList"
|
||
stripe
|
||
class="list-table inner-table"
|
||
>
|
||
<el-table-column
|
||
label="产出物类型"
|
||
min-width="150"
|
||
align="center"
|
||
header-align="center"
|
||
show-overflow-tooltip
|
||
>
|
||
<template #default="{ row }">
|
||
<el-select
|
||
v-model="row.outputTypeId"
|
||
placeholder="选择类型"
|
||
filterable
|
||
class="inner-cell-select"
|
||
>
|
||
<el-option
|
||
v-for="ot in outputTypeOptions"
|
||
:key="ot.id"
|
||
:label="ot.typeName || ot.name"
|
||
:value="ot.id"
|
||
/>
|
||
</el-select>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
label="产出物名称"
|
||
min-width="150"
|
||
align="center"
|
||
header-align="center"
|
||
show-overflow-tooltip
|
||
>
|
||
<template #default="{ row }">
|
||
<el-input
|
||
v-model="row.outputName"
|
||
placeholder="名称"
|
||
class="inner-cell-input"
|
||
/>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
label="产出物条码"
|
||
min-width="150"
|
||
align="center"
|
||
header-align="center"
|
||
show-overflow-tooltip
|
||
>
|
||
<template #default="{ row }">
|
||
<el-input
|
||
v-model="row.outputBarcode"
|
||
placeholder="条码"
|
||
class="inner-cell-input"
|
||
/>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
label="备注"
|
||
min-width="120"
|
||
align="center"
|
||
header-align="center"
|
||
show-overflow-tooltip
|
||
>
|
||
<template #default="{ row }">
|
||
<el-input v-model="row.remark" placeholder="备注" class="inner-cell-input" />
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" min-width="80" align="center" header-align="center">
|
||
<template #default="{ $index }">
|
||
<el-button link type="danger" @click="outputList.splice($index, 1)">
|
||
<el-icon><Delete /></el-icon>
|
||
删除
|
||
</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<el-empty
|
||
v-else
|
||
description="暂无产出物,点击上方按钮添加"
|
||
:image-size="48"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="form-section-data-collections" class="form-tab-pane">
|
||
<div class="section-card">
|
||
<div class="section-title">
|
||
<div class="section-title-left">
|
||
<h3>数据采集</h3>
|
||
</div>
|
||
</div>
|
||
<div class="route-config-tab-list">
|
||
<div class="list-toolbar">
|
||
<div class="list-toolbar-row">
|
||
<el-button class="btn-main" type="primary" @click="addDataCollectionRow">
|
||
<el-icon><Plus /></el-icon>
|
||
添加采集项
|
||
</el-button>
|
||
<span class="inner-table-sort-tip">拖拽左侧手柄可调整排序</span>
|
||
</div>
|
||
</div>
|
||
<div class="list-body list-wrap inner-table-wrap">
|
||
<el-table
|
||
v-if="dataCollectionList.length"
|
||
ref="dataCollectionTableRef"
|
||
:data="dataCollectionList"
|
||
row-key="_clientKey"
|
||
stripe
|
||
class="list-table inner-table data-collection-sort-table"
|
||
>
|
||
<el-table-column
|
||
label=""
|
||
min-width="52"
|
||
align="center"
|
||
header-align="center"
|
||
class-name="drag-col"
|
||
>
|
||
<template #default>
|
||
<el-icon class="data-collection-drag-handle" title="拖拽排序"
|
||
><Rank
|
||
/></el-icon>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
type="index"
|
||
label="序号"
|
||
min-width="56"
|
||
align="center"
|
||
header-align="center"
|
||
:index="(index) => index + 1"
|
||
/>
|
||
<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"
|
||
/>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
label="单位"
|
||
min-width="150"
|
||
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('')"
|
||
>
|
||
<el-option
|
||
v-for="item in itemUnitOptions"
|
||
:key="item.value"
|
||
:label="item.label"
|
||
:value="item.value"
|
||
/>
|
||
</el-select>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
label="排序号"
|
||
min-width="88"
|
||
align="center"
|
||
header-align="center"
|
||
>
|
||
<template #default="{ $index }">
|
||
<span class="data-collection-order-num">{{ $index + 1 }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
label="备注"
|
||
min-width="120"
|
||
align="center"
|
||
header-align="center"
|
||
show-overflow-tooltip
|
||
>
|
||
<template #default="{ row }">
|
||
<el-input v-model="row.remark" placeholder="备注" class="inner-cell-input" />
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" min-width="80" align="center" header-align="center">
|
||
<template #default="{ $index }">
|
||
<el-button link type="danger" @click="removeDataCollectionRow($index)">
|
||
<el-icon><Delete /></el-icon>
|
||
删除
|
||
</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<el-empty
|
||
v-else
|
||
description="暂无采集项,点击上方按钮添加"
|
||
:image-size="48"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-form>
|
||
</DialogFormBody>
|
||
|
||
<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-if="SHOW_PROCESS_REAGENT_CONSUMABLE"
|
||
v-model="selectReagentVisible"
|
||
title="选择试剂"
|
||
width="1000px"
|
||
append-to-body
|
||
destroy-on-close
|
||
:close-on-click-modal="false"
|
||
>
|
||
<div class="picker-toolbar">
|
||
<el-input
|
||
v-model="reagentKeyword"
|
||
placeholder="搜索试剂编码/名称"
|
||
clearable
|
||
class="picker-search"
|
||
@input="filterReagents"
|
||
/>
|
||
<span class="picker-count">
|
||
已选 <span class="sel-num">{{ selectedReagents.length }}</span> 项
|
||
</span>
|
||
</div>
|
||
<el-table
|
||
ref="reagentSelectTableRef"
|
||
class="list-table picker-table"
|
||
:data="filteredReagentList"
|
||
stripe
|
||
height="360"
|
||
row-key="id"
|
||
@selection-change="handleReagentSelectionChange"
|
||
>
|
||
<el-table-column
|
||
type="selection"
|
||
min-width="48"
|
||
align="center"
|
||
header-align="center"
|
||
:reserve-selection="true"
|
||
/>
|
||
<el-table-column
|
||
prop="reagentCode"
|
||
label="试剂编码"
|
||
min-width="120"
|
||
align="center"
|
||
header-align="center"
|
||
/>
|
||
<el-table-column
|
||
prop="reagentName"
|
||
label="试剂名称"
|
||
min-width="140"
|
||
align="center"
|
||
header-align="center"
|
||
/>
|
||
<el-table-column
|
||
prop="spec"
|
||
label="规格"
|
||
min-width="100"
|
||
align="center"
|
||
header-align="center"
|
||
/>
|
||
<el-table-column
|
||
prop="standardUsage"
|
||
label="标准用量"
|
||
min-width="110"
|
||
align="center"
|
||
header-align="center"
|
||
show-overflow-tooltip
|
||
/>
|
||
<el-table-column
|
||
prop="unit"
|
||
label="单位"
|
||
min-width="80"
|
||
align="center"
|
||
header-align="center"
|
||
/>
|
||
<el-table-column
|
||
prop="remark"
|
||
label="备注"
|
||
min-width="120"
|
||
align="center"
|
||
header-align="center"
|
||
show-overflow-tooltip
|
||
/>
|
||
<el-table-column
|
||
prop="description"
|
||
label="描述"
|
||
min-width="160"
|
||
align="center"
|
||
header-align="center"
|
||
show-overflow-tooltip
|
||
/>
|
||
</el-table>
|
||
<template #footer>
|
||
<el-button class="btn-ghost" @click="selectReagentVisible = false">取消</el-button>
|
||
<el-button
|
||
class="btn-main"
|
||
type="primary"
|
||
:disabled="selectedReagents.length === 0"
|
||
@click="confirmAddReagent"
|
||
>
|
||
确定添加
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 选择耗材内弹窗 -->
|
||
<el-dialog
|
||
v-if="SHOW_PROCESS_REAGENT_CONSUMABLE"
|
||
v-model="selectConsumableVisible"
|
||
title="选择耗材"
|
||
width="1000px"
|
||
append-to-body
|
||
destroy-on-close
|
||
:close-on-click-modal="false"
|
||
>
|
||
<div class="picker-toolbar">
|
||
<el-input
|
||
v-model="consumableKeyword"
|
||
placeholder="搜索耗材编码/名称"
|
||
clearable
|
||
class="picker-search"
|
||
@input="filterConsumables"
|
||
/>
|
||
<span class="picker-count">
|
||
已选 <span class="sel-num">{{ selectedConsumables.length }}</span> 项
|
||
</span>
|
||
</div>
|
||
<el-table
|
||
ref="consumableSelectTableRef"
|
||
class="list-table picker-table"
|
||
:data="filteredConsumableList"
|
||
stripe
|
||
height="360"
|
||
row-key="id"
|
||
@selection-change="handleConsumableSelectionChange"
|
||
>
|
||
<el-table-column
|
||
type="selection"
|
||
min-width="48"
|
||
align="center"
|
||
header-align="center"
|
||
:reserve-selection="true"
|
||
/>
|
||
<el-table-column
|
||
prop="consumableCode"
|
||
label="耗材编码"
|
||
min-width="120"
|
||
align="center"
|
||
header-align="center"
|
||
/>
|
||
<el-table-column
|
||
prop="consumableName"
|
||
label="耗材名称"
|
||
min-width="140"
|
||
align="center"
|
||
header-align="center"
|
||
/>
|
||
<el-table-column
|
||
prop="specModel"
|
||
label="规格型号"
|
||
min-width="120"
|
||
align="center"
|
||
header-align="center"
|
||
/>
|
||
<el-table-column
|
||
prop="standardUsage"
|
||
label="标准用量"
|
||
min-width="110"
|
||
align="center"
|
||
header-align="center"
|
||
show-overflow-tooltip
|
||
/>
|
||
<el-table-column
|
||
prop="unit"
|
||
label="单位"
|
||
min-width="80"
|
||
align="center"
|
||
header-align="center"
|
||
/>
|
||
<el-table-column
|
||
prop="remark"
|
||
label="备注"
|
||
min-width="120"
|
||
align="center"
|
||
header-align="center"
|
||
show-overflow-tooltip
|
||
/>
|
||
<el-table-column
|
||
prop="description"
|
||
label="描述"
|
||
min-width="160"
|
||
align="center"
|
||
header-align="center"
|
||
show-overflow-tooltip
|
||
/>
|
||
</el-table>
|
||
<template #footer>
|
||
<el-button class="btn-ghost" @click="selectConsumableVisible = false">取消</el-button>
|
||
<el-button
|
||
class="btn-main"
|
||
type="primary"
|
||
:disabled="selectedConsumables.length === 0"
|
||
@click="confirmAddConsumable"
|
||
>
|
||
确定添加
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 选择耗材包导入 -->
|
||
<el-dialog
|
||
v-if="SHOW_PROCESS_REAGENT_CONSUMABLE"
|
||
v-model="importPackVisible"
|
||
title="从耗材包导入"
|
||
width="960px"
|
||
append-to-body
|
||
destroy-on-close
|
||
:close-on-click-modal="false"
|
||
>
|
||
<el-input
|
||
v-model="packKeyword"
|
||
placeholder="搜索耗材包名称"
|
||
clearable
|
||
class="picker-search"
|
||
style="margin-bottom: 12px"
|
||
@input="filterPacks"
|
||
/>
|
||
<el-table
|
||
class="list-table picker-table pack-import-table"
|
||
:data="filteredPackList"
|
||
stripe
|
||
height="360"
|
||
row-key="id"
|
||
@row-click="handlePackRowClick"
|
||
>
|
||
<el-table-column label="" min-width="52" align="center" header-align="center">
|
||
<template #default="{ row }">
|
||
<el-radio v-model="selectedPackId" :value="row.id" @click.stop />
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
prop="packName"
|
||
label="耗材包名称"
|
||
min-width="160"
|
||
align="center"
|
||
header-align="center"
|
||
/>
|
||
<el-table-column
|
||
prop="packCode"
|
||
label="编码"
|
||
min-width="120"
|
||
align="center"
|
||
header-align="center"
|
||
/>
|
||
<el-table-column
|
||
prop="usageScene"
|
||
label="使用场景"
|
||
min-width="140"
|
||
align="center"
|
||
header-align="center"
|
||
show-overflow-tooltip
|
||
/>
|
||
<el-table-column label="类型" min-width="130" align="center" header-align="center">
|
||
<template #default="{ row }">
|
||
<el-tag :type="packTypeTagType(row.packType)" size="small">
|
||
{{ packTypeLabel(row.packType) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" min-width="90" align="center" header-align="center">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
|
||
{{ row.status === 1 ? '启用' : '停用' }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
prop="remark"
|
||
label="备注"
|
||
min-width="120"
|
||
align="center"
|
||
header-align="center"
|
||
show-overflow-tooltip
|
||
/>
|
||
</el-table>
|
||
<template #footer>
|
||
<el-button class="btn-ghost" @click="importPackVisible = false">取消</el-button>
|
||
<el-button
|
||
class="btn-main"
|
||
type="primary"
|
||
:loading="importPackLoading"
|
||
:disabled="!selectedPackId"
|
||
@click="confirmImportPack"
|
||
>
|
||
确定导入
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</el-tab-pane>
|
||
|
||
<el-tab-pane label="工艺路线" name="process-route" lazy>
|
||
<ProcessRouteTab />
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { Plus, Close, Check, Rank, RefreshLeft, Search, Delete } from '@element-plus/icons-vue'
|
||
import Sortable from 'sortablejs'
|
||
import type { SortableEvent } from 'sortablejs'
|
||
import {
|
||
fetchProcessList,
|
||
fetchProcessDetail,
|
||
fetchCreateProcess,
|
||
fetchUpdateProcess,
|
||
fetchDeleteProcess,
|
||
fetchAllProcessCategories,
|
||
fetchAllSopDocuments,
|
||
fetchAllReagents,
|
||
fetchAllConsumables,
|
||
fetchAllOutputTypes,
|
||
fetchAllConsumablePacks,
|
||
fetchConsumablePackDetail,
|
||
fetchAllEquipments
|
||
} from '@/api/production'
|
||
import { fetchItemUnitList } from '@/api/admin'
|
||
import { equipmentOptionLabel, parseEquipmentIds } from '@/utils/equipment-select'
|
||
import { SHOW_PROCESS_REAGENT_CONSUMABLE } from '@/utils/process-material-ui'
|
||
import { packTypeLabel, packTypeTagType } from '@/utils/status-tag-registry'
|
||
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
|
||
import DialogFormBody from '@/components/business/dialog-form-body/index.vue'
|
||
import ProcessRouteTab from './ProcessRouteTab.vue'
|
||
|
||
defineOptions({ name: 'ProductionProcess' })
|
||
|
||
const route = useRoute()
|
||
const activeTab = ref('list')
|
||
|
||
// 搜索 & 列表
|
||
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
|
||
const tableData = ref<any[]>([])
|
||
const selectedRows = ref<any[]>([])
|
||
const loading = ref(false)
|
||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||
|
||
// 弹窗状态
|
||
const dialogVisible = ref(false)
|
||
const dialogLoading = ref(false)
|
||
const dialogTitle = ref('新增工艺')
|
||
const isEdit = ref(false)
|
||
const submitLoading = ref(false)
|
||
const formRef = ref()
|
||
const editingId = ref<string | null>(null)
|
||
const activeFormTab = ref('basic')
|
||
|
||
const formTabs = [
|
||
{ key: 'basic', label: '基本信息' },
|
||
...(SHOW_PROCESS_REAGENT_CONSUMABLE
|
||
? [
|
||
{ key: 'reagents', label: '试剂关联' },
|
||
{ key: 'consumables', label: '耗材关联' }
|
||
]
|
||
: []),
|
||
{ key: 'outputs', label: '产出物' },
|
||
{ key: 'data-collections', label: '数据采集' }
|
||
] as const
|
||
|
||
function scrollToSection(key: string) {
|
||
activeFormTab.value = key
|
||
const el = document.getElementById(`form-section-${key}`)
|
||
if (el) {
|
||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||
}
|
||
}
|
||
|
||
// 下拉选项
|
||
const categoryOptions = ref<any[]>([])
|
||
const sopOptions = ref<any[]>([])
|
||
const equipmentOptions = ref<any[]>([])
|
||
const legacyEquipmentHint = ref('')
|
||
const outputTypeOptions = ref<any[]>([])
|
||
const itemUnitOptions = ref<{ label: string; value: string }[]>([])
|
||
const itemUnitOptionsLoading = ref(false)
|
||
const packOptions = ref<any[]>([])
|
||
const allReagentOptions = ref<any[]>([])
|
||
const allConsumableOptions = ref<any[]>([])
|
||
|
||
// 列表中的分类映射
|
||
const categoryMap = computed(() => {
|
||
const map: Record<string, string> = {}
|
||
categoryOptions.value.forEach((c: any) => {
|
||
map[c.id] = c.categoryName || c.name || ''
|
||
})
|
||
return map
|
||
})
|
||
|
||
// 表单数据
|
||
const formData = reactive({
|
||
processCode: '',
|
||
processName: '',
|
||
categoryId: '',
|
||
description: '',
|
||
equipmentIds: [] as string[],
|
||
estimatedDuration: '',
|
||
sopIds: [] as string[],
|
||
remark: ''
|
||
})
|
||
|
||
const formRules = {
|
||
processCode: [{ required: true, message: '请输入工序代码', trigger: 'blur' }],
|
||
processName: [{ required: true, message: '请输入工序名称', trigger: 'blur' }]
|
||
}
|
||
|
||
// 子表数据
|
||
const reagentList = ref<any[]>([])
|
||
const consumableList = ref<any[]>([])
|
||
const outputList = ref<any[]>([])
|
||
const dataCollectionList = ref<any[]>([])
|
||
const dataCollectionTableRef = ref()
|
||
let dataCollectionSortable: Sortable | null = null
|
||
let dataCollectionKeySeq = 0
|
||
|
||
function nextDataCollectionClientKey() {
|
||
dataCollectionKeySeq += 1
|
||
return `dc_${Date.now()}_${dataCollectionKeySeq}`
|
||
}
|
||
|
||
function withDataCollectionClientKey(row: Record<string, any>) {
|
||
return {
|
||
...row,
|
||
_clientKey: row._clientKey || nextDataCollectionClientKey()
|
||
}
|
||
}
|
||
|
||
function syncDataCollectionOrderNum() {
|
||
dataCollectionList.value.forEach((item, index) => {
|
||
item.orderNum = index + 1
|
||
})
|
||
}
|
||
|
||
function destroyDataCollectionSortable() {
|
||
dataCollectionSortable?.destroy()
|
||
dataCollectionSortable = null
|
||
}
|
||
|
||
function initDataCollectionSortable() {
|
||
destroyDataCollectionSortable()
|
||
nextTick(() => {
|
||
const tableEl = dataCollectionTableRef.value?.$el as HTMLElement | undefined
|
||
const tbody = tableEl?.querySelector('.el-table__body-wrapper tbody')
|
||
if (!tbody || dataCollectionList.value.length === 0) return
|
||
|
||
dataCollectionSortable = Sortable.create(tbody as HTMLElement, {
|
||
handle: '.data-collection-drag-handle',
|
||
animation: 150,
|
||
ghostClass: 'data-collection-row-ghost',
|
||
onEnd: (evt: SortableEvent) => {
|
||
const { oldIndex, newIndex } = evt
|
||
if (oldIndex == null || newIndex == null || oldIndex === newIndex) return
|
||
const list = [...dataCollectionList.value]
|
||
const [moved] = list.splice(oldIndex, 1)
|
||
list.splice(newIndex, 0, moved)
|
||
dataCollectionList.value = list
|
||
syncDataCollectionOrderNum()
|
||
}
|
||
})
|
||
})
|
||
}
|
||
|
||
// 选择试剂弹窗
|
||
const selectReagentVisible = ref(false)
|
||
const filteredReagentList = ref<any[]>([])
|
||
const reagentKeyword = ref('')
|
||
const selectedReagents = ref<any[]>([])
|
||
const reagentSelectTableRef = ref()
|
||
|
||
// 选择耗材弹窗
|
||
const selectConsumableVisible = ref(false)
|
||
const filteredConsumableList = ref<any[]>([])
|
||
const consumableKeyword = ref('')
|
||
const selectedConsumables = ref<any[]>([])
|
||
const consumableSelectTableRef = ref()
|
||
|
||
// 耗材包导入
|
||
const importPackVisible = ref(false)
|
||
const importPackLoading = ref(false)
|
||
const packKeyword = ref('')
|
||
const filteredPackList = ref<any[]>([])
|
||
const selectedPackId = ref<string | null>(null)
|
||
|
||
const handleSelectionChange = (rows: any[]) => {
|
||
selectedRows.value = rows
|
||
}
|
||
|
||
async function loadData() {
|
||
loading.value = true
|
||
try {
|
||
const res = await fetchProcessList({
|
||
page: pagination.page,
|
||
pageSize: pagination.pageSize,
|
||
keyword: searchForm.keyword || undefined
|
||
})
|
||
tableData.value = res.list || []
|
||
pagination.total = res.total ?? 0
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
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 ensureDataCollectionUnitOptions() {
|
||
dataCollectionList.value.forEach((row) => ensureItemUnitOption(row.unit))
|
||
}
|
||
|
||
// 加载所有下拉参考数据
|
||
async function loadReferenceData() {
|
||
try {
|
||
const [cats, sops, reagents, consumables, outputTypes, packs, equipments] =
|
||
await Promise.all([
|
||
fetchAllProcessCategories(),
|
||
fetchAllSopDocuments(),
|
||
fetchAllReagents(),
|
||
fetchAllConsumables(),
|
||
fetchAllOutputTypes(),
|
||
fetchAllConsumablePacks(),
|
||
fetchAllEquipments()
|
||
])
|
||
categoryOptions.value = cats || []
|
||
sopOptions.value = sops || []
|
||
allReagentOptions.value = reagents || []
|
||
allConsumableOptions.value = consumables || []
|
||
outputTypeOptions.value = outputTypes || []
|
||
packOptions.value = packs || []
|
||
equipmentOptions.value = equipments || []
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
function handleSearch() {
|
||
pagination.page = 1
|
||
loadData()
|
||
}
|
||
|
||
function handleReset() {
|
||
searchForm.keyword = ''
|
||
pagination.page = 1
|
||
loadData()
|
||
}
|
||
|
||
function resetForm() {
|
||
formData.processCode = ''
|
||
formData.processName = ''
|
||
formData.categoryId = ''
|
||
formData.description = ''
|
||
formData.equipmentIds = []
|
||
legacyEquipmentHint.value = ''
|
||
formData.estimatedDuration = ''
|
||
formData.sopIds = []
|
||
formData.remark = ''
|
||
reagentList.value = []
|
||
consumableList.value = []
|
||
outputList.value = []
|
||
dataCollectionList.value = []
|
||
editingId.value = null
|
||
activeFormTab.value = 'basic'
|
||
selectedPackId.value = null
|
||
packKeyword.value = ''
|
||
}
|
||
|
||
function isReagentDuplicate(reagentId: string, reagentCode: string) {
|
||
return reagentList.value.some(
|
||
(item) =>
|
||
(reagentId && (item.reagentId === reagentId || item.id === reagentId)) ||
|
||
(reagentCode && item.reagentCode === reagentCode)
|
||
)
|
||
}
|
||
|
||
function isConsumableDuplicate(consumableId: string, consumableCode: string) {
|
||
return consumableList.value.some(
|
||
(item) =>
|
||
(consumableId && (item.consumableId === consumableId || item.id === consumableId)) ||
|
||
(consumableCode && item.consumableCode === consumableCode)
|
||
)
|
||
}
|
||
|
||
/** 详情接口 sopIds 为逗号分隔字符串,多选需转为数组 */
|
||
function parseSopIds(value: string | string[] | null | undefined): string[] {
|
||
if (!value) return []
|
||
if (Array.isArray(value)) return value.filter(Boolean)
|
||
return String(value)
|
||
.split(',')
|
||
.map((s) => s.trim())
|
||
.filter(Boolean)
|
||
}
|
||
|
||
function serializeSopIds(ids: string[]): string | undefined {
|
||
const list = (ids || []).filter(Boolean)
|
||
return list.length ? list.join(',') : undefined
|
||
}
|
||
|
||
/** 接口 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 appendReagentFromMaster(r: any) {
|
||
if (isReagentDuplicate(r.id, r.reagentCode)) return false
|
||
reagentList.value.push({
|
||
reagentId: r.id,
|
||
reagentCode: r.reagentCode || '',
|
||
reagentName: r.reagentName || '',
|
||
spec: r.spec || '',
|
||
standardUsage: r.standardUsage ?? '',
|
||
unit: r.unit || '',
|
||
remark: r.remark ?? '',
|
||
isKeyMaterial: false
|
||
})
|
||
return true
|
||
}
|
||
|
||
function appendConsumableFromMaster(
|
||
c: any,
|
||
preset?: { standardUsage?: string; remark?: string }
|
||
) {
|
||
if (isConsumableDuplicate(c.id || c.consumableId, c.consumableCode)) return false
|
||
consumableList.value.push({
|
||
consumableId: c.consumableId || c.id,
|
||
consumableCode: c.consumableCode || '',
|
||
consumableName: c.consumableName || '',
|
||
specModel: c.specModel || '',
|
||
standardUsage: preset?.standardUsage ?? c.standardUsage ?? '',
|
||
unit: c.unit || '',
|
||
isRequired: false,
|
||
remark: preset?.remark ?? c.remark ?? ''
|
||
})
|
||
return true
|
||
}
|
||
|
||
async function handleAdd() {
|
||
isEdit.value = false
|
||
dialogTitle.value = '新增工艺'
|
||
resetForm()
|
||
dialogVisible.value = true
|
||
dialogLoading.value = true
|
||
nextTick(() => formRef.value?.clearValidate?.())
|
||
try {
|
||
await Promise.all([loadReferenceData(), searchItemUnitOptions('')])
|
||
} finally {
|
||
dialogLoading.value = false
|
||
nextTick(() => initDataCollectionSortable())
|
||
}
|
||
}
|
||
|
||
async function handleEdit(row: any) {
|
||
resetForm()
|
||
isEdit.value = true
|
||
dialogTitle.value = '编辑工艺'
|
||
editingId.value = row.id
|
||
dialogVisible.value = true
|
||
dialogLoading.value = true
|
||
nextTick(() => formRef.value?.clearValidate?.())
|
||
try {
|
||
const [, detail] = await Promise.all([loadReferenceData(), fetchProcessDetail(row.id)])
|
||
formData.processCode = detail.processCode || ''
|
||
formData.processName = detail.processName || ''
|
||
formData.categoryId = detail.categoryId || ''
|
||
formData.description = detail.description || ''
|
||
formData.equipmentIds = parseEquipmentIds(detail.equipmentIds || detail.equipmentName)
|
||
legacyEquipmentHint.value =
|
||
formData.equipmentIds.length === 0 && detail.equipmentName
|
||
? `历史文本:${detail.equipmentName},请重新选择设备`
|
||
: ''
|
||
formData.estimatedDuration = detail.estimatedDuration || ''
|
||
formData.sopIds = parseSopIds(detail.sopIds)
|
||
formData.remark = detail.remark || ''
|
||
reagentList.value = (detail.reagents || []).map((r: any) => ({
|
||
...r,
|
||
remark: r.remark ?? '',
|
||
isKeyMaterial: fromApiFlag(r.isKeyMaterial)
|
||
}))
|
||
consumableList.value = (detail.consumables || []).map((c: any) => ({
|
||
...c,
|
||
remark: c.remark ?? '',
|
||
isRequired: fromApiFlag(c.isRequired)
|
||
}))
|
||
outputList.value = (detail.outputs || []).map((o: any) => ({
|
||
...o,
|
||
remark: o.remark ?? ''
|
||
}))
|
||
dataCollectionList.value = (detail.dataCollections || []).map((d: any) =>
|
||
withDataCollectionClientKey({
|
||
...d,
|
||
remark: d.remark ?? ''
|
||
})
|
||
)
|
||
await searchItemUnitOptions('')
|
||
ensureDataCollectionUnitOptions()
|
||
} catch {
|
||
ElMessage.error('加载详情失败')
|
||
} finally {
|
||
dialogLoading.value = false
|
||
nextTick(() => initDataCollectionSortable())
|
||
}
|
||
}
|
||
|
||
async function handleSubmit() {
|
||
await formRef.value?.validate?.()
|
||
submitLoading.value = true
|
||
try {
|
||
const payload = {
|
||
processCode: formData.processCode.trim(),
|
||
processName: formData.processName.trim(),
|
||
categoryId: formData.categoryId || undefined,
|
||
description: formData.description?.trim() || undefined,
|
||
equipmentIds: formData.equipmentIds.length ? formData.equipmentIds : null,
|
||
estimatedDuration: formData.estimatedDuration?.trim() || undefined,
|
||
sopIds: serializeSopIds(formData.sopIds),
|
||
remark: formData.remark?.trim() || undefined,
|
||
reagents: reagentList.value.map((r) => ({
|
||
reagentId: r.reagentId || r.id,
|
||
reagentCode: r.reagentCode,
|
||
reagentName: r.reagentName,
|
||
spec: r.spec,
|
||
standardUsage: r.standardUsage,
|
||
unit: r.unit,
|
||
isKeyMaterial: toApiFlag(r.isKeyMaterial),
|
||
remark: r.remark || ''
|
||
})),
|
||
consumables: consumableList.value.map((c) => ({
|
||
consumableId: c.consumableId || c.id,
|
||
consumableCode: c.consumableCode,
|
||
consumableName: c.consumableName,
|
||
specModel: c.specModel,
|
||
standardUsage: c.standardUsage,
|
||
unit: c.unit || undefined,
|
||
isRequired: toApiFlag(c.isRequired),
|
||
remark: c.remark || ''
|
||
})),
|
||
outputs: outputList.value.map((o) => ({
|
||
outputTypeId: o.outputTypeId,
|
||
outputName: o.outputName,
|
||
outputBarcode: o.outputBarcode,
|
||
remark: o.remark || ''
|
||
})),
|
||
dataCollections: dataCollectionList.value.map((d) => ({
|
||
collectionName: d.collectionName,
|
||
unit: d.unit || undefined,
|
||
orderNum: d.orderNum ?? 0,
|
||
remark: d.remark || ''
|
||
}))
|
||
}
|
||
if (isEdit.value && editingId.value) {
|
||
await fetchUpdateProcess(editingId.value, payload)
|
||
} else {
|
||
await fetchCreateProcess(payload)
|
||
}
|
||
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
|
||
dialogVisible.value = false
|
||
await loadData()
|
||
} catch {
|
||
/* 校验或接口错误已由全局处理 */
|
||
} finally {
|
||
submitLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function handleDelete(row: any) {
|
||
try {
|
||
await ElMessageBox.confirm('确定要删除该工艺吗?', '提示', { type: 'warning' })
|
||
} catch {
|
||
return
|
||
}
|
||
await withFullscreenLoading(async () => {
|
||
await fetchDeleteProcess(row.id)
|
||
ElMessage.success('删除成功')
|
||
await loadData()
|
||
}, fullscreenLoadingText.delete)
|
||
}
|
||
|
||
async function handleBatchDelete() {
|
||
const ids = selectedRows.value.map((r) => r.id)
|
||
if (!ids.length) return
|
||
try {
|
||
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 个工艺吗?`, '提示', {
|
||
type: 'warning'
|
||
})
|
||
} catch {
|
||
return
|
||
}
|
||
await withFullscreenLoading(async () => {
|
||
for (const id of ids) {
|
||
await fetchDeleteProcess(id)
|
||
}
|
||
ElMessage.success('批量删除成功')
|
||
selectedRows.value = []
|
||
await loadData()
|
||
}, fullscreenLoadingText.batchDelete)
|
||
}
|
||
|
||
// =============== 试剂弹窗 ===============
|
||
function openAddReagentDialog() {
|
||
selectedReagents.value = []
|
||
reagentKeyword.value = ''
|
||
filteredReagentList.value = [...allReagentOptions.value]
|
||
selectReagentVisible.value = true
|
||
nextTick(() => reagentSelectTableRef.value?.clearSelection?.())
|
||
}
|
||
|
||
function filterReagents() {
|
||
const kw = reagentKeyword.value.trim().toLowerCase()
|
||
if (!kw) {
|
||
filteredReagentList.value = [...allReagentOptions.value]
|
||
return
|
||
}
|
||
filteredReagentList.value = allReagentOptions.value.filter(
|
||
(r) =>
|
||
(r.reagentCode || '').toLowerCase().includes(kw) ||
|
||
(r.reagentName || '').toLowerCase().includes(kw)
|
||
)
|
||
}
|
||
|
||
function handleReagentSelectionChange(rows: any[]) {
|
||
selectedReagents.value = rows
|
||
}
|
||
|
||
function confirmAddReagent() {
|
||
if (!selectedReagents.value.length) return
|
||
let added = 0
|
||
let skipped = 0
|
||
for (const r of selectedReagents.value) {
|
||
if (appendReagentFromMaster(r)) added++
|
||
else skipped++
|
||
}
|
||
if (added > 0) {
|
||
const msg =
|
||
skipped > 0 ? `已添加 ${added} 项试剂,跳过 ${skipped} 项重复` : `已添加 ${added} 项试剂`
|
||
ElMessage.success(msg)
|
||
} else {
|
||
ElMessage.warning('所选试剂均已在列表中')
|
||
}
|
||
selectReagentVisible.value = false
|
||
}
|
||
|
||
// =============== 耗材弹窗 ===============
|
||
function openAddConsumableDialog() {
|
||
selectedConsumables.value = []
|
||
consumableKeyword.value = ''
|
||
filteredConsumableList.value = [...allConsumableOptions.value]
|
||
selectConsumableVisible.value = true
|
||
nextTick(() => consumableSelectTableRef.value?.clearSelection?.())
|
||
}
|
||
|
||
function filterConsumables() {
|
||
const kw = consumableKeyword.value.trim().toLowerCase()
|
||
if (!kw) {
|
||
filteredConsumableList.value = [...allConsumableOptions.value]
|
||
return
|
||
}
|
||
filteredConsumableList.value = allConsumableOptions.value.filter(
|
||
(c) =>
|
||
(c.consumableCode || '').toLowerCase().includes(kw) ||
|
||
(c.consumableName || '').toLowerCase().includes(kw)
|
||
)
|
||
}
|
||
|
||
function handleConsumableSelectionChange(rows: any[]) {
|
||
selectedConsumables.value = rows
|
||
}
|
||
|
||
function confirmAddConsumable() {
|
||
if (!selectedConsumables.value.length) return
|
||
let added = 0
|
||
let skipped = 0
|
||
for (const c of selectedConsumables.value) {
|
||
if (appendConsumableFromMaster(c)) added++
|
||
else skipped++
|
||
}
|
||
if (added > 0) {
|
||
const msg =
|
||
skipped > 0 ? `已添加 ${added} 项耗材,跳过 ${skipped} 项重复` : `已添加 ${added} 项耗材`
|
||
ElMessage.success(msg)
|
||
} else {
|
||
ElMessage.warning('所选耗材均已在列表中')
|
||
}
|
||
selectConsumableVisible.value = false
|
||
}
|
||
|
||
// =============== 从耗材包导入 ===============
|
||
function openImportPackDialog() {
|
||
selectedPackId.value = null
|
||
packKeyword.value = ''
|
||
filteredPackList.value = [...packOptions.value]
|
||
importPackVisible.value = true
|
||
}
|
||
|
||
function filterPacks() {
|
||
const kw = packKeyword.value.trim().toLowerCase()
|
||
if (!kw) {
|
||
filteredPackList.value = [...packOptions.value]
|
||
return
|
||
}
|
||
filteredPackList.value = packOptions.value.filter((p) =>
|
||
(p.packName || '').toLowerCase().includes(kw)
|
||
)
|
||
}
|
||
|
||
function handlePackRowClick(row: any) {
|
||
if (row?.id) selectedPackId.value = row.id
|
||
}
|
||
|
||
async function confirmImportPack() {
|
||
if (!selectedPackId.value) return
|
||
importPackLoading.value = true
|
||
try {
|
||
await handleImportPack(selectedPackId.value)
|
||
importPackVisible.value = false
|
||
} finally {
|
||
importPackLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function handleImportPack(packId: string) {
|
||
try {
|
||
const detail = await fetchConsumablePackDetail(packId)
|
||
const packItems = detail.items || []
|
||
let added = 0
|
||
let skipped = 0
|
||
for (const item of packItems) {
|
||
if (
|
||
appendConsumableFromMaster(item, {
|
||
standardUsage: item.standardUsage || '',
|
||
remark: item.remark || ''
|
||
})
|
||
) {
|
||
added++
|
||
} else {
|
||
skipped++
|
||
}
|
||
}
|
||
if (added > 0) {
|
||
const msg =
|
||
skipped > 0
|
||
? `导入成功,新增 ${added} 项耗材,跳过 ${skipped} 项重复`
|
||
: `导入成功,新增 ${added} 项耗材`
|
||
ElMessage.success(msg)
|
||
} else if (packItems.length === 0) {
|
||
ElMessage.warning('该耗材包暂无明细')
|
||
} else {
|
||
ElMessage.warning('耗材包中的耗材均已在列表中')
|
||
}
|
||
} catch {
|
||
ElMessage.error('导入耗材包失败')
|
||
}
|
||
}
|
||
|
||
// =============== 产出物 ===============
|
||
function addOutputRow() {
|
||
outputList.value.push({
|
||
outputTypeId: '',
|
||
outputName: '',
|
||
outputBarcode: '',
|
||
remark: ''
|
||
})
|
||
}
|
||
|
||
function addDataCollectionRow() {
|
||
dataCollectionList.value.push(
|
||
withDataCollectionClientKey({
|
||
collectionName: '',
|
||
unit: '',
|
||
orderNum: dataCollectionList.value.length + 1,
|
||
remark: ''
|
||
})
|
||
)
|
||
syncDataCollectionOrderNum()
|
||
nextTick(() => initDataCollectionSortable())
|
||
}
|
||
|
||
function removeDataCollectionRow(index: number) {
|
||
dataCollectionList.value.splice(index, 1)
|
||
syncDataCollectionOrderNum()
|
||
nextTick(() => initDataCollectionSortable())
|
||
}
|
||
|
||
watch(dialogVisible, (visible) => {
|
||
if (!visible) {
|
||
destroyDataCollectionSortable()
|
||
}
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
destroyDataCollectionSortable()
|
||
})
|
||
|
||
// 初始化加载列表 & 分类数据(用于列表列的分类名映射)
|
||
// 输入检索条件时自动搜索(防抖 300ms)
|
||
watchDebounced(
|
||
searchForm,
|
||
() => {
|
||
pagination.page = 1
|
||
loadData()
|
||
},
|
||
{ debounce: 300, deep: true }
|
||
)
|
||
onMounted(async () => {
|
||
const tab = route.query.tab
|
||
if (tab === 'process-route') {
|
||
activeTab.value = 'process-route'
|
||
}
|
||
await loadData()
|
||
// 静默加载分类数据用于列表展示
|
||
try {
|
||
categoryOptions.value = (await fetchAllProcessCategories()) || []
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.process-dialog-body {
|
||
overflow: visible;
|
||
background: #f5f7fa;
|
||
}
|
||
|
||
.process-form {
|
||
:deep(.el-form-item) {
|
||
margin-bottom: 4px;
|
||
}
|
||
}
|
||
|
||
.detail-tabs {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 1000;
|
||
display: flex;
|
||
gap: 4px;
|
||
background: #f0fdf4;
|
||
padding: 4px;
|
||
border-radius: 8px;
|
||
margin: 0 28px 16px;
|
||
width: fit-content;
|
||
isolation: isolate;
|
||
box-shadow: 0 1px 0 #f0fdf4;
|
||
}
|
||
|
||
.detail-tab {
|
||
padding: 10px 20px;
|
||
cursor: pointer;
|
||
border-radius: 6px;
|
||
font-size: 14px;
|
||
color: #606266;
|
||
transition: all 0.3s;
|
||
|
||
&.active {
|
||
background: #fff;
|
||
color: #065f46;
|
||
font-weight: 500;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||
}
|
||
|
||
&:hover:not(.active) {
|
||
color: #065f46;
|
||
}
|
||
}
|
||
|
||
.form-tab-pane {
|
||
padding: 0 28px 4px;
|
||
|
||
& + .form-tab-pane {
|
||
margin-top: 0;
|
||
padding-top: 0;
|
||
}
|
||
}
|
||
|
||
.section-card {
|
||
border-radius: 12px;
|
||
padding: 22px 24px;
|
||
margin-bottom: 20px;
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||
transition:
|
||
transform 0.2s,
|
||
box-shadow 0.2s;
|
||
position: relative;
|
||
background: #fff;
|
||
|
||
&:hover {
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||
}
|
||
}
|
||
|
||
.section-title {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 18px;
|
||
padding-bottom: 12px;
|
||
border-bottom: 1px solid #e2e8f0;
|
||
}
|
||
|
||
.section-title-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
|
||
&::before {
|
||
content: '';
|
||
width: 3px;
|
||
height: 18px;
|
||
background: #047857;
|
||
border-radius: 2px;
|
||
margin-right: 10px;
|
||
flex-shrink: 0;
|
||
}
|
||
}
|
||
|
||
.section-title h3 {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: #1e293b;
|
||
}
|
||
|
||
.section-title-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.form-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 16px 20px;
|
||
}
|
||
|
||
.form-col-span-2 {
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
.inner-table-sort-tip {
|
||
font-size: 12px;
|
||
font-weight: 400;
|
||
color: var(--mint-text-muted, #64748b);
|
||
}
|
||
|
||
/* 弹窗内嵌列表:append-to-body 不在 page-container,须本地复刻标准列表视觉 */
|
||
.route-config-tab-list {
|
||
border: 1px solid var(--mint-border, #e2e8f0);
|
||
border-radius: var(--mint-radius, 10px);
|
||
overflow: hidden;
|
||
background: var(--mint-surface, #fff);
|
||
|
||
.list-toolbar {
|
||
padding: 0;
|
||
background: var(--mint-table-head, #f8fafc);
|
||
border-bottom: none;
|
||
}
|
||
|
||
.list-toolbar-row {
|
||
padding: 8px 16px;
|
||
gap: 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
justify-content: flex-start;
|
||
|
||
:deep(.el-button + .el-button) {
|
||
margin-left: 0 !important;
|
||
}
|
||
}
|
||
|
||
.list-body.list-wrap.inner-table-wrap {
|
||
border-top: 1px solid var(--mint-border, #e2e8f0);
|
||
border-radius: 0;
|
||
overflow: hidden;
|
||
|
||
:deep(.el-table) {
|
||
--el-table-header-bg-color: var(--mint-table-head, #f8fafc);
|
||
--el-table-border-color: var(--mint-border, #e2e8f0);
|
||
--el-table-row-hover-bg-color: var(--mint-row-hover, #f0fdf4);
|
||
--el-table-text-color: var(--mint-text, #0f172a);
|
||
--el-table-header-text-color: var(--mint-text-muted, #64748b);
|
||
font-size: 14px;
|
||
}
|
||
|
||
:deep(.el-table th.el-table__cell) {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--mint-text-muted, #64748b);
|
||
}
|
||
|
||
:deep(.el-table td.el-table__cell) {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: var(--mint-text, #0f172a);
|
||
}
|
||
|
||
:deep(.el-empty) {
|
||
padding: 24px 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
.data-collection-drag-handle {
|
||
cursor: move;
|
||
color: #909399;
|
||
font-size: 18px;
|
||
|
||
&:hover {
|
||
color: #065f46;
|
||
}
|
||
}
|
||
|
||
.data-collection-order-num {
|
||
display: inline-block;
|
||
min-width: 24px;
|
||
font-size: 14px;
|
||
color: var(--mint-text-muted, #64748b);
|
||
line-height: 32px;
|
||
}
|
||
|
||
.data-collection-sort-table {
|
||
:deep(.data-collection-row-ghost) {
|
||
opacity: 0.55;
|
||
background: #e8f4ee !important;
|
||
}
|
||
}
|
||
|
||
.inner-table {
|
||
width: 100%;
|
||
|
||
:deep(td.el-table__cell) {
|
||
padding-top: 8px;
|
||
padding-bottom: 8px;
|
||
}
|
||
|
||
:deep(.inner-cell-input) {
|
||
.el-input__wrapper {
|
||
min-height: 36px;
|
||
padding-top: 2px;
|
||
padding-bottom: 2px;
|
||
}
|
||
|
||
.el-input__inner {
|
||
height: 32px;
|
||
line-height: 32px;
|
||
}
|
||
}
|
||
|
||
:deep(.inner-cell-input--usage .el-input__inner) {
|
||
text-align: center;
|
||
}
|
||
|
||
:deep(.inner-cell-select) {
|
||
width: 100%;
|
||
|
||
.el-select__wrapper {
|
||
min-height: 36px;
|
||
}
|
||
}
|
||
}
|
||
|
||
.picker-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.picker-search {
|
||
flex: 1;
|
||
}
|
||
|
||
.picker-count {
|
||
flex-shrink: 0;
|
||
font-size: 13px;
|
||
color: var(--mint-text-muted, #64748b);
|
||
white-space: nowrap;
|
||
|
||
.sel-num {
|
||
color: var(--primary, #047857);
|
||
font-weight: 600;
|
||
}
|
||
}
|
||
|
||
.picker-table {
|
||
width: 100%;
|
||
}
|
||
|
||
.pack-import-table {
|
||
:deep(.el-radio) {
|
||
height: auto;
|
||
margin-right: 0;
|
||
}
|
||
|
||
:deep(.el-radio__label) {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.form-grid {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
}
|
||
}
|
||
</style>
|