Explorar o código

feat: 优化 BOM 清单操作与物料详情展示

- 优化 BOM 新增/修改弹窗布局、默认上级 BOM、设备分类切换和提交加载态
- 优化 BOM 列表操作列交互,增加操作图标并关闭操作列溢出提示
- 将物料详情抽屉切换为 ZmTable,并按设备表单风格调整信息卡片和表格样式
- 优化物料数量校验、分页刷新、更新/删除后列表刷新逻辑
Zimo hai 1 semana
pai
achega
293e984f78
Modificáronse 3 ficheiros con 465 adicións e 238 borrados
  1. 61 38
      src/views/pms/bom/BomForm.vue
  2. 345 171
      src/views/pms/bom/MaterialListDrawer.vue
  3. 59 29
      src/views/pms/bom/index.vue

+ 61 - 38
src/views/pms/bom/BomForm.vue

@@ -1,12 +1,13 @@
 <template>
-  <Dialog v-model="dialogVisible" :title="dialogTitle" :close-on-click-modal="false">
+  <Dialog v-model="dialogVisible" :title="dialogTitle" width="640px" :close-on-click-modal="false">
     <el-form
       ref="formRef"
       v-loading="formLoading"
       :model="formData"
       :rules="formRules"
       label-width="120px"
-    >
+      size="default"
+      class="bom-form">
       <el-form-item label="设备分类" prop="deviceCategoryId">
         <el-tree-select
           v-model="formData.deviceCategoryId"
@@ -18,8 +19,9 @@
           value-key="id"
           filterable
           clearable
-          @node-click="handleDeviceCategoryTreeNodeClick"
-        />
+          size="default"
+          class="w-full"
+          @node-click="handleDeviceCategoryTreeNodeClick" />
       </el-form-item>
       <el-form-item label="上级BOM" prop="parentId">
         <el-tree-select
@@ -32,10 +34,10 @@
           clearable
           placeholder="请选择上级BOM"
           value-key="bomId"
-        />
+          size="default"
+          class="w-full" />
       </el-form-item>
       <el-form-item label="BOM节点名称" prop="name">
-<!--        <el-input v-model="formData.name" placeholder="请输入BOM节点名称" />-->
         <lang-input v-model="formData.name" placeholder="请输入BOM节点名称" />
       </el-form-item>
       <el-form-item label="BOM节点属性" prop="type">
@@ -43,59 +45,71 @@
           <el-checkbox
             v-for="dict in getIntDictOptions(DICT_TYPE.PMS_BOM_NODE_EXT_ATTR)"
             :key="dict.value"
-            :value="dict.value"
-          >
+            :value="dict.value">
             {{ dict.label }}
           </el-checkbox>
         </el-checkbox-group>
       </el-form-item>
       <el-form-item label="显示排序" prop="sort">
-        <el-input-number v-model="formData.sort" :min="0" controls-position="right" />
+        <el-input-number
+          v-model="formData.sort"
+          :min="0"
+          controls-position="right"
+          size="default"
+          class="w-full" />
       </el-form-item>
       <el-form-item label="状态" prop="status">
-        <el-select v-model="formData.status" clearable placeholder="请选择状态">
+        <el-select
+          v-model="formData.status"
+          clearable
+          placeholder="请选择状态"
+          size="default"
+          class="w-full">
           <el-option
             v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
             :key="dict.value"
             :label="dict.label"
-            :value="dict.value"
-          />
+            :value="dict.value" />
         </el-select>
       </el-form-item>
     </el-form>
     <template #footer>
-      <el-button type="primary" @click="submitForm">确 定</el-button>
-      <el-button @click="dialogVisible = false">取 消</el-button>
+      <el-button type="primary" size="default" :loading="formLoading" @click="submitForm">
+        确 定
+      </el-button>
+      <el-button size="default" :disabled="formLoading" @click="dialogVisible = false">
+        取 消
+      </el-button>
     </template>
   </Dialog>
 </template>
 <script lang="ts" setup>
-import {DICT_TYPE, getIntDictOptions, getStrDictOptions} from '@/utils/dict'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
 import { defaultProps, findPath, handleTree } from '@/utils/tree'
 import * as BomApi from '@/api/pms/bom'
 import { CommonStatusEnum } from '@/utils/constants'
-import { useTreeStore } from '@/store/modules/treeStore';
+import { useTreeStore } from '@/store/modules/treeStore'
 import * as DeviceCategoryApi from '@/api/pms/productclassify'
 import { FormRules } from 'element-plus'
-import { defineEmits, ref } from 'vue';
+import { defineEmits, ref } from 'vue'
 
 defineOptions({ name: 'BomForm' })
 
 // 从store中共享设备分类id
-const treeStore = useTreeStore();
+const treeStore = useTreeStore()
 const { t } = useI18n() // 国际化
 const message = useMessage() // 消息弹窗
-const localCategoryId = ref(null);  // 通过store存储的设备分类id 由父组件传递过来
-const selfDeviceCategoryId = ref(null);  // 当前页面定义的 设备分类id
+const localCategoryId = ref<number | undefined>() // 通过store存储的设备分类id 由父组件传递过来
+const selfDeviceCategoryId = ref<number | undefined>() // 当前页面定义的 设备分类id
 const dialogVisible = ref(false) // 弹窗的是否展示
 const dialogTitle = ref('') // 弹窗的标题
 const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
-const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formType = ref<'create' | 'update' | ''>('') // 表单的类型:create - 新增;update - 修改
 const formData = ref({
   id: undefined,
   deviceCategoryId: localCategoryId.value,
   title: '',
-  parentId: undefined,
+  parentId: 0,
   name: undefined,
   sort: 0,
   type: undefined,
@@ -103,14 +117,14 @@ const formData = ref({
 })
 const formRules = reactive<FormRules>({
   deviceCategoryId: [{ required: true, message: '设备分类不能为空', trigger: 'blur' }],
-  parentId: [{ required: true, message: '上级Bom不能为空', trigger: 'blur' }],
+  parentId: [{ required: true, message: '上级BOM不能为空', trigger: 'change' }],
   name: [{ required: true, message: 'Bom节点名称不能为空', trigger: 'blur' }],
   sort: [{ required: true, message: '显示排序不能为空', trigger: 'blur' }],
   status: [{ required: true, message: '状态不能为空', trigger: 'blur' }]
 })
 const formRef = ref() // 表单 Ref
 const bomTree = ref() // BOM 树形结构
-const deviceCategoryTree = ref<Tree[]>([])  // 设备分类树
+const deviceCategoryTree = ref<Tree[]>([]) // 设备分类树
 const deviceCategoryExpandedKeys = ref<Array<number | string>>([])
 
 const queryParams = reactive({
@@ -118,31 +132,30 @@ const queryParams = reactive({
   pageSize: 10,
   name: undefined,
   status: undefined,
-  deviceCategoryId: localCategoryId.value,
+  deviceCategoryId: localCategoryId.value
 })
 
 /** 打开弹窗 */
 const open = async (type: string, id?: number, parentId?: number, deviceCategoryId?: number) => {
   dialogVisible.value = true
-  console.log('open方法内部的设备分类id:' + deviceCategoryId)
-  console.log('open方法内部的上级BOM - id:' + parentId)
   // 获取store中的设备分类id
   // 优先使用传入的设备分类ID
-  const targetCategoryId = deviceCategoryId || treeStore.selectedId;
+  const targetCategoryId = deviceCategoryId || treeStore.selectedId
   // localCategoryId.value = treeStore.selectedId;
   // selfDeviceCategoryId.value = treeStore.selectedId;
-  localCategoryId.value = targetCategoryId || localCategoryId.value;
-  selfDeviceCategoryId.value = targetCategoryId || selfDeviceCategoryId.value;
+  localCategoryId.value = targetCategoryId || localCategoryId.value
+  selfDeviceCategoryId.value = targetCategoryId || selfDeviceCategoryId.value
 
   dialogTitle.value = t('action.' + type)
-  formType.value = type
+  formType.value = type as 'create' | 'update'
   resetForm()
   // 获得 设备分类树
   await getDeviceCategoryTree()
   // 获得 bom 树
   await getTree()
-  if (type === 'create' && parentId !== undefined) {
-    formData.value.parentId = parentId
+  if (type === 'create') {
+    formData.value.parentId = parentId ?? 0
+    formData.value.deviceCategoryId = selfDeviceCategoryId.value
   }
   // 修改时,设置数据
   if (id) {
@@ -150,7 +163,9 @@ const open = async (type: string, id?: number, parentId?: number, deviceCategory
     try {
       formData.value = await BomApi.getBom(id)
       selfDeviceCategoryId.value = formData.value.deviceCategoryId
-      deviceCategoryExpandedKeys.value = getDeviceCategoryExpandedKeys(formData.value.deviceCategoryId)
+      deviceCategoryExpandedKeys.value = getDeviceCategoryExpandedKeys(
+        formData.value.deviceCategoryId
+      )
     } finally {
       formLoading.value = false
     }
@@ -165,6 +180,8 @@ const handleDeviceCategoryTreeNodeClick = async (row: { [key: string]: any }) =>
   emit('node-click', row)
   // 清空设备分类bom树的选择,重新查询筛选bom树
   selfDeviceCategoryId.value = row.id
+  formData.value.deviceCategoryId = row.id
+  formData.value.parentId = 0
   deviceCategoryExpandedKeys.value = getDeviceCategoryExpandedKeys(row.id)
   await getTree()
 }
@@ -203,7 +220,7 @@ const resetForm = () => {
     id: undefined,
     deviceCategoryId: selfDeviceCategoryId.value,
     title: '',
-    parentId: undefined,
+    parentId: 0,
     name: undefined,
     sort: 0,
     type: undefined,
@@ -216,7 +233,7 @@ const resetForm = () => {
 const getDeviceCategoryTree = async () => {
   deviceCategoryTree.value = []
   const res = await DeviceCategoryApi.IotProductClassifyApi.getSimpleProductClassifyList()
-  let device: Tree = { id: 0, name: '顶级设备分类', children: [] }
+  const device: Tree = { id: 0, name: '顶级设备分类', children: [] }
   device.children = handleTree(res)
   deviceCategoryTree.value.push(device)
   deviceCategoryExpandedKeys.value = getDeviceCategoryExpandedKeys(selfDeviceCategoryId.value)
@@ -238,9 +255,9 @@ const getDeviceCategoryExpandedKeys = (deviceCategoryId?: number | string | null
 /** 获得 Bom 树 */
 const getTree = async () => {
   bomTree.value = []
-  queryParams.deviceCategoryId = selfDeviceCategoryId.value;
+  queryParams.deviceCategoryId = selfDeviceCategoryId.value
   const data = await BomApi.getSimpleBomList(queryParams)
-  let bom: Tree = { id: 0, name: '顶级Bom', children: [] }
+  const bom: Tree = { id: 0, name: '顶级BOM', children: [] }
   bom.children = handleTree(data)
   bomTree.value.push(bom)
 }
@@ -250,3 +267,9 @@ onMounted(() => {
   formData.value.sort = 0
 })
 </script>
+
+<style lang="scss" scoped>
+.bom-form {
+  padding: 8px 8px 0;
+}
+</style>

+ 345 - 171
src/views/pms/bom/MaterialListDrawer.vue

@@ -1,103 +1,157 @@
 <template>
   <el-drawer
-    title="物料详情"
     :append-to-body="true"
     :model-value="modelValue"
-    @update:model-value="$emit('update:modelValue', $event)"
     :show-close="false"
     direction="rtl"
     :size="computedSize"
     :before-close="handleClose"
-  >
+    class="material-detail-drawer"
+    @update:model-value="$emit('update:modelValue', $event)">
+    <template #header>
+      <div class="drawer-header">
+        <div class="drawer-header__main">
+          <div class="drawer-header__icon">
+            <Icon icon="ep:goods" :size="22" />
+          </div>
+          <div>
+            <div class="drawer-title">物料详情</div>
+            <div class="drawer-subtitle">查看并维护当前 BOM 节点关联的物料数量</div>
+          </div>
+        </div>
+        <el-button circle size="default" @click="handleClose">
+          <Icon icon="ep:close" />
+        </el-button>
+      </div>
+    </template>
+
     <template v-if="nodeId">
-      <div v-loading="loading" style="height: 100%">
+      <div v-loading="loading" class="drawer-body">
+        <section class="info-card">
+          <div class="info-card__header">
+            <div class="info-card__title">
+              <span class="info-card__marker"></span>
+              <span>BOM 信息</span>
+            </div>
+          </div>
 
-        <div class="info-header">
-          <div class="info-item">
-            <span class="info-label">设备分类:</span>
-            <span class="info-value">{{ rowInfo.deviceCategoryName }}</span>
+          <div class="info-card__body">
+            <div class="detail-grid">
+              <div class="detail-item">
+                <div class="detail-item__label">设备分类</div>
+                <div class="detail-item__value">{{ rowInfo.deviceCategoryName }}</div>
+              </div>
+              <div class="detail-item">
+                <div class="detail-item__label">BOM节点</div>
+                <div class="detail-item__value">{{ rowInfo.bomNodeName }}</div>
+              </div>
+            </div>
           </div>
-          <div class="separator"></div> <!-- 分隔线 -->
-          <div class="info-item">
-            <span class="info-label">BOM节点:</span>
-            <span class="info-value">{{ rowInfo.bomNodeName }}</span>
+        </section>
+
+        <section class="info-card material-card">
+          <div class="info-card__header">
+            <div class="info-card__title">
+              <span class="info-card__marker"></span>
+              <span>关联物料</span>
+            </div>
+            <span class="material-total">共 {{ total }} 条</span>
           </div>
-        </div>
 
-        <el-table :data="materials" style="width: 100%" fit :row-style="{ height: '38px' }">
-          <el-table-column prop="name" :label="t('workOrderMaterial.materialName')" min-width="150" show-overflow-tooltip/>
-          <el-table-column prop="code" :label="t('workOrderMaterial.materialCode')" min-width="80" />
-          <el-table-column prop="quantity" :label="t('route.quantity')" min-width="70" align="center">
-            <template #default="scope">
-              <el-form
-                :model="scope.row"
-                :rules="quantityRules"
-                ref="quantityFormRef"
-                @submit.prevent
-              >
-                <el-form-item prop="quantity" :error="scope.row.quantityError">
-                  <el-input
-                    v-model.number="scope.row.quantity"
-                    type="number"
-                    size="small"
-                    placeholder="请输入数量"
-                    @blur="clearQuantityError(scope.row)"
-                    @keyup.enter="handleUpdate(scope.row)"
-                    :class="{ 'error-input': scope.row.quantityError }"
-                  />
-                </el-form-item>
-              </el-form>
-            </template>
-          </el-table-column>
-          <el-table-column prop="unit" :label="t('workOrderMaterial.unit')" min-width="50" align="center"/>
-          <el-table-column
-            :label="t('deviceList.createTime')"
-            align="center"
-            prop="createTime"
-            min-width="110"
-            :formatter="dateFormatter"
-          />
-          <el-table-column label="操作" align="right" width="180" fixed="right">
-            <template #default="scope">
-              <el-button
-                size="small"
-                type="success"
-                :loading="scope.row.updating"
-                @click="handleUpdate(scope.row)"
-              >{{ t('iotDevice.update') }}</el-button>
-
-              <el-button
-                size="small"
-                type="danger"
-                @click="handleDelete(scope.row)"
-              >{{ t('info.delete') }}</el-button>
-            </template>
-          </el-table-column>
-        </el-table>
-        <!-- 分页 -->
-        <Pagination
-          :total="total"
-          v-model:page="queryParams.pageNo"
-          v-model:limit="queryParams.pageSize"
-          @pagination="loadMaterials(props.nodeId)"
-        />
+          <div class="info-card__body material-card__body">
+            <ZmTable
+              :data="materials"
+              :loading="loading"
+              :show-border="true"
+              settings-cache-key="pms-bom-material-detail"
+              class="material-table">
+              <ZmTableColumn
+                prop="name"
+                :label="t('workOrderMaterial.materialName')"
+                min-width="180"
+                align="left"
+                fixed="left" />
+              <ZmTableColumn
+                prop="code"
+                :label="t('workOrderMaterial.materialCode')"
+                min-width="130" />
+              <ZmTableColumn prop="quantity" :label="t('route.quantity')" min-width="130">
+                <template #default="{ row }">
+                  <el-form
+                    :model="row"
+                    :rules="quantityRules"
+                    size="default"
+                    class="quantity-form"
+                    @submit.prevent>
+                    <el-form-item prop="quantity" :error="row.quantityError">
+                      <el-input
+                        v-model.number="row.quantity"
+                        type="number"
+                        size="default"
+                        placeholder="请输入数量"
+                        min="0"
+                        class="quantity-input"
+                        @blur="clearQuantityError(row)"
+                        @keyup.enter="handleUpdate(row)" />
+                    </el-form-item>
+                  </el-form>
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn prop="unit" :label="t('workOrderMaterial.unit')" min-width="90" />
+              <ZmTableColumn
+                :label="t('deviceList.createTime')"
+                prop="createTime"
+                min-width="170"
+                :formatter="dateFormatter" />
+              <ZmTableColumn label="操作" width="180" fixed="right" action>
+                <template #default="{ row }">
+                  <div class="table-actions">
+                    <el-button
+                      size="default"
+                      type="primary"
+                      link
+                      :loading="row.updating"
+                      @click="handleUpdate(row)">
+                      <Icon icon="ep:check" class="mr-4px" />修改
+                    </el-button>
+                    <el-button size="default" type="danger" link @click="handleDelete(row)">
+                      <Icon icon="ep:delete" class="mr-4px" />删除
+                    </el-button>
+                  </div>
+                </template>
+              </ZmTableColumn>
+            </ZmTable>
+
+            <Pagination
+              v-show="total > 0"
+              :total="total"
+              v-model:page="queryParams.pageNo"
+              v-model:limit="queryParams.pageSize"
+              @pagination="loadMaterials(props.nodeId, false)" />
+          </div>
+        </section>
       </div>
     </template>
-
   </el-drawer>
 </template>
 <script setup lang="ts">
+import { ref, watch } from 'vue'
+import { ElMessage, type FormRules } from 'element-plus'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+import { CommonBomMaterialApi, type CommonBomMaterialVO } from '@/api/pms/commonbommaterial'
+import { dateFormatter } from '@/utils/formatTime'
 
-import { ref, watch, defineOptions, defineEmits } from 'vue'
-import { ElMessage } from 'element-plus'
-import * as PmsMaterialApi from '@/api/pms/material'
-import {CommonBomMaterialApi, CommonBomMaterialVO} from '@/api/pms/commonbommaterial'
-import {dateFormatter} from "@/utils/formatTime";
-const drawerVisible = ref<boolean>(false)
 const emit = defineEmits(['update:modelValue', 'add', 'delete', 'refresh'])
 const message = useMessage() // 消息弹窗
 const { t } = useI18n() // 国际化
 
+type MaterialDetailRow = CommonBomMaterialVO & {
+  updating?: boolean
+  quantityError?: string
+}
+
+const { ZmTable, ZmTableColumn } = useTableComponents<MaterialDetailRow>()
+
 defineOptions({
   name: 'MaterialListDrawer'
 })
@@ -117,32 +171,36 @@ const quantityRules: FormRules = {
     {
       validator: (rule, value, callback) => {
         if (isNaN(value)) {
-          callback(new Error('请输入有效数字'));
+          callback(new Error('请输入有效数字'))
         } else if (value <= 0) {
-          callback(new Error('数量必须大于0'));
+          callback(new Error('数量必须大于0'))
         } else {
-          callback();
+          callback()
         }
       },
       trigger: 'blur'
     }
   ]
-};
+}
 
 const windowWidth = ref(window.innerWidth)
 // 动态计算百分比
 const computedSize = computed(() => {
+  if (windowWidth.value <= 768) return '100%'
   return windowWidth.value > 1200 ? '60%' : '80%'
 })
+const updateWindowWidth = () => {
+  windowWidth.value = window.innerWidth
+}
 
 const loading = ref(false)
 const total = ref(0) // 列表的总页数
-const materials = ref([])
+const materials = ref<MaterialDetailRow[]>([])
 
 const props = defineProps({
   modelValue: Boolean,
   nodeId: Number,
-  rowInfo: {  // 新增props
+  rowInfo: {
     type: Object,
     default: () => ({
       deviceCategoryName: '',
@@ -152,21 +210,27 @@ const props = defineProps({
 })
 
 // 监听bom树节点ID变化
-watch(() => props.nodeId, async (newVal) => {
-  if (newVal) {
-    await loadMaterials(newVal)
+watch(
+  () => props.nodeId,
+  async (newVal) => {
+    if (newVal) {
+      await loadMaterials(newVal)
+    }
   }
-})
+)
 
 // 加载指定bom节点下的物料数据
-const loadMaterials = async (nodeId) => {
-  queryParams.bomNodeId = nodeId
-  queryParams.pageNo = 1
+const loadMaterials = async (nodeId?: number, resetPage = true) => {
+  if (!nodeId) return
+  queryParams.bomNodeId = nodeId as any
+  if (resetPage) {
+    queryParams.pageNo = 1
+  }
   try {
     loading.value = true
     // API调用
     const data = await CommonBomMaterialApi.getCommonBomMaterialPage(queryParams)
-    materials.value = data.list.map(item => ({
+    materials.value = data.list.map((item) => ({
       ...item,
       updating: false, // 更新状态标记
       quantityError: '' // 错误状态标记
@@ -180,30 +244,30 @@ const loadMaterials = async (nodeId) => {
 }
 
 /** 清除数量错误状态 */
-const clearQuantityError = (row: CommonBomMaterialVO) => {
+const clearQuantityError = (row: MaterialDetailRow) => {
   // 延迟清除错误状态,避免在验证过程中清除
   setTimeout(() => {
-    row.quantityError = '';
-  }, 100);
+    row.quantityError = ''
+  }, 100)
 }
 
 /** 更新物料数据 */
-const handleUpdate = async (row: CommonBomMaterialVO) => {
+const handleUpdate = async (row: MaterialDetailRow) => {
   try {
     if (isNaN(row.quantity)) {
-      row.quantityError = '请输入有效数字';
-      ElMessage.warning('请输入有效的数字');
-      return;
+      row.quantityError = '请输入有效数字'
+      ElMessage.warning('请输入有效的数字')
+      return
     }
 
     if (row.quantity <= 0) {
-      row.quantityError = '数量必须大于0';
-      ElMessage.warning('数量必须大于0');
-      return;
+      row.quantityError = '数量必须大于0'
+      ElMessage.warning('数量必须大于0')
+      return
     }
 
     // 清除错误状态
-    row.quantityError = '';
+    row.quantityError = ''
 
     // 设置更新状态
     row.updating = true
@@ -213,11 +277,10 @@ const handleUpdate = async (row: CommonBomMaterialVO) => {
       bomNodeId: props.nodeId
     }
     // 调用更新接口
-    await CommonBomMaterialApi.updateCommonBomMaterial(updateParams)
+    await CommonBomMaterialApi.updateCommonBomMaterial(updateParams as CommonBomMaterialVO)
     // 更新成功反馈
     message.success('数量更新成功')
-    // 可选:刷新父组件数据
-    // emit('refresh')
+    emit('refresh')
   } catch (error) {
     ElMessage.error('更新失败')
   } finally {
@@ -226,7 +289,9 @@ const handleUpdate = async (row: CommonBomMaterialVO) => {
 }
 
 /** 删除BOM节点已经挂载的物料 */
-const handleDelete = async (row) => {
+const handleDelete = async (row: MaterialDetailRow) => {
+  if (!props.nodeId) return
+
   try {
     // 删除的二次确认
     await message.delConfirm()
@@ -234,18 +299,19 @@ const handleDelete = async (row) => {
     await CommonBomMaterialApi.deleteBomMaterial(props.nodeId, row.code)
     message.success(t('common.delSuccess'))
     // 刷新列表
-    loadMaterials(props.nodeId)
+    await loadMaterials(props.nodeId, false)
+    emit('refresh')
   } catch {}
 }
 
 // 打开抽屉
 const openDrawer = () => {
-  drawerVisible.value = true
+  emit('update:modelValue', true)
 }
 
 // 关闭抽屉
 const closeDrawer = () => {
-  drawerVisible.value = false
+  handleClose()
 }
 
 // 关闭抽屉
@@ -257,97 +323,205 @@ const handleClose = () => {
 
 defineExpose({ openDrawer, closeDrawer, loadMaterials }) // 暴露方法给父组件
 
+onMounted(() => {
+  window.addEventListener('resize', updateWindowWidth)
+})
+
+onUnmounted(() => {
+  window.removeEventListener('resize', updateWindowWidth)
+})
 </script>
 
 <style lang="scss" scoped>
+.drawer-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  width: 100%;
+}
 
-/* 添加表格样式优化 */
-.el-table {
-  :deep(.cell) {
-    white-space: nowrap;  /* 防止单元格内容换行 */
-  }
+.drawer-header__main {
+  display: flex;
+  gap: 12px;
+  align-items: center;
+}
 
-  /* 表头样式 */
-  :deep(th.el-table__cell) {
-    background-color: #f5f7fa;
-    font-weight: bold;
-    color: #606266;
-  }
+.drawer-header__icon {
+  display: flex;
+  width: 44px;
+  height: 44px;
+  color: var(--el-color-primary);
+  background: rgb(64 158 255 / 12%);
+  border: 1px solid rgb(64 158 255 / 18%);
+  border-radius: 8px;
+  align-items: center;
+  justify-content: center;
 }
 
-/* 优化输入框样式 */
-.el-input {
-  width: 80px;
+.drawer-title {
+  font-size: 18px;
+  font-weight: 600;
+  color: var(--el-text-color-primary);
+}
 
-  :deep(.el-input__inner) {
-    text-align: center;
-    padding: 0 5px;
-  }
-  &.error-input :deep(.el-input__inner) {
-    border-color: #f56c6c;
-    background-color: #fff6f7;
-  }
+.drawer-subtitle {
+  margin-top: 4px;
+  font-size: 13px;
+  color: var(--el-text-color-secondary);
+}
+
+.drawer-body {
+  display: flex;
+  flex-direction: column;
+  gap: 18px;
+  height: 100%;
+  padding: 4px 4px 0;
+}
+
+.info-card {
+  overflow: hidden;
+  background: var(--el-bg-color);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+  box-shadow: 0 10px 28px rgb(31 45 61 / 6%);
 }
 
-/* 错误提示样式 */
-:deep(.el-form-item__error) {
+.info-card__header {
+  display: flex;
+  min-height: 58px;
+  padding: 0 20px;
+  background: linear-gradient(90deg, rgb(64 158 255 / 8%) 0%, rgb(255 255 255 / 0%) 72%);
+  border-bottom: 1px solid var(--el-border-color-lighter);
+  align-items: center;
+  justify-content: space-between;
+}
+
+.info-card__title {
+  display: flex;
+  gap: 10px;
+  font-size: 16px;
+  font-weight: 600;
+  line-height: 22px;
+  color: var(--el-text-color-primary);
+  align-items: center;
+}
+
+.info-card__marker {
+  width: 4px;
+  height: 18px;
+  background: var(--el-color-primary);
+  border-radius: 2px;
+}
+
+.info-card__body {
+  padding: 20px;
+}
+
+.detail-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 16px;
+}
+
+.detail-item {
+  min-width: 0;
+  padding: 14px 16px;
+  background: var(--el-fill-color-lighter);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+}
+
+.detail-item__label {
+  margin-bottom: 6px;
+  font-size: 13px;
+  line-height: 18px;
+  color: var(--el-text-color-secondary);
+}
+
+.detail-item__value {
+  overflow: hidden;
+  font-size: 15px;
+  font-weight: 600;
+  line-height: 22px;
+  color: var(--el-text-color-primary);
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.material-card {
+  flex: 1;
+  min-height: 0;
+}
+
+.material-card__body {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  height: 100%;
+  min-height: 0;
+}
+
+.material-total {
+  font-size: 13px;
+  color: var(--el-text-color-secondary);
+}
+
+.material-table {
+  flex: 1;
+  min-height: 0;
+}
+
+.quantity-form {
+  display: flex;
+  justify-content: center;
+}
+
+.quantity-form :deep(.el-form-item) {
+  width: 96px;
+  margin-bottom: 0;
+}
+
+.quantity-form :deep(.el-form-item__error) {
   position: absolute;
   top: 100%;
   left: 0;
   padding-top: 2px;
   font-size: 12px;
   line-height: 1;
-  color: #f56c6c;
+  color: var(--el-color-danger);
   white-space: nowrap;
 }
 
-/* 更新按钮自定义样式 */
-.el-button--success {
-  background-color: #e8f5e9;
-  border-color: #c8e6c9;
-  color: #2e7d32;
-
-  &:hover {
-    background-color: #c8e6c9;
-  }
+.quantity-input :deep(.el-input__inner) {
+  text-align: center;
 }
 
-.info-header {
+.table-actions {
   display: flex;
+  justify-content: center;
+  gap: 8px;
   align-items: center;
-  padding: 18px 24px;
-  background-color: #f5f7fa;
-  border-bottom: 1px solid #ebeef5;
-  margin-bottom: 20px;
-
-  .info-item {
-    flex: 1; /* 平分宽度 */
-    display: flex;
-    align-items: center; /* 垂直居中 */
+}
+
+@media (width <= 768px) {
+  .drawer-body {
+    padding: 0;
   }
 
-  .separator {
-    width: 1px;
-    height: 40px;
-    background-color: #dcdfe6;
-    margin: 0 24px;
+  .info-card__header {
+    padding: 12px 16px;
+    align-items: flex-start;
+    flex-direction: column;
+    gap: 4px;
   }
 
-  .info-label {
-    font-weight: bold;
-    color: #606266;
-    margin-right: 8px; /* 标签和值之间的间距 */
-    font-size: 14px;
-    white-space: nowrap; /* 防止标签换行 */
+  .info-card__body {
+    padding: 16px;
   }
 
-  .info-value {
-    color: #303133;
-    font-size: 16px;
-    font-weight: 500;
-    white-space: nowrap; /* 防止值换行 */
-    overflow: hidden;
-    text-overflow: ellipsis; /* 超出部分显示省略号 */
+  .detail-grid {
+    grid-template-columns: minmax(0, 1fr);
   }
 }
 </style>

+ 59 - 29
src/views/pms/bom/index.vue

@@ -2,7 +2,7 @@
 import { useTableComponents } from '@/components/ZmTable/useTableComponents'
 import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
 import * as BomApi from '@/api/pms/bom'
-import { CommonBomMaterialApi, CommonBomMaterialVO } from '@/api/pms/commonbommaterial'
+import { CommonBomMaterialApi, type CommonBomMaterialVO } from '@/api/pms/commonbommaterial'
 import { useTreeStore } from '@/store/modules/treeStore'
 import BomForm from './BomForm.vue'
 import DeviceCategoryTree from '@/components/DeviceCategoryTree/index.vue'
@@ -18,6 +18,8 @@ type BomRow = Omit<BomApi.BomVO, 'id'> & {
   materials?: number
 }
 
+type FormActionType = 'create' | 'update'
+
 const { t } = useI18n()
 const message = useMessage()
 const treeStore = useTreeStore()
@@ -51,7 +53,7 @@ const currentRowInfo = ref({
   bomNodeName: ''
 })
 
-const commonBomMaterialData = ref({
+const commonBomMaterialData = ref<Partial<CommonBomMaterialVO>>({
   id: undefined,
   deviceCategoryId: undefined,
   bomNodeId: undefined,
@@ -62,6 +64,7 @@ const commonBomMaterialData = ref({
 })
 
 const selectedId = computed(() => treeStore.selectedId)
+const activeRowId = computed(() => selectedRow.value?.id)
 
 const normalizeListTreeResult = (data: BomRow[] | PageResult<BomRow[]>) => {
   if (Array.isArray(data)) {
@@ -134,9 +137,17 @@ const clearRowSelection = () => {
   parentId.value = undefined
 }
 
-const openForm = (type: string, row?: BomRow | null) => {
-  if (type === 'create' && selectedRow.value) {
-    formRef.value.open(type, null, selectedRow.value.id, selectedRow.value.deviceCategoryId)
+const getCurrentDeviceCategoryId = (row?: BomRow | null) => {
+  return row?.deviceCategoryId || selectedRow.value?.deviceCategoryId || selectedId.value
+}
+
+const openForm = (type: FormActionType, row?: BomRow | null) => {
+  if (!formRef.value?.open) return
+
+  if (type === 'create') {
+    const targetParentId = selectedRow.value?.id || parentId.value
+    const targetDeviceCategoryId = getCurrentDeviceCategoryId(row)
+    formRef.value.open(type, undefined, targetParentId, targetDeviceCategoryId)
     return
   }
 
@@ -146,7 +157,7 @@ const openForm = (type: string, row?: BomRow | null) => {
     return
   }
 
-  formRef.value.open(type, null, parentId.value, selectedId.value)
+  message.warning('请选择需要修改的BOM节点')
 }
 
 const toggleExpandAll = () => {
@@ -158,38 +169,39 @@ const toggleExpandAll = () => {
 }
 
 const openSelectMaterialForm = (row: BomRow) => {
-  materialListRef.value.open(row)
+  if (!materialListRef.value?.open) return
   currentBomNodeId.value = row.id
   commonBomMaterialData.value.deviceCategoryId = row.deviceCategoryId
+  materialListRef.value.open(row)
 }
 
 const handleView = async (row: BomRow) => {
+  if (!showDrawer.value?.openDrawer) return
   currentBomNodeId.value = row.id
   currentRowInfo.value = {
     deviceCategoryName: row.deviceCategoryName || '暂无',
     bomNodeName: row.name || '暂无'
   }
   drawerVisible.value = true
+  await nextTick()
   showDrawer.value.openDrawer()
   await showDrawer.value.loadMaterials(row.id)
 }
 
-const chooseSingleMaterial = async (row) => {
-  try {
-    commonBomMaterialData.value.bomNodeId = currentBomNodeId.value
-    commonBomMaterialData.value.materialId = row.id
-    commonBomMaterialData.value.name = row.name
-    commonBomMaterialData.value.code = row.code
-    const data = commonBomMaterialData.value as unknown as CommonBomMaterialVO
-    await CommonBomMaterialApi.createCommonBomMaterial(data)
-    message.success(t('common.createSuccess'))
-    showDrawer.value.loadMaterials(currentBomNodeId.value)
-    await getList()
-  } finally {
+const chooseMaterial = async (selectedMaterials: CommonBomMaterialVO[]) => {
+  if (!selectedMaterials.length) {
+    message.warning('请至少选择一个物料')
+    return
+  }
+
+  const invalidMaterial = selectedMaterials.find(
+    (material) => !material.quantity || material.quantity <= 0
+  )
+  if (invalidMaterial) {
+    message.warning('请填写大于0的物料数量')
+    return
   }
-}
 
-const chooseMaterial = async (selectedMaterials) => {
   try {
     const materialsData = selectedMaterials.map((material) => ({
       deviceCategoryId: commonBomMaterialData.value.deviceCategoryId,
@@ -202,7 +214,7 @@ const chooseMaterial = async (selectedMaterials) => {
 
     const resultCount = await CommonBomMaterialApi.addMaterials(materialsData)
     message.success(`成功添加物料数量:${resultCount}`)
-    showDrawer.value.loadMaterials(currentBomNodeId.value)
+    showDrawer.value?.loadMaterials(currentBomNodeId.value)
     await getList()
   } catch (error) {
     message.error('添加物料失败!')
@@ -286,7 +298,7 @@ onMounted(() => {
         </el-form-item>
       </div>
 
-      <el-form-item>
+      <el-form-item class="bom-query-actions">
         <el-button type="primary" @click="handleQuery">
           <Icon icon="ep:search" class="mr-5px" />搜索
         </el-button>
@@ -294,7 +306,7 @@ onMounted(() => {
         <el-button
           type="primary"
           plain
-          @click="openForm('create', null)"
+          @click="openForm('create')"
           v-hasPermi="['rq:iot-bom:create']">
           <Icon icon="ep:plus" class="mr-5px" />新增
         </el-button>
@@ -318,6 +330,7 @@ onMounted(() => {
               :default-expand-all="isExpandAll"
               row-key="id"
               show-border
+              :row-class-name="({ row }) => (row.id === activeRowId ? 'is-active-bom-row' : '')"
               @row-click="handleClick">
               <ZmTableColumn prop="name" label="BOM节点" min-width="180" align="left" fixed="left">
                 <template #default="{ row }">
@@ -343,34 +356,43 @@ onMounted(() => {
               </ZmTableColumn>
               <ZmTableColumn prop="sort" label="排序" width="80" />
               <ZmTableColumn prop="materials" label="物料数量" width="100" />
-              <ZmTableColumn label="操作" width="300" fixed="right" action>
+              <ZmTableColumn
+                label="操作"
+                width="300"
+                fixed="right"
+                :show-overflow-tooltip="false"
+                action>
                 <template #default="{ row }">
                   <el-button
                     link
                     type="primary"
-                    @click="openForm('update', row)"
+                    @click.stop="openForm('update', row)"
                     v-hasPermi="['rq:iot-bom:update']">
+                    <Icon icon="ep:edit" class="mr-4px" />
                     修改
                   </el-button>
                   <el-button
                     link
                     type="primary"
-                    @click="openSelectMaterialForm(row)"
+                    @click.stop="openSelectMaterialForm(row)"
                     v-hasPermi="['rq:iot-bom:update']">
+                    <Icon icon="ep:plus" class="mr-4px" />
                     添加物料
                   </el-button>
                   <el-button
                     link
                     type="primary"
-                    @click="handleView(row)"
+                    @click.stop="handleView(row)"
                     v-hasPermi="['rq:iot-bom:update']">
+                    <Icon icon="ep:view" class="mr-4px" />
                     物料详情
                   </el-button>
                   <el-button
                     link
                     type="danger"
-                    @click="handleDelete(row.id)"
+                    @click.stop="handleDelete(row.id)"
                     v-hasPermi="['rq:iot-bom:delete']">
+                    <Icon icon="ep:delete" class="mr-4px" />
                     删除
                   </el-button>
                 </template>
@@ -419,4 +441,12 @@ onMounted(() => {
   text-overflow: ellipsis;
   white-space: nowrap;
 }
+
+.bom-query-actions {
+  margin-left: auto;
+}
+
+:deep(.is-active-bom-row .el-table__cell) {
+  background-color: var(--el-color-primary-light-9) !important;
+}
 </style>