Преглед изворни кода

feat(lss): implement LSS management feature with API integration and UI

- Added LSS management page with search and pagination functionality.
- Created API for fetching LSS list with pagination support.
- Introduced LSS types and heartbeat status definitions in types.
- Updated router to include LSS management route.
- Enhanced layout to include LSS management in the menu.
- Integrated ElDrawer for LSS details and device management.
- Added sorting and filtering capabilities for LSS data display.
yb пре 1 недеља
родитељ
комит
ad33cce609
6 измењених фајлова са 754 додато и 0 уклоњено
  1. 9 0
      src/api/lss.ts
  2. 1 0
      src/components.d.ts
  3. 1 0
      src/layout/index.vue
  4. 6 0
      src/router/index.ts
  5. 22 0
      src/types/index.ts
  6. 715 0
      src/views/lss/index.vue

+ 9 - 0
src/api/lss.ts

@@ -0,0 +1,9 @@
+import { post } from '@/utils/request'
+import type { IPageResponse, LssDTO, LssListRequest } from '@/types'
+
+// ==================== LSS Admin APIs ====================
+
+// 获取 LSS 列表 (分页)
+export function listLss(params?: LssListRequest): Promise<IPageResponse<LssDTO>> {
+  return post('/admin/lss/list', params || {})
+}

+ 1 - 0
src/components.d.ts

@@ -20,6 +20,7 @@ declare module 'vue' {
     ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
     ElDialog: typeof import('element-plus/es')['ElDialog']
     ElDivider: typeof import('element-plus/es')['ElDivider']
+    ElDrawer: typeof import('element-plus/es')['ElDrawer']
     ElDropdown: typeof import('element-plus/es')['ElDropdown']
     ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
     ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']

+ 1 - 0
src/layout/index.vue

@@ -413,6 +413,7 @@ const menuItems: MenuItem[] = [
   { path: '/', title: '仪表盘', icon: DashboardIcon },
   { path: '/machine', title: '机器管理', icon: MachineIcon },
   { path: '/camera', title: '摄像头管理', icon: CameraIcon },
+  { path: '/lss', title: 'LSS 管理', icon: ConnectionIcon },
   // { path: '/user', title: '用户管理', icon: UserIcon },
   { path: '/cc', title: 'Cloudflare Stream', icon: CloudIcon },
   { path: '/webrtc', title: 'WebRTC 流', icon: ConnectionIcon },

+ 6 - 0
src/router/index.ts

@@ -38,6 +38,12 @@ const routes: RouteRecordRaw[] = [
         component: () => import('@/views/camera/index.vue'),
         meta: { title: '摄像头管理', icon: 'VideoCamera' }
       },
+      {
+        path: 'lss',
+        name: 'LSS',
+        component: () => import('@/views/lss/index.vue'),
+        meta: { title: 'LSS 管理', icon: 'Connection' }
+      },
       {
         path: 'cloud',
         name: 'cloud',

+ 22 - 0
src/types/index.ts

@@ -326,3 +326,25 @@ export interface RecordItem {
   secrecy?: number
   type?: string
 }
+
+// ==================== LSS 相关类型 ====================
+
+// LSS 心跳状态
+export type LssHeartbeatStatus = 'active' | 'hold' | 'dead'
+
+// LSS 信息
+export interface LssDTO {
+  id: number
+  lssId: string
+  name: string
+  address: string
+  publicIp: string
+  heartbeat: LssHeartbeatStatus
+  heartbeatTime?: string
+  ablyInfo?: string
+}
+
+// LSS 列表请求参数
+export interface LssListRequest extends PageRequest {
+  heartbeat?: LssHeartbeatStatus
+}

+ 715 - 0
src/views/lss/index.vue

@@ -0,0 +1,715 @@
+<template>
+  <div class="page-container">
+    <!-- 搜索表单 -->
+    <div class="search-form">
+      <el-form :model="searchForm" inline data-id="search-form">
+        <el-form-item>
+          <el-input
+            v-model.trim="searchForm.keyword"
+            placeholder="LSS ID / 名称"
+            clearable
+            data-id="search-keyword"
+            @keyup.enter="handleSearch"
+          />
+        </el-form-item>
+        <el-form-item>
+          <el-select v-model="searchForm.heartbeat" placeholder="心跳状态" clearable data-id="search-heartbeat">
+            <el-option label="全部" value="" />
+            <el-option label="active" value="active" />
+            <el-option label="hold" value="hold" />
+            <el-option label="dead" value="dead" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" :icon="Search" data-id="btn-search" @click="handleSearch">
+            {{ t('查询') }}
+          </el-button>
+          <el-button :icon="RefreshRight" data-id="btn-reset" @click="handleReset">{{ t('重置') }}</el-button>
+        </el-form-item>
+      </el-form>
+    </div>
+
+    <!-- 数据表格 -->
+    <div class="table-wrapper">
+      <el-table
+        ref="tableRef"
+        v-loading="loading"
+        :data="sortedList"
+        stripe
+        size="default"
+        data-id="lss-table"
+        height="100%"
+        @sort-change="handleSortChange"
+      >
+        <el-table-column prop="lssId" label="LSS ID" min-width="120" sortable="custom" show-overflow-tooltip />
+        <el-table-column prop="name" :label="t('名称')" min-width="140" sortable="custom" show-overflow-tooltip />
+        <el-table-column prop="address" :label="t('地址')" min-width="180" sortable="custom" show-overflow-tooltip />
+        <el-table-column prop="publicIp" :label="t('公网IP')" min-width="130" sortable="custom" show-overflow-tooltip />
+        <el-table-column prop="heartbeat" :label="t('心跳')" min-width="200" sortable="custom">
+          <template #default="{ row }">
+            <span :class="getHeartbeatClass(row.heartbeat)">
+              {{ formatHeartbeat(row) }}
+            </span>
+          </template>
+        </el-table-column>
+        <el-table-column label="詳情" width="70" align="center">
+          <template #default="{ row }">
+            <el-button type="primary" link :icon="View" @click="handleViewDetail(row)" />
+          </template>
+        </el-table-column>
+        <el-table-column label="設備列表" width="90" align="center">
+          <template #default="{ row }">
+            <el-button type="primary" link :icon="List" @click="handleViewDevices(row)" />
+          </template>
+        </el-table-column>
+        <el-table-column prop="ablyInfo" label="ably信息" min-width="120" show-overflow-tooltip />
+        <el-table-column :label="t('操作')" width="90" align="center" fixed="right">
+          <template #default="{ row }">
+            <el-button type="primary" link :icon="Edit" @click="handleEdit(row)" />
+            <el-button type="danger" link :icon="Delete" @click="handleDelete(row)" />
+          </template>
+        </el-table-column>
+      </el-table>
+    </div>
+
+    <!-- LSS 详情抽屉 -->
+    <el-drawer v-model="detailDrawerVisible" title="LSS 详情" direction="rtl" size="500px" destroy-on-close>
+      <el-descriptions :column="1" border>
+        <el-descriptions-item label="LSS ID">{{ currentLss?.lssId }}</el-descriptions-item>
+        <el-descriptions-item label="名称">{{ currentLss?.name }}</el-descriptions-item>
+        <el-descriptions-item label="地址">{{ currentLss?.address }}</el-descriptions-item>
+        <el-descriptions-item label="公网IP">{{ currentLss?.publicIp }}</el-descriptions-item>
+        <el-descriptions-item label="心跳状态">
+          <span :class="getHeartbeatClass(currentLss?.heartbeat || 'dead')">
+            {{ formatHeartbeat(currentLss) }}
+          </span>
+        </el-descriptions-item>
+        <el-descriptions-item label="ably信息">{{ currentLss?.ablyInfo || '-' }}</el-descriptions-item>
+      </el-descriptions>
+    </el-drawer>
+
+    <!-- 设备列表抽屉 -->
+    <el-drawer v-model="deviceDrawerVisible" title="設備列表" direction="rtl" size="80%" destroy-on-close>
+      <template #header>
+        <div class="drawer-header">
+          <span>設備列表</span>
+          <el-button type="primary" :icon="Plus" size="small" @click="handleAddDevice">新增</el-button>
+        </div>
+      </template>
+      <el-table :data="deviceList" stripe size="default" height="100%">
+        <el-table-column prop="localIp" label="本地IP" min-width="100" />
+        <el-table-column prop="deviceId" label="設備ID" min-width="100" />
+        <el-table-column prop="name" label="名稱" min-width="100" />
+        <el-table-column prop="heartbeat" label="狀態(心跳)" min-width="180">
+          <template #default="{ row }">
+            <span :class="getHeartbeatClass(row.heartbeat)">
+              {{ formatDeviceHeartbeat(row) }}
+            </span>
+          </template>
+        </el-table-column>
+        <el-table-column label="參數配置" width="80" align="center">
+          <template #default="{ row }">
+            <el-link type="primary" @click="handleViewConfig(row)">查看</el-link>
+          </template>
+        </el-table-column>
+        <el-table-column label="運行參數" width="80" align="center">
+          <template #default="{ row }">
+            <el-link type="primary" @click="handleViewRunParams(row)">查看</el-link>
+          </template>
+        </el-table-column>
+        <el-table-column prop="manufacturer" label="廠商" min-width="100" />
+        <el-table-column prop="model" label="型號" min-width="140" />
+        <el-table-column prop="addTime" label="添加時間" min-width="150" />
+        <el-table-column label="設備控制" width="100" align="center">
+          <template #default="{ row }">
+            <el-button type="primary" link :icon="Edit" @click="handleEditDevice(row)" />
+            <el-button type="danger" link :icon="Delete" @click="handleDeleteDevice(row)" />
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-drawer>
+
+    <!-- 分页 -->
+    <div class="pagination-container">
+      <el-pagination
+        v-model:current-page="currentPage"
+        v-model:page-size="pageSize"
+        :page-sizes="[10, 20, 50, 100]"
+        :total="total"
+        layout="total, sizes, prev, pager, next, jumper"
+        background
+        @size-change="handleSizeChange"
+        @current-change="handleCurrentChange"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted, computed } from 'vue'
+import { Search, RefreshRight, Edit, Delete, View, List, Plus } from '@element-plus/icons-vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { listLss } from '@/api/lss'
+import type { LssDTO, LssHeartbeatStatus } from '@/types'
+import dayjs from 'dayjs'
+import { useI18n } from 'vue-i18n'
+
+const { t } = useI18n({ useScope: 'global' })
+
+// 格式化心跳显示
+function formatHeartbeat(row: LssDTO | null | undefined): string {
+  if (!row) return '-'
+  if (row.heartbeat === 'active') {
+    const time = row.heartbeatTime ? dayjs(row.heartbeatTime).format('YY-MM-DD HH:mm:ss') : ''
+    return `active [${time}]`
+  } else if (row.heartbeat === 'hold') {
+    return 'hold(5分鐘內)'
+  } else if (row.heartbeat === 'dead') {
+    return 'dead (超過5分)'
+  }
+  return row.heartbeat || '-'
+}
+
+// 获取心跳状态样式类
+function getHeartbeatClass(status: LssHeartbeatStatus): string {
+  switch (status) {
+    case 'active':
+      return 'heartbeat-active'
+    case 'hold':
+      return 'heartbeat-hold'
+    case 'dead':
+      return 'heartbeat-dead'
+    default:
+      return ''
+  }
+}
+
+const loading = ref(false)
+const lssList = ref<LssDTO[]>([])
+const tableRef = ref()
+
+// 抽屉状态
+const detailDrawerVisible = ref(false)
+const deviceDrawerVisible = ref(false)
+const currentLss = ref<LssDTO | null>(null)
+
+interface DeviceItem {
+  id: number
+  localIp: string
+  deviceId: string
+  name: string
+  heartbeat: LssHeartbeatStatus
+  heartbeatTime?: string
+  manufacturer: string
+  model: string
+  addTime: string
+}
+
+const deviceList = ref<DeviceItem[]>([])
+
+// 排序状态
+const sortState = reactive<{
+  prop: string
+  order: 'ascending' | 'descending' | null
+}>({
+  prop: '',
+  order: null
+})
+
+// 搜索表单
+const searchForm = reactive<{
+  keyword: string
+  heartbeat: LssHeartbeatStatus | ''
+}>({
+  keyword: '',
+  heartbeat: ''
+})
+
+// 分页相关
+const currentPage = ref(1)
+const pageSize = ref(20)
+const total = ref(0)
+
+// 排序后的数据
+const sortedList = computed(() => {
+  const list = [...lssList.value]
+  if (sortState.prop && sortState.order) {
+    list.sort((a, b) => {
+      const aVal = a[sortState.prop as keyof LssDTO]
+      const bVal = b[sortState.prop as keyof LssDTO]
+
+      if (aVal == null && bVal == null) return 0
+      if (aVal == null) return sortState.order === 'ascending' ? -1 : 1
+      if (bVal == null) return sortState.order === 'ascending' ? 1 : -1
+
+      let result = 0
+      if (typeof aVal === 'number' && typeof bVal === 'number') {
+        result = aVal - bVal
+      } else {
+        result = String(aVal).localeCompare(String(bVal))
+      }
+
+      return sortState.order === 'ascending' ? result : -result
+    })
+  }
+  return list
+})
+
+// 模拟测试数据
+const mockData: LssDTO[] = [
+  {
+    id: 1,
+    lssId: 'L001',
+    name: '现场-初台1',
+    address: '西新宿初台3-3-2',
+    publicIp: '10.72.44.56',
+    heartbeat: 'active',
+    heartbeatTime: '2026-01-18T12:33:31',
+    ablyInfo: 'ably-channel-001'
+  },
+  {
+    id: 2,
+    lssId: 'L002',
+    name: '现场-�的谷2',
+    address: '涩谷区道玄坂1-2-3',
+    publicIp: '10.72.44.57',
+    heartbeat: 'active',
+    heartbeatTime: '2026-01-18T12:35:22',
+    ablyInfo: 'ably-channel-002'
+  },
+  {
+    id: 3,
+    lssId: 'L003',
+    name: '现场-新宿3',
+    address: '新宿区歌舞伎町1-1-1',
+    publicIp: '10.72.44.58',
+    heartbeat: 'hold',
+    heartbeatTime: '2026-01-18T12:30:00',
+    ablyInfo: 'ably-channel-003'
+  },
+  {
+    id: 4,
+    lssId: 'L004',
+    name: '现场-池袋4',
+    address: '�的島区南池袋2-5-6',
+    publicIp: '10.72.44.59',
+    heartbeat: 'dead',
+    heartbeatTime: '2026-01-18T12:00:00',
+    ablyInfo: ''
+  },
+  {
+    id: 5,
+    lssId: 'L005',
+    name: '现场-秋叶原5',
+    address: '千代田区外神田4-4-4',
+    publicIp: '10.72.44.60',
+    heartbeat: 'active',
+    heartbeatTime: '2026-01-18T12:36:10',
+    ablyInfo: 'ably-channel-005'
+  },
+  {
+    id: 6,
+    lssId: 'L006',
+    name: '现场-银座6',
+    address: '中央区银座5-5-5',
+    publicIp: '10.72.44.61',
+    heartbeat: 'hold',
+    heartbeatTime: '2026-01-18T12:32:00'
+  },
+  {
+    id: 7,
+    lssId: 'L007',
+    name: '现场-上野7',
+    address: '台东区上野公园7-7',
+    publicIp: '10.72.44.62',
+    heartbeat: 'dead',
+    heartbeatTime: '2026-01-18T11:50:00',
+    ablyInfo: 'ably-channel-007'
+  },
+  {
+    id: 8,
+    lssId: 'L008',
+    name: '现场-品川8',
+    address: '港区高轮3-3-3',
+    publicIp: '10.72.44.63',
+    heartbeat: 'active',
+    heartbeatTime: '2026-01-18T12:36:55',
+    ablyInfo: 'ably-channel-008'
+  }
+]
+
+async function getList() {
+  loading.value = true
+  try {
+    // TODO: 替换为真实 API 调用
+    // const params: Record<string, any> = {
+    //   page: currentPage.value,
+    //   size: pageSize.value
+    // }
+    // if (searchForm.keyword) {
+    //   params.keyword = searchForm.keyword
+    // }
+    // if (searchForm.heartbeat) {
+    //   params.heartbeat = searchForm.heartbeat
+    // }
+    // const res = await listLss(params)
+    // if (res.success) {
+    //   lssList.value = res.data.list
+    //   total.value = res.data.total || 0
+    // }
+
+    // 使用模拟数据
+    await new Promise((resolve) => setTimeout(resolve, 300))
+    let filtered = [...mockData]
+
+    // 关键词过滤
+    if (searchForm.keyword) {
+      const kw = searchForm.keyword.toLowerCase()
+      filtered = filtered.filter(
+        (item) => item.lssId.toLowerCase().includes(kw) || item.name.toLowerCase().includes(kw)
+      )
+    }
+
+    // 心跳状态过滤
+    if (searchForm.heartbeat) {
+      filtered = filtered.filter((item) => item.heartbeat === searchForm.heartbeat)
+    }
+
+    total.value = filtered.length
+    const start = (currentPage.value - 1) * pageSize.value
+    lssList.value = filtered.slice(start, start + pageSize.value)
+  } finally {
+    loading.value = false
+  }
+}
+
+function handleSearch() {
+  currentPage.value = 1
+  getList()
+}
+
+function handleReset() {
+  searchForm.keyword = ''
+  searchForm.heartbeat = ''
+  currentPage.value = 1
+  sortState.prop = ''
+  sortState.order = null
+  getList()
+}
+
+function handleSortChange({ prop, order }: { prop: string; order: 'ascending' | 'descending' | null }) {
+  sortState.prop = prop || ''
+  sortState.order = order
+  getList()
+}
+
+function handleSizeChange(val: number) {
+  pageSize.value = val
+  currentPage.value = 1
+  getList()
+}
+
+function handleCurrentChange(val: number) {
+  currentPage.value = val
+  getList()
+}
+
+function handleViewDetail(row: LssDTO) {
+  currentLss.value = row
+  detailDrawerVisible.value = true
+}
+
+function handleViewDevices(row: LssDTO) {
+  currentLss.value = row
+  // 模拟设备列表数据
+  deviceList.value = [
+    {
+      id: 1,
+      localIp: '192.168.0.64',
+      deviceId: 'CAM-069',
+      name: '攝像頭名稱1',
+      heartbeat: 'active',
+      heartbeatTime: '2026-01-16T14:33:33',
+      manufacturer: 'HIKVISION',
+      model: 'DS-2DE2A404IW-DE3',
+      addTime: '26-01-16 14:33:33'
+    },
+    {
+      id: 2,
+      localIp: '10.0.2.102',
+      deviceId: 'CAM-069',
+      name: '攝像頭名稱1',
+      heartbeat: 'hold',
+      heartbeatTime: '2026-01-16T14:30:00',
+      manufacturer: 'HIKVISION',
+      model: 'DS-2DE2A404IW-DE3',
+      addTime: '26-01-16 14:33:33'
+    },
+    {
+      id: 3,
+      localIp: '10.0.2.102',
+      deviceId: 'CAM-069',
+      name: '攝像頭名稱1',
+      heartbeat: 'dead',
+      heartbeatTime: '2026-01-16T14:00:00',
+      manufacturer: 'HIKVISION',
+      model: 'DS-2DE2A404IW-DE3',
+      addTime: '26-01-16 14:33:33'
+    },
+    {
+      id: 4,
+      localIp: '10.0.2.102',
+      deviceId: 'CAM-069',
+      name: '攝像頭名稱1',
+      heartbeat: 'active',
+      heartbeatTime: '2026-01-16T14:33:33',
+      manufacturer: 'HIKVISION',
+      model: 'DS-2DE2A404IW-DE3',
+      addTime: '26-01-16 14:33:33'
+    },
+    {
+      id: 5,
+      localIp: '10.0.2.102',
+      deviceId: 'CAM-069',
+      name: '攝像頭名稱1',
+      heartbeat: 'hold',
+      heartbeatTime: '2026-01-16T14:30:00',
+      manufacturer: 'HIKVISION',
+      model: 'DS-2DE2A404IW-DE3',
+      addTime: '26-01-16 14:33:33'
+    }
+  ]
+  deviceDrawerVisible.value = true
+}
+
+// 格式化设备心跳显示
+function formatDeviceHeartbeat(row: DeviceItem): string {
+  if (row.heartbeat === 'active') {
+    const time = row.heartbeatTime ? dayjs(row.heartbeatTime).format('YY-MM-DD HH:mm:ss') : ''
+    return `active [${time}]`
+  } else if (row.heartbeat === 'hold') {
+    return 'hold(5分鐘內)'
+  } else if (row.heartbeat === 'dead') {
+    return 'dead (超過5分)'
+  }
+  return row.heartbeat || '-'
+}
+
+function handleAddDevice() {
+  ElMessage.info('新增设备')
+}
+
+function handleViewConfig(row: DeviceItem) {
+  ElMessage.info(`查看参数配置: ${row.deviceId}`)
+}
+
+function handleViewRunParams(row: DeviceItem) {
+  ElMessage.info(`查看运行参数: ${row.deviceId}`)
+}
+
+function handleEditDevice(row: DeviceItem) {
+  ElMessage.info(`编辑设备: ${row.deviceId}`)
+}
+
+async function handleDeleteDevice(row: DeviceItem) {
+  try {
+    await ElMessageBox.confirm(`确定要删除设备 "${row.name}" 吗?`, '提示', {
+      type: 'warning'
+    })
+    ElMessage.success('删除成功')
+  } catch (error) {
+    if (error !== 'cancel') {
+      console.error('删除失败', error)
+    }
+  }
+}
+
+function handleEdit(row: LssDTO) {
+  // TODO: 实现编辑功能
+  ElMessage.info(`编辑 ${row.name}`)
+}
+
+async function handleDelete(row: LssDTO) {
+  try {
+    await ElMessageBox.confirm(`确定要删除 LSS "${row.name}" 吗?`, '提示', {
+      type: 'warning'
+    })
+    // TODO: 调用删除 API
+    ElMessage.success('删除成功')
+    getList()
+  } catch (error) {
+    if (error !== 'cancel') {
+      console.error('删除失败', error)
+    }
+  }
+}
+
+onMounted(() => {
+  getList()
+})
+</script>
+
+<style lang="scss" scoped>
+.page-container {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  padding: 1rem;
+  box-sizing: border-box;
+  overflow: hidden;
+}
+
+.search-form {
+  flex-shrink: 0;
+  margin-bottom: 16px;
+  padding: 16px 16px 4px 16px;
+  background: #f5f7fa;
+
+  :deep(.el-form-item) {
+    margin-bottom: 12px;
+    margin-right: 16px;
+  }
+
+  :deep(.el-input),
+  :deep(.el-select) {
+    width: 160px;
+  }
+
+  :deep(.el-button--primary) {
+    background-color: #4f46e5;
+    border-color: #4f46e5;
+
+    &:hover,
+    &:focus {
+      background-color: #6366f1;
+      border-color: #6366f1;
+    }
+  }
+}
+
+.table-wrapper {
+  flex: 1;
+  min-height: 0;
+  overflow: hidden;
+}
+
+.pagination-container {
+  flex-shrink: 0;
+  display: flex;
+  justify-content: flex-end;
+  padding-top: 16px;
+
+  :deep(.el-pagination) {
+    .el-pager li.is-active {
+      background-color: #4f46e5;
+      color: #fff;
+    }
+
+    .el-pager li:not(.is-active):hover {
+      color: #4f46e5;
+    }
+
+    .btn-prev:hover,
+    .btn-next:hover {
+      color: #4f46e5;
+    }
+  }
+}
+
+// 心跳状态样式
+.heartbeat-active {
+  color: #67c23a;
+}
+
+.heartbeat-hold {
+  color: #e6a23c;
+}
+
+.heartbeat-dead {
+  color: #f56c6c;
+}
+
+// 抽屉样式
+.drawer-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  width: 100%;
+
+  span {
+    font-size: 16px;
+    font-weight: 600;
+  }
+
+  :deep(.el-button--primary) {
+    background-color: #4f46e5;
+    border-color: #4f46e5;
+
+    &:hover,
+    &:focus {
+      background-color: #6366f1;
+      border-color: #6366f1;
+    }
+  }
+}
+
+:deep(.el-drawer) {
+  .el-drawer__header {
+    margin-bottom: 0;
+    padding: 16px 20px;
+    border-bottom: 1px solid #e5e7eb;
+  }
+
+  .el-drawer__body {
+    padding: 16px;
+  }
+
+  .el-descriptions {
+    .el-descriptions__label {
+      width: 100px;
+      font-weight: 600;
+    }
+  }
+
+  .el-link--primary {
+    color: #4f46e5;
+
+    &:hover {
+      color: #6366f1;
+    }
+  }
+}
+
+// 表格样式
+:deep(.el-table) {
+  --el-table-row-hover-bg-color: #f0f0ff;
+
+  .el-table__row--striped td.el-table__cell {
+    background-color: #f8f9fc;
+  }
+
+  .el-table__header th {
+    background-color: #f5f7fa;
+    color: #333;
+    font-weight: 600;
+  }
+
+  .el-button--primary.is-link {
+    color: #4f46e5;
+
+    &:hover {
+      color: #6366f1;
+    }
+  }
+
+  .caret-wrapper {
+    .sort-caret.ascending {
+      border-bottom-color: #4f46e5;
+    }
+
+    .sort-caret.descending {
+      border-top-color: #4f46e5;
+    }
+  }
+}
+</style>