Просмотр исходного кода

feat: 优化设备责任人设置与调整记录展示

- 重构设备责任人设置页布局,优化设备和责任人选择交互
- 调整设备列表为单行展示,责任人部门标签支持折叠和限宽提示
- 选择设备后防抖加载责任人列表,减少连续勾选时的重复请求
- 调整记录抽屉改用 ZmTable,并优化抽屉样式和分页加载逻辑
Zimo 1 неделя назад
Родитель
Сommit
1ff78244b9

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