Procházet zdrojové kódy

Merge branch 'feature/bom-list'

zhangcl před 1 týdnem
rodič
revize
4bab8dd6b1

+ 27 - 1
src/components/ZmTable/README.md

@@ -75,6 +75,8 @@ const { ZmTable, ZmTableColumn } = useTableComponents<ListItem>()
 | `showBorder` | `boolean` | `false` | 控制组件自己的边框显示样式。 |
 | `hoverHighlight` | `boolean` | `true` | 控制鼠标悬浮行时是否显示 hover 背景。 |
 | `showOverflowTooltip` | `boolean` | `true` | 继承自 Element Plus。想让文字换行时传 `false`,再配合页面 CSS 覆盖 `.cell` 的换行样式。 |
+| `settingsCache` | `boolean` | `true` | 是否缓存列设置。当前使用 `localStorage`,缓存列顺序、显隐和固定状态。 |
+| `settingsCacheKey` | `string` | - | 列设置缓存 key。建议业务页面传稳定唯一值,没传时组件会用当前路由和列 key 自动生成。 |
 
 组件内部默认还会给 Element Plus 表格设置这些值:
 
@@ -99,7 +101,7 @@ const { ZmTable, ZmTableColumn } = useTableComponents<ListItem>()
 | 属性 | 类型 | 默认值 | 说明 |
 | --- | --- | --- | --- |
 | `prop` | `keyof T \| string` | - | 字段名,也会作为列设置 key 的优先来源。 |
-| `action` | `boolean` | `false` | 标记为操作列,并在表头显示列设置按钮。操作列本身不会进入列设置列表。 |
+| `action` | `boolean` | `false` | 标记为操作列,并在表头显示列设置按钮。操作列会进入列设置列表。 |
 | `hideInColumnSettings` | `boolean` | `false` | 不显示在列设置面板里,适合序号列、选择列等固定功能列。 |
 | `isParent` | `boolean` | `false` | 标记当前列是多级表头父级。需要控制子级列排序/显隐/固定时必须加。 |
 | `zmSortable` | `boolean` | `false` | 显示排序按钮。组件只维护/展示排序状态,具体排序逻辑由调用方处理。 |
@@ -243,6 +245,30 @@ function handleSortChange(payload: { prop: string; order: 'asc' | 'desc' | null
 - 显示 / 隐藏列。
 - 固定到左侧 / 固定到右侧 / 取消固定。
 - 重置为初始列配置。
+- 自动缓存列设置,下次进入同一张表时恢复。
+
+缓存默认开启,当前先写入 `localStorage`。后续接接口时,建议继续使用稳定的 `settingsCacheKey` 作为后端保存和读取配置的业务标识。
+
+```vue
+<ZmTable :data="list" :loading="loading" settings-cache-key="device-monitor-list">
+  <ZmTableColumn prop="name" label="名称" />
+  <ZmTableColumn prop="status" label="状态" />
+
+  <ZmTableColumn label="操作" width="120" fixed="right" action>
+    <template #default="{ row }">
+      <el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
+    </template>
+  </ZmTableColumn>
+</ZmTable>
+```
+
+不希望某张表缓存时,可以关闭:
+
+```vue
+<ZmTable :data="list" :loading="loading" :settings-cache="false">
+  ...
+</ZmTable>
+```
 
 列设置 key 的优先级:
 

+ 120 - 4
src/components/ZmTable/index.vue

@@ -10,8 +10,10 @@ import {
   provide,
   ref,
   useAttrs,
-  useSlots
+  useSlots,
+  watch
 } from 'vue'
+import { useRoute } from 'vue-router'
 import { TableContextKey } from './token'
 import type { ColumnAlign, ColumnFixed, ColumnSettingItem } from './token'
 import type { DefaultRow } from 'element-plus/es/components/table/src/table/defaults'
@@ -38,15 +40,21 @@ interface Props
   hoverHighlight?: boolean
   align?: ColumnAlign
   columnMaxWidth?: number
+  settingsCache?: boolean
+  settingsCacheKey?: string
 }
 
 const props = defineProps<Props>()
 const attrs = useAttrs()
 const slots = useSlots()
+const route = useRoute()
 const tableRef = ref<TableInstance>()
 const columnSettings = ref<ColumnSettingItem[]>([])
 const columnDefaultSettings = ref<ColumnSettingItem[]>([])
 const slotColumnSignature = ref('')
+const columnSettingsStorageKey = ref('')
+const isSyncingColumnSettings = ref(false)
+const columnSettingsStoragePrefix = 'zm-table:column-settings:'
 
 const defaultOptions: Partial<Props> = {
   size: 'default',
@@ -71,6 +79,8 @@ const bindProps = computed(() => {
     hoverHighlight: _hoverHighlight,
     align: _align,
     columnMaxWidth,
+    settingsCache: _settingsCache,
+    settingsCacheKey: _settingsCacheKey,
     ...otherProps
   } = props
 
@@ -93,6 +103,7 @@ const safeColumnMaxWidth = computed(() => {
   const maxWidth = Number(props.columnMaxWidth)
   return Number.isFinite(maxWidth) && maxWidth > 0 ? maxWidth : 360
 })
+const isColumnSettingsCacheEnabled = computed(() => props.settingsCache !== false)
 
 const forwardedSlots = computed(() => {
   const { default: _default, ...restSlots } = slots
@@ -169,7 +180,7 @@ const getColumnMeta = (node: VNode, index: number, parentKey?: string): ColumnMe
     label: getColumnLabel(node, index, parentKey),
     visible: getPropValue(node.props, 'visible'),
     action,
-    configurable: !action && !hideInColumnSettings,
+    configurable: !hideInColumnSettings,
     fixed: normalizeFixed(getPropValue(node.props, 'fixed')),
     children: getColumnChildren(node)
       .map((child, childIndex) => getColumnMeta(child, childIndex, key))
@@ -197,8 +208,11 @@ const syncColumnSettings = (nodes: VNode[]) => {
   slotColumnSignature.value = signature
   nextTick(() => {
     const defaults = metas.map(createDefaultColumnSetting)
+    const storageKey = resolveColumnSettingsStorageKey(defaults)
+    const cachedSettings = loadColumnSettings(storageKey)
     columnDefaultSettings.value = defaults
-    columnSettings.value = mergeColumnSettings(defaults, columnSettings.value)
+    columnSettingsStorageKey.value = storageKey
+    setColumnSettings(mergeColumnSettings(defaults, cachedSettings || columnSettings.value))
   })
 }
 
@@ -291,9 +305,111 @@ const cloneColumnSettings = (items: ColumnSettingItem[]): ColumnSettingItem[] =>
 }
 
 const resetColumnSettings = () => {
-  columnSettings.value = cloneColumnSettings(columnDefaultSettings.value)
+  setColumnSettings(cloneColumnSettings(columnDefaultSettings.value))
 }
 
+const flattenColumnSettingKeys = (items: ColumnSettingItem[]): string[] => {
+  return items.flatMap((item) => [
+    item.key,
+    ...(item.children ? flattenColumnSettingKeys(item.children) : [])
+  ])
+}
+
+const encodeStorageKeyPart = (value: string) => {
+  try {
+    return encodeURIComponent(value)
+  } catch {
+    return value
+  }
+}
+
+const resolveColumnSettingsStorageKey = (defaults: ColumnSettingItem[]) => {
+  if (!isColumnSettingsCacheEnabled.value) return ''
+
+  const userKey = props.settingsCacheKey?.trim()
+  if (userKey) return `${columnSettingsStoragePrefix}${encodeStorageKeyPart(userKey)}`
+
+  const routeKey = String(route.name || route.path || 'global')
+  const columnKey = flattenColumnSettingKeys(defaults).sort().join('|')
+  return `${columnSettingsStoragePrefix}${encodeStorageKeyPart(`${routeKey}:${columnKey}`)}`
+}
+
+const isColumnSettingItem = (item: unknown): item is ColumnSettingItem => {
+  if (!item || typeof item !== 'object') return false
+  const setting = item as ColumnSettingItem
+  const validFixed =
+    setting.fixed === false || setting.fixed === 'left' || setting.fixed === 'right'
+  return typeof setting.key === 'string' && typeof setting.visible === 'boolean' && validFixed
+}
+
+const normalizeCachedColumnSettings = (items: unknown): ColumnSettingItem[] => {
+  if (!Array.isArray(items)) return []
+
+  return items.filter(isColumnSettingItem).map((item) => ({
+    key: item.key,
+    label: typeof item.label === 'string' ? item.label : item.key,
+    visible: item.visible,
+    fixed: item.fixed,
+    children: item.children ? normalizeCachedColumnSettings(item.children) : undefined
+  }))
+}
+
+const loadColumnSettings = (storageKey: string) => {
+  if (!storageKey || typeof window === 'undefined') return null
+
+  try {
+    const rawValue = window.localStorage.getItem(storageKey)
+    if (!rawValue) return null
+
+    const cachedValue = JSON.parse(rawValue)
+    const settings = normalizeCachedColumnSettings(cachedValue?.settings)
+    return settings.length ? settings : null
+  } catch {
+    return null
+  }
+}
+
+const saveColumnSettings = (storageKey: string, settings: ColumnSettingItem[]) => {
+  if (!storageKey || typeof window === 'undefined') return
+
+  try {
+    window.localStorage.setItem(
+      storageKey,
+      JSON.stringify({
+        version: 1,
+        settings
+      })
+    )
+  } catch {
+    // localStorage 可能被浏览器策略禁用,失败时不影响表格正常使用。
+  }
+}
+
+const setColumnSettings = (settings: ColumnSettingItem[]) => {
+  isSyncingColumnSettings.value = true
+  columnSettings.value = settings
+  nextTick(() => {
+    isSyncingColumnSettings.value = false
+    saveColumnSettings(columnSettingsStorageKey.value, columnSettings.value)
+  })
+}
+
+watch(
+  columnSettings,
+  (settings) => {
+    if (isSyncingColumnSettings.value) return
+    saveColumnSettings(columnSettingsStorageKey.value, settings)
+  },
+  { deep: true }
+)
+
+watch(
+  [() => props.settingsCache, () => props.settingsCacheKey, () => route.name, () => route.path],
+  () => {
+    slotColumnSignature.value = ''
+  }
+)
+
 const applyColumnSetting = (node: VNode, setting: ColumnSettingItem) => {
   const clonedNode = cloneVNode(
     node,

+ 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>

+ 288 - 410
src/views/pms/bom/MaterialList.vue

@@ -1,501 +1,379 @@
 <template>
-  <Dialog v-model="dialogVisible" :title="t('iotMaintain.selectMaterials')"
-          style="width: 1300px; min-height: 300px" :close-on-click-modal="false" @close="handleClose">
-
-    <!-- 设备分类和BOM节点信息显示 -->
-    <div style="margin: 0 0px 15px; background: #f5f7fa; padding: 12px 20px; border-radius: 4px;">
-      <el-row>
-        <el-col :span="12">
-          <span style="font-weight: bold; margin-right: 10px;">设备分类:</span>
-          <span>{{ deviceCategoryName }}</span>
-        </el-col>
-        <el-col :span="12">
-          <span style="font-weight: bold; margin-right: 10px;">BOM节点:</span>
-          <span>{{ bomNodeName }}</span>
-        </el-col>
-      </el-row>
-    </div>
-
-    <ContentWrap>
-      <el-form
-        class="-mb-15px"
-        :model="queryParams"
-        ref="queryFormRef"
-        :inline="true"
-        label-width="68px"
-      >
-        <el-form-item :label="t('workOrderMaterial.materialName')" prop="name" style="margin-left: 28px">
-          <el-input
-            v-model="queryParams.name"
-            :placeholder="t('workOrderMaterial.nameHolder')"
-            clearable
-            @keyup.enter="handleQuery"
-            class="!w-200px"
-          />
-        </el-form-item>
-        <el-form-item :label="t('workOrderMaterial.materialCode')" prop="code">
-          <el-input
-            v-model="queryParams.code"
-            :placeholder="t('workOrderMaterial.codeHolder')"
-            clearable
-            @keyup.enter="handleQuery"
-            class="!w-200px"
-          />
-        </el-form-item>
-        <el-form-item>
-          <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" />
-            {{ t('workOrderMaterial.search') }}</el-button>
-          <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" />
-            {{ t('workOrderMaterial.reset') }}</el-button>
-          <el-button @click="handleConfirm" class="custom-green-button"><Icon icon="ep:check" class="mr-5px" /> {{ t('workOrderMaterial.confirm') }}</el-button>
-        </el-form-item>
-      </el-form>
-    </ContentWrap>
-
-    <!-- 已选物料标签区域 -->
-    <ContentWrap v-if="selectedRows.length > 0"
-                 style="margin: -10px 0px 10px; padding: 10px 15px; background: #f8fafc; border-radius: 4px; border: 1px solid #ebeef5;">
-      <div style="display: flex; align-items: center; flex-wrap: wrap; gap: 8px;">
-        <div style="font-weight: bold; color: #606266; margin-right: 10px;">已选物料:</div>
-        <el-tag
-          v-for="item in selectedRows"
-          :key="item.id"
-          @close="removeTag(item)"
-          style="margin-bottom: 5px; position: relative; padding-right: 25px;"
-        >
-          {{ item.name }}-{{ item.code }}
-          <!-- 自定义关闭按钮 -->
-          <span class="close-icon" @click.stop="removeTag(item)">
-            <Icon icon="ep:close" style="font-size: 12px; position: absolute; right: 8px; top: 50%; transform: translateY(-50%);"/>
-          </span>
-        </el-tag>
-      </div>
-    </ContentWrap>
-
-    <ContentWrap class="table-content-wrap">
-      <div class="flex-table-container">
-        <el-table
+  <Dialog
+    v-model="dialogVisible"
+    :title="t('iotMaintain.selectMaterials')"
+    width="1200px"
+    :close-on-click-modal="false"
+    @close="handleClose">
+    <div class="material-picker">
+      <section class="context-panel">
+        <div class="context-item">
+          <span class="context-label">设备分类</span>
+          <span class="context-value">{{ deviceCategoryName || '暂无' }}</span>
+        </div>
+        <div class="context-item">
+          <span class="context-label">BOM节点</span>
+          <span class="context-value">{{ bomNodeName || '暂无' }}</span>
+        </div>
+      </section>
+
+      <section class="filter-panel">
+        <el-form
+          ref="queryFormRef"
+          :model="queryParams"
+          :inline="true"
+          label-width="78px"
+          size="default"
+          class="filter-form">
+          <el-form-item :label="t('workOrderMaterial.materialName')" prop="name">
+            <el-input
+              v-model="queryParams.name"
+              :placeholder="t('workOrderMaterial.nameHolder')"
+              clearable
+              class="filter-input"
+              @keyup.enter="handleQuery" />
+          </el-form-item>
+          <el-form-item :label="t('workOrderMaterial.materialCode')" prop="code">
+            <el-input
+              v-model="queryParams.code"
+              :placeholder="t('workOrderMaterial.codeHolder')"
+              clearable
+              class="filter-input"
+              @keyup.enter="handleQuery" />
+          </el-form-item>
+          <el-form-item class="filter-actions">
+            <el-button type="primary" size="default" @click="handleQuery">
+              <Icon icon="ep:search" class="mr-5px" />
+              {{ t('workOrderMaterial.search') }}
+            </el-button>
+            <el-button size="default" @click="resetQuery">
+              <Icon icon="ep:refresh" class="mr-5px" />
+              {{ t('workOrderMaterial.reset') }}
+            </el-button>
+            <el-button type="success" size="default" @click="handleConfirm">
+              <Icon icon="ep:check" class="mr-5px" />
+              {{ t('workOrderMaterial.confirm') }}
+            </el-button>
+          </el-form-item>
+        </el-form>
+      </section>
+
+      <section class="table-panel">
+        <ZmTable
           ref="tableRef"
-          v-loading="loading"
-                  :data="list"
-                  :stripe="true"
-                  :row-key="rowKey"
-                  :show-overflow-tooltip="true"
-                  table-layout="auto"
-                  @row-click="handleRowClick"
-                  class="full-width-table" >
-          <el-table-column :label="t('workOrderMaterial.select')" class-name="select-column" min-width="30">
+          :data="list"
+          :loading="loading"
+          :show-border="true"
+          class="material-table"
+          settings-cache-key="pms-bom-material-select"
+          row-key="id"
+          @row-click="handleRowClick"
+          @selection-change="handleSelectionChange">
+          <ZmTableColumn
+            type="selection"
+            width="56"
+            :reserve-selection="true"
+            hide-in-column-settings />
+          <ZmTableColumn :label="t('workOrderMaterial.materialName')" prop="name" min-width="220" />
+          <ZmTableColumn :label="t('workOrderMaterial.materialCode')" prop="code" min-width="160" />
+          <ZmTableColumn :label="t('workOrderMaterial.unit')" prop="unit" width="110" />
+          <ZmTableColumn :label="t('route.quantity')" prop="quantity" width="150">
             <template #default="{ row }">
-              <el-checkbox
-                :model-value="selectedRows.some(item => item.id === row.id)"
-                @click.stop="selectRow(row)"
-                class="no-label-radio"
-              />
-            </template>
-          </el-table-column>
-          <el-table-column :label="t('workOrderMaterial.materialName')" align="center" prop="name"
-                           class-name="name-column" min-width="180"/>
-          <el-table-column :label="t('workOrderMaterial.materialCode')" align="center" prop="code" min-width="130"/>
-          <el-table-column :label="t('workOrderMaterial.unit')" align="center" prop="unit" min-width="50"/>
-          <el-table-column :label="t('route.quantity')" align="center" prop="quantity" min-width="80" class-name="quantity-column">
-            <template #default="scope">
-              <el-input
-                type="number"
+              <el-input-number
+                v-model="row.quantity"
                 :controls="false"
-                v-model="scope.row.quantity"
-                @click.stop=""
-                @focus="handleInputFocus(scope.row)"
-                @blur="(event) => handleQuantityBlur(event, scope.row)"
+                :min="0"
+                :precision="2"
+                size="default"
                 class="quantity-input"
-              />
+                @click.stop
+                @focus="selectIfNeeded(row)"
+                @change="() => syncSelectedQuantity(row)" />
             </template>
-          </el-table-column>
-          <el-table-column
+          </ZmTableColumn>
+          <ZmTableColumn
             :label="t('deviceList.createTime')"
-            align="center"
             prop="createTime"
-            min-width="100"
-            :formatter="dateFormatter"
-          />
-        </el-table>
-      </div>
-      <!-- 分页 -->
-      <Pagination
-        :total="total"
-        v-model:page="queryParams.pageNo"
-        v-model:limit="queryParams.pageSize"
-        @pagination="getList"
-      />
-    </ContentWrap>
-
+            min-width="170"
+            :formatter="dateFormatter" />
+        </ZmTable>
+
+        <div class="pagination-row">
+          <Pagination
+            :total="total"
+            v-model:page="queryParams.pageNo"
+            v-model:limit="queryParams.pageSize"
+            @pagination="getList" />
+        </div>
+      </section>
+    </div>
   </Dialog>
 </template>
 
 <script setup lang="ts">
-import { DictDataVO } from '@/api/system/dict/dict.data'
+import { ElMessage } from 'element-plus'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
 import { dateFormatter } from '@/utils/formatTime'
 import * as MaterialApi from '@/api/pms/material'
-import {defaultProps, handleTree} from "@/utils/tree";
-import * as MaterialGroupApi from '@/api/pms/materialgroup'
-import { shallowRef, nextTick } from 'vue'
 
-defineOptions({
-  name: 'MaterialList'
-})
-const { t } = useI18n() // 国际化
-const materialGroupList = ref<Tree[]>([]) // 物料组树形结构
+defineOptions({ name: 'MaterialList' })
 
+type MaterialRow = Omit<MaterialApi.MaterialVO, 'quantity'> & {
+  quantity?: number
+}
+
+const { t } = useI18n()
+const { ZmTable, ZmTableColumn } = useTableComponents<MaterialRow>()
 const emit = defineEmits<{
   (e: 'choose', value: MaterialApi.MaterialVO[]): void
   (e: 'close'): void
 }>()
 
-const dialogVisible = ref(false) // 弹窗的是否展示
-const loading = ref(true) // 列表的加载中
-const queryFormRef = ref() // 搜索的表单
-const list = ref<MaterialApi.MaterialVO[]>([]) // 列表的数据
-const total = ref(0) // 列表的总页数
-const selectedRows = ref<MaterialApi.MaterialVO[]>([]); // 多选数据(存储所有选中行的数组)
-const tableRef = ref<InstanceType<typeof ElTable>>();
-
-const deviceCategoryName = ref('') // 存储设备分类名称
-const bomNodeName = ref('') // 存储BOM节点名称
+const dialogVisible = ref(false)
+const loading = ref(false)
+const queryFormRef = ref()
+const tableRef = ref()
+const list = ref<MaterialRow[]>([])
+const total = ref(0)
+const selectedRows = ref<MaterialRow[]>([])
+const syncingSelection = ref(false)
+const deviceCategoryName = ref('')
+const bomNodeName = ref('')
 
 const queryParams = reactive({
   pageNo: 1,
   pageSize: 10,
-  materialGroupId: '',
   code: '',
-  name: '',
-  model: '',
-  unit: ''
+  name: ''
 })
 
-const selectedRow = ref(null);
+const selectedIdSet = computed(() => new Set(selectedRows.value.map((item) => item.id)))
 
-// 更新选中物料数量的方法
-const updateSelectedRowQuantity = (row: MaterialApi.MaterialVO) => {
-  const selectedItem = selectedRows.value.find(item => item.id === row.id);
-  if (selectedItem) {
-    selectedItem.quantity = row.quantity;
-  }
-};
+const getSelectedRow = (row: MaterialRow) => {
+  return selectedRows.value.find((item) => item.id === row.id)
+}
 
-const adjustColumnWidths = () => {
-  nextTick(() => {
-    if (tableRef.value) {
-      tableRef.value.doLayout();
-    }
-  });
-};
-
-// 处理单选逻辑
-const selectRow = (row) => {
-  const index = selectedRows.value.findIndex(item => item.id === row.id);
-  if (index > -1) {
-    selectedRows.value.splice(index, 1); // 取消选中
-    row.quantity = undefined;
-  } else {
-    selectedRows.value.push({ ...row }); // 选中
-  }
-};
-
-// 点击整行选中
-const handleRowClick = (row) => {
-  selectRow(row);
-};
-const open = async (row: any) => {
-  // 设置传递过来的设备分类名称和BOM节点名称
-  deviceCategoryName.value = row.deviceCategoryName || ''
-  bomNodeName.value = row.name || ''
+const normalizeQuantity = (quantity?: number) => {
+  const value = Number(quantity)
+  if (!Number.isFinite(value) || value <= 0) return undefined
+  return Number(value.toFixed(2))
+}
 
-  queryParams.name = '';
-  queryParams.code = '';
-  dialogVisible.value = true
+const applySelectedQuantities = () => {
+  list.value.forEach((row) => {
+    const selected = getSelectedRow(row)
+    if (selected) {
+      row.quantity = selected.quantity
+    }
+  })
+}
 
-  await getList();
+const syncCurrentPageSelection = async () => {
+  await nextTick()
+  const elTableRef = tableRef.value?.elTableRef
+  if (!elTableRef) return
+
+  syncingSelection.value = true
+  list.value.forEach((row) => {
+    elTableRef.toggleRowSelection(row, selectedIdSet.value.has(row.id))
+  })
+  await nextTick()
+  syncingSelection.value = false
 }
-defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
 const getList = async () => {
   loading.value = true
   try {
     const data = await MaterialApi.getMaterialPage(queryParams)
-    list.value = data.list
-    total.value = data.total
-
-    // 同步selectedRows中的数量到当前页数据
-    list.value.forEach(row => {
-      const selectedItem = selectedRows.value.find(item => item.id === row.id);
-      if (selectedItem && selectedItem.quantity !== undefined) {
-        row.quantity = selectedItem.quantity;
-      }
-    });
-
-    // 数据加载后自动调整列宽
-    adjustColumnWidths();
+    list.value = data.list || []
+    total.value = data.total || 0
+    applySelectedQuantities()
+    await syncCurrentPageSelection()
   } finally {
     loading.value = false
   }
 }
 
-// 关闭时清空选择
-const handleClose = () => {
-  tableRef.value?.clearSelection();
+const open = async (row: { deviceCategoryName?: string; name?: string }) => {
+  deviceCategoryName.value = row.deviceCategoryName || ''
+  bomNodeName.value = row.name || ''
+  queryParams.pageNo = 1
+  queryParams.name = ''
+  queryParams.code = ''
   selectedRows.value = []
-  emit('close')
-};
-
-// 移除标签方法
-const removeTag = (material: MaterialApi.MaterialVO) => {
-  // 从已选列表中移除
-  const index = selectedRows.value.findIndex(item => item.id === material.id);
-  if (index !== -1) {
-    selectedRows.value.splice(index, 1);
-  }
+  tableRef.value?.elTableRef?.clearSelection()
+  dialogVisible.value = true
+  await getList()
+}
 
-  // 如果当前行在当前页,取消其选中状态
-  const rowInTable = list.value.find(item => item.id === material.id);
-  if (rowInTable) {
-    const rowIndex = list.value.indexOf(rowInTable);
-    // 这里可以添加逻辑强制更新行状态(如果需要)
-    rowInTable.quantity = undefined;
-  }
-};
-
-// 处理 消耗数量 quantity 输入框失焦事件
-const handleQuantityBlur = (event: Event, row: any) => {
-  const inputValue = (event.target as HTMLInputElement).value;
-  let num = parseFloat(inputValue);
-  // 处理无效值
-  if (isNaN(num)) {
-    row.quantity = 0;
-    return;
-  }
-  // 处理小于等于0的情况
-  if (num <= 0) {
-    row.quantity = 0;
-    return;
-  }
-  // 保留两位小数
-  row.quantity = parseFloat(num.toFixed(2));
-  // 实时更新selectedRows
-  updateSelectedRowQuantity(row);
-};
-
-// 处理输入框焦点事件(自动选中当前行)
-const handleInputFocus = (row: MaterialApi.MaterialVO) => {
-  const getRowUniqueKey = (row) => {
-    return `${row.code}_${row.name}`
-  }
-  const currentKey = getRowUniqueKey(row)
-  // 如果未选中则添加到选中列表
-  const exists = selectedRows.value.some(item => getRowUniqueKey(item) === currentKey)
-  if (!exists) {
-    selectedRows.value.push(row)
+defineExpose({ open })
+
+const selectIfNeeded = (row: MaterialRow) => {
+  if (!selectedIdSet.value.has(row.id)) {
+    tableRef.value?.elTableRef?.toggleRowSelection(row, true)
   }
 }
 
-// 计算列宽
-const flexColumnWidth = (prop: string, label: string, data: any[]) => {
-  if (!data || !data.length) return 'auto';
-
-  const contentWidths = data.map(item => {
-    const value = prop && item[prop] ? String(item[prop]) : '';
-    return getTextWidth(value);
-  });
-
-  const labelWidth = getTextWidth(label);
-  const maxContentWidth = Math.max(...contentWidths);
-  const maxWidth = Math.max(labelWidth, maxContentWidth);
-
-  return Math.min(Math.max(maxWidth + 10, 120), 600) + 'px'; // 10px内边距,120px最小宽度
-};
-
-// 获取文本宽度
-const getTextWidth = (text: string) => {
-  if (!text) return 0;
-
-  const span = document.createElement('span');
-  span.style.visibility = 'hidden';
-  span.style.position = 'absolute';
-  span.style.whiteSpace = 'nowrap';
-  span.style.font = '14px Microsoft YaHei'; // 匹配表格字体
-  span.textContent = text;
-  document.body.appendChild(span);
-
-  const width = span.offsetWidth;
-  document.body.removeChild(span);
-  return width;
-};
-
-// 生成行唯一标识
-const rowKey = (row: any) => {
-  return `${row.id}`; // 确保行更新时重新渲染
-};
-
-// 确认选择
-const handleConfirm = () => {
-  // 同步当前页所有选中行的最新数量
-  list.value.forEach(row => {
-    if (selectedRows.value.some(item => item.id === row.id)) {
-      updateSelectedRowQuantity(row);
+const syncSelectedQuantity = (row: MaterialRow) => {
+  const selected = getSelectedRow(row)
+  if (!selected) return
+  const quantity = normalizeQuantity(row.quantity)
+  selected.quantity = quantity
+  row.quantity = quantity
+}
+
+const handleSelectionChange = (selection: MaterialRow[]) => {
+  if (syncingSelection.value) return
+
+  const currentPageIds = new Set(list.value.map((row) => row.id))
+  const currentSelectionIds = new Set(selection.map((row) => row.id))
+  const previousOtherPageRows = selectedRows.value.filter((row) => !currentPageIds.has(row.id))
+  const currentPageRows = selection.map((row) => {
+    const quantity = normalizeQuantity(row.quantity) || 1
+    row.quantity = quantity
+    return { ...row, quantity }
+  })
+
+  selectedRows.value = [...previousOtherPageRows, ...currentPageRows]
+  list.value.forEach((row) => {
+    if (!currentSelectionIds.has(row.id)) {
+      row.quantity = undefined
     }
-  });
+  })
+}
+
+const handleRowClick = (row: MaterialRow) => {
+  tableRef.value?.elTableRef?.toggleRowSelection(row)
+}
 
-  if (selectedRows.value.length === 0) {
-    ElMessage.warning('请至少选择一个物料')
-    return
-  }
-  emit('choose', selectedRows.value.map(row => ({
-    ...row,
-    // 确保返回必要字段
-    code: row.code,
-    name: row.name,
-    unit: row.unit,
-    quantity: row.quantity,
-  })))
-  dialogVisible.value = false;
-  handleClose()
-};
-
-/** 搜索按钮操作 */
 const handleQuery = () => {
   queryParams.pageNo = 1
   getList()
 }
-const choose = (row: DictDataVO) => {
-  emit('choose', row)
-  dialogVisible.value = false
-}
-/** 重置按钮操作 */
+
 const resetQuery = () => {
-  queryFormRef.value.resetFields()
+  queryFormRef.value?.resetFields()
   handleQuery()
 }
-/** 初始化 **/
-onMounted(async () => {
-  materialGroupList.value = handleTree(await MaterialGroupApi.getSimpleMaterialGroupList())
-  await getList()
-})
 
-// 清除事件监听
-onUnmounted(() => {
-  window.removeEventListener('resize', adjustColumnWidths);
-});
+const handleConfirm = () => {
+  if (!selectedRows.value.length) {
+    ElMessage.warning('请至少选择一个物料')
+    return
+  }
 
-</script>
-<style lang="scss" scoped>
-.no-label-radio .el-radio__label {
-  display: none;
-}
-.no-label-radio .el-radio__inner {
-  margin-right: 0;
+  const invalidMaterial = selectedRows.value.find((item) => !normalizeQuantity(item.quantity))
+  if (invalidMaterial) {
+    ElMessage.warning('请填写大于0的物料数量')
+    return
+  }
+
+  emit(
+    'choose',
+    selectedRows.value.map((row) => ({
+      ...row,
+      quantity: normalizeQuantity(row.quantity)
+    })) as MaterialApi.MaterialVO[]
+  )
+  dialogVisible.value = false
 }
 
-.table-content-wrap {
-  display: flex;
-  flex-direction: column;
-  flex: 1;
-  min-height: 300px; // 确保有最小高度
+const handleClose = () => {
+  selectedRows.value = []
+  list.value = []
+  emit('close')
 }
+</script>
 
-.flex-table-container {
-  flex: 1;
+<style lang="scss" scoped>
+.material-picker {
   display: flex;
   flex-direction: column;
-  overflow: hidden;
-
-  // 确保表格填满容器
-  .full-width-table {
-    width: 100%;
-    flex: 1;
-
-    // 让表格头与内容同步宽度
-    :deep(.el-table__header) {
-      width: 100% !important;
-    }
+  gap: 16px;
+}
 
-    :deep(.el-table__body) {
-      width: 100% !important;
-    }
-  }
+.context-panel,
+.filter-panel,
+.table-panel {
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 6px;
 }
 
-// 名称列:优先分配剩余空间
-:deep(.name-column) {
-  flex-grow: 1 !important;
-  min-width: 180px;
+.context-panel {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 16px;
+  padding: 14px 16px;
+  background: var(--el-fill-color-lighter);
+}
 
-  .cell {
-    white-space: nowrap;
-    overflow: hidden;
-    text-overflow: ellipsis;
-    max-width: 100%;
-  }
+.context-item {
+  min-width: 0;
 }
 
-/* 表格容器样式 */
-.table-container {
-  width: 100%;
-  overflow-x: auto;
+.context-label {
+  margin-right: 10px;
+  font-size: 13px;
+  color: var(--el-text-color-secondary);
 }
 
-/* 自定义淡绿色按钮 */
-:deep(.custom-green-button) {
-  background-color: #e1f3d8;
-  border-color: #e1f3d8;
-  color: #67c23a;
+.context-value {
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--el-text-color-primary);
 }
 
-/* 悬停效果 */
-:deep(.custom-green-button:hover) {
-  background-color: #d1e8c0;
-  border-color: #d1e8c0;
-  color: #5daf34;
+.filter-panel {
+  padding: 16px 16px 0;
+  background: var(--el-bg-color);
 }
 
-/* 点击效果 */
-:deep(.custom-green-button:active) {
-  background-color: #c2dca8;
-  border-color: #c2dca8;
+.filter-form {
+  display: flex;
+  align-items: flex-start;
+  flex-wrap: wrap;
+  gap: 0 12px;
 }
 
-// 标签样式
-.close-icon {
-  cursor: pointer;
-  color: #999;
-  transition: color 0.2s;
+.filter-input {
+  width: 200px;
+}
 
-  &:hover {
-    color: #ff4d4f;
-  }
+.filter-actions {
+  margin-left: auto;
 }
 
-.el-tag {
-  position: relative;
-  padding-right: 25px;
-  transition: all 0.3s;
+.table-panel {
+  padding: 16px 16px 0;
+  background: var(--el-bg-color);
+}
 
-  &:hover {
-    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
-  }
+.material-table {
+  width: 100%;
 }
 
-/* 调整输入框宽度 */
-:deep(.el-input) {
-  width: 95% !important; /* 保留5%空间避免溢出 */
+.quantity-input {
+  width: 96px;
 }
 
-/* 确保输入框获得焦点时保持可见 */
-:deep(.el-input__inner:focus) {
-  border-color: #409eff !important;
-  box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2) !important;
+.quantity-input :deep(.el-input__inner) {
+  text-align: center;
 }
 
-/* 输入框获得焦点时提升层级 */
-.quantity-input:deep(.el-input__inner) {
-  position: relative;
-  z-index: 2;
+.pagination-row {
+  display: flex;
+  justify-content: flex-end;
+  padding: 12px 0 0;
 }
 
+@media (width <= 768px) {
+  .context-panel {
+    grid-template-columns: minmax(0, 1fr);
+  }
+
+  .filter-actions {
+    width: 100%;
+    margin-left: 0;
+  }
+}
 </style>

+ 338 - 175
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 } 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: '',
@@ -151,22 +209,18 @@ const props = defineProps({
   }
 })
 
-// 监听bom树节点ID变化
-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 +234,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 +267,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 +279,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,120 +289,228 @@ 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()
 }
 
 // 关闭抽屉
 const handleClose = () => {
   emit('update:modelValue', false)
-  emit('refresh') // 添加刷新事件
   materials.value = []
 }
 
 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%);
+}
+
+.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;
 }
 
-/* 错误提示样式 */
-:deep(.el-form-item__error) {
+.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>

+ 2 - 0
src/views/pms/iotrhdailyreport/components/DailyStatistics.vue

@@ -852,6 +852,8 @@ const { ZmTable, ZmTableColumn } = useTableComponents()
                       :loading="listLoading"
                       :data="list"
                       :height="height"
+                      :settings-cache="true"
+                      settings-cache-key="rhSummary"
                       show-border>
                       <template v-for="item in columns(type)" :key="item.prop">
                         <zm-table-column

+ 219 - 344
src/views/system/tree/PmsTree.vue

@@ -1,409 +1,284 @@
-<template>
-  <div class="head-container">
-    <el-input v-model="deptName" class="mb-15px" clearable placeholder="请输入名称">
-      <template #prefix>
-        <Icon icon="ep:search" />
-      </template>
-    </el-input>
-  </div>
-  <div ref="treeContainer" class="tree-container">
-    <el-tree
-      ref="treeRef"
-      :data="treeList"
-      :expand-on-click-node="false"
-      :filter-node-method="filterNode"
-      :props="defaultProps"
-      :default-expanded-keys="expandedKeys"
-    highlight-current
-    node-key="id"
-    @node-click="handleNodeClick"
-    @node-contextmenu="handleRightClick"
-    style="height: 52em"
-    >
-    <template #default="{ node }">
-      <div
-        style="display: flex; justify-content: space-between; align-items: center; width: 100%"
-      >
-        <div>
-          <Icon
-            style="vertical-align: middle;fill: currentColor;color: orange"
-            v-if="node.data.type === 'dept'"
-            icon="fa:folder-open"
-          />
-          <Icon
-            style="vertical-align: middle;fill: currentColor;color:dodgerblue"
-            v-if="node.data.type === 'device'"
-            icon="fa:tasks"
-          />
-          <Icon icon="fa:folder-open" v-if="node.data.type === 'file'" style="vertical-align: middle;color: orange;fill: currentColor;"/>
-          <span style="vertical-align: middle; margin-left: 3px">{{ node.data.name }}</span>
-        </div>
-      </div>
-    </template>
-    </el-tree>
-  </div>
-  <div v-show="deviceVisible" ref="contextMenuRef" class="custom-menu" :style="{ left: menuX + 'px', top: menuY + 'px' }">
-    <ul>
-      <li style="border-bottom: 1px solid #ccc;" @click="handleDeviceClick('add')">设备详情</li>
-      <li style="border-bottom: 0px solid #ccc;" @click="handleMenuClick('add')">新建目录</li>
-    </ul>
-  </div>
-  <div v-show="menuVisible" ref="contextMenuRef" class="custom-menu" :style="{ left: menuX + 'px', top: menuY + 'px' }">
-    <ul>
-      <li style="border-bottom: 1px solid #ccc;" @click="handleMenuClick('add')">新建目录</li>
-      <li style="border-bottom: 1px solid #ccc;" @click="handleMenuClick('edit')">编辑</li>
-      <li @click="handleMenuClick('delete')">删除目录</li>
-    </ul>
-  </div>
-
-  <Dialog v-model="dialogVisible" :title="dialogTitle" style="width: 40em">
-    <el-form
-      ref="formRef"
-      v-loading="formLoading"
-      :model="formData"
-      :rules="formRules"
-      label-width="80px"
-    >
-      <el-form-item label="目录名称" prop="name" label-width="110px">
-        <el-input v-model="formData.name" placeholder="请输入目录名称" />
-      </el-form-item>
-      <el-form-item label="显示排序" prop="sort" label-width="110px">
-        <el-input-number v-model="formData.sort" :min="0" controls-position="right" />
-      </el-form-item>
-<!--      <el-form-item label="备注" prop="remark" label-width="110px">-->
-<!--        <el-input-->
-<!--          v-model="formData.remark"-->
-<!--          maxlength="11"-->
-<!--          placeholder="请输入备注"-->
-<!--          type="textarea"-->
-<!--        />-->
-<!--      </el-form-item>-->
-    </el-form>
-    <template #footer>
-      <el-button type="primary" @click="submitForm">确 定</el-button>
-      <el-button @click="dialogVisible = false">取 消</el-button>
-    </template>
-  </Dialog>
-</template>
-
 <script lang="ts" setup>
 import { ElTree } from 'element-plus'
-import { defaultProps, handleTree } from '@/utils/tree'
-import { CommonStatusEnum } from '@/utils/constants'
+import { CaretLeft, CaretRight, Search } from '@element-plus/icons-vue'
+import type { IotTreeVO } from '@/api/system/tree'
 import { IotTreeApi } from '@/api/system/tree'
-import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
-import { useI18n } from 'vue-i18n'
-import { useRouter } from 'vue-router'
-import {IotInfoClassifyApi} from "@/api/pms/info";
-
-// 类型定义
-interface Tree {
-  id: number | string
-  name: string
-  type: string
-  children?: Tree[]
-  [key: string]: any
-}
+import { defaultProps, handleTree } from '@/utils/tree'
 
-const { t } = useI18n() // 国际化
-const message = useMessage() // 消息弹窗
-const dialogVisible = ref(false) // 弹窗的是否展示
-const dialogTitle = ref('') // 弹窗的标题
-const formRef = ref() // 搜索的表单
-const firstLevelKeys = ref<(number | string)[]>([])
-const expandedKeys = ref<(number | string)[]>([]) // 用于展开节点的路径
-
-// 表单相关
-const formLoading = ref(false)
-const formType = ref<'create' | 'update'>('create')
-const formData = ref({
-  id: undefined,
-  title: '',
-  parentId: undefined,
-  name: undefined,
-  sort: undefined,
-  leaderUserId: undefined,
-  phone: undefined,
-  email: undefined,
-  status: CommonStatusEnum.ENABLE
-})
-const formRules = ref({
-  name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
-  sort: [{ required: true, message: '请输入排序', trigger: 'blur' }]
-})
+defineOptions({ name: 'IotTree' })
 
-const openForm = (type: string, id?: number) => {
-  formRef.value.open(type, id)
+interface TreeNode extends IotTreeVO {
+  children?: TreeNode[]
 }
 
-/** 重置表单 */
-const resetForm = () => {
-  formData.value = {
-    id: undefined,
-    title: '',
-    parentId: undefined,
-    name: undefined,
-    sort: undefined,
-    leaderUserId: undefined,
-    phone: undefined,
-    email: undefined,
-    status: CommonStatusEnum.ENABLE
-  }
-  formRef.value?.resetFields()
-}
+type TreeKey = number | string
 
-defineOptions({ name: 'IotTree' })
+const props = withDefaults(
+  defineProps<{
+    deviceId: number
+    currentId?: TreeKey | null
+  }>(),
+  {
+    currentId: null
+  }
+)
 
-// 接收外部参数
-const props = defineProps({
-  deviceId: { type: Number, required: true },
-  currentId: { type: [Number, String], default: null } // 新增:接收需要定位的节点ID
-})
+const emits = defineEmits<{
+  (event: 'node-click', value: TreeNode): void
+  (event: 'success', value?: number): void
+}>()
 
-const deptName = ref('')
-const nodeInfo = ref({})
-const treeList = ref<Tree[]>([]) // 树形结构
+const isCollapsed = ref(false)
+const treeName = ref('')
+const treeList = ref<TreeNode[]>([])
 const treeRef = ref<InstanceType<typeof ElTree>>()
-const menuVisible = ref(false)
-const deviceVisible = ref(false)
-const menuX = ref(0)
-const menuY = ref(0)
-const contextMenuRef = ref(null) // 弹窗DOM引用
-let selectedNode = null
-const parentId = ref()
-
-const { push } = useRouter() // 路由跳转
-
-// 动态高度计算
-const treeContainer = ref(null)
-const setHeight = () => {
-  if (!treeContainer.value) return
-  const windowHeight = window.innerHeight
-  const containerTop = treeContainer.value.offsetTop
-  treeContainer.value.style.height = `${windowHeight * 0.78}px` // 60px 底部预留
+const expandedKeys = ref<Array<number | string>>([])
+
+const getNodeIcon = (type: string) => {
+  if (type === 'device') return 'fa:tasks'
+  return 'fa:folder-open'
+}
+
+const getNodeIconClass = (type: string) => {
+  return type === 'device' ? 'node-icon node-icon--device' : 'node-icon node-icon--folder'
 }
 
-// 新增:查找节点路径的递归方法
-const findNodePath = (nodes: Tree[], targetId: number | string, path: (number | string)[] = []): (number | string)[] | null => {
+const findNodePath = (
+  nodes: TreeNode[],
+  targetId: number | string,
+  path: Array<number | string> = []
+): Array<number | string> | null => {
   for (const node of nodes) {
-    path.push(node.id)
+    const nextPath = [...path, node.id]
+
     if (node.id === targetId) {
-      return [...path]
+      return nextPath
     }
-    if (node.children && node.children.length) {
-      const result = findNodePath(node.children, targetId, path)
-      if (result) {
-        return result
-      }
+
+    if (node.children?.length) {
+      const result = findNodePath(node.children, targetId, nextPath)
+      if (result) return result
     }
-    path.pop()
   }
+
   return null
 }
 
-// 新增:定位节点并高亮
 const locateNode = (targetId: number | string) => {
   if (!targetId || !treeList.value.length) return
 
   const pathIds = findNodePath(treeList.value, targetId)
-  if (pathIds) {
-    // 展开所有父节点
-    expandedKeys.value = pathIds.slice(0, -1)
-
-    // 等待DOM更新后设置当前节点
-    nextTick(() => {
-      if (treeRef.value) {
-        treeRef.value.setCurrentKey(targetId)
-
-        // 滚动到节点位置
-        const node = treeRef.value.getNode(targetId)
-        if (node && node.$el) {
-          node.$el.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
-        }
-      }
-    })
-  }
-}
-
-onMounted(async () => {
-  await getTreeInfo()
-  setHeight()
-  window.addEventListener('resize', setHeight)
-})
-
-onUnmounted(() => {
-  window.removeEventListener('resize', setHeight)
-})
-
-const handleRightClick = (event, node, data) => {
-  nodeInfo.value = node
-  event.preventDefault()
-  menuX.value = event.clientX
-  menuY.value = event.clientY
-  selectedNode = data
-  debugger
-  parentId.value = data.data.id
-  if (nodeInfo.value.type === 'device') {
-    deviceVisible.value = true
-  } else if (nodeInfo.value.type === 'file') {
-    menuVisible.value = true
-  }
-}
+  if (!pathIds) return
 
-const handleDeviceClick = async () => {
-  const id = nodeInfo.value.originId
-  push({ name: 'DeviceDetailInfo', params: { id } })
-  deviceVisible.value = false
-  menuVisible.value = false
-}
+  expandedKeys.value = [...new Set([...expandedKeys.value, ...pathIds.slice(0, -1)])]
 
-const handleMenuClick = async (action) => {
-  switch (action) {
-    case 'add':
-      dialogVisible.value = true
-      dialogTitle.value = '新增目录'
-      formType.value = 'create'
-      resetForm()
-      break
-    case 'edit':
-      resetForm()
-      dialogVisible.value = true
-      dialogTitle.value = '编辑目录'
-      formType.value = 'update'
-      formData.value = { ...nodeInfo.value }
-      debugger
-      break
-    case 'delete':
-      // 删除的二次确认
-      await message.delConfirm()
-      // 假设存在删除接口
-      await IotTreeApi.deleteIotTree(nodeInfo.value.id)
-      message.success(t('common.delSuccess'))
-      // 刷新列表
-      await getTreeInfo()
-      break
-  }
-  deviceVisible.value = false
-  menuVisible.value = false
+  nextTick(() => {
+    treeRef.value?.setCurrentKey(targetId)
+    treeRef.value?.getNode(targetId)?.$el?.scrollIntoView({
+      behavior: 'smooth',
+      block: 'nearest'
+    })
+  })
 }
 
-/** 获得部门树 */
 const getTreeInfo = async () => {
   const res = await IotTreeApi.getSimpleTreeList()
-  treeList.value = []
-  treeList.value.push(...handleTree(res))
+  treeList.value = handleTree(res) as TreeNode[]
+  expandedKeys.value = treeList.value.map((node) => node.id)
 
-  // 处理展开逻辑:有currentId则展开对应路径,否则展开一级节点
   if (props.currentId) {
     locateNode(props.currentId)
-  } else {
-    firstLevelKeys.value = treeList.value.map(node => node.id)
-    expandedKeys.value = [...firstLevelKeys.value]
   }
+
   emits('success', treeList.value[0]?.id)
 }
 
-/** 基于名字过滤 */
-const filterNode = (name: string, data: Tree) => {
-  if (!name) return true
-  return data.name.includes(name)
+const filterNode = (name: string, data: TreeNode) => {
+  return !name || data.name.includes(name)
 }
 
-/** 处理节点被点击 */
-const handleNodeClick = async (row: { [key: string]: any }) => {
-  deviceVisible.value = false
-  menuVisible.value = false
+const handleNodeClick = (row: TreeNode) => {
   emits('node-click', row)
 }
 
-/** 提交表单 */
-const submitForm = async () => {
-  // 表单验证逻辑
-  formLoading.value = true
-  try {
-    formData.value.deviceId = props.deviceId
-    debugger
-    // formData.value.parentId = parentId.value
-    if (formData.value.parentId===undefined||formData.value.parentId===null) {
-      formData.value.parentId = props.currentId
-    }
-    formData.value.type = 'file'
-    if (formType.value === 'create') {
-      debugger
-      await IotTreeApi.createIotTree(formData.value)
-    } else {
-      await IotTreeApi.updateIotTree(formData.value)
-    }
-    message.success(t(formType.value === 'create' ? 'common.addSuccess' : 'common.updateSuccess'))
-    dialogVisible.value = false
-    await getTreeInfo()
-  } catch (error) {
-    console.error(error)
-  } finally {
-    formLoading.value = false
-  }
-}
-
-const emits = defineEmits(['node-click', 'success'])
-
-/** 监听搜索框变化 */
-watch(deptName, (val) => {
-  treeRef.value!.filter(val)
+watch(treeName, (val) => {
+  treeRef.value?.filter(val)
 })
 
-/** 监听currentId变化,重新定位 */
 watch(
   () => props.currentId,
   (newVal) => {
-    if (newVal && treeList.value.length) {
+    if (newVal) {
       locateNode(newVal)
     }
-  },
-  { immediate: true }
+  }
 )
+
+onMounted(getTreeInfo)
 </script>
 
+<template>
+  <div
+    class="pms-tree-aside relative bg-white dark:bg-[#1d1e1f] shadow rounded-lg transition-all duration-300 ease-in-out overflow-visible"
+    :class="[isCollapsed ? 'is-collapsed' : 'p-4']">
+    <div v-show="!isCollapsed" class="h-full flex flex-col gap-4 overflow-hidden w-full">
+      <div class="shrink-0">
+        <el-input
+          v-model="treeName"
+          clearable
+          placeholder="请输入名称"
+          size="default"
+          :prefix-icon="Search" />
+      </div>
+
+      <div class="tree-body">
+        <el-auto-resizer class="absolute">
+          <template #default="{ height }">
+            <el-scrollbar :style="{ height: `${height}px` }">
+              <el-tree
+                ref="treeRef"
+                :data="treeList"
+                :default-expanded-keys="expandedKeys"
+                :expand-on-click-node="false"
+                :filter-node-method="filterNode"
+                :props="defaultProps"
+                highlight-current
+                node-key="id"
+                @node-click="handleNodeClick">
+                <template #default="{ node }">
+                  <div class="tree-node">
+                    <Icon
+                      :class="getNodeIconClass(node.data.type)"
+                      :icon="getNodeIcon(node.data.type)" />
+                    <span class="tree-node__label">{{ node.data.name }}</span>
+                  </div>
+                </template>
+              </el-tree>
+            </el-scrollbar>
+          </template>
+        </el-auto-resizer>
+      </div>
+    </div>
+
+    <div class="collapse-handle" @click="isCollapsed = !isCollapsed">
+      <el-icon size="12">
+        <CaretLeft v-if="!isCollapsed" />
+        <CaretRight v-else />
+      </el-icon>
+    </div>
+  </div>
+</template>
+
 <style lang="scss" scoped>
-.custom-menu {
-  position: fixed;
-  background: white;
-  border: 1px solid #ccc;
-  box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.1);
-  z-index: 1000;
+.pms-tree-aside {
+  width: 100%;
+  height: calc(
+    100vh - 20px - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height)
+  );
+  min-width: 240px;
+  box-sizing: border-box;
+  flex-shrink: 0;
+}
+
+.pms-tree-aside.is-collapsed {
+  width: 0 !important;
+  min-width: 0 !important;
+  padding: 0 !important;
+  overflow: visible !important;
+  pointer-events: none;
+  box-shadow: none;
+}
+
+:global(.left-tree:has(.pms-tree-aside.is-collapsed)) {
+  width: 0 !important;
+  overflow: visible !important;
+  background: transparent !important;
+}
+
+:global(.left-tree:has(.pms-tree-aside.is-collapsed) + .divider-tree) {
+  display: none;
+}
+
+.tree-body {
+  position: relative;
+  min-height: 0;
+  overflow: hidden;
+  flex: 1;
+}
+
+.tree-node {
+  display: flex;
+  width: 100%;
+  min-width: 0;
+  align-items: center;
+}
+
+.node-icon {
+  margin-right: 6px;
+  fill: currentcolor;
+  flex-shrink: 0;
+}
+
+.node-icon--folder {
+  color: #e6a23c;
+}
+
+.node-icon--device {
+  color: #409eff;
 }
-.custom-menu ul {
-  list-style: none;
-  padding: 0;
-  margin: 0;
+
+.tree-node__label {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
-.custom-menu li {
-  padding: 2px 10px;
+
+.collapse-handle {
+  position: absolute;
+  top: 50%;
+  right: -14px;
+  z-index: 200;
+  display: flex;
+  width: 14px;
+  height: 60px;
+  color: var(--el-text-color-secondary);
+  pointer-events: auto;
   cursor: pointer;
-  font-size: 12px;
-  margin: 2px;
+  background-color: var(--el-bg-color);
+  border: 1px solid var(--el-border-color-light);
+  border-left: none;
+  border-radius: 0 12px 12px 0;
+  transform: translateY(-50%);
+  box-shadow: 2px 0 6px rgb(0 0 0 / 5%);
+  transition: right 0.3s;
+  align-items: center;
+  justify-content: center;
 }
-.custom-menu li:hover {
-  background: #77a0ec;
+
+.is-collapsed .collapse-handle {
+  right: -8px;
+  border-left: 1px solid var(--el-border-color-light);
 }
-.tree-container {
-  overflow-y: auto;
-  min-width: 100%;
-  border: 1px solid #e4e7ed;
-  border-radius: 4px;
+
+.collapse-handle:hover {
+  color: var(--el-color-primary);
+  background-color: var(--el-fill-color-light);
 }
-/* 自定义滚动条 */
-.tree-container::-webkit-scrollbar {
-  width: 6px;
+
+:deep(.el-tree) {
+  --el-tree-node-hover-bg-color: var(--el-fill-color-light);
+
+  background: transparent;
 }
-.tree-container::-webkit-scrollbar-thumb {
-  background: #c0c4cc;
-  border-radius: 3px;
+
+:deep(.el-tree-node__content) {
+  height: 32px;
+  border-radius: 4px;
 }
 
-/* 自定义高亮样式 */
-::v-deep .el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content {
-  background-color: #eaf5ff !important;
-  color: #1890ff !important;
-  font-weight: bold;
+:deep(.el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content) {
+  font-weight: 600;
+  color: var(--el-color-primary);
+  background-color: var(--el-color-primary-light-9);
 }
 </style>

+ 306 - 386
src/views/system/tree/index.vue

@@ -1,264 +1,261 @@
 <template>
-  <ContentWrap>
-    <div style="display: flex; justify-content: space-between; align-items: center">
+  <div
+    class="tree-page grid grid-cols-[auto_1fr] grid-rows-[48px_48px_1fr] gap-x-4 gap-y-3 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]">
+    <PmsTree
+      class="row-span-3"
+      @node-click="handleFileNodeClick"
+      @success="successList"
+      :currentId="queryParams.classId"
+      :deviceId="id" />
+
+    <el-form
+      class="search-panel bg-white dark:bg-[#1d1e1f] rounded-lg shadow px-6 gap-8 flex items-center justify-between"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px">
+      <div class="flex items-center gap-8">
+        <el-form-item :label="t('file.name')" prop="filename">
+          <el-input
+            v-model="queryParams.filename"
+            :placeholder="t('file.nameHolder')"
+            size="default"
+            clearable
+            @keyup.enter="handleQuery"
+            class="!w-240px" />
+        </el-form-item>
+        <el-form-item v-show="false" :label="t('file.fileType')" prop="fileType">
+          <el-select
+            v-model="queryParams.fileType"
+            :placeholder="t('file.fileTypeHolder')"
+            size="default"
+            clearable
+            class="!w-200px">
+            <el-option
+              v-for="dict in getStrDictOptions(DICT_TYPE.PMS_FILE_TYPE)"
+              :key="dict.value"
+              :label="dict.label"
+              :value="dict.value" />
+          </el-select>
+        </el-form-item>
+      </div>
+      <el-form-item>
+        <el-button size="default" @click="handleQuery">
+          <Icon icon="ep:search" class="mr-5px" />{{ t('file.search') }}
+        </el-button>
+        <el-button size="default" @click="resetQuery">
+          <Icon icon="ep:refresh" class="mr-5px" />{{ t('file.reset') }}
+        </el-button>
+        <el-button
+          type="primary"
+          size="default"
+          :loading="uploadLoading"
+          @click="openForm('create')">
+          <Icon icon="ep:plus" class="mr-5px" />{{ t('file.upload') }}
+        </el-button>
+      </el-form-item>
+    </el-form>
+
+    <div
+      class="breadcrumb-panel bg-white dark:bg-[#1d1e1f] rounded-lg shadow px-4 flex items-center justify-between">
       <el-breadcrumb separator=">" class="breadcrumb-container">
         <el-breadcrumb-item
           v-for="(item, index) in breadcrumbs"
           :key="index"
           @click="handleBreadcrumbClick(index)"
           :class="{ 'current-crumb': index === breadcrumbs.length - 1 }"
-          class="custom-breadcrumb-item"
-        >
+          class="custom-breadcrumb-item">
           {{ item.name }}
         </el-breadcrumb-item>
       </el-breadcrumb>
       <el-input
         v-model="queryParams.allName"
         :placeholder="'在' + breadcrumbs[breadcrumbs.length - 1].name + '下搜索'"
-        style="width: 250px; height: 30px"
-        @input="searchFolderAndFile"
-      />
+        size="default"
+        style="width: 250px"
+        @input="searchFolderAndFile" />
     </div>
-  </ContentWrap>
-  <div class="container-tree" ref="container">
-    <el-row>
-      <div class="left-tree" :style="{ width: leftWidth + 'px' }">
-        <ContentWrapNoBottom>
-          <PmsTree
-            @node-click="handleFileNodeClick"
-            @success="successList"
-            :currentId="queryParams.classId"
-            :deviceId="id"
-          />
-        </ContentWrapNoBottom>
-      </div>
-      <!--    </el-col>-->
-      <div class="divider-tree" @mousedown="startDrag"></div>
-      <div class="right-tree" :style="{ width: rightWidth + 'px' }">
-        <ContentWrap>
-          <el-form
-            class="-mb-15px"
-            :model="queryParams"
-            ref="queryFormRef"
-            :inline="true"
-            label-width="68px"
-          >
-            <el-form-item :label="t('file.name')" prop="filename">
-              <el-input
-                v-model="queryParams.filename"
-                :placeholder="t('file.nameHolder')"
-                clearable
-                @keyup.enter="handleQuery"
-                class="!w-240px"
-              />
-            </el-form-item>
-            <el-form-item v-show="false" :label="t('file.fileType')" prop="fileType">
-              <el-select
-                v-model="queryParams.fileType"
-                :placeholder="t('file.fileTypeHolder')"
-                clearable
-                class="!w-200px"
-              >
-                <el-option
-                  v-for="dict in getStrDictOptions(DICT_TYPE.PMS_FILE_TYPE)"
-                  :key="dict.value"
-                  :label="dict.label"
-                  :value="dict.value"
-                />
-              </el-select>
-            </el-form-item>
-            <el-form-item>
-              <el-button @click="handleQuery"
-                ><Icon icon="ep:search" /> {{ t('file.search') }}</el-button
-              >
-              <el-button @click="resetQuery"
-                ><Icon icon="ep:refresh" />{{ t('file.reset') }}</el-button
-              >
-              <el-button type="primary" :loading="uploadLoading" @click="openForm('create')">
-                <Icon icon="ep:plus" /> {{ t('file.upload') }}
+
+    <div class="table-panel bg-white dark:bg-[#1d1e1f] rounded-lg shadow overflow-hidden p-3">
+      <zm-table
+        :loading="formLoading"
+        :data="list"
+        :stripe="true"
+        :show-overflow-tooltip="true"
+        settings-cache-key="system-tree-file-list"
+        @row-dblclick="inContent"
+        class="custom-table">
+        <zm-table-column :label="t('file.name')" align="left" prop="filename" min-width="300">
+          <template #default="scope">
+            <div style="display: flex; align-items: center; gap: 5px">
+              <Icon v-if="scope.row.fileType === 'content'" icon="fa:folder-open" color="orange" />
+              <Icon
+                v-else-if="scope.row.fileType === 'device'"
+                icon="fa:folder-open"
+                color="blue" />
+              <Icon
+                v-else-if="
+                  scope.row.fileType === 'pic' ||
+                  scope.row.fileClassify === 'jpg' ||
+                  scope.row.fileClassify === 'png' ||
+                  scope.row.fileClassify === 'JPG'
+                "
+                icon="ep:picture-filled"
+                color="#2183D1" />
+              <Icon
+                v-else-if="
+                  scope.row.fileType === 'file' &&
+                  (scope.row.fileClassify === 'pdf' || scope.row.fileClassify === 'PDF')
+                "
+                icon="fa-solid:file-pdf"
+                color="#E20012" />
+              <Icon
+                v-else-if="
+                  scope.row.fileType === 'file' &&
+                  (scope.row.fileClassify === 'doc' || scope.row.fileClassify === 'docx')
+                "
+                icon="fa:file-word-o"
+                color="blue" />
+              <Icon
+                v-else-if="
+                  scope.row.fileType === 'file' &&
+                  (scope.row.fileClassify === 'xls' || scope.row.fileClassify === 'xlsx')
+                "
+                icon="fa-solid:file-excel"
+                color="#107C41" />
+              <Icon
+                v-else-if="scope.row.fileType === 'file' && scope.row.fileClassify === 'txt'"
+                icon="fa:file-text-o" />
+              <Icon
+                v-else-if="scope.row.fileType === 'file' && scope.row.fileClassify === 'mp4'"
+                icon="fa:play-circle-o"
+                color="#009fff" />
+              <Icon
+                v-else-if="
+                  scope.row.fileType === 'file' &&
+                  (scope.row.fileClassify === 'ppt' || scope.row.fileClassify === 'pptx')
+                "
+                icon="fa-solid:file-powerpoint"
+                color="#C43E1C" />
+              <Icon v-else icon="fa-solid:file-alt" />
+              {{ scope.row.filename }}
+            </div>
+          </template>
+        </zm-table-column>
+        <zm-table-column :label="t('file.fileType')" align="center" prop="fileType">
+          <template #default="scope">
+            <dict-tag :type="DICT_TYPE.PMS_FILE_TYPE" :value="scope.row.fileType" />
+          </template>
+        </zm-table-column>
+        <zm-table-column :label="t('file.fileSize')" align="center" prop="fileSize" />
+        <zm-table-column :label="t('file.dept')" align="center" prop="deptName" />
+        <zm-table-column
+          :label="t('file.device')"
+          align="center"
+          prop="deviceCode"
+          min-width="220" />
+        <zm-table-column
+          :label="t('file.operation')"
+          align="center"
+          width="160"
+          fixed="right"
+          action>
+          <template #default="scope">
+            <div class="flex items-center justify-center">
+              <el-button
+                type="primary"
+                v-if="scope.row.fileType !== 'content'"
+                link
+                @click="handleDownload(scope.row.filePath)"
+                v-hasPermi="['rq:iot-info:download']">
+                <Icon icon="ep:download" />{{ t('file.dow') }}
               </el-button>
-            </el-form-item>
-          </el-form>
-        </ContentWrap>
-        <ContentWrap>
-          <el-table
-            v-loading="formLoading"
-            :data="list"
-            :stripe="true"
-            :show-overflow-tooltip="true"
-            @row-dblclick="inContent"
-            class="custom-table"
-          >
-            <el-table-column :label="t('file.name')" align="left" prop="filename" min-width="300">
-              <template #default="scope">
-                <div style="display: flex; align-items: center; gap: 5px">
-                  <Icon
-                    v-if="scope.row.fileType === 'content'"
-                    icon="fa:folder-open"
-                    color="orange"
-                  />
-                  <Icon
-                    v-else-if="scope.row.fileType === 'device'"
-                    icon="fa:folder-open"
-                    color="blue"
-                  />
-                  <Icon
-                    v-else-if="
-                      scope.row.fileType === 'pic' ||
-                      scope.row.fileClassify === 'jpg' ||
-                      scope.row.fileClassify === 'png' ||
-                      scope.row.fileClassify === 'JPG'
-                    "
-                    icon="ep:picture-filled"
-                    color="#2183D1"
-                  />
-                  <Icon
-                    v-else-if="
-                      scope.row.fileType === 'file' &&
-                      (scope.row.fileClassify === 'pdf' || scope.row.fileClassify === 'PDF')
-                    "
-                    icon="fa-solid:file-pdf"
-                    color="#E20012"
-                  />
-                  <Icon
-                    v-else-if="
-                      scope.row.fileType === 'file' &&
-                      (scope.row.fileClassify === 'doc' || scope.row.fileClassify === 'docx')
-                    "
-                    icon="fa:file-word-o"
-                    color="blue"
-                  />
-                  <Icon
-                    v-else-if="
-                      scope.row.fileType === 'file' &&
-                      (scope.row.fileClassify === 'xls' || scope.row.fileClassify === 'xlsx')
-                    "
-                    icon="fa-solid:file-excel"
-                    color="#107C41"
-                  />
-                  <Icon
-                    v-else-if="scope.row.fileType === 'file' && scope.row.fileClassify === 'txt'"
-                    icon="fa:file-text-o"
-                  />
-                  <Icon
-                    v-else-if="scope.row.fileType === 'file' && scope.row.fileClassify === 'mp4'"
-                    icon="fa:play-circle-o"
-                    color="#009fff"
-                  />
-                  <Icon
-                    v-else-if="
-                      scope.row.fileType === 'file' &&
-                      (scope.row.fileClassify === 'ppt' || scope.row.fileClassify === 'pptx')
-                    "
-                    icon="fa-solid:file-powerpoint"
-                    color="#C43E1C"
-                  />
-                  <Icon v-else icon="fa-solid:file-alt" />
-                  {{ scope.row.filename }}
-                </div>
-              </template>
-            </el-table-column>
-            <el-table-column :label="t('file.fileType')" align="center" prop="fileType">
-              <template #default="scope">
-                <dict-tag :type="DICT_TYPE.PMS_FILE_TYPE" :value="scope.row.fileType" />
-              </template>
-            </el-table-column>
-            <el-table-column :label="t('file.fileSize')" align="center" prop="fileSize" />
-            <!--            <el-table-column :label="t('file.preview') " align="center" prop="filePath" >-->
-            <!--              <template #default="scope">-->
-            <!--                <el-button v-if="scope.row.fileType!=='content'" link type="primary" @click="openWeb(scope.row.filePath)"> <Icon size="19" icon="ep:view"  /> </el-button>-->
-            <!--              </template>-->
-            <!--            </el-table-column>-->
-            <el-table-column :label="t('file.dept')" align="center" prop="deptName" />
-            <el-table-column
-              :label="t('file.device')"
-              align="center"
-              prop="deviceCode"
-              min-width="220"
-            />
-            />
-            <el-table-column :label="t('file.operation')" align="center" width="160">
-              <template #default="scope">
-                <div class="flex items-center justify-center">
-                  <el-button
-                    type="primary"
-                    v-if="scope.row.fileType !== 'content'"
-                    link
-                    @click="handleDownload(scope.row.filePath)"
-                    v-hasPermi="['rq:iot-info:download']"
-                  >
-                    <Icon icon="ep:download" />{{ t('file.dow') }}
-                  </el-button>
-                  <el-button
-                    type="danger"
-                    v-if="scope.row.fileType !== 'content'"
-                    link
-                    @click="deleteInfo(scope.row.id)"
-                    v-hasPermi="['rq:iot-info:download']"
-                  >
-                    <Icon icon="ep:delete" />{{ t('file.delete') }}
-                  </el-button>
-                </div>
-              </template>
-            </el-table-column>
-          </el-table>
-        </ContentWrap>
-      </div>
-    </el-row>
+              <el-button
+                type="danger"
+                v-if="scope.row.fileType !== 'content'"
+                link
+                @click="deleteInfo(scope.row.id)"
+                v-hasPermi="['rq:iot-info:download']">
+                <Icon icon="ep:delete" />{{ t('file.delete') }}
+              </el-button>
+            </div>
+          </template>
+        </zm-table-column>
+      </zm-table>
+    </div>
   </div>
+
   <IotInfoFormTree
     ref="formRef"
     @success="getList"
     :deviceId="deviceId"
     :nodeId="nodeId"
-    :classId="clickNodeId"
-  />
+    :classId="clickNodeId" />
 </template>
 <script lang="ts" setup>
-import { IotDeviceApi, IotDeviceVO } from '@/api/pms/device'
-import { dateFormatter } from '@/utils/formatTime'
 import IotInfoFormTree from '@/views/pms/iotinfo/IotInfoFormTree.vue'
 import * as IotInfoApi from '@/api/pms/iotinfo'
-import { IotInfoVO } from '@/api/pms/iotinfo'
-import { ref, onMounted, onUnmounted } from 'vue'
+import { ref, onMounted } from 'vue'
+import { Base64 } from 'js-base64'
 
 import PmsTree from '@/views/system/tree/PmsTree.vue'
-import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
 import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
 import { IotInfoClassifyApi } from '@/api/pms/info'
 import { IotTreeApi } from '@/api/system/tree'
 defineOptions({ name: 'IotTree' })
 
-const container = ref(null)
-const leftWidth = ref(350) // 初始左侧宽度
-const rightWidth = ref(window.innerWidth * 0.8)
-let isDragging = false
+type TreeKey = number | string
+
+interface QueryParams {
+  pageNo: number
+  pageSize: number
+  filename: string | null
+  fileType: string | null
+  createTime: string[]
+  deviceId: TreeKey | null
+  classId: TreeKey | null
+  deptId?: TreeKey
+  allName: string | null
+}
+
+interface BreadcrumbItem {
+  id: TreeKey | null
+  name: string
+  type?: string
+}
+
+interface FileRow {
+  id: TreeKey
+  originId?: TreeKey
+  parentId?: TreeKey
+  name?: string
+  filename?: string
+  fileType?: string
+  filePath?: string
+  fileSize?: string
+  fileClassify?: string
+  deptName?: string
+  deviceCode?: string
+  type?: string
+}
+
+interface IotInfoFormTreeExpose {
+  open: (type: string, id?: number) => void
+}
+
 const uploadLoading = ref(false)
 
 const searchFolderAndFile = async () => {
-  debugger
   formLoading.value = true
   const data = await IotInfoApi.IotInfoApi.getAllChildContentFile(queryParams)
-  debugger
   list.value = data
   formLoading.value = false
   queryParams.filename = ''
 }
-const openWeb = (url) => {
-  window.open(
-    'http://1.94.244.160:8012/onlinePreview?url=' + encodeURIComponent(Base64.encode(url))
-  )
-}
-const handleView = (row) => {
-  openForm('detail', row.id)
-}
-const startDrag = (e) => {
-  isDragging = true
-  document.addEventListener('mousemove', onDrag)
-  document.addEventListener('mouseup', stopDrag)
-}
-const topNodeId = ref('')
-const successList = async (id) => {
+const topNodeId = ref<TreeKey | null>(null)
+const successList = async (id?: TreeKey) => {
+  if (id === undefined) return
+
   queryParams.classId = id
   topNodeId.value = id
   const rootItem = breadcrumbs.value.find((item) => item.type === 'root')
@@ -270,34 +267,15 @@ const successList = async (id) => {
   // queryParams.classId = ''
 }
 
-const onDrag = (e) => {
-  if (!isDragging) return
-
-  const containerRect = container.value.getBoundingClientRect()
-  const newWidth = e.clientX - containerRect.left
-
-  // 设置最小和最大宽度限制
-  if (newWidth > 300 && newWidth < containerRect.width - 100) {
-    leftWidth.value = newWidth
-  }
-}
-
-const stopDrag = () => {
-  isDragging = false
-  document.removeEventListener('mousemove', onDrag)
-  document.removeEventListener('mouseup', stopDrag)
-}
-
 const queryFormRef = ref() // 搜索的表单
 const { t } = useI18n() // 国际化
 const message = useMessage() // 消息弹窗
-const loading = ref(true) // 列表的加载中
 const { params } = useRoute() // 查询参数
 const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
-const list = ref<IotDeviceVO[]>([]) // 列表的数据
+const list = ref<FileRow[]>([]) // 列表的数据
 // const total = ref(0) // 列表的总页数
-const id = ref()
-const queryParams = reactive({
+const id = ref(0)
+const queryParams = reactive<QueryParams>({
   pageNo: 1,
   pageSize: 10,
   filename: null,
@@ -308,18 +286,8 @@ const queryParams = reactive({
   deptId: undefined,
   allName: null
 })
-// SPU 表单数据
-const formData = ref({
-  id: undefined,
-  deviceId: undefined,
-  deptId: undefined,
-  filename: undefined,
-  fileType: undefined,
-  filePath: undefined,
-  remark: undefined,
-  classId: undefined
-})
-const handleDownload = async (url) => {
+const handleDownload = async (url?: string) => {
+  if (!url) return
   try {
     formLoading.value = true
     const response = await fetch(url)
@@ -328,7 +296,7 @@ const handleDownload = async (url) => {
 
     const link = document.createElement('a')
     link.href = downloadUrl
-    link.download = url.split('/').pop() // 自动获取文件名‌:ml-citation{ref="3" data="citationList"}
+    link.download = url.split('/').pop()! // 自动获取文件名‌:ml-citation{ref="3" data="citationList"}
     link.click()
 
     URL.revokeObjectURL(downloadUrl)
@@ -337,9 +305,9 @@ const handleDownload = async (url) => {
     console.error('下载失败:', error)
   }
 }
-const deleteInfo = async (id) => {
+const deleteInfo = async (id: TreeKey) => {
   await message.delConfirm()
-  await IotInfoApi.IotInfoApi.deleteIotInfo(id).then((res) => {
+  await IotInfoApi.IotInfoApi.deleteIotInfo(Number(id)).then((res) => {
     if (res) {
       message.success('文件删除成功')
       getList()
@@ -348,23 +316,7 @@ const deleteInfo = async (id) => {
     }
   })
 }
-// const handleFileView = (url: string) => {
-//   window.open(
-//     'http://1.94.244.160:8012/onlinePreview?url=' + encodeURIComponent(Base64.encode(url))
-//   )
-// }
-const handleDelete = async (id: number) => {
-  try {
-    // 删除的二次确认
-    await message.delConfirm()
-    // 发起删除
-    await IotInfoApi.IotInfoApi.deleteIotInfo(id)
-    message.success(t('common.delSuccess'))
-    // 刷新列表
-    await getList()
-  } catch {}
-}
-const formRef = ref()
+const formRef = ref<IotInfoFormTreeExpose>()
 const openForm = async (type: string, id?: number) => {
   // if (classType.value==='dept'){
   //   message.error(t('common.deptChoose'))
@@ -373,10 +325,11 @@ const openForm = async (type: string, id?: number) => {
   uploadLoading.value = true
   if (!queryParams.classId) {
     message.error(t('common.leftNode'))
+    uploadLoading.value = false
     return
   }
-  formRef.value.open(type, id)
-  await new Promise((resolve) => {
+  formRef.value?.open(type, id)
+  await new Promise<void>((resolve) => {
     setTimeout(() => {
       resolve() // 操作完成后resolve
     }, 2000) // 模拟2秒耗时
@@ -386,14 +339,13 @@ const openForm = async (type: string, id?: number) => {
 const deviceId = ref('')
 const clickNodeId = ref('')
 const nodeId = ref('')
-const classType = ref('')
 
-const breadcrumbs = ref([
+const breadcrumbs = ref<BreadcrumbItem[]>([
   { id: null, name: '科瑞石油技术', type: 'root' } // 根节点
 ])
 
 // 共享的面包屑更新逻辑
-const updateBreadcrumbs = async (node) => {
+const updateBreadcrumbs = async (node: FileRow) => {
   // 查找当前节点是否已在面包屑中
   const currentIndex = breadcrumbs.value.findIndex((item) => item.id === node.id)
 
@@ -402,7 +354,7 @@ const updateBreadcrumbs = async (node) => {
     breadcrumbs.value = breadcrumbs.value.slice(0, currentIndex + 1)
   } else {
     // 新增节点到面包屑
-    breadcrumbs.value.push({ id: node.id, name: node.filename || node.name })
+    breadcrumbs.value.push({ id: node.id, name: node.filename || node.name || '' })
   }
 
   // 更新表格数据
@@ -412,8 +364,10 @@ const updateBreadcrumbs = async (node) => {
 }
 
 // 表格行点击事件
-const inContent = async (row) => {
+const inContent = async (row: FileRow) => {
   if (row.fileType != 'content') {
+    if (!row.filePath) return
+
     window.open(
       'http://doc.deepoil.cc:8012/onlinePreview?url=' +
         encodeURIComponent(Base64.encode(row.filePath))
@@ -426,14 +380,14 @@ const inContent = async (row) => {
   await updateBreadcrumbs(row)
   // 可以添加其他表格行点击需要的逻辑
   queryParams.classId = row.id
-  nodeId.value = row.id
+  nodeId.value = String(row.id)
   const data = await IotInfoApi.IotInfoApi.getChildContentFile(queryParams)
   formLoading.value = false
   list.value = data
 }
 
 // 文件树节点点击事件
-const handleFileNodeClick = async (row) => {
+const handleFileNodeClick = async (row: FileRow) => {
   queryParams.filename = ''
   const parentItems = await IotTreeApi.getParentIds(row.id)
   breadcrumbs.value = []
@@ -443,12 +397,11 @@ const handleFileNodeClick = async (row) => {
 
   queryParams.classId = row.id
   if (row.type == 'device') {
-    id.value = row.originId
+    id.value = Number(row.originId)
   }
 
-  classType.value = row.type
   if (row.type === 'device') {
-    deviceId.value = row.originId
+    deviceId.value = String(row.originId)
     const queryParam = {
       deviceId: row.originId,
       pageNo: 1,
@@ -457,25 +410,26 @@ const handleFileNodeClick = async (row) => {
     const data = await IotInfoClassifyApi.getIotInfoClassifyPage(queryParam)
     if (data) {
       const target = data.filter((item) => item.parentId === 0)
-      clickNodeId.value = target[0].id
+      clickNodeId.value = String(target[0]?.id ?? '')
     }
   } else if (row.type === 'file') {
-    clickNodeId.value = row.originId
+    clickNodeId.value = String(row.originId)
   } else if (row.type === 'dept') {
     // message.error("请选择设备及文件节点")
     // return
   }
-  nodeId.value = row.id
+  nodeId.value = String(row.id)
   await getList()
 }
 
 // 面包屑点击事件
-const handleBreadcrumbClick = async (index) => {
+const handleBreadcrumbClick = async (index: number) => {
   queryParams.filename = ''
-  formLoading.value = true
   // 忽略当前节点的点击
   if (index === breadcrumbs.value.length - 1) return
 
+  formLoading.value = true
+
   // 截断面包屑到点击的节点
   const targetBreadcrumbs = breadcrumbs.value.slice(0, index + 1)
   breadcrumbs.value = targetBreadcrumbs
@@ -485,57 +439,24 @@ const handleBreadcrumbClick = async (index) => {
   if (!targetId) {
     targetId = topNodeId.value
   }
+  if (!targetId) {
+    formLoading.value = false
+    return
+  }
+
   queryParams.classId = targetId
-  clickNodeId.value = targetId
-  nodeId.value = targetId
-  debugger
+  clickNodeId.value = String(targetId)
+  nodeId.value = String(targetId)
   const data = await IotInfoApi.IotInfoApi.getChildContentFile(queryParams)
   list.value = data
   formLoading.value = false
 }
 
-// const handleFileNodeClick = async (row) => {
-//   queryParams.classId = row.id
-//   classType.value = row.type
-//   if (row.type==='device') {
-//     deviceId.value = row.originId
-//     const queryParam = {
-//       deviceId: row.originId,
-//       pageNo: 1,
-//       pagesize: 10,
-//     }
-//     const data = await IotInfoClassifyApi.getIotInfoClassifyPage(queryParam)
-//     debugger
-//     if (data){
-//       const target = data.filter((item)=> item.parentId===0)
-//       clickNodeId.value = target[0].id
-//     }
-//   } else if (row.type === 'file'){
-//     clickNodeId.value = row.originId
-//   } else if (row.type==='dept') {
-//     // message.error("请选择设备及文件节点")
-//     // return
-//   }
-//   nodeId.value = row.id
-//   await getList()
-// }
-/** 获得详情 */
-// const getDetail = async () => {
-//   if (id) {
-//     formLoading.value = true
-//     try {
-//       formData.value = (await IotDeviceApi.getIotDevice(id)) as IotDeviceVO
-//     } finally {
-//       formLoading.value = false
-//     }
-//   }
-// }
 /** 查询列表 */
 const getList = async () => {
   formLoading.value = true
   try {
     const data = await IotInfoApi.IotInfoApi.getChildContentFile(queryParams)
-    debugger
     list.value = data
   } finally {
     formLoading.value = false
@@ -552,90 +473,89 @@ const resetQuery = () => {
   queryFormRef.value?.resetFields()
   handleQuery()
 }
-const { wsCache } = useCache()
+
 /** 初始化 */
 onMounted(async () => {
-  // await getDetail()
-  // queryParams.deptId = wsCache.get(CACHE_KEY.USER).user.deptId;
-  // await getList()
-  // deviceId.value = params.id as unknown as number
-  id.value = params.id as unknown as number
+  id.value = Number(params.id) || 0
 })
 </script>
 <style scoped>
-::v-deep .breadcrumb-container {
-  padding: 12px 16px;
-  background-color: #f5f7fa;
-  border-radius: 6px;
-  box-shadow: 0 1px 1px rgba(0, 0, 0, 0.06);
+:deep(.el-form-item) {
+  margin-bottom: 0;
+}
+
+:deep(.search-panel .el-form-item) {
+  display: flex;
+  align-items: center;
+}
+
+:deep(.search-panel .el-form-item__label) {
+  height: 32px;
+  line-height: 32px;
+  align-items: center;
+}
+
+:deep(.search-panel .el-form-item__content) {
+  display: flex;
+  align-items: center;
 }
-::v-deep .custom-breadcrumb-item {
+
+.tree-page {
+  min-width: 0;
+}
+
+.search-panel,
+.breadcrumb-panel,
+.table-panel {
+  min-width: 0;
+}
+
+:deep(.breadcrumb-container) {
+  min-width: 0;
+  padding: 0;
+  overflow: hidden;
+}
+
+:deep(.custom-breadcrumb-item) {
   font-size: 14px;
-  font-weight: bold;
+  font-weight: 600;
 }
 
-/* 面包屑文本样式 */
-::v-deep .el-breadcrumb__item .el-breadcrumb__inner {
+:deep(.el-breadcrumb__item .el-breadcrumb__inner) {
+  padding: 2px 4px;
   color: #101010;
   text-decoration: none;
-  padding: 2px 4px;
   border-radius: 2px;
   transition: all 0.2s ease;
 }
 
-/* 可点击项悬停效果 */
-::v-deep .el-breadcrumb__item:not(:last-child) .el-breadcrumb__inner:hover {
+:deep(.el-breadcrumb__item:not(:last-child) .el-breadcrumb__inner:hover) {
   color: #409eff;
-  background-color: rgba(64, 158, 255, 0.1);
   cursor: pointer;
+  background-color: rgb(64 158 255 / 10%);
 }
 
-/* 当前项样式 */
-::v-deep .el-breadcrumb__item:last-child .el-breadcrumb__inner {
-  color: #1890ff;
+:deep(.el-breadcrumb__item:last-child .el-breadcrumb__inner) {
   font-weight: 500;
+  color: #1890ff;
   cursor: default;
 }
 
-/* 分隔符样式优化 */
-::v-deep .el-breadcrumb__separator {
+:deep(.el-breadcrumb__separator) {
   margin: 0 8px;
-  color: #0954f6;
   font-size: 12px;
+  color: #0954f6;
 }
 
-.custom-table {
-  cursor: pointer;
-  --el-table-row-hover-bg-color: #f5f7fa; /* 优化悬停背景色 */
-}
-
-.container-tree {
-  display: flex;
-  height: 100%;
-  user-select: none; /* 防止拖动时选中文本 */
-}
-
-.left-tree {
-  background: #f0f0f0;
+:deep(.zm-table) {
   height: 100%;
-  overflow: auto;
-}
 
-.right-tree {
-  flex: 1;
-  height: 100%;
-  overflow: auto;
-  margin-left: 15px;
-}
-
-.divider-tree {
-  width: 2px;
-  background: #ccc;
-  cursor: col-resize;
-  position: relative;
-}
+  .el-table__cell {
+    height: 42px;
+  }
 
-.divider-tree:hover {
-  background: #666;
+  .el-table__row {
+    cursor: pointer;
+  }
 }
 </style>