Files
zkxw/web-admin/src/views/production/process/index.vue
T
2026-06-16 07:38:27 +08:00

2077 lines
65 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 工艺库管理 -->
<template>
<div class="page-container">
<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>