yanghao hace 1 semana
padre
commit
43ebd27148

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

+ 575 - 467
src/views/pms/device/allotlog/ConfigDeviceAllot.vue

@@ -1,397 +1,482 @@
 <template>
-  <div class="container">
-    <el-row :gutter="20" class="equal-height-row">
-      <!-- 左侧设备列表 -->
-      <el-col :span="12" class="col-height">
-        <div class="card left-card">
-          <h3>{{ t('configPerson.deviceList') }}</h3>
-          <div class="dept-select">
+  <div class="allot-config-page">
+    <div class="selection-grid">
+      <section class="panel">
+        <div class="panel-header">
+          <div class="panel-title">
+            <span class="panel-marker"></span>
+            <span>{{ t('configPerson.deviceList') }}</span>
+          </div>
+          <el-tag size="small" type="info">{{ filteredDevices.length }} 台</el-tag>
+        </div>
+
+        <div class="panel-toolbar">
+          <el-tree-select
+            v-model="formData.sourceDeptId"
+            :data="deptList"
+            :props="defaultProps"
+            check-strictly
+            node-key="id"
+            filterable
+            clearable
+            size="default"
+            class="toolbar-control"
+            :placeholder="t('configPerson.deviceListHolder')"
+            @node-click="handleSourceDeptClick"
+            @clear="handleClearSourceDept" />
+          <el-input
+            v-model="deviceFilterText"
+            :placeholder="t('devicePerson.filterDevicePlaceholder')"
+            :disabled="!devicesLoaded"
+            clearable
+            size="default"
+            class="toolbar-control">
+            <template #prefix>
+              <Icon icon="ep:search" />
+            </template>
+          </el-input>
+        </div>
+
+        <el-scrollbar class="option-scroll">
+          <el-checkbox-group v-model="selectedDevices" class="option-list">
+            <el-checkbox
+              v-for="device in filteredDevices"
+              :key="device.id"
+              :value="device.id"
+              class="option-item"
+              :class="{ 'is-selected': selectedDevices.includes(device.id) }">
+              <span class="option-content">
+                <span class="option-main">{{ device.deviceCode || '暂无编码' }}</span>
+                <span class="option-sub">{{ device.deviceName || '暂无名称' }}</span>
+                <span class="option-extra">当前部门:{{ device.deptName || '暂无' }}</span>
+              </span>
+            </el-checkbox>
+          </el-checkbox-group>
+          <el-empty
+            v-if="devicesLoaded && filteredDevices.length === 0"
+            :image-size="92"
+            description="暂无设备" />
+        </el-scrollbar>
+      </section>
+
+      <section class="panel">
+        <div class="panel-header">
+          <div class="panel-title">
+            <span class="panel-marker"></span>
+            <span>{{ t('configDevice.deptList') }}</span>
+          </div>
+          <el-tag size="small" type="info">{{ selectedDevices.length }} 台已选</el-tag>
+        </div>
+
+        <el-form class="target-form" label-position="top" size="default">
+          <el-form-item>
+            <template #label>
+              <span>调拨部门</span>
+              <span class="required-star">*</span>
+            </template>
             <el-tree-select
-              clearable
-              v-model="formData.deptId"
+              v-model="formData.targetDeptId"
               :data="deptList"
               :props="defaultProps"
               check-strictly
               node-key="id"
               filterable
+              clearable
+              class="toolbar-control"
+              :disabled="selectedDevices.length === 0"
               :placeholder="t('configPerson.deviceListHolder')"
-              @node-click="handleDeptDeviceTreeNodeClick"
-            />
-          </div>
-
-          <!-- 设备搜索框 -->
-          <div class="filter-input">
-            <el-input
-              v-model="deviceFilterText"
-              :placeholder="t('devicePerson.filterDevicePlaceholder')"
-              :disabled="!devicesLoaded"
+              @change="handleTargetDeptChange"
+              @clear="handleClearTargetDept" />
+          </el-form-item>
+
+          <el-form-item>
+            <template #label>
+              <span>{{ t('devicePerson.rp') }}</span>
+              <span class="required-star">*</span>
+            </template>
+            <el-select
+              v-model="selectedPersons"
+              multiple
+              filterable
               clearable
-              prefix-icon="Search"
-            />
-          </div>
-
-          <el-scrollbar height="500px">
-            <el-checkbox-group v-model="selectedDevices">
-              <div v-for="device in filteredDevices" :key="device.id" class="checkbox-item">
-                <el-checkbox :label="device.id">
-                  {{ device.deviceCode }} ({{ device.deviceName }}) - {{ device.deptName }} ——
-                  {{ device.devicePersons }}
-                </el-checkbox>
-              </div>
-            </el-checkbox-group>
-          </el-scrollbar>
-        </div>
-      </el-col>
-
-      <!-- 右侧部门选择 -->
-      <el-col :span="12" class="col-height">
-        <div class="card right-card">
-          <h3>{{ t('configDevice.deptList') }}</h3>
-          <ContentWrap class="dept-tree-container" height="400px">
-            <DeptTree2
-              ref="deptTreeRef"
-              v-model="selectedDeptId"
-              height="100%"
-              @update:model-value="handleDeptChange"
-            />
-          </ContentWrap>
-        </div>
-      </el-col>
-    </el-row>
-
-    <!-- 暂存关联列表 -->
-    <div class="submit-area">
-      <div class="card selection-area">
-        <div class="control-row">
-          <!-- 左侧人员选择 -->
-          <div class="control-group">
-            <label class="control-title">{{ t('devicePerson.rp') }} <span class="required-star">*</span></label>
-            <div class="person-selector">
-              <el-select
-                v-model="selectedPersons"
-                multiple
-                filterable
-                :placeholder="t('configPerson.selectPersons')"
-                style="width: 100%"
-              >
-                <el-option
-                  v-for="person in simpleUsers"
-                  :key="person.id"
-                  :label="person.nickname"
-                  :value="person.id"
-                />
-              </el-select>
-            </div>
+              collapse-tags
+              collapse-tags-tooltip
+              :max-collapse-tags="6"
+              popper-class="allot-person-tags-popper"
+              class="toolbar-control person-select"
+              :loading="personLoading"
+              :disabled="!formData.targetDeptId"
+              :placeholder="t('configPerson.selectPersons')"
+              @change="syncRelations">
+              <el-option
+                v-for="person in simpleUsers"
+                :key="person.id"
+                :label="person.nickname || person.username"
+                :value="person.id" />
+            </el-select>
+          </el-form-item>
+
+          <div class="target-tip">
+            选择目标部门后会加载该部门人员,调拨记录会按设备逐条生成预览。
           </div>
+        </el-form>
+      </section>
+    </div>
 
-          <div class="control-group">
-            <label class="control-title"
-              >{{ t('configDevice.reasonForAdjustment')
-              }}<span class="required-star">*</span></label
-            >
-            <div class="reason-input-wrapper">
-              <el-input
-                v-model="formData.reason"
-                :placeholder="t('configDevice.rfaHolder')"
-                class="reason-input"
-                type="textarea"
-                :rows="4"
-                resize="none"
-                @input="updateTempRelations"
-              />
-            </div>
-          </div>
+    <section class="panel reason-panel">
+      <div class="panel-header">
+        <div class="panel-title">
+          <span class="panel-marker"></span>
+          <span>{{ t('configDevice.reasonForAdjustment') }}</span>
+          <span class="required-star">*</span>
         </div>
       </div>
-
-      <div class="temp-list card" v-if="true">
-        <h3>{{ t('configPerson.adjustmentRecords') }}</h3>
-        <el-table :data="tempRelations" style="width: 100%">
-          <el-table-column prop="deviceNames" label="设备" width="200" />
-          <el-table-column prop="deptName" label="部门" />
-          <el-table-column prop="deptId" label="部门id" v-if="false" />
-          <el-table-column prop="reason" label="调拨原因" />
-          <el-table-column label="操作" width="120">
-            <template #default="{ row }">
-              <el-button type="danger" size="small" @click="removeTempRelation(row.deviceId)">
-                删除
-              </el-button>
-            </template>
-          </el-table-column>
-        </el-table>
+      <el-input
+        v-model="formData.reason"
+        :placeholder="t('configDevice.rfaHolder')"
+        type="textarea"
+        :rows="4"
+        resize="none"
+        size="default"
+        :disabled="selectedDevices.length === 0 || !formData.targetDeptId"
+        @input="syncRelations" />
+    </section>
+
+    <section class="panel record-panel">
+      <div class="panel-header">
+        <div class="panel-title">
+          <span class="panel-marker"></span>
+          <span>{{ t('configPerson.adjustmentRecords') }}</span>
+        </div>
+        <el-button
+          type="primary"
+          size="default"
+          :disabled="isSaveDisabled"
+          @click="submitRelations">
+          <Icon icon="ep:check" class="mr-5px" />
+          {{ t('iotMaintain.save') }}
+        </el-button>
       </div>
 
-      <el-button
-        type="primary"
-        size="large"
-        style="min-width: 180px"
-        @click="submitRelations"
-        :disabled="tempRelations.length === 0 || !formData.reason.trim()"
-      >
-        {{ t('iotMaintain.save') }}
-      </el-button>
-    </div>
+      <ZmTable
+        :data="tempRelations"
+        :loading="false"
+        :show-border="true"
+        settings-cache-key="pms-device-allot-config-record"
+        class="record-table">
+        <ZmTableColumn prop="deviceNames" :label="t('deviceStatus.deviceName')" min-width="240" />
+        <ZmTableColumn prop="oldDeptName" label="原部门" min-width="160" />
+        <ZmTableColumn prop="deptName" label="调拨部门" min-width="160" />
+        <ZmTableColumn prop="personNames" :label="t('devicePerson.rp')" min-width="220">
+          <template #default="{ row }">
+            <span>{{ row.personNames || '未选择责任人' }}</span>
+          </template>
+        </ZmTableColumn>
+        <ZmTableColumn
+          prop="reason"
+          :label="t('configDevice.reasonForAdjustment')"
+          min-width="280"
+          align="left">
+          <template #default="{ row }">
+            <span>{{ row.reason || '未填写调拨原因' }}</span>
+          </template>
+        </ZmTableColumn>
+        <ZmTableColumn :label="t('deviceStatus.operation')" width="120" fixed="right" action>
+          <template #default="{ row }">
+            <el-button type="danger" link size="default" @click="removeTempRelation(row.deviceId)">
+              <Icon icon="ep:delete" class="mr-4px" />
+              {{ t('fault.del') }}
+            </el-button>
+          </template>
+        </ZmTableColumn>
+      </ZmTable>
+    </section>
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, computed, onMounted } from 'vue'
+import { computed, onMounted, reactive, ref, unref, watch } from 'vue'
 import { ElMessage } from 'element-plus'
-import { defaultProps, handleTree } from '@/utils/tree'
+import { useRoute, useRouter } from 'vue-router'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
 import * as DeptApi from '@/api/system/dept'
 import * as UserApi from '@/api/system/user'
-import { IotDeviceApi, IotDeviceVO } from '@/api/pms/device'
-import DeptTree2 from '@/views/pms/device/DeptTree2.vue'
-import { useRouter, useRoute } from 'vue-router'
+import type { UserVO } from '@/api/system/user'
+import { IotDeviceApi, type IotDeviceVO } from '@/api/pms/device'
 import { useTagsViewStore } from '@/store/modules/tagsView'
-import { UserVO } from '@/api/system/user'
+import { defaultProps, handleTree } from '@/utils/tree'
 
-const router = useRouter()
-const route = useRoute() // 获取路由参数
-const { t } = useI18n() // 国际化
 defineOptions({ name: 'ConfigDeviceAllot' })
-const selectedDeptId = ref<number | string>('')
+
+type TempRelation = {
+  deviceId: number
+  deviceNames: string
+  oldDeptId?: number
+  oldDeptName: string
+  deptId: number
+  deptName: string
+  personIds: number[]
+  personNames: string
+  reason: string
+}
+
+type ConfigFormData = {
+  sourceDeptId?: number
+  targetDeptId?: number
+  reason: string
+}
+
+const router = useRouter()
+const route = useRoute()
+const { t } = useI18n()
+const { delView } = useTagsViewStore()
+const { ZmTable, ZmTableColumn } = useTableComponents<TempRelation>()
+
+const deptList = ref<Tree[]>([])
 const simpleDevices = ref<IotDeviceVO[]>([])
-const deptList = ref<Tree[]>([]) // 树形结构
-const selectedDevices = ref<number[]>([]) // 改为数组存储多选
-const { delView } = useTagsViewStore() // 视图操作
-// 设备加载状态标记 只有加载完设备才能通过文本框筛选
+const simpleUsers = ref<UserVO[]>([])
+const selectedDevices = ref<number[]>([])
+const selectedPersons = ref<number[]>([])
+const tempRelations = ref<TempRelation[]>([])
+const deviceFilterText = ref('')
 const devicesLoaded = ref(false)
+const personLoading = ref(false)
 
-const formData = ref({
-  id: undefined,
-  deviceCode: undefined,
-  deviceName: undefined,
-  brand: undefined,
-  model: undefined,
-  deptId: undefined as string | undefined,
-  deviceStatus: undefined,
-  reason: '',
-  assetProperty: undefined,
-  picUrl: undefined
+const formData = reactive<ConfigFormData>({
+  sourceDeptId: undefined,
+  targetDeptId: undefined,
+  reason: ''
 })
 
-const queryParams = reactive({
-  deptId: formData.value.deptId
-})
-
-const deptTreeRef = ref<InstanceType<typeof DeptTree2>>()
-
-const emit = defineEmits(['success', 'node-click']) // 定义 success 树点击 事件,用于操作成功后的回调
+const filteredDevices = computed(() => {
+  const searchText = deviceFilterText.value.toLowerCase().trim()
+  if (!searchText) return simpleDevices.value
 
-const simpleUsers = ref<UserVO[]>([]) // 人员下拉列表选项
-const selectedPersons = ref<number[]>([]) // 存储选中的人员ID
+  return simpleDevices.value.filter((device) => {
+    return (
+      (device.deviceCode || '').toLowerCase().includes(searchText) ||
+      (device.deviceName || '').toLowerCase().includes(searchText) ||
+      (device.deptName || '').toLowerCase().includes(searchText)
+    )
+  })
+})
 
-// 响应式数据
-const tempRelationsMap = ref(
-  new Map<
-    number,
-    {
-      deviceId: number
-      deviceNames: string
-      deptId: number
-      deptName: string
-      reason: string
-    }
-  >()
-)
+const selectedPersonObjects = computed(() => {
+  return simpleUsers.value.filter((person) => selectedPersons.value.includes(person.id))
+})
 
-// 计算属性转换 Map 为数组
-const tempRelations = computed(() => Array.from(tempRelationsMap.value.values()))
+const selectedPersonNames = computed(() => {
+  return selectedPersonObjects.value
+    .map((person) => person.nickname || person.username)
+    .filter(Boolean)
+    .join('、')
+})
 
-const updateTempRelations = () => {
-  if (!selectedDeptId.value) {
-    // 未选择部门时清除所有关联
-    tempRelationsMap.value.clear()
-    return
-  }
+const targetDeptName = computed(() => {
+  if (!formData.targetDeptId) return ''
+  return findDeptNode(deptList.value, formData.targetDeptId)?.name || '未知部门'
+})
 
-  // 获取部门信息
-  const treeNode = deptTreeRef.value?.treeRef?.getNode(selectedDeptId.value)
-  const deptName = treeNode?.data?.name || '未知部门'
+const isSaveDisabled = computed(() => {
+  return (
+    tempRelations.value.length === 0 ||
+    !formData.targetDeptId ||
+    !selectedPersons.value.length ||
+    tempRelations.value.some((item) => !item.reason?.trim() || !item.personIds.length)
+  )
+})
 
-  // 创建当前应该存在的设备集合
-  const currentDeviceIds = new Set(selectedDevices.value)
+watch(
+  selectedDevices,
+  (newVal, oldVal) => {
+    const removedDeviceIds = oldVal.filter((id) => !newVal.includes(id))
+    tempRelations.value = tempRelations.value.filter(
+      (relation) => !removedDeviceIds.includes(relation.deviceId)
+    )
 
-  // 清理无效关联(包含:1.当前部门外的关联 2.未选中的设备)
-  Array.from(tempRelationsMap.value.entries()).forEach(([deviceId, relation]) => {
-    if (relation.deptId !== selectedDeptId.value || !currentDeviceIds.has(deviceId)) {
-      tempRelationsMap.value.delete(deviceId)
+    if (!newVal.length) {
+      selectedPersons.value = []
+      simpleUsers.value = []
+      formData.targetDeptId = undefined
+      formData.reason = ''
+      tempRelations.value = []
+      return
     }
-  })
 
-  // 添加/更新当前选中设备的关联
-  selectedDevices.value.forEach((deviceId) => {
-    const device = simpleDevices.value.find((d) => d.id === deviceId)
-    if (device) {
-      tempRelationsMap.value.set(deviceId, {
-        deviceId,
-        deviceNames: `${device.deviceCode} (${device.deviceName})`,
-        deptId: selectedDeptId.value as number,
-        deptName,
-        reason: formData.value.reason
-      })
+    if (formData.targetDeptId && hasSameTargetDept()) {
+      ElMessage.warning('不能选择设备原属部门作为调拨部门')
+      formData.targetDeptId = undefined
+      selectedPersons.value = []
+      simpleUsers.value = []
     }
-  })
-}
 
-// 添加设备选择监听
-watch(
-  [selectedDevices, selectedDeptId],
-  () => {
-    updateTempRelations()
+    syncRelations()
   },
-  { deep: true, immediate: true, debounce: 100 }
+  { deep: true }
 )
 
-// 监听部门变化
-watch(selectedDeptId, (newVal) => {
-  if (newVal) {
-    loadDeptPersons(newVal as number)
-  } else {
-    simpleUsers.value = []
-  }
-  selectedPersons.value = [] // 切换部门时清空已选人员
-})
+const getDeviceLabel = (device: IotDeviceVO) => {
+  return `${device.deviceCode || '暂无编码'}(${device.deviceName || '暂无名称'})`
+}
 
-// 修改部门变更处理方法
-const handleDeptChange = (deptId) => {
-  if (!deptId) {
-    selectedDeptId.value = ''
-    return
-  }
-  // 校验部门选择
-  if (!validateDeptSelection(deptId)) {
-    // 重置部门选择
-    selectedDeptId.value = ''
-    deptTreeRef.value?.treeRef?.setCurrentKey(undefined) // 清除树的选择状态
-    deptTreeRef.value?.treeRef?.setCurrentNode(undefined)
+const hasSameTargetDept = () => {
+  return selectedDevices.value.some((deviceId) => {
+    const device = simpleDevices.value.find((item) => item.id === deviceId)
+    return device?.deptId === formData.targetDeptId
+  })
+}
+
+const syncRelations = () => {
+  if (!formData.targetDeptId) {
+    tempRelations.value = []
     return
   }
-  selectedDeptId.value = deptId
-  updateTempRelations()
-}
 
-/** 处理 部门-设备 树 被点击 */
-const handleDeptDeviceTreeNodeClick = async (row: { [key: string]: any }) => {
-  emit('node-click', row)
-  formData.value.deptId = row.id
-  selectedDevices.value = []
-  await getDeviceList()
+  const selectedDeviceSet = new Set(selectedDevices.value)
+  const personIds = [...selectedPersons.value]
+  const personNames = selectedPersonNames.value
+  tempRelations.value = simpleDevices.value
+    .filter((device) => selectedDeviceSet.has(device.id))
+    .map((device) => ({
+      deviceId: device.id,
+      deviceNames: getDeviceLabel(device),
+      oldDeptId: device.deptId,
+      oldDeptName: device.deptName || '暂无',
+      deptId: formData.targetDeptId!,
+      deptName: targetDeptName.value,
+      personIds,
+      personNames,
+      reason: formData.reason
+    }))
 }
 
-/** 获得 部门下的设备 列表 */
 const getDeviceList = async () => {
+  devicesLoaded.value = false
   try {
-    // 查询到数据后才能进行搜索 筛选
-    devicesLoaded.value = false
-
-    const params = { deptId: formData.value.deptId }
-    const data = await IotDeviceApi.simpleDevices(params)
+    const data = await IotDeviceApi.simpleDevices({ deptId: formData.sourceDeptId })
     simpleDevices.value = data || []
-
-    devicesLoaded.value = true
+    selectedDevices.value = selectedDevices.value.filter((id) =>
+      simpleDevices.value.some((device) => device.id === id)
+    )
+    syncRelations()
   } catch (error) {
     simpleDevices.value = []
-    // 即使失败也启用搜索框(避免卡在禁用状态)
+    ElMessage.error('获取设备列表失败')
+  } finally {
     devicesLoaded.value = true
-    console.error('获取设备列表失败:', error)
   }
 }
 
-// 选择部门后 加载部门人员
-const loadDeptPersons = async (deptId: number) => {
-  if (!deptId) {
+const getUserList = async () => {
+  if (!formData.targetDeptId) {
     simpleUsers.value = []
+    selectedPersons.value = []
+    syncRelations()
     return
   }
+
+  personLoading.value = true
   try {
-    // 调用API获取部门人员
-    const params = { deptId: deptId, pageNo: 1, pageSize: 10 }
-    const data = await UserApi.simpleUserList(params)
-    simpleUsers.value = data.map((user) => ({
-      id: user.id,
-      nickname: user.nickname || user.username
-    }))
+    const data = await UserApi.simpleUserList({
+      deptId: formData.targetDeptId,
+      pageNo: 1,
+      pageSize: 100
+    })
+    simpleUsers.value = data || []
+    selectedPersons.value = selectedPersons.value.filter((id) =>
+      simpleUsers.value.some((person) => person.id === id)
+    )
+    syncRelations()
   } catch (error) {
-    console.error('获取部门人员失败:', error)
     simpleUsers.value = []
+    selectedPersons.value = []
+    ElMessage.error('获取责任人列表失败')
+  } finally {
+    personLoading.value = false
   }
 }
 
-// 计算选中的设备原部门集合
-const originDeptIds = computed(
-  () =>
-    selectedDevices.value
-      .map((deviceId) => simpleDevices.value.find((d) => d.id === deviceId)?.deptId)
-      .filter(Boolean) as number[]
-)
-
-// 部门选择校验方法
-const validateDeptSelection = (deptId: number) => {
-  if (originDeptIds.value.includes(deptId)) {
-    ElMessage.error('不能选择设备原属部门作为调拨部门')
-    return false
-  }
-  return true
+const handleSourceDeptClick = async (row: Tree) => {
+  formData.sourceDeptId = row.id
+  selectedDevices.value = []
+  selectedPersons.value = []
+  simpleUsers.value = []
+  formData.targetDeptId = undefined
+  deviceFilterText.value = ''
+  await getDeviceList()
 }
 
-// 设备过滤文本
-const deviceFilterText = ref('')
+const handleClearSourceDept = () => {
+  simpleDevices.value = []
+  selectedDevices.value = []
+  selectedPersons.value = []
+  tempRelations.value = []
+  deviceFilterText.value = ''
+  devicesLoaded.value = false
+}
 
-// 计算属性:过滤设备列表
-const filteredDevices = computed(() => {
-  const searchText = deviceFilterText.value.toLowerCase().trim()
-  if (!searchText) return simpleDevices.value
+const handleTargetDeptChange = async () => {
+  if (formData.targetDeptId && hasSameTargetDept()) {
+    ElMessage.error('不能选择设备原属部门作为调拨部门')
+    formData.targetDeptId = undefined
+    selectedPersons.value = []
+    simpleUsers.value = []
+    syncRelations()
+    return
+  }
 
-  return simpleDevices.value.filter((device) => {
-    return (
-      (device.deviceCode || '').toLowerCase().includes(searchText) ||
-      (device.deviceName || '').toLowerCase().includes(searchText)
-    )
-  })
-})
+  selectedPersons.value = []
+  await getUserList()
+}
 
-// 在计算属性区域添加 selectedPersonNames
-const selectedPersonNames = computed(() => {
-  return selectedPersons.value
-    .map((id) => {
-      const user = simpleUsers.value.find((u) => u.id === id)
-      return user?.nickname || ''
-    })
-    .filter(Boolean) // 过滤掉空值
-})
+const handleClearTargetDept = () => {
+  formData.targetDeptId = undefined
+  selectedPersons.value = []
+  simpleUsers.value = []
+  syncRelations()
+}
 
 const removeTempRelation = (deviceId: number) => {
-  tempRelationsMap.value.delete(deviceId)
   selectedDevices.value = selectedDevices.value.filter((id) => id !== deviceId)
+  tempRelations.value = tempRelations.value.filter((relation) => relation.deviceId !== deviceId)
 }
 
 const submitRelations = async () => {
-  try {
-    // 提交前二次校验
-    const invalidDept = tempRelations.value.some((r) => originDeptIds.value.includes(r.deptId))
-    if (invalidDept) {
-      ElMessage.error('存在调拨部门与设备原属部门相同的情况,请检查')
-      return
-    }
+  if (!selectedDevices.value.length) {
+    ElMessage.error(t('iotMaintain.deviceHolder'))
+    return
+  }
 
-    if (!selectedPersons.value.length) {
-      ElMessage.error('请选择责任人')
-      return
-    }
+  if (!formData.targetDeptId) {
+    ElMessage.error('请选择调拨部门')
+    return
+  }
 
-    // 转换为后端需要的格式
-    const submitData = tempRelations.value.map((r) => ({
-      deviceId: r.deviceId,
-      deptId: r.deptId,
-      reason: r.reason,
-      personIds: selectedPersons.value || [], // 添加人员ID列表
-      personNames: [...selectedPersonNames.value]
+  if (!selectedPersons.value.length) {
+    ElMessage.error(t('configPerson.selectPersons'))
+    return
+  }
+
+  if (hasSameTargetDept()) {
+    ElMessage.error('存在调拨部门与设备原属部门相同的情况,请检查')
+    return
+  }
+
+  if (tempRelations.value.some((item) => !item.reason?.trim())) {
+    ElMessage.error(t('configDevice.rfaHolder'))
+    return
+  }
+
+  try {
+    const submitData = tempRelations.value.map((relation) => ({
+      deviceId: relation.deviceId,
+      deptId: relation.deptId,
+      reason: relation.reason,
+      personIds: relation.personIds,
+      personNames: relation.personNames ? relation.personNames.split('、') : []
     }))
+
     await IotDeviceApi.saveDeviceAllot(submitData)
-    // 模拟API调用
-    console.log('提交数据:', submitData)
     ElMessage.success('提交成功')
     tempRelations.value = []
     delView(unref(router.currentRoute.value))
@@ -401,239 +486,262 @@ const submitRelations = async () => {
   }
 }
 
-/** 初始化 */
+const findDeptNode = (nodes: Tree[], deptId: number): Tree | null => {
+  for (const node of nodes) {
+    if (node.id === deptId) return node
+
+    if (node.children?.length) {
+      const found = findDeptNode(node.children, deptId)
+      if (found) return found
+    }
+  }
+
+  return null
+}
+
 onMounted(async () => {
   try {
-    // 初始化部门树
     const deptData = await DeptApi.getSimpleDeptList()
-    deptList.value = handleTree(deptData) // 转换为树形结构
+    deptList.value = handleTree(deptData)
 
-    // 从路由参数获取部门ID
-    const routeDeptId = route.query.deptId ? Number(route.query.deptId) : null
+    const routeDeptId = route.query.deptId ? Number(route.query.deptId) : undefined
+    const targetDeptId = routeDeptId || deptList.value[0]?.id
 
-    let targetDeptId: number
-
-    if (routeDeptId) {
-      // 如果路由参数有部门ID,使用路由参数中的部门ID
-      targetDeptId = routeDeptId
-    } else if (deptList.value.length > 0) {
-      // 否则使用第一个节点
-      targetDeptId = deptList.value[0].id
-    } else {
-      console.warn('部门树数据为空,无法设置默认值')
+    if (!targetDeptId) {
+      ElMessage.warning('暂无可用部门数据')
       return
     }
 
-    // 设置默认选中的部门(转换后树的第一个节点)
-    // if (deptList.value.length > 0) {
-    // 获取转换后树的第一个节点
-    // const firstRootNode = deptList.value[0]
-
-    // 设置设备部门的默认值
-    formData.value.deptId = targetDeptId
-    // 触发设备部门的节点点击事件,加载设备列表
+    formData.sourceDeptId = targetDeptId
     const targetDeptNode = findDeptNode(deptList.value, targetDeptId)
-    // 触发设备部门的节点点击事件,加载设备列表
     if (targetDeptNode) {
-      await handleDeptDeviceTreeNodeClick(targetDeptNode)
+      await handleSourceDeptClick(targetDeptNode)
+    } else {
+      await getDeviceList()
     }
-    // } else {
-    // console.warn("部门树数据为空,无法设置默认值")
-    // }
   } catch (error) {
-    console.error('初始化部门树失败:', error)
     ElMessage.error('加载部门数据失败')
   }
-
-  nextTick(() => {
-    // 强制重新计算布局
-    window.dispatchEvent(new Event('resize'))
-  })
 })
-
-// 在部门树中查找指定ID的节点
-const findDeptNode = (nodes: Tree[], deptId: number): Tree | null => {
-  for (const node of nodes) {
-    if (node.id === deptId) {
-      return node
-    }
-    if (node.children && node.children.length > 0) {
-      const found = findDeptNode(node.children, deptId)
-      if (found) return found
-    }
-  }
-  return null
-}
 </script>
 
-<style scoped>
-.container {
-  padding: 20px;
+<style lang="scss" scoped>
+.allot-config-page {
+  min-height: calc(
+    100vh - 20px - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height)
+  );
+  background: #f5f7fb;
 }
 
-.card {
-  border: 1px solid #ebeef5;
-  border-radius: 4px;
-  padding: 20px;
-  margin-bottom: 20px;
-  background: white;
-  display: flex;
-  flex-direction: column;
-  height: 100%; /* 根据实际需要调整整体高度 */
-  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
-  transition: box-shadow 0.2s;
+.selection-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 16px;
 }
 
-.card:hover {
-  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
+.panel {
+  overflow: hidden;
+  background: var(--el-bg-color);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 6px;
+  box-shadow: 0 8px 24px rgb(15 35 70 / 5%);
 }
 
-.list-item {
-  padding: 8px 12px;
-  border-bottom: 1px solid #f0f0f0;
+.panel-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  min-height: 56px;
+  padding: 0 18px;
+  background: #f4f9ff;
+  border-bottom: 1px solid var(--el-border-color-lighter);
 }
 
-.dept-select {
-  margin-bottom: 20px;
+.panel-title {
+  display: flex;
+  align-items: center;
+  font-size: 16px;
+  font-weight: 600;
+  color: var(--el-text-color-primary);
 }
 
-.user-list {
-  margin-bottom: 20px;
-  border: 1px solid #ebeef5;
+.panel-marker {
+  width: 4px;
+  height: 18px;
+  margin-right: 10px;
+  background: var(--el-color-primary);
   border-radius: 4px;
-  padding: 10px;
 }
 
-.action-bar {
-  text-align: right;
+.panel-toolbar {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr);
+  gap: 12px;
+  padding: 16px 18px 0;
 }
 
-.temp-list {
-  margin-top: 20px;
+.toolbar-control {
+  width: 100%;
 }
 
-.submit-area {
-  margin-top: 20px;
-  text-align: center;
+.target-form {
+  padding: 18px;
 }
 
-h3 {
-  margin: 0 0 15px 0;
-  color: #303133;
+.target-tip {
+  padding: 10px 12px;
+  font-size: 12px;
+  line-height: 18px;
+  color: var(--el-text-color-secondary);
+  background: #f8fbff;
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 6px;
 }
 
-.dept-select {
-  flex-shrink: 0; /* 防止搜索框被压缩 */
+.person-select :deep(.el-select__tags) {
+  flex-wrap: nowrap;
+  max-width: calc(100% - 48px);
+  overflow: hidden;
 }
 
-.el-scrollbar__wrap {
-  overflow-x: hidden;
+.person-select :deep(.el-tag) {
+  flex-shrink: 0;
+  max-width: 92px;
 }
 
-.tree-container .el-tree {
-  height: 100% !important;
+.person-select :deep(.el-tag__content) {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
-/* 左侧滚动区域 */
-.el-scrollbar,
-.tree-container {
-  scrollbar-width: thin;
-  scrollbar-color: #c0c4cc transparent;
+:global(.allot-person-tags-popper .el-select__selection) {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  max-width: 520px;
+  max-height: 220px;
+  overflow-y: auto;
 }
 
-.left-card,
-.right-card {
-  flex: 1;
-  display: flex;
-  flex-direction: column;
-  height: 100%; /* 继承父级高度 */
+:global(.allot-person-tags-popper .el-select__selected-item) {
+  max-width: 120px;
 }
 
-.dept-tree-container {
-  flex: 1;
-  min-height: 0; /* 关键:允许内容区域收缩 */
-  position: relative;
+:global(.allot-person-tags-popper .el-tag__content),
+:global(.allot-person-tags-popper .el-select__tags-text) {
+  display: inline-block;
+  max-width: 92px;
   overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  vertical-align: bottom;
 }
 
-/* 调整树形组件内部滚动 */
-:deep(.el-tree) {
-  height: 100%;
-  overflow: auto;
+.option-scroll {
+  height: 430px;
+  padding: 14px 18px 18px;
 }
 
-.equal-height-row {
+.option-list {
   display: flex;
-  align-items: stretch; /* 关键:等高布局 */
+  flex-direction: column;
+  gap: 10px;
 }
 
-.col-height {
+.option-item {
   display: flex;
-  flex-direction: column;
-  min-height: 400px; /* 统一最小高度 */
-}
+  height: auto;
+  min-width: 0;
+  padding: 12px;
+  cursor: pointer;
+  background: var(--el-fill-color-blank);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 6px;
+  transition:
+    border-color 0.2s,
+    background-color 0.2s,
+    box-shadow 0.2s;
+  align-items: flex-start;
+
+  &:hover,
+  &.is-selected {
+    background: #f7fbff;
+    border-color: var(--el-color-primary-light-5);
+    box-shadow: 0 6px 18px rgb(64 158 255 / 10%);
+  }
 
-.filter-input {
-  margin-bottom: 15px;
-}
+  :deep(.el-checkbox__input) {
+    margin-top: 2px;
+  }
 
-.no-data {
-  padding: 20px;
-  text-align: center;
-  color: #999;
+  :deep(.el-checkbox__label) {
+    flex: 1;
+    min-width: 0;
+    padding-left: 10px;
+  }
 }
 
-.control-row {
-  display: flex;
+.option-content {
+  display: grid;
   width: 100%;
-  gap: 20px;
+  min-width: 0;
+  align-items: center;
+  grid-template-columns: minmax(90px, 0.7fr) minmax(120px, 1fr) minmax(150px, 1.2fr);
+  gap: 12px;
 }
 
-/* 调整文本域高度 */
-.reason-input-wrapper :deep(.el-textarea__inner) {
-  height: 100% !important;
-  min-height: 100px;
+.option-main,
+.option-sub,
+.option-extra {
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
-/* 响应式调整 */
-@media (max-width: 992px) {
-  .control-row {
-    flex-direction: column;
-    gap: 15px;
-  }
+.option-main {
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--el-text-color-primary);
+}
 
-  .control-group {
-    width: 100%;
-  }
+.option-sub,
+.option-extra {
+  font-size: 12px;
+  color: var(--el-text-color-secondary);
 }
 
-.selection-area {
-  display: flex;
+.reason-panel,
+.record-panel {
+  margin-top: 16px;
 }
 
-.control-group {
-  flex: 1;
-  min-width: 0;
-  display: flex;
-  flex-direction: column;
+.reason-panel {
+  padding-bottom: 18px;
+
+  :deep(.el-textarea) {
+    padding: 18px 18px 0;
+  }
 }
 
-.control-title {
-  display: block;
-  margin-bottom: 8px;
-  font-weight: bold;
-  color: #606266;
+.record-panel {
+  padding-bottom: 18px;
 }
 
-.person-selector,
-.reason-input-wrapper {
-  flex: 1;
-  min-height: 100px;
+.record-table {
+  width: calc(100% - 36px);
+  margin: 18px;
 }
 
 .required-star {
-  color: #ff4d4f;
-  margin-left: 3px;
-  vertical-align: middle;
+  margin-left: 4px;
+  color: var(--el-color-danger);
+}
+
+@media (width <= 1180px) {
+  .selection-grid {
+    grid-template-columns: minmax(0, 1fr);
+  }
 }
 </style>

+ 234 - 67
src/views/pms/device/allotlog/DeviceAllotLogDrawer.vue

@@ -1,50 +1,106 @@
 <template>
   <el-drawer
-    :title="t('configPerson.adjustmentRecords')"
     :append-to-body="true"
     :model-value="modelValue"
-    @update:model-value="$emit('update:modelValue', $event)"
     :show-close="false"
     direction="rtl"
     :size="computedSize"
+    class="allot-log-drawer"
     :before-close="handleClose"
-  >
-    <template v-if="deviceId">
-      <div v-loading="loading" style="height: 100%">
-        <el-table :data="deviceAllots" style="width: 100%">
-          <el-table-column prop="deviceName" :label="t('deviceStatus.deviceName')" width="180" />
-          <el-table-column prop="deviceCode" :label="t('deviceStatus.deviceCode')" width="180" />
-          <el-table-column prop="oldDeptName" :label="t('deviceStatus.beforeDept')" width="180" />
-          <el-table-column prop="newDeptName" :label="t('deviceStatus.afterDept')" width="180" />
-          <el-table-column prop="reason" :label="t('configDevice.reasonForAdjustment')" width="180" />
-          <el-table-column prop="creatorName" :label="t('deviceStatus.adjuster')" width="180" />
-          <el-table-column
-            :label="t('deviceStatus.adjustTime')"
-            align="center"
-            prop="createTime"
-            width="180"
-            :formatter="dateFormatter"
-          />
-        </el-table>
-        <!-- 分页 -->
-        <Pagination
-          :total="total"
-          v-model:page="queryParams.pageNo"
-          v-model:limit="queryParams.pageSize"
-          @pagination="handlePagination"
-        />
+    @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:position" :size="22" />
+          </div>
+          <div>
+            <div class="drawer-title">{{ t('configPerson.adjustmentRecords') }}</div>
+            <div class="drawer-subtitle">查看设备调拨前后的部门变化记录</div>
+          </div>
+        </div>
+        <el-button circle size="default" @click="handleClose">
+          <Icon icon="ep:close" />
+        </el-button>
       </div>
     </template>
 
+    <template v-if="deviceId">
+      <div v-loading="loading" class="drawer-body">
+        <section class="info-card record-card">
+          <div class="info-card__header">
+            <div class="info-card__title">
+              <span class="info-card__marker"></span>
+              <span>{{ t('configPerson.adjustmentRecords') }}</span>
+            </div>
+            <span class="record-total">共 {{ total }} 条</span>
+          </div>
+
+          <div class="info-card__body record-card__body">
+            <ZmTable
+              :data="deviceAllots"
+              :loading="loading"
+              :show-border="true"
+              settings-cache-key="pms-device-allot-log"
+              class="record-table">
+              <ZmTableColumn
+                prop="deviceName"
+                :label="t('deviceStatus.deviceName')"
+                min-width="170" />
+              <ZmTableColumn
+                prop="deviceCode"
+                :label="t('deviceStatus.deviceCode')"
+                min-width="150" />
+              <ZmTableColumn
+                prop="oldDeptName"
+                :label="t('deviceStatus.beforeDept')"
+                min-width="170" />
+              <ZmTableColumn
+                prop="newDeptName"
+                :label="t('deviceStatus.afterDept')"
+                min-width="170" />
+              <ZmTableColumn
+                prop="reason"
+                :label="t('configDevice.reasonForAdjustment')"
+                min-width="220"
+                align="left" />
+              <ZmTableColumn
+                prop="creatorName"
+                :label="t('deviceStatus.adjuster')"
+                min-width="130" />
+              <ZmTableColumn
+                :label="t('deviceStatus.adjustTime')"
+                prop="createTime"
+                min-width="170"
+                :formatter="dateFormatter" />
+            </ZmTable>
+
+            <Pagination
+              v-show="total > 0"
+              :total="total"
+              v-model:page="queryParams.pageNo"
+              v-model:limit="queryParams.pageSize"
+              @pagination="handlePagination" />
+          </div>
+        </section>
+      </div>
+    </template>
   </el-drawer>
 </template>
+
 <script setup lang="ts">
-const { t } = useI18n() // 国际化
-import { ref, watch, defineOptions, defineEmits, toRefs } from 'vue'
+import { ref } from 'vue'
 import { ElMessage } from 'element-plus'
-import {dateFormatter} from "@/utils/formatTime";
-import {IotDeviceAllotLogApi} from "@/api/pms/iotdeviceallotlog";
-const drawerVisible = ref<boolean>(false)
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+import { IotDeviceAllotLogApi, type IotDeviceAllotLogVO } from '@/api/pms/iotdeviceallotlog'
+import { dateFormatter } from '@/utils/formatTime'
+
+type DeviceAllotLogRow = IotDeviceAllotLogVO & {
+  createTime?: string
+}
+
+const { t } = useI18n()
+const { ZmTable, ZmTableColumn } = useTableComponents<DeviceAllotLogRow>()
 const emit = defineEmits(['update:modelValue', 'add', 'delete'])
 
 defineOptions({
@@ -55,53 +111,40 @@ const queryParams = reactive({
   pageNo: 1,
   pageSize: 10,
   createTime: [],
-  deviceId: '',
+  deviceId: undefined as number | undefined,
   name: '',
   code: ''
 })
 
 const windowWidth = ref(window.innerWidth)
-// 动态计算百分比
 const computedSize = computed(() => {
+  if (windowWidth.value <= 768) return '100%'
   return windowWidth.value > 1200 ? '60%' : '80%'
 })
 
 const loading = ref(false)
-const total = ref(0) // 列表的总页数
-const deviceAllots = ref([])
+const total = ref(0)
+const deviceAllots = ref<DeviceAllotLogRow[]>([])
 
 const props = defineProps({
   modelValue: Boolean,
   deviceId: Number
 })
 
-// 使用 toRefs 解构响应式属性
-const { modelValue, deviceId } = toRefs(props)
-
-// 监听抽屉打开事件
-watch(modelValue, (isOpen) => {
-  if (isOpen && deviceId.value) {
-    loadDeviceAllots(deviceId.value)
-  }
-})
-
-// 监听bom树节点ID变化
-watch(() => props.deviceId, async (newVal) => {
-  if (newVal) {
-    await loadDeviceAllots(newVal)
-  }
-})
+const updateWindowWidth = () => {
+  windowWidth.value = window.innerWidth
+}
 
-// 加载设备的状态调整记录
-const loadDeviceAllots = async (deviceId: number) => {
+const loadDeviceAllots = async (deviceId: number, resetPage = true) => {
   queryParams.deviceId = deviceId
-  queryParams.pageNo = 1
+  if (resetPage) {
+    queryParams.pageNo = 1
+  }
   try {
     loading.value = true
-    // API调用
     const data = await IotDeviceAllotLogApi.getIotDeviceAllotLogPage(queryParams)
-    deviceAllots.value = data.list
-    total.value = data.total
+    deviceAllots.value = data.list || []
+    total.value = data.total || 0
   } catch (error) {
     ElMessage.error('数据加载失败')
   } finally {
@@ -109,34 +152,158 @@ const loadDeviceAllots = async (deviceId: number) => {
   }
 }
 
-// 修改分页处理
 const handlePagination = (pagination: any) => {
   queryParams.pageNo = pagination.page
   queryParams.pageSize = pagination.limit
   if (props.deviceId) {
-    loadDeviceAllots(props.deviceId)
+    loadDeviceAllots(props.deviceId, false)
   }
 }
 
-// 打开抽屉
 const openDrawer = () => {
-  drawerVisible.value = true
+  emit('update:modelValue', true)
 }
 
-// 关闭抽屉
 const closeDrawer = () => {
-  drawerVisible.value = false
+  handleClose()
 }
 
-// 关闭抽屉
 const handleClose = () => {
   emit('update:modelValue', false)
   deviceAllots.value = []
   total.value = 0
 }
 
-defineExpose({ openDrawer, closeDrawer, loadDeviceAllots }) // 暴露方法给父组件
+defineExpose({ openDrawer, closeDrawer, loadDeviceAllots })
 
+onMounted(() => {
+  window.addEventListener('resize', updateWindowWidth)
+})
+
+onUnmounted(() => {
+  window.removeEventListener('resize', updateWindowWidth)
+})
 </script>
 
-<style lang="scss" scoped></style>
+<style lang="scss" scoped>
+.drawer-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  width: 100%;
+  padding-right: 4px;
+}
+
+.drawer-header__main {
+  display: flex;
+  align-items: center;
+  min-width: 0;
+}
+
+.drawer-header__icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 42px;
+  height: 42px;
+  margin-right: 12px;
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+  border: 1px solid var(--el-color-primary-light-7);
+  border-radius: 6px;
+}
+
+.drawer-title {
+  font-size: 18px;
+  font-weight: 600;
+  line-height: 24px;
+  color: var(--el-text-color-primary);
+}
+
+.drawer-subtitle {
+  margin-top: 4px;
+  font-size: 13px;
+  color: var(--el-text-color-secondary);
+}
+
+.drawer-body {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+  min-height: 100%;
+}
+
+.info-card {
+  overflow: hidden;
+  background: var(--el-bg-color);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 6px;
+}
+
+.info-card__header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  min-height: 56px;
+  padding: 0 20px;
+  background: #f4f9ff;
+  border-bottom: 1px solid var(--el-border-color-lighter);
+}
+
+.info-card__title {
+  display: flex;
+  align-items: center;
+  font-size: 16px;
+  font-weight: 600;
+  color: var(--el-text-color-primary);
+}
+
+.info-card__marker {
+  width: 4px;
+  height: 18px;
+  margin-right: 10px;
+  background: var(--el-color-primary);
+  border-radius: 4px;
+}
+
+.info-card__body {
+  padding: 20px;
+}
+
+.record-card {
+  flex: 1;
+  min-height: 0;
+}
+
+.record-total {
+  font-size: 13px;
+  color: var(--el-text-color-secondary);
+}
+
+.record-card__body {
+  display: flex;
+  flex-direction: column;
+  min-height: 420px;
+}
+
+.record-table {
+  flex: 1;
+  width: 100%;
+}
+
+:global(.allot-log-drawer .el-drawer__body) {
+  padding: 0 24px 24px;
+}
+
+:global(.allot-log-drawer .el-drawer__header) {
+  padding: 24px;
+  margin-bottom: 0;
+}
+
+@media (width <= 768px) {
+  :global(.allot-log-drawer .el-drawer__body) {
+    padding: 0 16px 16px;
+  }
+}
+</style>

+ 576 - 520
src/views/pms/device/personlog/ConfigDevicePerson.vue

@@ -1,528 +1,483 @@
 <template>
-  <div class="container">
-    <el-row :gutter="20" class="equal-height-row">
-      <!-- 左侧设备列表 -->
-      <el-col :span="12">
-        <div class="card">
-          <h3>{{ t('configPerson.deviceList') }}</h3>
-          <div class="dept-select">
-            <el-tree-select
-              clearable
-              v-model="formData.deptId1"
-              :data="deptList"
-              :props="defaultProps"
-              check-strictly
-              node-key="id"
-              filterable
-              :placeholder="t('configPerson.deviceListHolder')"
-              @node-click="handleDeptDeviceTreeNodeClick"
-              @clear="handleClearDeptForDevice"
-            />
-          </div>
-          <!-- 设备搜索框 -->
-          <div class="filter-input">
-            <el-input
-              v-model="deviceFilterText"
-              :placeholder="t('devicePerson.filterDevicePlaceholder')"
-              :disabled="!devicesLoaded"
-              clearable
-              prefix-icon="Search"
-            />
+  <div class="person-config-page">
+    <div class="selection-grid">
+      <section class="panel">
+        <div class="panel-header">
+          <div class="panel-title">
+            <span class="panel-marker"></span>
+            <span>{{ t('configPerson.deviceList') }}</span>
           </div>
+          <el-tag size="small" type="info">{{ filteredDevices.length }} 台</el-tag>
+        </div>
 
-          <el-scrollbar height="450px">
-            <el-checkbox-group v-model="selectedDevices">
-              <div
-                v-for="device in filteredDevices"
-                :key="device.id"
-                class="radio-item"
-              >
-                <el-checkbox :label="device.id">
-                  {{ device.deviceCode }} ({{ device.deviceName }}) - {{ device.devicePersons }}
-                </el-checkbox>
-              </div>
-            </el-checkbox-group>
-          </el-scrollbar>
+        <div class="panel-toolbar">
+          <el-tree-select
+            v-model="formData.deptId1"
+            :data="deptList"
+            :props="defaultProps"
+            check-strictly
+            node-key="id"
+            filterable
+            clearable
+            size="default"
+            class="toolbar-control"
+            :placeholder="t('configPerson.deviceListHolder')"
+            @node-click="handleDeptDeviceTreeNodeClick"
+            @clear="handleClearDeptForDevice" />
+          <el-input
+            v-model="deviceFilterText"
+            :placeholder="t('devicePerson.filterDevicePlaceholder')"
+            :disabled="!devicesLoaded"
+            clearable
+            size="default"
+            class="toolbar-control">
+            <template #prefix>
+              <Icon icon="ep:search" />
+            </template>
+          </el-input>
         </div>
-      </el-col>
-
-      <!-- 右侧责任人选择 -->
-      <el-col :span="12">
-        <div class="card">
-          <h3>{{ t('configPerson.rp') }}</h3>
-          <div class="dept-select">
-            <el-tree-select
-              clearable
-              v-model="formData.deptIds"
-              :data="deptList"
-              :props="defaultProps"
-              multiple
-              show-checkbox
-              check-strictly
-              node-key="id"
-              filterable
-              :placeholder="t('configPerson.rpHolder')"
-              @node-click="handleDeptCheckChange"
-              @clear="handleClearDeptForPerson"
-            />
-          </div>
 
-          <!-- 责任人搜索框 -->
-          <div class="filter-input">
-            <el-input
-              v-model="userFilterText"
-              :placeholder="t('devicePerson.filterUserPlaceholder')"
-              :disabled="!personLoaded"
-              clearable
-              prefix-icon="Search"
-            />
+        <el-scrollbar class="option-scroll">
+          <el-checkbox-group v-model="selectedDevices" class="option-list">
+            <el-checkbox
+              v-for="device in filteredDevices"
+              :key="device.id"
+              :value="device.id"
+              class="option-item"
+              :class="{ 'is-selected': selectedDevices.includes(device.id) }">
+              <span class="option-content">
+                <span class="option-main">{{ device.deviceCode || '暂无编码' }}</span>
+                <span class="option-sub">{{ device.deviceName || '暂无名称' }}</span>
+                <span class="option-extra">当前责任人:{{ device.devicePersons || '暂无' }}</span>
+              </span>
+            </el-checkbox>
+          </el-checkbox-group>
+          <el-empty
+            v-if="devicesLoaded && filteredDevices.length === 0"
+            :image-size="92"
+            description="暂无设备" />
+        </el-scrollbar>
+      </section>
+
+      <section class="panel">
+        <div class="panel-header">
+          <div class="panel-title">
+            <span class="panel-marker"></span>
+            <span>{{ t('configPerson.rp') }}</span>
           </div>
+          <el-tag size="small" type="info">{{ filteredUsers.length }} 人</el-tag>
+        </div>
+
+        <div class="panel-toolbar">
+          <el-tree-select
+            v-model="formData.deptIds"
+            :data="deptList"
+            :props="defaultProps"
+            multiple
+            show-checkbox
+            check-strictly
+            node-key="id"
+            filterable
+            clearable
+            collapse-tags
+            collapse-tags-tooltip
+            :max-collapse-tags="8"
+            popper-class="dept-tags-popper"
+            size="default"
+            class="toolbar-control dept-tags-select"
+            :placeholder="t('configPerson.rpHolder')"
+            @change="handlePersonDeptChange"
+            @clear="handleClearDeptForPerson" />
+          <el-input
+            v-model="userFilterText"
+            :placeholder="t('devicePerson.filterUserPlaceholder')"
+            clearable
+            size="default"
+            class="toolbar-control">
+            <template #prefix>
+              <Icon icon="ep:search" />
+            </template>
+          </el-input>
+        </div>
 
-          <el-scrollbar height="450px">
-            <el-checkbox-group v-model="selectedUsers" @change="handleUserSelectionChange">
-              <div
-                v-for="user in filteredUsers"
-                :key="user.id"
-                class="list-item"
-              >
-                <el-checkbox :label="user.id">
-                  {{ user.nickname }} ({{ user.deptName }})
-                </el-checkbox>
-              </div>
-            </el-checkbox-group>
-          </el-scrollbar>
+        <el-scrollbar class="option-scroll">
+          <el-checkbox-group
+            v-model="selectedUsers"
+            class="option-list"
+            @change="handleUserSelectionChange">
+            <el-checkbox
+              v-for="user in filteredUsers"
+              :key="user.id"
+              :value="user.id"
+              class="option-item"
+              :class="{ 'is-selected': selectedUsers.includes(user.id) }">
+              <span class="option-content">
+                <span class="option-main">{{ user.nickname || user.username }}</span>
+                <span class="option-sub">{{ user.deptName || '暂无部门' }}</span>
+              </span>
+            </el-checkbox>
+          </el-checkbox-group>
+          <el-empty
+            v-if="personLoaded && filteredUsers.length === 0"
+            :image-size="92"
+            description="暂无责任人" />
+        </el-scrollbar>
+      </section>
+    </div>
+
+    <section class="panel reason-panel">
+      <div class="panel-header">
+        <div class="panel-title">
+          <span class="panel-marker"></span>
+          <span>{{ t('configPerson.reasonForAdjustment') }}</span>
+          <span class="required-star">*</span>
         </div>
-      </el-col>
-    </el-row>
-
-    <!-- 暂存关联列表 -->
-    <div class="submit-area">
-      <div class="card">
-        <h3>{{ t('configPerson.reasonForAdjustment') }}<span class="required-star">*</span></h3>
-        <el-input
-          v-model="formData.reason"
-          :placeholder="t('configPerson.rfaHolder')"
-          class="reason-input"
-          type="textarea"
-          :rows="3"
-          @input="handleReasonInput"
-          :disabled="selectedDevices.length === 0 || selectedUsers.length === 0"
-        />
       </div>
-      <div class="temp-list card">
-        <h3>{{ t('configPerson.adjustmentRecords') }}</h3>
-        <el-table :data="tempRelations" style="width: 100%">
-          <el-table-column prop="deviceNames" :label="t('devicePerson.deviceName')" width="200" />
-          <el-table-column prop="userNames" :label="t('devicePerson.rp')" />
-          <el-table-column prop="reason" :label="t('configPerson.reasonForAdjustment')" />
-          <el-table-column :label="t('devicePerson.operation')" width="120">
-            <template #default="{ row }">
-              <el-button
-                type="danger"
-                size="small"
-                @click="removeTempRelation(row.deviceIds)"
-              >
-                {{ t('fault.del') }}
-              </el-button>
-            </template>
-          </el-table-column>
-        </el-table>
-
-        <div class="submit-area">
-          <el-button
-            type="primary"
-            size="large"
-            @click="submitRelations"
-            :disabled="isSaveDisabled"
-          >
-            {{ t('iotMaintain.save') }}
-          </el-button>
+      <el-input
+        v-model="formData.reason"
+        :placeholder="t('configPerson.rfaHolder')"
+        type="textarea"
+        :rows="4"
+        resize="none"
+        size="default"
+        :disabled="selectedDevices.length === 0 || selectedUsers.length === 0"
+        @input="handleReasonInput" />
+    </section>
+
+    <section class="panel record-panel">
+      <div class="panel-header">
+        <div class="panel-title">
+          <span class="panel-marker"></span>
+          <span>{{ t('configPerson.adjustmentRecords') }}</span>
         </div>
+        <el-button
+          type="primary"
+          size="default"
+          :disabled="isSaveDisabled"
+          @click="submitRelations">
+          <Icon icon="ep:check" class="mr-5px" />
+          {{ t('iotMaintain.save') }}
+        </el-button>
       </div>
-    </div>
+
+      <ZmTable
+        :data="tempRelations"
+        :loading="false"
+        :show-border="true"
+        settings-cache-key="pms-device-person-config-record"
+        class="record-table">
+        <ZmTableColumn prop="deviceNames" :label="t('devicePerson.deviceName')" min-width="240" />
+        <ZmTableColumn prop="userNames" :label="t('devicePerson.rp')" min-width="220">
+          <template #default="{ row }">
+            <span>{{ row.userNames || '未选择责任人' }}</span>
+          </template>
+        </ZmTableColumn>
+        <ZmTableColumn
+          prop="reason"
+          :label="t('configPerson.reasonForAdjustment')"
+          min-width="280"
+          align="left">
+          <template #default="{ row }">
+            <span>{{ row.reason || '未填写调整原因' }}</span>
+          </template>
+        </ZmTableColumn>
+        <ZmTableColumn :label="t('devicePerson.operation')" width="120" fixed="right" action>
+          <template #default="{ row }">
+            <el-button type="danger" link size="default" @click="removeTempRelation(row.deviceIds)">
+              <Icon icon="ep:delete" class="mr-4px" />
+              {{ t('fault.del') }}
+            </el-button>
+          </template>
+        </ZmTableColumn>
+      </ZmTable>
+    </section>
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, computed, onMounted } from 'vue'
+import { computed, onMounted, reactive, ref, unref, watch } from 'vue'
+import { useDebounceFn } from '@vueuse/core'
 import { ElMessage } from 'element-plus'
-import {defaultProps, handleTree} from "@/utils/tree";
-import * as DeptApi from "@/api/system/dept";
-import {IotDeviceApi, IotDeviceVO} from "@/api/pms/device";
-import {IotDevicePersonApi, IotDevicePersonVO} from "@/api/pms/iotdeviceperson";
-import * as UserApi from "@/api/system/user";
-import {simpleUserList, UserVO} from "@/api/system/user";
-import { useRouter, useRoute } from 'vue-router'
-const router = useRouter()
-const route = useRoute() // 获取路由参数
-const { t } = useI18n() // 国际化
-import { useTagsViewStore } from "@/store/modules/tagsView";
+import { useRoute, useRouter } from 'vue-router'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+import * as DeptApi from '@/api/system/dept'
+import * as UserApi from '@/api/system/user'
+import type { UserVO } from '@/api/system/user'
+import { IotDeviceApi, type IotDeviceVO } from '@/api/pms/device'
+import { IotDevicePersonApi } from '@/api/pms/iotdeviceperson'
+import { useTagsViewStore } from '@/store/modules/tagsView'
+import { defaultProps, handleTree } from '@/utils/tree'
 
 defineOptions({ name: 'ConfigDevicePerson' })
 
-const simpleDevices = ref<IotDeviceVO[]>([])
-const simpleUsers = ref<UserVO[]>([])
-
-const deptList = ref<Tree[]>([]) // 树形结构
-const { delView } = useTagsViewStore() // 视图操作
-
-const formData = ref({
-  id: undefined,
-  deviceCode: undefined,
-  deviceName: undefined,
-  brand: undefined,
-  model: undefined,
-  deptId: undefined as string | undefined,
-  deptId1: undefined as string | undefined,
-  deviceStatus: undefined,
-  assetProperty: undefined,
-  reason: '',
-  picUrl: undefined,
-  deptIds: [] as number[], // 改为部门ID数组
-})
-
-const queryParams = reactive({
-  deptId1: formData.value.deptId1,
-  deptId: formData.value.deptId,
-})
-
-const emit = defineEmits(['success', 'node-click']) // 定义 success 树点击 事件,用于操作成功后的回调
-
-// 响应式数据
-const selectedDevices = ref<number[]>([])
-// 责任人过滤文本
-const userFilterText = ref('')
-const selectedUsers = ref<number[]>([])
-const tempRelations = ref<Array<{
+type TempRelation = {
   deviceIds: number[]
   deviceNames: string
   userIds: number[]
   userNames: string
   reason: string
-}>>([])
+}
 
-watch(selectedDevices, async (newVal, oldVal) => {
-  // 找出新增的设备
-  const addedDevices = newVal.filter(id => !oldVal.includes(id));
-  // 找出取消选择的设备
-  const removedDevices = oldVal.filter(id => !newVal.includes(id))
+type ConfigFormData = {
+  deptId1?: number
+  deptIds: number[]
+  reason: string
+}
 
-  // 移除对应设备的关联记录
-  tempRelations.value = tempRelations.value.filter(
-    r => !removedDevices.includes(r.deviceIds[0])
-  )
+const router = useRouter()
+const route = useRoute()
+const { t } = useI18n()
+const { delView } = useTagsViewStore()
+const { ZmTable, ZmTableColumn } = useTableComponents<TempRelation>()
 
-  // 为新增的设备创建关联记录
-  if (addedDevices.length > 0) {
-    const addedDeviceObjects = simpleDevices.value.filter(d =>
-      addedDevices.includes(d.id)
-    );
-    addedDeviceObjects.forEach(device => {
-      // 使用当前已选的责任人和调整原因
-      updateDeviceRelation(device, selectedUsers.value);
-    });
-  }
+const deptList = ref<Tree[]>([])
+const simpleDevices = ref<IotDeviceVO[]>([])
+const simpleUsers = ref<UserVO[]>([])
+const selectedDevices = ref<number[]>([])
+const selectedUsers = ref<number[]>([])
+const tempRelations = ref<TempRelation[]>([])
+const deviceFilterText = ref('')
+const userFilterText = ref('')
+const devicesLoaded = ref(false)
+const personLoaded = ref(false)
 
-  // 当没有选中设备时清空其他选项
-  if (newVal.length === 0) {
-    selectedUsers.value = []
-    formData.value.reason = ''
-  }
+const formData = reactive<ConfigFormData>({
+  deptId1: undefined,
+  deptIds: [],
+  reason: ''
+})
 
-  // 获取设备部门ID集合
-  const deptIdSet = new Set<number>()
-  newVal.forEach(deviceId => {
-    const device = simpleDevices.value.find(d => d.id === deviceId)
-    if (device?.deptId) deptIdSet.add(device.deptId)
+const filteredDevices = computed(() => {
+  const searchText = deviceFilterText.value.toLowerCase().trim()
+  if (!searchText) return simpleDevices.value
+
+  return simpleDevices.value.filter((device) => {
+    return (
+      (device.deviceCode || '').toLowerCase().includes(searchText) ||
+      (device.deviceName || '').toLowerCase().includes(searchText) ||
+      (device.devicePersons || '').toLowerCase().includes(searchText)
+    )
   })
-  formData.value.deptIds = Array.from(deptIdSet)
-  // 部门更新后自动加载人员
-  await getUserList()
-}, { deep: true })
+})
+
+const filteredUsers = computed(() => {
+  const searchText = userFilterText.value.toLowerCase().trim()
+  if (!searchText) return simpleUsers.value
+
+  return simpleUsers.value.filter((user) => {
+    return (
+      (user.nickname || '').toLowerCase().includes(searchText) ||
+      (user.username || '').toLowerCase().includes(searchText) ||
+      (user.deptName || '').toLowerCase().includes(searchText)
+    )
+  })
+})
+
+const selectedUserObjects = computed(() => {
+  return simpleUsers.value.filter((user) => selectedUsers.value.includes(user.id))
+})
+
+const isSaveDisabled = computed(() => {
+  return (
+    tempRelations.value.length === 0 ||
+    selectedUsers.value.length === 0 ||
+    tempRelations.value.some((item) => !item.reason?.trim() || item.userIds.length === 0)
+  )
+})
 
-// 监听部门ID变化 部门变化后重新筛选用户
 watch(
-  () => formData.value.deptIds,
-  async (newVal, oldVal) => {
-    // 找出被删除的部门
-    const deletedDeptIds = oldVal?.filter(id => !newVal?.includes(id)) || []
-
-    if (deletedDeptIds.length > 0) {
-      // 找出被删除部门下的用户ID
-      const usersToRemove = simpleUsers.value
-        .filter(user => deletedDeptIds.includes(user.deptId))
-        .map(user => user.id)
-
-      // 同步更新已选用户
-      selectedUsers.value = selectedUsers.value.filter(
-        id => !usersToRemove.includes(id)
-      )
-
-      // 更新关联记录
-      tempRelations.value = tempRelations.value.map(relation => {
-        const validUserIds = relation.userIds.filter(
-          id => !usersToRemove.includes(id)
-        )
-
-        // 获取有效用户名称
-        const validUsers = simpleUsers.value.filter(
-          u => validUserIds.includes(u.id)
-        )
-
-        return {
-          ...relation,
-          userIds: validUserIds,
-          userNames: validUsers.map(u => u.nickname).join(', ')
-        }
-      })
+  selectedDevices,
+  (newVal, oldVal) => {
+    const removedDeviceIds = oldVal.filter((id) => !newVal.includes(id))
+    tempRelations.value = tempRelations.value.filter(
+      (relation) => !removedDeviceIds.includes(relation.deviceIds[0])
+    )
+
+    if (newVal.length === 0) {
+      selectedUsers.value = []
+      formData.deptIds = []
+      formData.reason = ''
+      simpleUsers.value = []
+      personLoaded.value = false
+      tempRelations.value = []
+      return
     }
 
-    // 重新加载人员列表
-    await getUserList()
+    syncPersonDeptBySelectedDevices()
+    syncRelations()
+    void debouncedGetUserList()
   },
-  { deep: true } // 深度监听数组变化
+  { deep: true }
 )
 
-const handleUserSelectionChange = (userIds: number[]) => {
-  // 处理清空责任人的情况
-  if (userIds.length === 0) {
-    tempRelations.value.forEach(relation => {
-      relation.userIds = []
-      relation.userNames = ''
-    })
-    return
-  }
-
-  if (selectedDevices.value.length === 0) {
-    ElMessage.warning('请先选择设备')
-    selectedUsers.value = []
-    return
-  }
-
-  // 获取所有选中设备
-  const devices = simpleDevices.value.filter(d =>
-    selectedDevices.value.includes(d.id)
-  )
+const getDeviceLabel = (device: IotDeviceVO) => {
+  return `${device.deviceCode || '暂无编码'}(${device.deviceName || '暂无名称'})`
+}
 
-  // 更新所有设备的关联关系
-  devices.forEach(device => {
-    updateDeviceRelation(device, userIds)
-  })
+const getUserNames = () => {
+  return selectedUserObjects.value.map((user) => user.nickname || user.username).join('、')
 }
 
-/** 处理 部门-设备 树 被点击 */
-const handleDeptDeviceTreeNodeClick = async (row: { [key: string]: any }) => {
-  emit('node-click', row)
-  formData.value.deptId1 = row.id
-  await getDeviceList()
+const syncRelations = () => {
+  const userIds = [...selectedUsers.value]
+  const userNames = getUserNames()
+  const selectedDeviceSet = new Set(selectedDevices.value)
+
+  tempRelations.value = simpleDevices.value
+    .filter((device) => selectedDeviceSet.has(device.id))
+    .map((device) => ({
+      deviceIds: [device.id],
+      deviceNames: getDeviceLabel(device),
+      userIds,
+      userNames,
+      reason: formData.reason
+    }))
 }
 
-// 新增计算属性:判断保存按钮是否禁用
-const isSaveDisabled = computed(() => {
-  // 当没有调整记录或调整原因为空时禁用按钮
-  return tempRelations.value.length === 0 || !formData.value.reason.trim();
-});
+const syncPersonDeptBySelectedDevices = () => {
+  const deptIdSet = new Set<number>()
+  selectedDevices.value.forEach((deviceId) => {
+    const device = simpleDevices.value.find((item) => item.id === deviceId)
+    if (device?.deptId) {
+      deptIdSet.add(device.deptId)
+    }
+  })
+  formData.deptIds = Array.from(deptIdSet)
+}
 
-/** 获得 部门下的设备 列表 */
 const getDeviceList = async () => {
+  devicesLoaded.value = false
   try {
-    // 查询到数据后才能进行搜索 筛选
-    devicesLoaded.value = false
-
-    const params = { deptId: formData.value.deptId1 }
-    const data = await IotDeviceApi.simpleDevices(params)
+    const data = await IotDeviceApi.simpleDevices({ deptId: formData.deptId1 })
     simpleDevices.value = data || []
-
-    devicesLoaded.value = true
+    selectedDevices.value = selectedDevices.value.filter((id) =>
+      simpleDevices.value.some((device) => device.id === id)
+    )
   } catch (error) {
     simpleDevices.value = []
-    // 即使失败也启用搜索框(避免卡在禁用状态)
+    ElMessage.error('获取设备列表失败')
+  } finally {
     devicesLoaded.value = true
-    console.error('获取设备列表失败:', error)
   }
 }
 
-/** 处理 部门-人员 树 被点击 */
-const handleDeptUserTreeNodeClick = async (row: { [key: string]: any }) => {
-  emit('node-click', row)
-  formData.value.deptId = row.id
-  await getUserList()
-}
-
-/** 获得 部门下的人员 列表 */
 const getUserList = async () => {
-  try {
-    // 开始加载时禁用搜索框
-    personLoaded.value = false
-
-    // 保留当前选中但不在被删除部门的用户
-    const currentDeptIds = formData.value.deptIds || []
-    const validUserIds = simpleUsers.value
-      .filter(user => currentDeptIds.includes(user.deptId))
-      .map(user => user.id)
+  const deptIds = formData.deptIds || []
 
-    selectedUsers.value = selectedUsers.value.filter(
-      id => validUserIds.includes(id)
-    )
+  if (!deptIds.length) {
+    simpleUsers.value = []
+    selectedUsers.value = []
+    personLoaded.value = true
+    syncRelations()
+    return
+  }
 
-    const params = {
-      deptIds: formData.value.deptIds,
-      // deptId: formData.value.deptId,
+  personLoaded.value = false
+  try {
+    const data = await UserApi.simpleUserList({
+      deptIds,
       pageNo: 1,
-      pageSize: 10 }
-    const data = await UserApi.simpleUserList(params)
+      pageSize: 100
+    } as any)
     simpleUsers.value = data || []
-
-    personLoaded.value = true
+    selectedUsers.value = selectedUsers.value.filter((id) =>
+      simpleUsers.value.some((user) => user.id === id)
+    )
+    syncRelations()
   } catch (error) {
     simpleUsers.value = []
-    // 即使失败也启用搜索框(避免卡在禁用状态)
+    selectedUsers.value = []
+    ElMessage.error('获取责任人列表失败')
+  } finally {
     personLoaded.value = true
-    console.error('获取人员列表失败:', error)
   }
 }
 
-// 设备加载状态标记 只有加载完设备才能通过文本框筛选
-const devicesLoaded = ref(false)
+const debouncedGetUserList = useDebounceFn(async () => {
+  if (!selectedDevices.value.length) return
+  await getUserList()
+}, 600)
 
-// 人员加载状态标记 只有加载完人员才能通过文本框筛选
-const personLoaded = ref(false)
+const handleDeptDeviceTreeNodeClick = async (row: Tree) => {
+  formData.deptId1 = row.id
+  selectedDevices.value = []
+  deviceFilterText.value = ''
+  await getDeviceList()
+}
 
-// 设备过滤文本
-const deviceFilterText = ref('')
+const handlePersonDeptChange = async () => {
+  userFilterText.value = ''
+  await getUserList()
+}
 
-// 计算属性:过滤设备列表
-const filteredDevices = computed(() => {
-  const searchText = deviceFilterText.value.toLowerCase().trim()
-  if (!searchText) return simpleDevices.value
+const handleUserSelectionChange = (userIds: number[]) => {
+  if (!selectedDevices.value.length) {
+    ElMessage.warning('请先选择设备')
+    selectedUsers.value = []
+    return
+  }
 
-  return simpleDevices.value.filter(device => {
-    return (
-      (device.deviceCode || '').toLowerCase().includes(searchText) ||
-      (device.deviceName || '').toLowerCase().includes(searchText)
-    )
-  })
-})
+  selectedUsers.value = userIds
+  syncRelations()
+}
 
-// 新增输入处理方法
 const handleReasonInput = (value: string) => {
-  formData.value.reason = value
-  // 同步到所有已选设备的记录
-  selectedDevices.value.forEach(deviceId => {
-    const relation = tempRelations.value.find(
-      r => r.deviceIds[0] === deviceId
-    )
-    if (relation) {
-      relation.reason = value
-    }
-  })
+  formData.reason = value
+  syncRelations()
 }
 
-// 更新设备关联关系
-const updateDeviceRelation = (device: IotDeviceVO, userIds: number[]) => {
-  // 获取当前选择的人员
-  const users = simpleUsers.value.filter(u => userIds.includes(u.id))
-
-  const newRelation = {
-    deviceIds: [device.id],
-    deviceNames: `${device.deviceCode} (${device.deviceName})`,
-    userIds: users.map(u => u.id),
-    userNames: users.map(u => u.nickname).join(', '),
-    reason: formData.value.reason
-  }
-
-  const existIndex = tempRelations.value.findIndex(
-    // r => r.deviceIds[0] === device.id
-    r => r.deviceIds.includes(device.id)
+const removeTempRelation = (deviceIds: number[]) => {
+  selectedDevices.value = selectedDevices.value.filter((id) => !deviceIds.includes(id))
+  tempRelations.value = tempRelations.value.filter(
+    (relation) => relation.deviceIds.join() !== deviceIds.join()
   )
-
-  if (existIndex > -1) {
-    tempRelations.value[existIndex] = newRelation
-  } else {
-    tempRelations.value.push(newRelation)
-  }
 }
 
-// 处理 设备关联 部门选择清空事件
 const handleClearDeptForDevice = () => {
   simpleDevices.value = []
   selectedDevices.value = []
-  devicesLoaded.value = false  // 清空时禁用搜索框
+  selectedUsers.value = []
+  tempRelations.value = []
   deviceFilterText.value = ''
+  devicesLoaded.value = false
 }
 
-// 处理 人员关联 部门选择清空事件
 const handleClearDeptForPerson = () => {
-  formData.value.deptIds = []
+  formData.deptIds = []
   simpleUsers.value = []
   selectedUsers.value = []
-  personLoaded.value = false  // 清空时禁用搜索框
   userFilterText.value = ''
-
-  // 清空所有暂存记录中的责任人
-  tempRelations.value.forEach(relation => {
-    relation.userIds = []
-    relation.userNames = ''
-  })
+  personLoaded.value = false
+  syncRelations()
 }
 
-// 新增部门选择监听
-const handleDeptCheckChange = () => {
-  // getUserList() // 选择变化时重新加载人员
-}
-
-// 过滤后的人员列表计算属性
-const filteredUsers = computed(() => {
-  const searchText = userFilterText.value.toLowerCase().trim()
-  if (!searchText) return simpleUsers.value
+const submitRelations = async () => {
+  if (!selectedDevices.value.length) {
+    ElMessage.error(t('iotMaintain.deviceHolder'))
+    return
+  }
 
-  return simpleUsers.value.filter(user => {
-    return (user.nickname || '').toLowerCase().includes(searchText)
-  })
-})
+  if (!selectedUsers.value.length) {
+    ElMessage.error(t('configPerson.selectPersons'))
+    return
+  }
 
-const removeTempRelation = (deviceIds: number[]) => {
-  tempRelations.value = tempRelations.value.filter(
-    r => r.deviceIds.join() !== deviceIds.join()
-  )
-  // 同步取消勾选设备
-  selectedDevices.value = selectedDevices.value.filter(
-    id => !deviceIds.includes(id)
-  )
-}
+  if (tempRelations.value.some((item) => !item.reason?.trim())) {
+    ElMessage.error(t('configPerson.rfaHolder'))
+    return
+  }
 
-const submitRelations = async () => {
   try {
-    // 校验必须选择设备
-    if (selectedDevices.value.length === 0) {
-      ElMessage.error(t('iotMaintain.deviceHolder')) // 请选择设备
-      return
-    }
-    // 校验必须选择责任人
-    if (selectedUsers.value.length === 0) {
-      ElMessage.error(t('configPerson.selectPersons')) // 请选择责任人
-      return
-    }
-    // 校验所有调整原因
-    const hasEmptyReason = tempRelations.value.some(
-      item => !item.reason?.trim()
-    )
-    if (hasEmptyReason) {
-      ElMessage.error(t('configPerson.rfaHolder'))
-      return
-    }
-    // 校验 设备 责任人 必须都选择 不能为空
-    // 转换为后端需要的格式
-    const submitData = tempRelations.value.flatMap(relation => {
-      return relation.deviceIds.map(deviceId => ({
+    const submitData = tempRelations.value.flatMap((relation) => {
+      return relation.deviceIds.map((deviceId) => ({
         deviceId,
         userIds: relation.userIds,
         userNames: relation.userNames,
         reason: relation.reason
       }))
     })
+
     await IotDevicePersonApi.saveDevicePersonRelation(submitData)
-    // 模拟API调用
     ElMessage.success('提交成功')
     tempRelations.value = []
     delView(unref(router.currentRoute.value))
@@ -532,150 +487,251 @@ const submitRelations = async () => {
   }
 }
 
-/** 初始化 */
+const findDeptNode = (nodes: Tree[], deptId: number): Tree | null => {
+  for (const node of nodes) {
+    if (node.id === deptId) return node
+
+    if (node.children?.length) {
+      const found = findDeptNode(node.children, deptId)
+      if (found) return found
+    }
+  }
+
+  return null
+}
+
 onMounted(async () => {
   try {
-    // 初始化部门树
     const deptData = await DeptApi.getSimpleDeptList()
-    deptList.value = handleTree(deptData) // 转换为树形结构
+    deptList.value = handleTree(deptData)
 
-    // 从路由参数获取部门ID
-    const routeDeptId = route.query.deptId ? Number(route.query.deptId) : null
+    const routeDeptId = route.query.deptId ? Number(route.query.deptId) : undefined
+    const targetDeptId = routeDeptId || deptList.value[0]?.id
 
-    let targetDeptId: number
-
-    if (routeDeptId) {
-      // 如果路由参数有部门ID,使用路由参数中的部门ID
-      targetDeptId = routeDeptId
-    } else if (deptList.value.length > 0) {
-      // 否则使用第一个节点
-      targetDeptId = deptList.value[0].id
-    } else {
-      console.warn("部门树数据为空,无法设置默认值")
+    if (!targetDeptId) {
+      ElMessage.warning('暂无可用部门数据')
       return
     }
 
-    // 设置默认选中的部门(转换后树的第一个节点)
-    // if (deptList.value.length > 0) {
-      // 获取转换后树的第一个节点
-      // const firstRootNode = deptList.value[0]
+    formData.deptId1 = targetDeptId
+    formData.deptIds = [targetDeptId]
 
-    // 设置设备部门的默认值
-    formData.value.deptId1 = targetDeptId
-    // 触发设备部门的节点点击事件,加载设备列表
     const targetDeptNode = findDeptNode(deptList.value, targetDeptId)
-    // 触发设备部门的节点点击事件,加载设备列表
     if (targetDeptNode) {
       await handleDeptDeviceTreeNodeClick(targetDeptNode)
+    } else {
+      await getDeviceList()
     }
-
-    // 设置责任人部门的默认值
-    formData.value.deptIds = [targetDeptId]
-    // 触发责任人部门的节点点击事件,加载人员列表
-    await getUserList() //handleDeptUserTreeNodeClick(firstRootNode)
-    // } else {
-      // console.warn("部门树数据为空,无法设置默认值")
-    // }
+    await getUserList()
   } catch (error) {
-    console.error("初始化部门树失败:", error)
-    ElMessage.error("加载部门数据失败")
+    ElMessage.error('加载部门数据失败')
   }
 })
+</script>
 
-// 在部门树中查找指定ID的节点
-const findDeptNode = (nodes: Tree[], deptId: number): Tree | null => {
-  for (const node of nodes) {
-    if (node.id === deptId) {
-      return node
-    }
-    if (node.children && node.children.length > 0) {
-      const found = findDeptNode(node.children, deptId)
-      if (found) return found
-    }
-  }
-  return null
+<style lang="scss" scoped>
+.person-config-page {
+  min-height: calc(
+    100vh - 20px - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height)
+  );
+  background: #f5f7fb;
 }
 
-</script>
+.selection-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 16px;
+}
 
-<style scoped>
-.equal-height-row {
+.panel {
+  overflow: hidden;
+  background: var(--el-bg-color);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 6px;
+  box-shadow: 0 8px 24px rgb(15 35 70 / 5%);
+}
+
+.panel-header {
   display: flex;
-  align-items: stretch;
+  align-items: center;
+  justify-content: space-between;
+  min-height: 56px;
+  padding: 0 18px;
+  background: #f4f9ff;
+  border-bottom: 1px solid var(--el-border-color-lighter);
 }
 
-.container {
-  padding: 20px;
+.panel-title {
+  display: flex;
+  align-items: center;
+  font-size: 16px;
+  font-weight: 600;
+  color: var(--el-text-color-primary);
 }
 
-.card {
-  border: 1px solid #ebeef5;
+.panel-marker {
+  width: 4px;
+  height: 18px;
+  margin-right: 10px;
+  background: var(--el-color-primary);
   border-radius: 4px;
-  padding: 20px;
-  margin-bottom: 20px;
-  background: white;
-  box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1);
-  transition: box-shadow 0.2s;
 }
 
-.list-item {
-  padding: 8px 12px;
-  border-bottom: 1px solid #f0f0f0;
+.panel-toolbar {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr);
+  gap: 12px;
+  padding: 16px 18px 0;
 }
 
-.dept-select {
-  margin-bottom: 20px;
+.toolbar-control {
+  width: 100%;
 }
 
-.action-bar {
-  text-align: right;
+.dept-tags-select :deep(.el-select__tags) {
+  flex-wrap: nowrap;
+  max-width: calc(100% - 48px);
+  overflow: hidden;
 }
 
-.temp-list {
-  margin-top: 20px;
+.dept-tags-select :deep(.el-tag) {
+  flex-shrink: 0;
+  max-width: 92px;
 }
 
-.submit-area {
-  margin-top: 20px;
-  text-align: center;
+.dept-tags-select :deep(.el-tag__content) {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
-h3 {
-  margin: 0 0 15px 0;
-  color: #303133;
+:global(.dept-tags-popper .el-select__selection) {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  max-width: 520px;
+  max-height: 220px;
+  overflow-y: auto;
 }
 
-.radio-item {
-  width: 100%;
-  padding: 8px 12px;
-  border-bottom: 1px solid #f0f0f0;
+:global(.dept-tags-popper .el-select__selected-item) {
+  max-width: 120px;
+}
+
+:global(.dept-tags-popper .el-tag__content),
+:global(.dept-tags-popper .el-select__tags-text) {
+  display: inline-block;
+  max-width: 92px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  vertical-align: bottom;
+}
+
+.option-scroll {
+  height: 430px;
+  padding: 14px 18px 18px;
+}
+
+.option-list {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.option-item {
+  display: flex;
+  height: auto;
+  min-width: 0;
+  padding: 12px;
+  cursor: pointer;
+  background: var(--el-fill-color-blank);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 6px;
+  transition:
+    border-color 0.2s,
+    background-color 0.2s,
+    box-shadow 0.2s;
+  align-items: flex-start;
+
+  &:hover,
+  &.is-selected {
+    background: #f7fbff;
+    border-color: var(--el-color-primary-light-5);
+    box-shadow: 0 6px 18px rgb(64 158 255 / 10%);
+  }
+
+  :deep(.el-checkbox__input) {
+    margin-top: 2px;
+  }
+
+  :deep(.el-checkbox__label) {
+    flex: 1;
+    min-width: 0;
+    padding-left: 10px;
+  }
 }
 
-.radio-item .el-radio {
+.option-content {
+  display: grid;
   width: 100%;
-  height: 100%;
+  min-width: 0;
+  align-items: center;
+  grid-template-columns: minmax(90px, 0.7fr) minmax(120px, 1fr) minmax(150px, 1.2fr);
+  gap: 12px;
 }
 
-.radio-item .el-radio__label {
-  display: block;
-  white-space: nowrap;
+.option-main,
+.option-sub,
+.option-extra {
+  min-width: 0;
   overflow: hidden;
   text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
-.required-star {
-  color: #ff4d4f;
-  margin-left: 3px;
-  vertical-align: middle;
+.option-main {
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--el-text-color-primary);
+}
+
+.option-sub,
+.option-extra {
+  font-size: 12px;
+  color: var(--el-text-color-secondary);
+}
+
+.reason-panel,
+.record-panel {
+  margin-top: 16px;
 }
 
-.filter-input {
-  margin-bottom: 15px;
+.reason-panel {
+  padding-bottom: 18px;
+
+  :deep(.el-textarea) {
+    padding: 18px 18px 0;
+  }
 }
 
-.no-data {
-  padding: 20px;
-  text-align: center;
-  color: #999;
+.record-panel {
+  padding-bottom: 18px;
+}
+
+.record-table {
+  width: calc(100% - 36px);
+  margin: 18px;
+}
+
+.required-star {
+  margin-left: 4px;
+  color: var(--el-color-danger);
+}
+
+@media (width <= 1180px) {
+  .selection-grid {
+    grid-template-columns: minmax(0, 1fr);
+  }
 }
 </style>

+ 238 - 68
src/views/pms/device/personlog/DevicePersonLogDrawer.vue

@@ -1,51 +1,110 @@
 <template>
   <el-drawer
-    :title="t('configDevice.adjustmentRecords')"
     :append-to-body="true"
     :model-value="modelValue"
-    @update:model-value="$emit('update:modelValue', $event)"
     :show-close="false"
     direction="rtl"
     :size="computedSize"
+    class="person-log-drawer"
     :before-close="handleClose"
-  >
-    <template v-if="deviceId">
-      <div v-loading="loading" style="height: 100%">
-        <el-table :data="devicePersons" style="width: 100%">
-          <el-table-column prop="deviceName" :label="t('deviceStatus.deviceName')" width="180" />
-          <el-table-column prop="deviceCode" :label="t('deviceStatus.deviceCode')" width="180" />
-          <el-table-column prop="oldPersonNames" :label="t('deviceStatus.beforePerson')" width="180" />
-          <el-table-column prop="newPersonNames" :label="t('deviceStatus.afterPerson')" width="180" />
-          <el-table-column prop="reason" :label="t('configDevice.reasonForAdjustment')" width="180" />
-          <el-table-column prop="creatorName"  :label="t('deviceStatus.adjuster')" width="180" />
-          <el-table-column
-            :label="t('deviceStatus.adjustTime')"
-            align="center"
-            prop="createTime"
-            width="180"
-            :formatter="dateFormatter"
-          />
-        </el-table>
-        <!-- 分页 -->
-        <Pagination
-          :total="total"
-          v-model:page="queryParams.pageNo"
-          v-model:limit="queryParams.pageSize"
-          @pagination="handlePagination"
-        />
+    @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:user" :size="22" />
+          </div>
+          <div>
+            <div class="drawer-title">{{ t('configDevice.adjustmentRecords') }}</div>
+            <div class="drawer-subtitle">查看设备责任人调整前后的变化记录</div>
+          </div>
+        </div>
+        <el-button circle size="default" @click="handleClose">
+          <Icon icon="ep:close" />
+        </el-button>
       </div>
     </template>
 
+    <template v-if="deviceId">
+      <div v-loading="loading" class="drawer-body">
+        <section class="info-card record-card">
+          <div class="info-card__header">
+            <div class="info-card__title">
+              <span class="info-card__marker"></span>
+              <span>{{ t('configDevice.adjustmentRecords') }}</span>
+            </div>
+            <span class="record-total">共 {{ total }} 条</span>
+          </div>
+
+          <div class="info-card__body record-card__body">
+            <ZmTable
+              :data="devicePersons"
+              :loading="loading"
+              :show-border="true"
+              settings-cache-key="pms-device-person-log"
+              class="record-table">
+              <ZmTableColumn
+                prop="deviceName"
+                :label="t('deviceStatus.deviceName')"
+                min-width="170" />
+              <ZmTableColumn
+                prop="deviceCode"
+                :label="t('deviceStatus.deviceCode')"
+                min-width="150" />
+              <ZmTableColumn
+                prop="oldPersonNames"
+                :label="t('deviceStatus.beforePerson')"
+                min-width="170" />
+              <ZmTableColumn
+                prop="newPersonNames"
+                :label="t('deviceStatus.afterPerson')"
+                min-width="170" />
+              <ZmTableColumn
+                prop="reason"
+                :label="t('configDevice.reasonForAdjustment')"
+                min-width="220"
+                align="left" />
+              <ZmTableColumn
+                prop="creatorName"
+                :label="t('deviceStatus.adjuster')"
+                min-width="130" />
+              <ZmTableColumn
+                :label="t('deviceStatus.adjustTime')"
+                prop="createTime"
+                min-width="170"
+                :formatter="dateFormatter" />
+            </ZmTable>
+
+            <Pagination
+              v-show="total > 0"
+              :total="total"
+              v-model:page="queryParams.pageNo"
+              v-model:limit="queryParams.pageSize"
+              @pagination="handlePagination" />
+          </div>
+        </section>
+      </div>
+    </template>
   </el-drawer>
 </template>
 <script setup lang="ts">
-const { t } = useI18n() // 国际化
-import { ref, watch, defineOptions, defineEmits, toRefs } from 'vue'
+import { ref } from 'vue'
 import { ElMessage } from 'element-plus'
-import {dateFormatter} from "@/utils/formatTime";
-import {DICT_TYPE} from "@/utils/dict";
-import {IotDevicePersonLogApi} from "@/api/pms/iotdevicepersonlog";
-const drawerVisible = ref<boolean>(false)
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+import { dateFormatter } from '@/utils/formatTime'
+import { IotDevicePersonLogApi, type IotDevicePersonLogVO } from '@/api/pms/iotdevicepersonlog'
+
+type DevicePersonLogRow = IotDevicePersonLogVO & {
+  deviceName?: string
+  deviceCode?: string
+  oldPersonNames?: string
+  newPersonNames?: string
+  creatorName?: string
+  createTime?: string
+}
+
+const { t } = useI18n()
+const { ZmTable, ZmTableColumn } = useTableComponents<DevicePersonLogRow>()
 const emit = defineEmits(['update:modelValue', 'add', 'delete'])
 
 defineOptions({
@@ -56,53 +115,40 @@ const queryParams = reactive({
   pageNo: 1,
   pageSize: 10,
   createTime: [],
-  deviceId: '',
+  deviceId: undefined as number | undefined,
   name: '',
   code: ''
 })
 
 const windowWidth = ref(window.innerWidth)
-// 动态计算百分比
 const computedSize = computed(() => {
+  if (windowWidth.value <= 768) return '100%'
   return windowWidth.value > 1200 ? '60%' : '80%'
 })
 
 const loading = ref(false)
-const total = ref(0) // 列表的总页数
-const devicePersons = ref([])
+const total = ref(0)
+const devicePersons = ref<DevicePersonLogRow[]>([])
 
 const props = defineProps({
   modelValue: Boolean,
   deviceId: Number
 })
 
-// 使用 toRefs 解构响应式属性
-const { modelValue, deviceId } = toRefs(props)
-
-// 监听抽屉打开事件
-watch(modelValue, (isOpen) => {
-  if (isOpen && deviceId.value) {
-    loadDevicePersons(deviceId.value)
-  }
-})
-
-// 监听bom树节点ID变化
-watch(() => props.deviceId, async (newVal) => {
-  if (newVal) {
-    await loadDevicePersons(newVal)
-  }
-})
+const updateWindowWidth = () => {
+  windowWidth.value = window.innerWidth
+}
 
-// 加载设备的状态调整记录
-const loadDevicePersons = async (deviceId: number) => {
+const loadDevicePersons = async (deviceId: number, resetPage = true) => {
   queryParams.deviceId = deviceId
-  queryParams.pageNo = 1
+  if (resetPage) {
+    queryParams.pageNo = 1
+  }
   try {
     loading.value = true
-    // API调用
     const data = await IotDevicePersonLogApi.getIotDevicePersonLogPage(queryParams)
-    devicePersons.value = data.list
-    total.value = data.total
+    devicePersons.value = data.list || []
+    total.value = data.total || 0
   } catch (error) {
     ElMessage.error('数据加载失败')
   } finally {
@@ -110,34 +156,158 @@ const loadDevicePersons = async (deviceId: number) => {
   }
 }
 
-// 修改分页处理
 const handlePagination = (pagination: any) => {
   queryParams.pageNo = pagination.page
   queryParams.pageSize = pagination.limit
   if (props.deviceId) {
-    loadDevicePersons(props.deviceId)
+    loadDevicePersons(props.deviceId, false)
   }
 }
 
-// 打开抽屉
 const openDrawer = () => {
-  drawerVisible.value = true
+  emit('update:modelValue', true)
 }
 
-// 关闭抽屉
 const closeDrawer = () => {
-  drawerVisible.value = false
+  handleClose()
 }
 
-// 关闭抽屉
 const handleClose = () => {
   emit('update:modelValue', false)
   devicePersons.value = []
   total.value = 0
 }
 
-defineExpose({ openDrawer, closeDrawer, loadDevicePersons }) // 暴露方法给父组件
+defineExpose({ openDrawer, closeDrawer, loadDevicePersons })
 
+onMounted(() => {
+  window.addEventListener('resize', updateWindowWidth)
+})
+
+onUnmounted(() => {
+  window.removeEventListener('resize', updateWindowWidth)
+})
 </script>
 
-<style lang="scss" scoped></style>
+<style lang="scss" scoped>
+.drawer-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  width: 100%;
+  padding-right: 4px;
+}
+
+.drawer-header__main {
+  display: flex;
+  align-items: center;
+  min-width: 0;
+}
+
+.drawer-header__icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 42px;
+  height: 42px;
+  margin-right: 12px;
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+  border: 1px solid var(--el-color-primary-light-7);
+  border-radius: 6px;
+}
+
+.drawer-title {
+  font-size: 18px;
+  font-weight: 600;
+  line-height: 24px;
+  color: var(--el-text-color-primary);
+}
+
+.drawer-subtitle {
+  margin-top: 4px;
+  font-size: 13px;
+  color: var(--el-text-color-secondary);
+}
+
+.drawer-body {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+  min-height: 100%;
+}
+
+.info-card {
+  overflow: hidden;
+  background: var(--el-bg-color);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 6px;
+}
+
+.info-card__header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  min-height: 56px;
+  padding: 0 20px;
+  background: #f4f9ff;
+  border-bottom: 1px solid var(--el-border-color-lighter);
+}
+
+.info-card__title {
+  display: flex;
+  align-items: center;
+  font-size: 16px;
+  font-weight: 600;
+  color: var(--el-text-color-primary);
+}
+
+.info-card__marker {
+  width: 4px;
+  height: 18px;
+  margin-right: 10px;
+  background: var(--el-color-primary);
+  border-radius: 4px;
+}
+
+.info-card__body {
+  padding: 20px;
+}
+
+.record-card {
+  flex: 1;
+  min-height: 0;
+}
+
+.record-total {
+  font-size: 13px;
+  color: var(--el-text-color-secondary);
+}
+
+.record-card__body {
+  display: flex;
+  flex-direction: column;
+  min-height: 420px;
+}
+
+.record-table {
+  flex: 1;
+  width: 100%;
+}
+
+:global(.person-log-drawer .el-drawer__body) {
+  padding: 0 24px 24px;
+}
+
+:global(.person-log-drawer .el-drawer__header) {
+  padding: 24px;
+  margin-bottom: 0;
+}
+
+@media (width <= 768px) {
+  :global(.person-log-drawer .el-drawer__body) {
+    padding: 0 16px 16px;
+  }
+}
+</style>

+ 394 - 380
src/views/pms/device/statuslog/ConfigDeviceStatus.vue

@@ -1,338 +1,320 @@
 <template>
-  <div class="container">
-    <el-row :gutter="20" class="equal-height-row">
-      <!-- 左侧设备列表 -->
-      <el-col :span="12" class="col-wrapper">
-        <div class="card">
-          <h3>{{ t('configPerson.deviceList') }}</h3>
-          <div class="dept-select">
-            <el-tree-select
-              clearable
-              v-model="formData.deptId1"
-              :data="deptList"
-              :props="defaultProps"
-              check-strictly
-              node-key="id"
-              filterable
-              :placeholder="t('configPerson.deviceListHolder')"
-              @node-click="handleDeptDeviceTreeNodeClick"
-            />
+  <div class="status-config-page">
+    <div class="selection-grid">
+      <section class="panel">
+        <div class="panel-header">
+          <div class="panel-title">
+            <span class="panel-marker"></span>
+            <span>{{ t('configPerson.deviceList') }}</span>
           </div>
-
-          <!-- 设备搜索框 -->
-          <div class="filter-input">
-            <el-input
-              v-model="deviceFilterText"
-              :placeholder="t('devicePerson.filterDevicePlaceholder')"
-              clearable
-              prefix-icon="Search"
-            />
-          </div>
-
-          <el-scrollbar height="400px">
-            <el-checkbox-group v-model="selectedDevices"  @change="handleDeviceChange">
-              <div
-                v-for="device in filteredDevices"
-                :key="device.id"
-                class="checkbox-item"
-              >
-                <el-checkbox :label="device.id">
-                  {{ device.deviceCode }} ({{ device.deviceName }}) - {{ device.deviceStatusName }}
-                </el-checkbox>
-              </div>
-            </el-checkbox-group>
-          </el-scrollbar>
+          <el-tag size="small" type="info">{{ filteredDevices.length }} 台</el-tag>
         </div>
-      </el-col>
-
-      <!-- 右侧设备状态 -->
-      <el-col :span="12" class="col-wrapper">
-        <div class="card">
-          <h3>{{ t('configDevice.rp') }}</h3>
-          <div class="dept-select">
-            <el-select
-              v-model="formData.deviceStatus"
-              @change="handleStatusChange"
-              :placeholder="t('deviceStatus.choose')"
-              clearable
-              :disabled="selectedDevices.length === 0"
-            >
-              <el-option
-                v-for="dict in getStrDictOptions(DICT_TYPE.PMS_DEVICE_STATUS)"
-                :key="dict.label"
-                :label="dict.label"
-                :value="dict.value"
-              />
-            </el-select>
-          </div>
 
+        <div class="panel-toolbar">
+          <el-tree-select
+            v-model="formData.deptId"
+            :data="deptList"
+            :props="defaultProps"
+            check-strictly
+            node-key="id"
+            filterable
+            clearable
+            size="default"
+            class="toolbar-control"
+            :placeholder="t('configPerson.deviceListHolder')"
+            @node-click="handleDeptDeviceTreeNodeClick"
+            @clear="handleClearDeptForDevice" />
           <el-input
-            v-model="formData.reason"
-            :placeholder="t('configDevice.rfaHolder')"
-            :disabled="!formData.deviceStatus"
-            class="reason-input"
-            type="textarea"
-            :rows="3"
-            @input="handleReasonInput"
-          />
-
-          <div class="action-bar">
+            v-model="deviceFilterText"
+            :placeholder="t('devicePerson.filterDevicePlaceholder')"
+            clearable
+            size="default"
+            class="toolbar-control">
+            <template #prefix>
+              <Icon icon="ep:search" />
+            </template>
+          </el-input>
+        </div>
 
+        <el-scrollbar class="option-scroll">
+          <el-checkbox-group v-model="selectedDevices" class="option-list">
+            <el-checkbox
+              v-for="device in filteredDevices"
+              :key="device.id"
+              :value="device.id"
+              class="option-item"
+              :class="{ 'is-selected': selectedDevices.includes(device.id) }">
+              <span class="option-content">
+                <span class="option-main">{{ device.deviceCode || '暂无编码' }}</span>
+                <span class="option-sub">{{ device.deviceName || '暂无名称' }}</span>
+                <span class="option-extra">当前状态:{{ device.deviceStatusName || '暂无' }}</span>
+              </span>
+            </el-checkbox>
+          </el-checkbox-group>
+          <el-empty v-if="filteredDevices.length === 0" :image-size="92" description="暂无设备" />
+        </el-scrollbar>
+      </section>
+
+      <section class="panel">
+        <div class="panel-header">
+          <div class="panel-title">
+            <span class="panel-marker"></span>
+            <span>{{ t('configDevice.rp') }}</span>
           </div>
+          <el-tag size="small" type="info">{{ selectedDevices.length }} 台已选</el-tag>
         </div>
-      </el-col>
-    </el-row>
-
-    <!-- 暂存关联列表 -->
-    <div class="temp-list card">
-      <h3>{{ t('configPerson.adjustmentRecords') }}</h3>
-      <el-table :data="tempRelations" style="width: 100%">
-        <el-table-column prop="deviceNames" :label="t('deviceStatus.deviceName')" width="200" >
-          <template #default="{ row }">
-            {{ row.deviceNames }}
-          </template>
-        </el-table-column>
-        <el-table-column prop="status" :label="t('deviceStatus.status')" >
-          <template #default="scope">
-            <dict-tag :type="DICT_TYPE.PMS_DEVICE_STATUS" :value="scope.row.status" />
-          </template>
-        </el-table-column>
-        <el-table-column prop="reason" :label="t('configDevice.reasonForAdjustment')" />
-        <el-table-column :label="t('deviceStatus.operation')" width="120">
-          <template #default="{ row }">
-            <el-button
-              type="danger"
-              size="small"
-              @click="removeTempRelation(row.deviceId)"
-            >
-              {{ t('fault.del') }}
-            </el-button>
-          </template>
-        </el-table-column>
-      </el-table>
 
-      <div class="submit-area">
+        <div class="status-form">
+          <el-form label-position="top" size="default">
+            <el-form-item :label="t('deviceStatus.status')">
+              <el-select
+                v-model="formData.deviceStatus"
+                :placeholder="t('deviceStatus.choose')"
+                clearable
+                class="toolbar-control"
+                :disabled="selectedDevices.length === 0"
+                @change="syncRelations">
+                <el-option
+                  v-for="dict in statusOptions"
+                  :key="dict.value"
+                  :label="dict.label"
+                  :value="dict.value" />
+              </el-select>
+            </el-form-item>
+
+            <el-form-item>
+              <template #label>
+                <span>{{ t('configDevice.reasonForAdjustment') }}</span>
+                <span class="required-star">*</span>
+              </template>
+              <el-input
+                v-model="formData.reason"
+                :placeholder="t('configDevice.rfaHolder')"
+                type="textarea"
+                :rows="8"
+                resize="none"
+                :disabled="!formData.deviceStatus"
+                @input="syncRelations" />
+            </el-form-item>
+          </el-form>
+        </div>
+      </section>
+    </div>
+
+    <section class="panel record-panel">
+      <div class="panel-header">
+        <div class="panel-title">
+          <span class="panel-marker"></span>
+          <span>{{ t('configPerson.adjustmentRecords') }}</span>
+        </div>
         <el-button
           type="primary"
-          size="large"
-          @click="submitRelations"
+          size="default"
           :disabled="isSaveDisabled"
-        >
+          @click="submitRelations">
+          <Icon icon="ep:check" class="mr-5px" />
           {{ t('iotMaintain.save') }}
         </el-button>
       </div>
-    </div>
+
+      <ZmTable
+        :data="tempRelations"
+        :loading="false"
+        :show-border="true"
+        settings-cache-key="pms-device-status-config-record"
+        class="record-table">
+        <ZmTableColumn prop="deviceNames" :label="t('deviceStatus.deviceName')" min-width="240" />
+        <ZmTableColumn prop="status" :label="t('deviceStatus.status')" min-width="140">
+          <template #default="{ row }">
+            <dict-tag :type="DICT_TYPE.PMS_DEVICE_STATUS" :value="row.status" />
+          </template>
+        </ZmTableColumn>
+        <ZmTableColumn
+          prop="reason"
+          :label="t('configDevice.reasonForAdjustment')"
+          min-width="280"
+          align="left">
+          <template #default="{ row }">
+            <span>{{ row.reason || '未填写调整原因' }}</span>
+          </template>
+        </ZmTableColumn>
+        <ZmTableColumn :label="t('deviceStatus.operation')" width="120" fixed="right" action>
+          <template #default="{ row }">
+            <el-button type="danger" link size="default" @click="removeTempRelation(row.deviceId)">
+              <Icon icon="ep:delete" class="mr-4px" />
+              {{ t('fault.del') }}
+            </el-button>
+          </template>
+        </ZmTableColumn>
+      </ZmTable>
+    </section>
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, computed, onMounted } from 'vue'
+import { computed, onMounted, reactive, ref, unref, watch } from 'vue'
 import { ElMessage } from 'element-plus'
-import {defaultProps, handleTree} from "@/utils/tree";
-import * as DeptApi from "@/api/system/dept";
-import {IotDeviceApi, IotDeviceVO} from "@/api/pms/device";
-import {DICT_TYPE, getStrDictOptions} from "@/utils/dict";
-import { useRouter, useRoute } from 'vue-router'
-import { useTagsViewStore } from "@/store/modules/tagsView";
-const router = useRouter()
-const route = useRoute() // 获取路由参数
+import { useRoute, useRouter } from 'vue-router'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+import * as DeptApi from '@/api/system/dept'
+import { IotDeviceApi, type IotDeviceVO } from '@/api/pms/device'
+import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
+import { useTagsViewStore } from '@/store/modules/tagsView'
+import { defaultProps, handleTree } from '@/utils/tree'
 
-const { t } = useI18n() // 国际化
 defineOptions({ name: 'ConfigDeviceStatus' })
 
-const simpleDevices = ref<IotDeviceVO[]>([])
-const currentStatus = ref<string>('')
-const adjustReason = ref<string>('')
-const deptList = ref<Tree[]>([]) // 树形结构
-const selectedDevices = ref<number[]>([]) // 改为数组存储多选
-// 获取当前设备对象
-const currentDevice = computed(() => {
-  return simpleDevices.value.find(d => d.id === selectedDevice.value)
-})
-
-const { delView } = useTagsViewStore() // 视图操作
-
-const formData = ref({
-  id: undefined,
-  deviceCode: undefined,
-  deviceName: undefined,
-  brand: undefined,
-  model: undefined,
-  deptId: undefined as string | undefined,
-  deptId1: undefined as string | undefined,
-  deviceStatus: undefined,
-  reason: '',
-  assetProperty: undefined,
-  picUrl: undefined,
-})
-
-const queryParams = reactive({
-  deptId1: formData.value.deptId1,
-  deptId: formData.value.deptId,
-})
-
-const emit = defineEmits(['success', 'node-click']) // 定义 success 树点击 事件,用于操作成功后的回调
-
-// 响应式数据
-const selectedDevice = ref<number>(0)
-const selectedDept = ref('')
-const tempRelations = ref<Array<{
+type TempRelation = {
   deviceId: number
   deviceNames: string
   status: string
-  statusLabel: string
   reason: string
-}>>([])
-
-// 设备切换时清空状态
-const handleDeviceChange = () => {
-  currentStatus.value = ''
-  adjustReason.value = ''
-  // 尝试从暂存记录恢复数据
-  const existRecord = tempRelations.value.find(r => r.deviceId === selectedDevice.value)
-  if (existRecord) {
-    currentStatus.value = existRecord.deviceStatus
-    adjustReason.value = existRecord.reason
-  }
 }
 
-// 修改watch监听
-watch(selectedDevices, (newVal, oldVal) => {
-  // 删除取消选择的设备
-  const removedDevices = oldVal.filter(id => !newVal.includes(id))
-  removedDevices.forEach(deviceId => {
-    const index = tempRelations.value.findIndex(r => r.deviceId === deviceId)
-    if (index > -1) tempRelations.value.splice(index, 1)
-  })
+type ConfigFormData = {
+  deptId?: number
+  deviceStatus?: string
+  reason: string
+}
 
-  // 自动应用当前状态到新选设备
-  if (formData.value.deviceStatus && formData.value.reason) {
-    saveCurrentRelation()
-  }
+const router = useRouter()
+const route = useRoute()
+const { t } = useI18n()
+const { delView } = useTagsViewStore()
+const { ZmTable, ZmTableColumn } = useTableComponents<TempRelation>()
+
+const deptList = ref<Tree[]>([])
+const simpleDevices = ref<IotDeviceVO[]>([])
+const selectedDevices = ref<number[]>([])
+const tempRelations = ref<TempRelation[]>([])
+const deviceFilterText = ref('')
+
+const formData = reactive<ConfigFormData>({
+  deptId: undefined,
+  deviceStatus: undefined,
+  reason: ''
 })
 
-// 修改保存方法
-const saveCurrentRelation = () => {
-  if (!formData.value.deviceStatus /*|| !formData.value.reason*/) return
+const statusOptions = computed(() => getStrDictOptions(DICT_TYPE.PMS_DEVICE_STATUS))
 
-  const statusDict = getStrDictOptions(DICT_TYPE.PMS_DEVICE_STATUS)
-    .find(d => d.value === formData.value.deviceStatus)
+const filteredDevices = computed(() => {
+  const searchText = deviceFilterText.value.toLowerCase().trim()
+  if (!searchText) return simpleDevices.value
 
-  selectedDevices.value.forEach(deviceId => {
-    const device = simpleDevices.value.find(d => d.id === deviceId)
-    if (!device) return
+  return simpleDevices.value.filter((device) => {
+    return (
+      (device.deviceCode || '').toLowerCase().includes(searchText) ||
+      (device.deviceName || '').toLowerCase().includes(searchText) ||
+      (device.deviceStatusName || '').toLowerCase().includes(searchText)
+    )
+  })
+})
 
-    const newRelation = {
-      deviceId,
-      deviceNames: `${device.deviceCode} (${device.deviceName})`,
-      status: formData.value.deviceStatus!,
-      statusLabel: statusDict?.label || '未知状态',
-      reason: formData.value.reason
-    }
+const isSaveDisabled = computed(() => {
+  return (
+    tempRelations.value.length === 0 ||
+    !formData.reason.trim() ||
+    tempRelations.value.some((item) => !item.status || !item.reason?.trim())
+  )
+})
 
-    const existIndex = tempRelations.value.findIndex(r => r.deviceId === deviceId)
-    if (existIndex > -1) {
-      tempRelations.value[existIndex] = newRelation
-    } else {
-      tempRelations.value.push(newRelation)
+watch(
+  selectedDevices,
+  (newVal, oldVal) => {
+    const removedDeviceIds = oldVal.filter((id) => !newVal.includes(id))
+    tempRelations.value = tempRelations.value.filter(
+      (relation) => !removedDeviceIds.includes(relation.deviceId)
+    )
+
+    if (newVal.length === 0) {
+      formData.deviceStatus = undefined
+      formData.reason = ''
+      tempRelations.value = []
+      return
     }
-  })
+
+    syncRelations()
+  },
+  { deep: true }
+)
+
+const getDeviceLabel = (device: IotDeviceVO) => {
+  return `${device.deviceCode || '暂无编码'}(${device.deviceName || '暂无名称'})`
 }
 
-/** 处理 部门-设备 树 被点击 */
-const handleDeptDeviceTreeNodeClick = async (row: { [key: string]: any }) => {
-  emit('node-click', row)
-  formData.value.deptId1 = row.id
-  await getDeviceList()
+const syncRelations = () => {
+  if (!formData.deviceStatus) {
+    tempRelations.value = []
+    return
+  }
+
+  const selectedDeviceSet = new Set(selectedDevices.value)
+  tempRelations.value = simpleDevices.value
+    .filter((device) => selectedDeviceSet.has(device.id))
+    .map((device) => ({
+      deviceId: device.id,
+      deviceNames: getDeviceLabel(device),
+      status: formData.deviceStatus!,
+      reason: formData.reason
+    }))
 }
 
-/** 获得 部门下的设备 列表 */
 const getDeviceList = async () => {
   try {
-    const params = { deptId: formData.value.deptId1 }
-    const data = await IotDeviceApi.simpleDevices(params)
+    const data = await IotDeviceApi.simpleDevices({ deptId: formData.deptId })
     simpleDevices.value = data || []
+    selectedDevices.value = selectedDevices.value.filter((id) =>
+      simpleDevices.value.some((device) => device.id === id)
+    )
+    syncRelations()
   } catch (error) {
     simpleDevices.value = []
-    console.error('获取设备列表失败:', error)
+    ElMessage.error('获取设备列表失败')
   }
 }
 
-// 添加计算属性:判断保存按钮是否禁用
-const isSaveDisabled = computed(() => {
-  // 当没有调整记录或调整原因为空时禁用按钮
-  return tempRelations.value.length === 0 || !formData.value.reason.trim();
-});
-
-// 添加状态变更处理
-const handleStatusChange = () => {
-  if (selectedDevices.value.length > 0) {
-    saveCurrentRelation()
-  }
+const handleDeptDeviceTreeNodeClick = async (row: Tree) => {
+  formData.deptId = row.id
+  selectedDevices.value = []
+  deviceFilterText.value = ''
+  await getDeviceList()
 }
 
-// 新增输入处理方法
-const handleReasonInput = (value: string) => {
-  formData.value.reason = value
-  if (selectedDevices.value.length > 0 && formData.value.deviceStatus) {
-    saveCurrentRelation()
-  }
+const handleClearDeptForDevice = () => {
+  simpleDevices.value = []
+  selectedDevices.value = []
+  tempRelations.value = []
+  deviceFilterText.value = ''
 }
 
-// 添加多设备判断方法
-const isMultiDevice = (row: any) => {
-  return selectedDevices.value.includes(row.deviceId) && selectedDevices.value.length > 1
+const removeTempRelation = (deviceId: number) => {
+  selectedDevices.value = selectedDevices.value.filter((id) => id !== deviceId)
+  tempRelations.value = tempRelations.value.filter((relation) => relation.deviceId !== deviceId)
 }
 
-// 设备过滤文本
-const deviceFilterText = ref('')
-
-// 计算属性:过滤设备列表
-const filteredDevices = computed(() => {
-  const searchText = deviceFilterText.value.toLowerCase().trim()
-  if (!searchText) return simpleDevices.value
-
-  return simpleDevices.value.filter(device => {
-    return (
-      (device.deviceCode || '').toLowerCase().includes(searchText) ||
-      (device.deviceName || '').toLowerCase().includes(searchText)
-    )
-  })
-})
-
-// 修改删除方法
-const removeTempRelation = (deviceId: number) => {
-  // 从暂存列表删除
-  tempRelations.value = tempRelations.value.filter(r => r.deviceId !== deviceId)
+const submitRelations = async () => {
+  if (!selectedDevices.value.length) {
+    ElMessage.error(t('iotMaintain.deviceHolder'))
+    return
+  }
 
-  // 从选中设备中移除
-  selectedDevices.value = selectedDevices.value.filter(id => id !== deviceId)
+  if (!formData.deviceStatus) {
+    ElMessage.error(t('deviceStatus.choose'))
+    return
+  }
 
-  // 如果当前表单状态为空,尝试恢复其他设备的状态
-  if (!formData.value.deviceStatus && tempRelations.value.length > 0) {
-    const firstRelation = tempRelations.value[0]
-    formData.value.deviceStatus = firstRelation.status
-    formData.value.reason = firstRelation.reason
+  if (tempRelations.value.some((item) => !item.reason?.trim())) {
+    ElMessage.error('请填写所有设备的调整原因')
+    return
   }
-}
 
-// 提交时处理数据
-const submitRelations = async () => {
   try {
-    // 检查是否有空的原因
-    const hasEmptyReason = tempRelations.value.some(
-      item => !item.reason?.trim()
-    )
-    if (hasEmptyReason) {
-      ElMessage.error('请填写所有设备的调整原因')
-      return
-    }
-    // 转换数据结构
-    const submitData = tempRelations.value.map(r => ({
-      deviceId: r.deviceId,
-      status: r.status,
-      reason: r.reason
+    const submitData = tempRelations.value.map((relation) => ({
+      deviceId: relation.deviceId,
+      status: relation.status,
+      reason: relation.reason
     }))
+
     await IotDeviceApi.saveDeviceStatuses(submitData)
     ElMessage.success('提交成功')
     tempRelations.value = []
@@ -343,168 +325,200 @@ const submitRelations = async () => {
   }
 }
 
-/** 初始化 */
+const findDeptNode = (nodes: Tree[], deptId: number): Tree | null => {
+  for (const node of nodes) {
+    if (node.id === deptId) return node
+
+    if (node.children?.length) {
+      const found = findDeptNode(node.children, deptId)
+      if (found) return found
+    }
+  }
+
+  return null
+}
+
 onMounted(async () => {
   try {
-    // 初始化部门树
     const deptData = await DeptApi.getSimpleDeptList()
-    deptList.value = handleTree(deptData) // 转换为树形结构
-
-    // 从路由参数获取部门ID
-    const routeDeptId = route.query.deptId ? Number(route.query.deptId) : null
-    let targetDeptId: number
-
-    if (routeDeptId) {
-      // 如果路由参数有部门ID,使用路由参数中的部门ID
-      targetDeptId = routeDeptId
-    } else if (deptList.value.length > 0) {
-      // 否则使用第一个节点
-      targetDeptId = deptList.value[0].id
-    } else {
-      console.warn("部门树数据为空,无法设置默认值")
+    deptList.value = handleTree(deptData)
+
+    const routeDeptId = route.query.deptId ? Number(route.query.deptId) : undefined
+    const targetDeptId = routeDeptId || deptList.value[0]?.id
+
+    if (!targetDeptId) {
+      ElMessage.warning('暂无可用部门数据')
       return
     }
 
-    // 设置默认选中的部门(转换后树的第一个节点)
-    // if (deptList.value.length > 0) {
-      // 获取转换后树的第一个节点
-      // const firstRootNode = deptList.value[0]
-
-    // 设置设备部门的默认值
-    formData.value.deptId1 = targetDeptId
-    // 触发设备部门的节点点击事件,加载设备列表
+    formData.deptId = targetDeptId
     const targetDeptNode = findDeptNode(deptList.value, targetDeptId)
-    // 触发设备部门的节点点击事件,加载设备列表
     if (targetDeptNode) {
       await handleDeptDeviceTreeNodeClick(targetDeptNode)
+    } else {
+      await getDeviceList()
     }
-    // } else {
-      // console.warn("部门树数据为空,无法设置默认值")
-    // }
   } catch (error) {
-    console.error("初始化部门树失败:", error)
-    ElMessage.error("加载部门数据失败")
+    ElMessage.error('加载部门数据失败')
   }
 })
+</script>
 
-// 在部门树中查找指定ID的节点
-const findDeptNode = (nodes: Tree[], deptId: number): Tree | null => {
-  for (const node of nodes) {
-    if (node.id === deptId) {
-      return node
-    }
-    if (node.children && node.children.length > 0) {
-      const found = findDeptNode(node.children, deptId)
-      if (found) return found
-    }
-  }
-  return null
+<style lang="scss" scoped>
+.status-config-page {
+  min-height: calc(
+    100vh - 20px - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height)
+  );
+  background: #f5f7fb;
 }
 
-</script>
-
-<style scoped>
-.container {
-  padding: 20px;
+.selection-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 16px;
 }
 
-.card {
-  border: 1px solid #ebeef5;
-  border-radius: 4px;
-  padding: 20px;
-  margin-bottom: 20px;
-  flex: 1;
-  display: flex;
-  flex-direction: column;
+.panel {
   overflow: hidden;
-  background: white;
-  box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1);
-  transition: box-shadow 0.2s;
+  background: var(--el-bg-color);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 6px;
+  box-shadow: 0 8px 24px rgb(15 35 70 / 5%);
 }
 
-.list-item {
-  padding: 8px 12px;
-  border-bottom: 1px solid #f0f0f0;
+.panel-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  min-height: 56px;
+  padding: 0 18px;
+  background: #f4f9ff;
+  border-bottom: 1px solid var(--el-border-color-lighter);
 }
 
-.col-wrapper {
+.panel-title {
   display: flex;
-  flex-direction: column;
+  align-items: center;
+  font-size: 16px;
+  font-weight: 600;
+  color: var(--el-text-color-primary);
 }
 
-.dept-select {
-  margin-bottom: 20px;
+.panel-marker {
+  width: 4px;
+  height: 18px;
+  margin-right: 10px;
+  background: var(--el-color-primary);
+  border-radius: 4px;
 }
 
-.user-list {
-  margin-bottom: 20px;
-  border: 1px solid #ebeef5;
-  border-radius: 4px;
-  padding: 10px;
+.panel-toolbar {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr);
+  gap: 12px;
+  padding: 16px 18px 0;
 }
 
-.action-bar {
-  text-align: right;
+.toolbar-control {
+  width: 100%;
 }
 
-.temp-list {
-  margin-top: 20px;
+.status-form {
+  padding: 18px;
 }
 
-.submit-area {
-  margin-top: 20px;
-  text-align: center;
+.option-scroll {
+  height: 430px;
+  padding: 14px 18px 18px;
 }
 
-h3 {
-  margin: 0 0 15px 0;
-  color: #303133;
+.option-list {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
 }
 
-.radio-item {
-  width: 100%;
-  padding: 8px 12px;
-  border-bottom: 1px solid #f0f0f0;
+.option-item {
+  display: flex;
+  align-items: flex-start;
+  height: auto;
+  min-width: 0;
+  padding: 12px;
+  cursor: pointer;
+  background: var(--el-fill-color-blank);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 6px;
+  transition:
+    border-color 0.2s,
+    background-color 0.2s,
+    box-shadow 0.2s;
+
+  &:hover,
+  &.is-selected {
+    background: #f7fbff;
+    border-color: var(--el-color-primary-light-5);
+    box-shadow: 0 6px 18px rgb(64 158 255 / 10%);
+  }
+
+  :deep(.el-checkbox__input) {
+    margin-top: 2px;
+  }
+
+  :deep(.el-checkbox__label) {
+    flex: 1;
+    min-width: 0;
+    padding-left: 10px;
+  }
 }
 
-.radio-item .el-radio {
+.option-content {
+  display: grid;
+  align-items: center;
+  grid-template-columns: minmax(90px, 0.7fr) minmax(120px, 1fr) minmax(150px, 1.2fr);
+  gap: 12px;
   width: 100%;
-  height: 100%;
+  min-width: 0;
 }
 
-.radio-item .el-radio__label {
-  display: block;
-  white-space: nowrap;
+.option-main,
+.option-sub,
+.option-extra {
+  min-width: 0;
   overflow: hidden;
   text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
-.equal-height-row {
-  display: flex;
-  align-items: stretch;
+.option-main {
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--el-text-color-primary);
 }
 
-.el-scrollbar {
-  flex: 1;
+.option-sub,
+.option-extra {
+  font-size: 12px;
+  color: var(--el-text-color-secondary);
 }
 
-.reason-input {
-  margin-top: 20px;
+.record-panel {
+  padding-bottom: 18px;
+  margin-top: 16px;
 }
 
-/* 添加多设备标签样式 */
-.el-tag {
-  margin-left: 8px;
-  vertical-align: middle;
+.record-table {
+  width: calc(100% - 36px);
+  margin: 18px;
 }
 
-.filter-input {
-  margin-bottom: 15px;
+.required-star {
+  margin-left: 4px;
+  color: var(--el-color-danger);
 }
 
-.no-data {
-  padding: 20px;
-  text-align: center;
-  color: #999;
+@media (width <= 1180px) {
+  .selection-grid {
+    grid-template-columns: minmax(0, 1fr);
+  }
 }
 </style>

+ 248 - 78
src/views/pms/device/statuslog/DeviceStatusLogDrawer.vue

@@ -1,61 +1,119 @@
 <template>
   <el-drawer
-    :title="t('configDevice.adjustmentRecords')"
     :append-to-body="true"
     :model-value="modelValue"
-    @update:model-value="$emit('update:modelValue', $event)"
     :show-close="false"
     direction="rtl"
     :size="computedSize"
+    class="status-log-drawer"
     :before-close="handleClose"
-  >
-    <template v-if="deviceId">
-      <div v-loading="loading" style="height: 100%">
-        <el-table :data="deviceStatuses" style="width: 100%">
-          <el-table-column prop="deviceName" :label="t('deviceStatus.deviceName')" width="180" />
-          <el-table-column prop="deviceCode" :label="t('deviceStatus.deviceCode')" width="180" />
-          <el-table-column prop="oldStatus" :label="t('deviceStatus.beforeStatus')" width="180" >
-            <template #default="scope">
-              <dict-tag :type="DICT_TYPE.PMS_DEVICE_STATUS" :value="scope.row.oldStatus" />
-            </template>
-          </el-table-column>
-          <el-table-column prop="newStatus" :label="t('deviceStatus.afterStatus')" width="180" >
-            <template #default="scope">
-              <dict-tag :type="DICT_TYPE.PMS_DEVICE_STATUS" :value="scope.row.newStatus" />
-            </template>
-          </el-table-column>
-          <el-table-column prop="reason" :label="t('configDevice.reasonForAdjustment')" width="180" />
-          <el-table-column prop="creatorName" :label="t('deviceStatus.adjuster')" width="180" />
-          <el-table-column
-            :label="t('deviceStatus.adjustTime')"
-            align="center"
-            prop="createTime"
-            width="180"
-            :formatter="dateFormatter"
-          />
-        </el-table>
-        <!-- 分页 -->
-        <Pagination
-          :total="total"
-          v-model:page="queryParams.pageNo"
-          v-model:limit="queryParams.pageSize"
-          @pagination="handlePagination"
-        />
+    @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:sort" :size="22" />
+          </div>
+          <div>
+            <div class="drawer-title">{{ t('configDevice.adjustmentRecords') }}</div>
+            <div class="drawer-subtitle">查看设备状态调整前后的变化记录</div>
+          </div>
+        </div>
+        <el-button circle size="default" @click="handleClose">
+          <Icon icon="ep:close" />
+        </el-button>
       </div>
     </template>
 
+    <template v-if="deviceId">
+      <div v-loading="loading" class="drawer-body">
+        <section class="info-card record-card">
+          <div class="info-card__header">
+            <div class="info-card__title">
+              <span class="info-card__marker"></span>
+              <span>{{ t('configDevice.adjustmentRecords') }}</span>
+            </div>
+            <span class="record-total">共 {{ total }} 条</span>
+          </div>
+
+          <div class="info-card__body record-card__body">
+            <ZmTable
+              :data="deviceStatuses"
+              :loading="loading"
+              :show-border="true"
+              settings-cache-key="pms-device-status-log"
+              class="record-table">
+              <ZmTableColumn
+                prop="deviceName"
+                :label="t('deviceStatus.deviceName')"
+                min-width="170" />
+              <ZmTableColumn
+                prop="deviceCode"
+                :label="t('deviceStatus.deviceCode')"
+                min-width="150" />
+              <ZmTableColumn
+                prop="oldStatus"
+                :label="t('deviceStatus.beforeStatus')"
+                min-width="150">
+                <template #default="{ row }">
+                  <dict-tag :type="DICT_TYPE.PMS_DEVICE_STATUS" :value="row.oldStatus" />
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn
+                prop="newStatus"
+                :label="t('deviceStatus.afterStatus')"
+                min-width="150">
+                <template #default="{ row }">
+                  <dict-tag :type="DICT_TYPE.PMS_DEVICE_STATUS" :value="row.newStatus" />
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn
+                prop="reason"
+                :label="t('configDevice.reasonForAdjustment')"
+                min-width="220"
+                align="left" />
+              <ZmTableColumn
+                prop="creatorName"
+                :label="t('deviceStatus.adjuster')"
+                min-width="130" />
+              <ZmTableColumn
+                :label="t('deviceStatus.adjustTime')"
+                prop="createTime"
+                min-width="170"
+                :formatter="dateFormatter" />
+            </ZmTable>
+
+            <Pagination
+              v-show="total > 0"
+              :total="total"
+              v-model:page="queryParams.pageNo"
+              v-model:limit="queryParams.pageSize"
+              @pagination="handlePagination" />
+          </div>
+        </section>
+      </div>
+    </template>
   </el-drawer>
 </template>
-<script setup lang="ts">
 
-import { ref, watch, defineOptions, defineEmits, toRefs } from 'vue'
+<script setup lang="ts">
+import { ref } from 'vue'
 import { ElMessage } from 'element-plus'
-import * as IotDeviceStatusLogApi from '@/api/pms/iotdevicestatuslog'
-import {dateFormatter} from "@/utils/formatTime";
-import {DICT_TYPE} from "@/utils/dict";
-const drawerVisible = ref<boolean>(false)
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+import { IotDeviceStatusLogApi, type IotDeviceStatusLogVO } from '@/api/pms/iotdevicestatuslog'
+import { DICT_TYPE } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+
+type DeviceStatusLogRow = IotDeviceStatusLogVO & {
+  deviceName?: string
+  deviceCode?: string
+  creatorName?: string
+  createTime?: string
+}
+
+const { t } = useI18n()
+const { ZmTable, ZmTableColumn } = useTableComponents<DeviceStatusLogRow>()
 const emit = defineEmits(['update:modelValue', 'add', 'delete'])
-const { t } = useI18n() // 国际化
 
 defineOptions({
   name: 'DeviceStatusLogDrawer'
@@ -65,53 +123,40 @@ const queryParams = reactive({
   pageNo: 1,
   pageSize: 10,
   createTime: [],
-  deviceId: '',
+  deviceId: undefined as number | undefined,
   name: '',
   code: ''
 })
 
 const windowWidth = ref(window.innerWidth)
-// 动态计算百分比
 const computedSize = computed(() => {
+  if (windowWidth.value <= 768) return '100%'
   return windowWidth.value > 1200 ? '60%' : '80%'
 })
 
 const loading = ref(false)
-const total = ref(0) // 列表的总页数
-const deviceStatuses = ref([])
+const total = ref(0)
+const deviceStatuses = ref<DeviceStatusLogRow[]>([])
 
 const props = defineProps({
   modelValue: Boolean,
   deviceId: Number
 })
 
-// 使用 toRefs 解构响应式属性
-const { modelValue, deviceId } = toRefs(props)
-
-// 监听抽屉打开事件
-watch(modelValue, (isOpen) => {
-  if (isOpen && deviceId.value) {
-    loadDeviceStatuses(deviceId.value)
-  }
-})
-
-// 监听bom树节点ID变化
-watch(() => props.deviceId, async (newVal) => {
-  if (newVal) {
-    await loadDeviceStatuses(newVal)
-  }
-})
+const updateWindowWidth = () => {
+  windowWidth.value = window.innerWidth
+}
 
-// 加载设备的状态调整记录
-const loadDeviceStatuses = async (deviceId: number) => {
+const loadDeviceStatuses = async (deviceId: number, resetPage = true) => {
   queryParams.deviceId = deviceId
-  queryParams.pageNo = 1
+  if (resetPage) {
+    queryParams.pageNo = 1
+  }
   try {
     loading.value = true
-    // API调用
-    const data = await IotDeviceStatusLogApi.IotDeviceStatusLogApi.getIotDeviceStatusLogPage(queryParams)
-    deviceStatuses.value = data.list
-    total.value = data.total
+    const data = await IotDeviceStatusLogApi.getIotDeviceStatusLogPage(queryParams)
+    deviceStatuses.value = data.list || []
+    total.value = data.total || 0
   } catch (error) {
     ElMessage.error('数据加载失败')
   } finally {
@@ -119,33 +164,158 @@ const loadDeviceStatuses = async (deviceId: number) => {
   }
 }
 
-// 修改分页处理
 const handlePagination = (pagination: any) => {
   queryParams.pageNo = pagination.page
   queryParams.pageSize = pagination.limit
   if (props.deviceId) {
-    loadDeviceStatuses(props.deviceId)
+    loadDeviceStatuses(props.deviceId, false)
   }
 }
 
-// 打开抽屉
 const openDrawer = () => {
-  drawerVisible.value = true
+  emit('update:modelValue', true)
 }
 
-// 关闭抽屉
 const closeDrawer = () => {
-  drawerVisible.value = false
+  handleClose()
 }
 
-// 关闭抽屉
 const handleClose = () => {
   emit('update:modelValue', false)
   deviceStatuses.value = []
+  total.value = 0
 }
 
-defineExpose({ openDrawer, closeDrawer, loadDeviceStatuses }) // 暴露方法给父组件
+defineExpose({ openDrawer, closeDrawer, loadDeviceStatuses })
 
+onMounted(() => {
+  window.addEventListener('resize', updateWindowWidth)
+})
+
+onUnmounted(() => {
+  window.removeEventListener('resize', updateWindowWidth)
+})
 </script>
 
-<style lang="scss" scoped></style>
+<style lang="scss" scoped>
+.drawer-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  width: 100%;
+  padding-right: 4px;
+}
+
+.drawer-header__main {
+  display: flex;
+  align-items: center;
+  min-width: 0;
+}
+
+.drawer-header__icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 42px;
+  height: 42px;
+  margin-right: 12px;
+  color: var(--el-color-primary);
+  background: var(--el-color-primary-light-9);
+  border: 1px solid var(--el-color-primary-light-7);
+  border-radius: 6px;
+}
+
+.drawer-title {
+  font-size: 18px;
+  font-weight: 600;
+  line-height: 24px;
+  color: var(--el-text-color-primary);
+}
+
+.drawer-subtitle {
+  margin-top: 4px;
+  font-size: 13px;
+  color: var(--el-text-color-secondary);
+}
+
+.drawer-body {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+  min-height: 100%;
+}
+
+.info-card {
+  overflow: hidden;
+  background: var(--el-bg-color);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 6px;
+}
+
+.info-card__header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  min-height: 56px;
+  padding: 0 20px;
+  background: #f4f9ff;
+  border-bottom: 1px solid var(--el-border-color-lighter);
+}
+
+.info-card__title {
+  display: flex;
+  align-items: center;
+  font-size: 16px;
+  font-weight: 600;
+  color: var(--el-text-color-primary);
+}
+
+.info-card__marker {
+  width: 4px;
+  height: 18px;
+  margin-right: 10px;
+  background: var(--el-color-primary);
+  border-radius: 4px;
+}
+
+.info-card__body {
+  padding: 20px;
+}
+
+.record-card {
+  flex: 1;
+  min-height: 0;
+}
+
+.record-total {
+  font-size: 13px;
+  color: var(--el-text-color-secondary);
+}
+
+.record-card__body {
+  display: flex;
+  flex-direction: column;
+  min-height: 420px;
+}
+
+.record-table {
+  flex: 1;
+  width: 100%;
+}
+
+:global(.status-log-drawer .el-drawer__body) {
+  padding: 0 24px 24px;
+}
+
+:global(.status-log-drawer .el-drawer__header) {
+  padding: 24px;
+  margin-bottom: 0;
+}
+
+@media (width <= 768px) {
+  :global(.status-log-drawer .el-drawer__body) {
+    padding: 0 16px 16px;
+  }
+}
+</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>