Jelajahi Sumber

系统配置模块机柜管理功能开发

wyn 5 bulan lalu
induk
melakukan
094528bfdb

+ 30 - 0
src/Dashboard.tsx

@@ -9,6 +9,7 @@ import LocationManagement from './components/LocationManagement';
 import IsolationWork from './components/IsolationWork';
 import ProfileSettings from './components/ProfileSettings';
 import CockpitDashboard from './components/CockpitDashboard';
+import LockCabinetDetail from './components/lockCabinet/LockCabinetDetail';
 import { authApi } from './api';
 import { toast } from 'sonner';
 import { env } from './config/env';
@@ -511,8 +512,23 @@ export default function Dashboard() {
   // 过滤后的二级菜单配置(已经根据后端菜单过滤了,包含通知管理)
   const filteredSubMenuConfig = enhancedSubMenuConfig;
 
+
+  // 监听路径变化,处理详情页显示
+  useEffect(() => {
+    // 如果是详情页,设置 activeMenu 为空,直接显示详情页
+    if (location.pathname.startsWith('/lock-cabinet/detail')) {
+      setActiveMenu('');
+    }
+  }, [location.pathname]);
+
   // 根据URL路径初始化菜单状态(仅在首次加载时执行)
   useEffect(() => {
+    // 如果是详情页,设置 activeMenu 为空,直接显示详情页
+    if (location.pathname.startsWith('/lock-cabinet/detail')) {
+      setActiveMenu('');
+      return;
+    }
+    
     // 只在路径不是 /dashboard 时才根据路径初始化(因为应用使用状态管理而非路由)
     // 如果路径是 /dashboard,则使用默认状态
     if (location.pathname === '/dashboard') {
@@ -549,6 +565,10 @@ export default function Dashboard() {
         console.log('设置系统配置子菜单:', { menuKey: 'systemConfig', subMenuKey });
         setActiveMenu('systemConfig');
         setActiveSubMenu(subMenuKey);
+      } else if (menuKey === 'cabinet' || menuKey === 'key' || menuKey === 'padlock' || menuKey === 'portable') {
+        // 硬件管理的子菜单
+        setActiveMenu('hardwareManagement');
+        setActiveSubMenu(menuKey);
       } else if (filteredSubMenuConfig[menuKey] && filteredSubMenuConfig[menuKey].length > 0) {
         // 其他菜单,设置第一个子菜单
         setActiveMenu(menuKey);
@@ -625,6 +645,10 @@ export default function Dashboard() {
                                     key={subItem.key}
                                     onClick={() => {
                                       console.log('点击子菜单:', { menuKey: item.key, subMenuKey: subItem.key });
+                                      // 如果当前在详情页,导航回对应的菜单页面
+                                      if (location.pathname.startsWith('/lock-cabinet/detail')) {
+                                        navigate('/dashboard');
+                                      }
                                       setActiveMenu(item.key);
                                       setActiveSubMenu(subItem.key);
                                       setShowDropdownMenu(null);
@@ -656,6 +680,10 @@ export default function Dashboard() {
                     <button
                       key={item.key}
                       onClick={() => {
+                        // 如果当前在详情页,导航回对应的菜单页面
+                        if (location.pathname.startsWith('/lock-cabinet/detail')) {
+                          navigate('/dashboard');
+                        }
                         setActiveMenu(item.key);
                         setActiveSubMenu(filteredSubMenuConfig[item.key]?.[0]?.key || '');
                       }}
@@ -807,6 +835,8 @@ export default function Dashboard() {
         {/* 主内容区域 */}
         {showProfileSettings ? (
           <ProfileSettings onBack={() => setShowProfileSettings(false)} />
+        ) : location.pathname.startsWith('/lock-cabinet/detail') && (!activeMenu || activeMenu === '') ? (
+          <LockCabinetDetail />
         ) : activeMenu === 'dashboard' ? (
           <CockpitDashboard />
         ) : activeMenu === 'systemConfig' ? (

+ 45 - 0
src/api/Hardware.ts

@@ -1,5 +1,38 @@
 import axiosInstance from '../utils/axios';
 
+// 硬件 VO 类型
+export interface HardwareVO {
+  id?: number;
+  hardwareCode?: string;
+  hardwareName: string;
+  hardwareTypeId?: number;
+  workshopId?: number;
+  enableFlag?: string;
+  remark?: string;
+  createBy?: string;
+  createTime?: Date | string;
+  updateBy?: string;
+  updateTime?: Date | string;
+}
+
+// 分页参数类型
+export interface HardwarePageParam {
+  pageNo?: number;
+  pageSize?: number;
+  hardwareCode?: string;
+  hardwareName?: string;
+  hardwareTypeId?: number;
+  workshopId?: number;
+  enableFlag?: string;
+  [key: string]: any;
+}
+
+// 分页响应类型
+export interface PageResponse<T> {
+  list: T[];
+  total: number;
+}
+
 // 硬件管理 API
 export const hardwareApi = {
   // 获取机柜列表
@@ -11,5 +44,17 @@ export const hardwareApi = {
   getKeys: (params?: any) => {
     return axiosInstance.get('/hardware/keys', { params });
   },
+
+  // 查询硬件列表
+  listHardware: (params: HardwarePageParam): Promise<PageResponse<HardwareVO>> => {
+    return axiosInstance.get('/iscs/hardware/getHardwarePage', { params });
+  },
+
+  // 获取硬件详细信息
+  getHardwareInfo: (id: number): Promise<HardwareVO> => {
+    return axiosInstance.get('/iscs/hardware/selectHardwareById', {
+      params: { id }
+    });
+  },
 };
 

+ 16 - 0
src/api/file.ts

@@ -0,0 +1,16 @@
+import axiosInstance from '../utils/axios';
+
+// 文件上传 API
+export const fileApi = {
+  // 上传文件
+  upload: (file: File): Promise<string> => {
+    const formData = new FormData();
+    formData.append('file', file);
+    return axiosInstance.post<string>('/system/file/upload', formData, {
+      headers: {
+        'Content-Type': 'multipart/form-data',
+      },
+    });
+  },
+};
+

+ 9 - 0
src/api/index.ts

@@ -10,6 +10,9 @@ import { userCharacteristicApi } from './user/characteristic';
 import { systemApi } from './System';
 import { hardwareApi } from './Hardware';
 import { deptApi } from './dept';
+import { lockCabinetApi } from './lockCabinet';
+import { workstationApi } from './workstation';
+import { fileApi } from './file';
 
 // API 响应类型
 export interface ApiResponse<T = any> {
@@ -31,6 +34,9 @@ export { userCharacteristicApi } from './user/characteristic';
 export { systemApi } from './System';
 export { hardwareApi } from './Hardware';
 export { deptApi } from './dept';
+export { lockCabinetApi } from './lockCabinet';
+export { workstationApi } from './workstation';
+export { fileApi } from './file';
 
 // 为了兼容旧代码,导出 authApi 作为 loginApi 的别名
 export { loginApi as authApi } from './Login';
@@ -51,5 +57,8 @@ export default {
   system: systemApi,
   hardware: hardwareApi,
   dept: deptApi,
+  lockCabinet: lockCabinetApi,
+  workstation: workstationApi,
+  file: fileApi,
 };
 

+ 68 - 0
src/api/lockCabinet/index.ts

@@ -0,0 +1,68 @@
+import axiosInstance from '../../utils/axios';
+
+// 分页参数类型
+export interface PageParam {
+  pageNo?: number;
+  pageSize?: number;
+  cabinetName?: string;
+  isOnline?: string;
+  status?: string;
+  [key: string]: any;
+}
+
+// 机柜 VO 类型
+export interface LockCabinetVO {
+  cabinetId?: number;
+  cabinetCode?: string;
+  cabinetName: string;
+  workstationId?: number;
+  workstationName?: string;
+  hardwareId?: number;
+  serialNumber?: string;
+  isOnline?: string;
+  status?: string;
+  cabinetIcon?: string;
+  cabinetPicture?: string;
+  remark?: string;
+  createTime?: Date | string;
+  createBy?: string;
+  updateTime?: Date | string;
+  updateBy?: string;
+}
+
+// 分页响应类型
+export interface PageResponse<T> {
+  list: T[];
+  total: number;
+}
+
+// 机柜管理 API
+export const lockCabinetApi = {
+  // 查询机柜列表
+  getIsLockCabinetPage: (params: PageParam): Promise<PageResponse<LockCabinetVO>> => {
+    return axiosInstance.get('/iscs/lock-cabinet/getLockCabinetPage', { params });
+  },
+
+  // 获取机柜详细信息
+  selectIsLockCabinetById: (id: number): Promise<LockCabinetVO> => {
+    return axiosInstance.get('/iscs/lock-cabinet/selectLockCabinetById', {
+      params: { id }
+    });
+  },
+
+  // 新增机柜
+  insertIsLockCabinet: (data: LockCabinetVO): Promise<void> => {
+    return axiosInstance.post('/iscs/lock-cabinet/insertLockCabinet', data);
+  },
+
+  // 修改机柜信息
+  updateIsLockCabinet: (data: LockCabinetVO): Promise<void> => {
+    return axiosInstance.put('/iscs/lock-cabinet/updateLockCabinet', data);
+  },
+
+  // 删除机柜信息
+  deleteIsLockCabinetByCabinetIds: (ids: string): Promise<void> => {
+    return axiosInstance.delete(`/iscs/lock-cabinet/deleteLockCabinetList?ids=${ids}`);
+  },
+};
+

+ 68 - 0
src/api/lockCabinet/slots.ts

@@ -0,0 +1,68 @@
+import axiosInstance from '../../utils/axios';
+
+// 仓位 VO 类型
+export interface LockCabinetSlotVO {
+  slotId?: number;
+  cabinetId?: number;
+  slotCode?: string;
+  slotName?: string;
+  slotType?: string;
+  row?: number;
+  col?: number;
+  isOccupied?: string;
+  hardwareId?: number;
+  status?: string;
+  remark?: string;
+  createTime?: Date | string;
+  createBy?: string;
+  updateTime?: Date | string;
+  updateBy?: string;
+}
+
+// 分页参数类型
+export interface SlotPageParam {
+  pageNo?: number;
+  pageSize?: number;
+  slotCode?: string;
+  slotType?: string;
+  status?: string;
+  cabinetId?: number;
+  [key: string]: any;
+}
+
+// 分页响应类型
+export interface PageResponse<T> {
+  list: T[];
+  total: number;
+}
+
+// 仓位管理 API
+export const slotApi = {
+  // 查询仓位列表
+  getIsLockCabinetSlotsPage: (params: SlotPageParam): Promise<PageResponse<LockCabinetSlotVO>> => {
+    return axiosInstance.get('/iscs/lock-cabinet-slots/getLockCabinetSlotsPage', { params });
+  },
+
+  // 获取仓位详细信息
+  selectIsLockCabinetSlotsById: (id: number): Promise<LockCabinetSlotVO> => {
+    return axiosInstance.get('/iscs/lock-cabinet-slots/selectLockCabinetSlotsById', {
+      params: { id }
+    });
+  },
+
+  // 新增仓位
+  insertIsLockCabinetSlots: (data: LockCabinetSlotVO): Promise<void> => {
+    return axiosInstance.post('/iscs/lock-cabinet-slots/insertLockCabinetSlots', data);
+  },
+
+  // 修改仓位信息
+  updateIsLockCabinetSlots: (data: LockCabinetSlotVO): Promise<void> => {
+    return axiosInstance.put('/iscs/lock-cabinet-slots/updateLockCabinetSlots', data);
+  },
+
+  // 删除仓位信息
+  deleteIsLockCabinetSlotsBySlotIds: (ids: string): Promise<void> => {
+    return axiosInstance.delete(`/iscs/lock-cabinet-slots/deleteLockCabinetSlotsList?ids=${ids}`);
+  },
+};
+

+ 23 - 0
src/api/systemAttribute.ts

@@ -0,0 +1,23 @@
+import axiosInstance from '../utils/axios';
+
+// 系统属性 VO 类型
+export interface SystemAttributeVO {
+  sysAttrId?: number;
+  sysAttrName?: string;
+  sysAttrKey?: string;
+  sysAttrType?: string;
+  sysAttrValue?: string;
+  remark?: string;
+  createTime?: Date | string;
+}
+
+// 系统属性 API
+export const systemAttributeApi = {
+  // 根据键名查询配置
+  getIsSystemAttributeByKey: (sysAttrKey: string): Promise<SystemAttributeVO> => {
+    return axiosInstance.get('/iscs/attribute/getSystemAttributeByKey', {
+      params: { sysAttrKey }
+    });
+  },
+};
+

+ 58 - 0
src/api/workstation/index.ts

@@ -0,0 +1,58 @@
+import axiosInstance from '../../utils/axios';
+
+// 分页参数类型
+export interface PageParam {
+  pageNo?: number;
+  pageSize?: number;
+  workstationCode?: string;
+  workstationName?: string;
+  enableFlag?: string;
+  [key: string]: any;
+}
+
+// 岗位 VO 类型
+export interface WorkstationVO {
+  id?: number;
+  workstationId?: number;
+  workstationCode: string;
+  workstationName: string;
+  parentId?: number;
+  enableFlag?: string;
+  remark?: string;
+  createBy?: string;
+  createTime?: Date | string;
+  updateBy?: string;
+  updateTime?: Date | string;
+  children?: WorkstationVO[];
+}
+
+// 岗位管理 API
+export const workstationApi = {
+  // 查询岗位列表
+  listMarsDept: (params: PageParam): Promise<{ list: WorkstationVO[] }> => {
+    return axiosInstance.get('/iscs/workstation/getWorkstationPage', { params });
+  },
+
+  // 查询岗位详细
+  getMarsDept: (id: number): Promise<WorkstationVO> => {
+    return axiosInstance.get('/iscs/workstation/selectWorkstationById', {
+      params: { id }
+    });
+  },
+
+  // 新增岗位
+  addMarsDept: (data: WorkstationVO): Promise<void> => {
+    return axiosInstance.post('/iscs/workstation/insertWorkstation', data);
+  },
+
+  // 修改岗位
+  updateMarsDept: (data: WorkstationVO): Promise<void> => {
+    return axiosInstance.put('/iscs/workstation/updateWorkstation', data);
+  },
+
+  // 删除岗位
+  delMarsDept: (ids: string): Promise<void> => {
+    return axiosInstance.delete(`/iscs/workstation/deleteWorkstationList?ids=${ids}`);
+  },
+};
+

+ 26 - 13
src/components/DepartmentManagement.tsx

@@ -1,5 +1,7 @@
 import React, { useState, useEffect, useRef } from 'react';
 import { Plus, Search, RefreshCw, ChevronRight, ChevronDown, Edit2, Trash2 } from 'lucide-react';
+import { Modal } from 'antd';
+import { ExclamationCircleOutlined } from '@ant-design/icons';
 import { Button } from './ui/button';
 import { Input } from './ui/input';
 import { Label } from './ui/label';
@@ -103,18 +105,29 @@ export default function DepartmentManagement() {
   };
 
   // 删除按钮操作
-  const handleDelete = async (id: number) => {
-    if (!confirm('确定要删除这个部门吗?')) {
-      return;
-    }
-
-    try {
-      await deptApi.deleteDept(id);
-      toast.success('删除成功');
-      await getList();
-    } catch (error: any) {
-      toast.error(error.message || '删除失败');
-    }
+  const handleDelete = async (id: number, name: string) => {
+    Modal.confirm({
+      title: '确认删除',
+      icon: <ExclamationCircleOutlined />,
+      content: (
+        <div>
+          <p>确定要删除部门 <strong>"{name}"</strong> 吗?</p>
+          <p style={{ color: '#ff4d4f', marginTop: '8px' }}>删除后无法恢复,请谨慎操作!</p>
+        </div>
+      ),
+      okText: '确定删除',
+      okType: 'danger',
+      cancelText: '取消',
+      onOk: async () => {
+        try {
+          await deptApi.deleteDept(id);
+          toast.success('删除成功');
+          await getList();
+        } catch (error: any) {
+          toast.error(error.message || '删除失败');
+        }
+      },
+    });
   };
 
   // 获取负责人名称
@@ -180,7 +193,7 @@ export default function DepartmentManagement() {
                   <Button
                     variant="ghost"
                     size="sm"
-                    onClick={() => handleDelete(node.id)}
+                    onClick={() => handleDelete(node.id, (node as any).name || '未命名部门')}
                     className="h-8 px-2 text-red-600 hover:text-red-700"
                   >
                     <Trash2 className="w-4 h-4"/>

+ 7 - 17
src/components/HardwareManagement.tsx

@@ -1,5 +1,6 @@
 import React, { useState } from 'react';
 import { Plus, Search, Edit2, Trash2, MoreVertical, Package, Key, Lock, Briefcase, QrCode, MapPin } from 'lucide-react';
+import HardwareLockCabinetManagement from './lockCabinet/HardwareLockCabinetManagement';
 
 interface TableRow {
   id: number;
@@ -259,25 +260,14 @@ export default function HardwareManagement({ subMenu }: HardwareManagementProps)
     },
   ];
 
+  // 机柜管理使用专用组件
+  if (subMenu === '机柜' || subMenu === 'cabinet') {
+    return <HardwareLockCabinetManagement key="hardware-lock-cabinet-management" />;
+  }
+
   // 根据当前子菜单获取数据和列配置
   const getTableConfig = () => {
-    if (subMenu === '机柜') {
-      return {
-        data: cabinetData,
-        columns: [
-          { key: 'code', label: '编号', width: '8%' },
-          { key: 'name', label: '名称', width: '12%' },
-          { key: 'location', label: '位置', width: '10%' },
-          { key: 'type', label: '类型', width: '8%' },
-          { key: 'voltage', label: '电压', width: '8%' },
-          { key: 'capacity', label: '容量', width: '8%' },
-          { key: 'status', label: '状态', width: '8%' },
-          { key: 'lockCount', label: '锁点数', width: '6%' },
-          { key: 'manufacturer', label: '厂商', width: '10%' },
-          { key: 'lastCheck', label: '最后检查', width: '12%' },
-        ],
-      };
-    } else if (subMenu === '钥匙') {
+    if (subMenu === '钥匙') {
       return {
         data: keyData,
         columns: [

+ 7 - 0
src/components/SystemConfig.tsx

@@ -4,6 +4,7 @@ import DepartmentManagement from './DepartmentManagement';
 import MenuManagement from './MenuManagement';
 import PostManagement from './PostManagement';
 import RoleManagement from './RoleManagement';
+import SystemLockCabinetManagement from './lockCabinet/SystemLockCabinetManagement';
 
 interface TableRow {
   id: number;
@@ -743,6 +744,12 @@ export default function SystemConfig({ subMenu }: SystemConfigProps) {
     return <DepartmentManagement key="department-management" />;
   }
 
+  // 机柜管理
+  if (subMenu === '机柜管理' || subMenu === 'cabinetManagement' || subMenu === 'cabinet') {
+    console.log('渲染机柜管理组件,subMenu:', subMenu);
+    return <SystemLockCabinetManagement key="system-lock-cabinet-management" />;
+  }
+
   // 其他菜单使用表格展示
   return (
     <div className="space-y-6">

+ 332 - 0
src/components/lockCabinet/HardwareLockCabinetManagement.tsx

@@ -0,0 +1,332 @@
+import React, { useState, useEffect } from 'react';
+import { Search, RefreshCw, Eye } from 'lucide-react';
+import { lockCabinetApi, LockCabinetVO, PageParam } from '../../api/lockCabinet';
+import { toast } from 'sonner';
+import { dateFormatter } from '../../utils/formatTime';
+import { DICT_TYPE, getDictLabel } from '../../utils/dict';
+import { Table, Button as AntButton, Image } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import { Button } from '../ui/button';
+import { Input } from '../ui/input';
+import { Label } from '../ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
+import { useNavigate } from 'react-router-dom';
+
+export default function HardwareLockCabinetManagement() {
+  const navigate = useNavigate();
+  const [loading, setLoading] = useState(true);
+  const [list, setList] = useState<LockCabinetVO[]>([]);
+  const [total, setTotal] = useState(0);
+  const [queryParams, setQueryParams] = useState<PageParam>({
+    pageNo: 1,
+    pageSize: 10,
+    cabinetName: undefined,
+    isOnline: undefined,
+  });
+  const [isOnlineOptions] = useState(() => {
+    try {
+      return require('../../utils/dict').getStrDictOptions(DICT_TYPE.ISONLINE_STATUS);
+    } catch {
+      return [];
+    }
+  });
+
+  // 获取机柜列表
+  const getList = async (params?: PageParam) => {
+    const currentParams = params || queryParams;
+    // 过滤掉 undefined 的参数
+    const cleanParams: PageParam = {};
+    Object.keys(currentParams).forEach(key => {
+      if (currentParams[key] !== undefined && currentParams[key] !== null && currentParams[key] !== '') {
+        cleanParams[key] = currentParams[key];
+      }
+    });
+    setLoading(true);
+    try {
+      console.log('HardwareLockCabinetManagement: 开始获取机柜列表', cleanParams);
+      const response = await lockCabinetApi.getIsLockCabinetPage(cleanParams);
+      console.log('HardwareLockCabinetManagement: API 响应', response);
+      
+      // 处理响应数据 - 兼容不同的响应格式
+      let data;
+      if (response && typeof response === 'object') {
+        // 如果响应有 data 属性,使用 data;否则直接使用 response
+        data = (response as any).data !== undefined ? (response as any).data : response;
+      } else {
+        data = response;
+      }
+      
+      // 确保 data 是对象
+      if (!data || typeof data !== 'object') {
+        console.warn('HardwareLockCabinetManagement: 响应数据格式异常', data);
+        setList([]);
+        setTotal(0);
+        return;
+      }
+      
+      setList(data.list || []);
+      setTotal(data.total || 0);
+      console.log('HardwareLockCabinetManagement: 设置列表数据', data.list, '总数', data.total);
+    } catch (error: any) {
+      console.error('HardwareLockCabinetManagement: 获取机柜列表失败', error);
+      console.error('错误详情:', {
+        message: error?.message,
+        response: error?.response,
+        status: error?.response?.status,
+        data: error?.response?.data,
+        code: error?.response?.data?.code,
+        config: error?.config,
+        url: error?.config?.url
+      });
+      setList([]);
+      setTotal(0);
+      
+      // 显示更详细的错误信息
+      let errorMessage = '获取机柜列表失败';
+      if (error?.response?.data?.message) {
+        errorMessage = error.response.data.message;
+      } else if (error?.message) {
+        errorMessage = error.message;
+      } else if (error?.response?.data) {
+        // 如果响应数据存在但没有 message,尝试显示整个响应
+        errorMessage = `请求失败: ${JSON.stringify(error.response.data)}`;
+      }
+      
+      // 只在非静默模式下显示错误(避免重复显示)
+      if (!error?.silent) {
+        toast.error(errorMessage);
+      }
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  // 组件挂载和分页参数变化时获取数据
+  useEffect(() => {
+    getList();
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [queryParams.pageNo, queryParams.pageSize]);
+
+  // 搜索
+  const handleQuery = () => {
+    const newParams = { ...queryParams, pageNo: 1 };
+    setQueryParams(newParams);
+    getList(newParams);
+  };
+
+  // 重置搜索
+  const resetQuery = () => {
+    const resetParams: PageParam = {
+      pageNo: 1,
+      pageSize: 10,
+      cabinetName: undefined,
+      isOnline: undefined,
+    };
+    setQueryParams(resetParams);
+    getList(resetParams);
+  };
+
+  // 查看详情
+  const lookDetail = (row: LockCabinetVO) => {
+    navigate(`/hw/lockCabinet/lookDetail?id=${row.cabinetId}`);
+  };
+
+  return (
+    <div className="space-y-4">
+      {/* 搜索栏 */}
+      <div className="bg-white rounded-xl border border-gray-200/50 shadow-sm p-5">
+        <div className="flex items-center justify-between gap-4 flex-wrap">
+          <div className="flex items-center gap-3 flex-1 min-w-[200px]">
+            <Label htmlFor="cabinet-name-search" className="text-sm font-medium text-gray-700 whitespace-nowrap">
+              锁柜名称
+            </Label>
+            <Input
+              id="cabinet-name-search"
+              placeholder="请输入锁柜名称"
+              value={queryParams.cabinetName || ''}
+              onChange={(e) => setQueryParams(prev => ({ ...prev, cabinetName: e.target.value }))}
+              onKeyDown={(e) => e.key === 'Enter' && handleQuery()}
+              className="h-10 bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition-all"
+            />
+          </div>
+
+          <div className="flex items-center gap-3 min-w-[200px]">
+            <Label htmlFor="is-online-search" className="text-sm font-medium text-gray-700 whitespace-nowrap">
+              是否在线
+            </Label>
+            <Select
+              value={queryParams.isOnline || 'all'}
+              onValueChange={(value) => setQueryParams(prev => ({ ...prev, isOnline: value === 'all' ? undefined : value }))}
+            >
+              <SelectTrigger className="h-10 bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400">
+                <SelectValue placeholder="请选择是否在线" />
+              </SelectTrigger>
+              <SelectContent>
+                <SelectItem value="all">全部</SelectItem>
+                {isOnlineOptions.map(option => (
+                  <SelectItem key={option.value} value={String(option.value)}>
+                    {option.label}
+                  </SelectItem>
+                ))}
+              </SelectContent>
+            </Select>
+          </div>
+
+          {/* 操作按钮组 */}
+          <div className="flex items-center gap-3">
+            <Button
+              onClick={handleQuery}
+              className="flex items-center gap-2"
+            >
+              <Search className="w-4 h-4" />
+              搜索
+            </Button>
+            
+            <Button
+              variant="outline"
+              onClick={resetQuery}
+              className="flex items-center gap-2"
+            >
+              <RefreshCw className="w-4 h-4" />
+              重置
+            </Button>
+          </div>
+        </div>
+      </div>
+
+      {/* 表格 */}
+      <div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
+        <Table
+          rowKey="cabinetId"
+          loading={loading}
+          dataSource={list}
+          columns={[
+            {
+              title: '锁柜名称',
+              dataIndex: 'cabinetName',
+              key: 'cabinetName',
+              width: 150,
+            },
+            {
+              title: '硬件ID',
+              dataIndex: 'hardwareId',
+              key: 'hardwareId',
+              width: 120,
+              render: (text) => text || '-',
+            },
+            {
+              title: '硬件序列号',
+              dataIndex: 'serialNumber',
+              key: 'serialNumber',
+              width: 150,
+              render: (text) => text || '-',
+            },
+            {
+              title: '岗位',
+              dataIndex: 'workstationName',
+              key: 'workstationName',
+              width: 150,
+              render: (text) => text || '-',
+            },
+            {
+              title: '图片',
+              dataIndex: 'cabinetPicture',
+              key: 'cabinetPicture',
+              width: 100,
+              render: (url: string) => {
+                if (!url) return '-';
+                return (
+                  <Image
+                    src={url}
+                    alt="图片"
+                    width={50}
+                    height={50}
+                    style={{ objectFit: 'cover', cursor: 'pointer' }}
+                    preview={{
+                      mask: '查看',
+                    }}
+                  />
+                );
+              },
+            },
+            {
+              title: '图标',
+              dataIndex: 'cabinetIcon',
+              key: 'cabinetIcon',
+              width: 100,
+              render: (url: string) => {
+                if (!url) return '-';
+                return (
+                  <Image
+                    src={url}
+                    alt="图标"
+                    width={50}
+                    height={50}
+                    style={{ objectFit: 'cover', cursor: 'pointer' }}
+                    preview={{
+                      mask: '查看',
+                    }}
+                  />
+                );
+              },
+            },
+            {
+              title: '是否在线',
+              dataIndex: 'isOnline',
+              key: 'isOnline',
+              width: 100,
+              render: (value: string) => getDictLabel(DICT_TYPE.ISONLINE_STATUS, value) || '-',
+            },
+            {
+              title: '状态',
+              dataIndex: 'status',
+              key: 'status',
+              width: 100,
+              render: (value: string) => {
+                if (value === null || value === undefined) return '-';
+                return getDictLabel(DICT_TYPE.CANBINET_STATUS, value) || '-';
+              },
+            },
+            {
+              title: '备注',
+              dataIndex: 'remark',
+              key: 'remark',
+              width: 150,
+              render: (text) => text || '-',
+            },
+            {
+              title: '创建时间',
+              dataIndex: 'createTime',
+              key: 'createTime',
+              width: 180,
+              render: (text) => text ? dateFormatter(text) : '-',
+            },
+            {
+              title: '详情',
+              key: 'detail',
+              width: 80,
+              align: 'center',
+              render: (_: any, record: LockCabinetVO) => (
+                <AntButton type="link" size="small" onClick={() => lookDetail(record)}>
+                  查看
+                </AntButton>
+              ),
+            },
+          ]}
+          pagination={{
+            current: queryParams.pageNo,
+            pageSize: queryParams.pageSize,
+            total: total,
+            showTotal: (total) => `共 ${total} 条记录`,
+            showSizeChanger: true,
+            showQuickJumper: true,
+            onChange: (page, pageSize) => {
+              setQueryParams(prev => ({ ...prev, pageNo: page, pageSize: pageSize || 10 }));
+            },
+          }}
+        />
+      </div>
+    </div>
+  );
+}
+

+ 26 - 0
src/components/lockCabinet/LockCabinetDetail.tsx

@@ -0,0 +1,26 @@
+import React, { useState } from 'react';
+import { useSearchParams } from 'react-router-dom';
+import { Radio } from 'antd';
+import SlotsList from './SlotsList';
+import MapData from './MapData';
+
+export default function LockCabinetDetail() {
+  const [searchParams] = useSearchParams();
+  const cabinetId = searchParams.get('cabinetId') || '';
+  const [tabPosition, setTabPosition] = useState<'first' | 'second'>('first');
+
+  const currentComponent = tabPosition === 'first' ? <MapData cabinetId={cabinetId} /> : <SlotsList cabinetId={cabinetId} />;
+
+  return (
+    <div className="p-4">
+      <div className="mb-4">
+        <Radio.Group value={tabPosition} onChange={(e) => setTabPosition(e.target.value)}>
+          <Radio.Button value="first">锁柜视图</Radio.Button>
+          <Radio.Button value="second">列表视图</Radio.Button>
+        </Radio.Group>
+      </div>
+      {currentComponent}
+    </div>
+  );
+}
+

+ 316 - 0
src/components/lockCabinet/LockCabinetForm.tsx

@@ -0,0 +1,316 @@
+import React, { useState, useImperativeHandle, forwardRef, useEffect } from 'react';
+import { Modal, Form, Input, Select, Radio, Spin, Row, Col, TreeSelect } from 'antd';
+import { lockCabinetApi, LockCabinetVO } from '../../api/lockCabinet';
+import { hardwareApi } from '../../api/Hardware';
+import { workstationApi, WorkstationVO } from '../../api/workstation';
+import { toast } from 'sonner';
+import { DICT_TYPE, getStrDictOptions } from '../../utils/dict';
+import { handleTree, TreeNode } from '../../utils/tree';
+import UploadImg from './UploadImg';
+
+interface LockCabinetFormProps {
+  onSuccess?: () => void;
+}
+
+export interface LockCabinetFormRef {
+  open: (type: string, id?: number) => void;
+}
+
+const LockCabinetForm = forwardRef<LockCabinetFormRef, LockCabinetFormProps>(({ onSuccess }, ref) => {
+  const [dialogVisible, setDialogVisible] = useState(false);
+  const [dialogTitle, setDialogTitle] = useState('');
+  const [formLoading, setFormLoading] = useState(false);
+  const [formType, setFormType] = useState<'create' | 'update'>('create');
+  const [currentId, setCurrentId] = useState<number | undefined>();
+  const [form] = Form.useForm();
+  const [hardwareOptions, setHardwareOptions] = useState<{ label: string; value: number }[]>([]);
+  const [workstationOptions, setWorkstationOptions] = useState<TreeNode[]>([]);
+  const [isOnlineOptions] = useState(() => getStrDictOptions(DICT_TYPE.ISONLINE_STATUS));
+  const [statusOptions] = useState(() => getStrDictOptions(DICT_TYPE.CANBINET_STATUS));
+
+  // 获取硬件列表
+  const getHardwareList = async () => {
+    try {
+      const response = await hardwareApi.listHardware({ pageNo: 1, pageSize: -1 });
+      const options = response.list.map(item => ({
+        label: item.hardwareName,
+        value: item.id!,
+      }));
+      setHardwareOptions(options);
+    } catch (error) {
+      console.error('获取硬件列表失败:', error);
+    }
+  };
+
+  // 获取岗位列表
+  const getWorkstationList = async () => {
+    try {
+      const response = await workstationApi.listMarsDept({ pageNo: 1, pageSize: -1 });
+      // 过滤掉 id 为 undefined 的项,并转换为 TreeNode 类型
+      const validList = response.list
+        .filter(item => item.id !== undefined)
+        .map(item => ({ ...item, id: item.id! })) as TreeNode[];
+      const treeData = handleTree(validList, 'id', 'parentId');
+      setWorkstationOptions(treeData);
+    } catch (error) {
+      console.error('获取岗位列表失败:', error);
+    }
+  };
+
+  // 当弹窗打开时重置表单(确保 Form 组件已经渲染)
+  useEffect(() => {
+    if (dialogVisible) {
+      // 使用 setTimeout 确保 Form 组件已经渲染完成
+      const timer = setTimeout(() => {
+        form.resetFields();
+      }, 0);
+      return () => clearTimeout(timer);
+    }
+  }, [dialogVisible, form]);
+
+  // 打开弹窗
+  const open = async (type: string, id?: number) => {
+    setDialogVisible(true);
+    setDialogTitle(type === 'create' ? '新增机柜' : '编辑机柜');
+    setFormType(type as 'create' | 'update');
+    setCurrentId(id);
+
+    // 加载选项数据
+    await Promise.all([getHardwareList(), getWorkstationList()]);
+
+    // 修改时,先调详情接口获取数据详情
+    if (id) {
+      setFormLoading(true);
+      try {
+        const data = await lockCabinetApi.selectIsLockCabinetById(id);
+        // 从返回的数据中获取 id(后端可能返回 id 而不是 cabinetId)
+        const cabinetId = (data as any).id || data.cabinetId || id;
+        // 更新 currentId,确保使用从接口返回的 id
+        setCurrentId(cabinetId);
+        // 使用 setTimeout 确保 Form 组件已经渲染完成
+        setTimeout(() => {
+          form.setFieldsValue({
+            cabinetId: cabinetId,
+            cabinetName: data.cabinetName,
+            workstationId: data.workstationId,
+            hardwareId: data.hardwareId,
+            isOnline: data.isOnline || '1',
+            status: data.status || '1',
+            cabinetIcon: data.cabinetIcon || '',
+            cabinetPicture: data.cabinetPicture || '',
+            remark: data.remark || '',
+          });
+        }, 0);
+      } catch (error: any) {
+        toast.error(error.message || '获取机柜信息失败');
+      } finally {
+        setFormLoading(false);
+      }
+    } else {
+      // 使用 setTimeout 确保 Form 组件已经渲染完成
+      setTimeout(() => {
+        form.setFieldsValue({
+          cabinetName: '',
+          workstationId: undefined,
+          hardwareId: undefined,
+          isOnline: '1',
+          status: '1',
+          cabinetIcon: '',
+          cabinetPicture: '',
+          remark: '',
+        });
+      }, 0);
+    }
+  };
+
+  useImperativeHandle(ref, () => ({
+    open,
+  }));
+
+  // 提交表单
+  const submitForm = async () => {
+    try {
+      const values = await form.validateFields();
+      setFormLoading(true);
+      
+      const data: LockCabinetVO = {
+        ...values,
+      };
+
+      if (formType === 'create') {
+        // 创建时移除 cabinetId
+        delete data.cabinetId;
+        await lockCabinetApi.insertIsLockCabinet(data);
+        toast.success('创建成功');
+        setDialogVisible(false);
+        // 延迟调用 onSuccess,确保对话框已关闭
+        setTimeout(() => {
+          onSuccess?.();
+        }, 100);
+      } else {
+        // 编辑时,必须使用从详情接口获取的 currentId
+        if (!currentId) {
+          toast.error('缺少机柜ID,无法更新');
+          setFormLoading(false);
+          return;
+        }
+        // 接口需要的是 id 而不是 cabinetId
+        delete data.cabinetId;
+        (data as any).id = currentId;
+        await lockCabinetApi.updateIsLockCabinet(data);
+        toast.success('更新成功');
+        setDialogVisible(false);
+        // 延迟调用 onSuccess,确保对话框已关闭
+        setTimeout(() => {
+          onSuccess?.();
+        }, 100);
+      }
+    } catch (error: any) {
+      if (error.errorFields) {
+        // 表单验证错误
+        return;
+      }
+      toast.error(error.message || '操作失败');
+    } finally {
+      setFormLoading(false);
+    }
+  };
+
+  // 转换树形数据为TreeSelect格式
+  const convertToTreeSelectData = (nodes: TreeNode[]): any[] => {
+    return nodes.map(node => ({
+      title: (node as any).workstationName || (node as any).name,
+      value: node.id,
+      key: node.id,
+      children: node.children ? convertToTreeSelectData(node.children) : undefined,
+    }));
+  };
+
+  return (
+    <Modal
+      title={dialogTitle}
+      open={dialogVisible}
+      onCancel={() => setDialogVisible(false)}
+      onOk={submitForm}
+      confirmLoading={formLoading}
+      width={650}
+      destroyOnHidden
+    >
+      <Spin spinning={formLoading && formType === 'update'}>
+        <Form
+          form={form}
+          layout="horizontal"
+          labelCol={{ span: 6 }}
+          wrapperCol={{ span: 18 }}
+          initialValues={{
+            isOnline: '1',
+            status: '1',
+          }}
+        >
+          <Form.Item
+            label="锁柜名称"
+            name="cabinetName"
+            rules={[{ required: true, message: '锁柜名称不能为空' }]}
+          >
+            <Input placeholder="请输入锁柜名称" />
+          </Form.Item>
+
+          <Form.Item
+            label="岗位序号"
+            name="workstationId"
+            rules={[{ required: true, message: '岗位序号不能为空' }]}
+          >
+            <TreeSelect
+              treeData={convertToTreeSelectData(workstationOptions)}
+              placeholder="请选择岗位"
+              treeDefaultExpandAll
+            />
+          </Form.Item>
+
+          <Form.Item
+            label="硬件ID"
+            name="hardwareId"
+            rules={[{ required: true, message: '硬件ID不能为空' }]}
+          >
+            <Select
+              placeholder="请选择硬件ID"
+              allowClear
+              options={hardwareOptions}
+            />
+          </Form.Item>
+
+          <Form.Item
+            label="是否在线"
+            name="isOnline"
+          >
+            <Radio.Group>
+              {isOnlineOptions.map(dict => (
+                <Radio key={dict.value} value={dict.value}>
+                  {dict.label}
+                </Radio>
+              ))}
+            </Radio.Group>
+          </Form.Item>
+
+          <Form.Item
+            label="状态"
+            name="status"
+          >
+            <Radio.Group>
+              {statusOptions.map(dict => (
+                <Radio key={dict.value} value={dict.value}>
+                  {dict.label}
+                </Radio>
+              ))}
+            </Radio.Group>
+          </Form.Item>
+
+          <Row gutter={16}>
+            <Col span={12}>
+              <Form.Item
+                label="图标"
+                name="cabinetIcon"
+                valuePropName="value"
+                getValueFromEvent={(value) => value}
+                labelCol={{ span: 12 }}
+                wrapperCol={{ span: 10 }}
+              >
+                <UploadImg
+                  height="64px"
+                  width="64px"
+                />
+              </Form.Item>
+            </Col>
+            <Col span={12}>
+              <Form.Item
+                label="图片"
+                name="cabinetPicture"
+                valuePropName="value"
+                getValueFromEvent={(value) => value}
+                labelCol={{ span: 6 }}
+                wrapperCol={{ span: 18 }}
+              >
+                <UploadImg
+                  height="64px"
+                  width="64px"
+                />
+              </Form.Item>
+            </Col>
+          </Row>
+
+          <Form.Item
+            label="备注"
+            name="remark"
+          >
+            <Input.TextArea placeholder="请输入备注" rows={4} />
+          </Form.Item>
+        </Form>
+      </Spin>
+    </Modal>
+  );
+});
+
+LockCabinetForm.displayName = 'LockCabinetForm';
+
+export default LockCabinetForm;
+

+ 267 - 0
src/components/lockCabinet/MapData.tsx

@@ -0,0 +1,267 @@
+import React, { useState, useEffect, useRef } from 'react';
+import { Modal, message } from 'antd';
+import { Clock } from 'lucide-react';
+import { slotApi, LockCabinetSlotVO, SlotPageParam } from '../../api/lockCabinet/slots';
+import { systemAttributeApi } from '../../api/systemAttribute';
+import { dateFormatter } from '../../utils/formatTime';
+
+interface MapDataProps {
+  cabinetId: string;
+}
+
+export default function MapData({ cabinetId }: MapDataProps) {
+  const canvasRef = useRef<HTMLCanvasElement>(null);
+  const [slotData, setSlotData] = useState<LockCabinetSlotVO[]>([]);
+  const [updateTime, setUpdateTime] = useState<string | null>(null);
+  const [dialogVisible, setDialogVisible] = useState(false);
+  const [errorInfo, setErrorInfo] = useState('');
+  const [cachedImages, setCachedImages] = useState<Record<string, HTMLImageElement>>({});
+  const [cachedResults, setCachedResults] = useState<Record<string, string>>({});
+
+  // 获取数据
+  const getData = async () => {
+    if (!cabinetId) return;
+
+    const params: SlotPageParam = {
+      pageNo: 1,
+      pageSize: -1,
+      cabinetId: Number(cabinetId),
+    };
+
+    try {
+      const response = await slotApi.getIsLockCabinetSlotsPage(params);
+      const slots = response.list || [];
+      setSlotData(slots);
+      if (slots.length > 0 && slots[0].createTime) {
+        setUpdateTime(dateFormatter(slots[0].createTime));
+      }
+
+      // 获取图标配置
+      const icons = [
+        'icon.locker.normal',
+        'icon.locker.out',
+        'icon.padlock.normal',
+        'icon.padlock.out',
+        'icon.locker.exception'
+      ];
+
+      const results: Record<string, string> = {};
+      for (const key of icons) {
+        try {
+          const attr = await systemAttributeApi.getIsSystemAttributeByKey(key);
+          results[key] = attr.sysAttrValue || '';
+        } catch (error) {
+          console.error(`获取图标配置失败: ${key}`, error);
+        }
+      }
+      setCachedResults(results);
+
+      // 预加载图片
+      await preloadImages(results);
+    } catch (error: any) {
+      console.error('获取数据失败:', error);
+      message.error(error.message || '获取数据失败');
+    }
+  };
+
+  // 预加载图片
+  const preloadImages = async (results: Record<string, string>) => {
+    const urls = Object.values(results).filter(url => url);
+    const images: Record<string, HTMLImageElement> = {};
+
+    for (const url of urls) {
+      if (cachedImages[url]) {
+        images[url] = cachedImages[url];
+      } else {
+        try {
+          const img = await loadImage(url);
+          images[url] = img;
+        } catch (error) {
+          console.error(`加载图片失败: ${url}`, error);
+        }
+      }
+    }
+
+    setCachedImages(prev => ({ ...prev, ...images }));
+  };
+
+  // 加载图片
+  const loadImage = (url: string): Promise<HTMLImageElement> => {
+    return new Promise((resolve, reject) => {
+      const img = new Image();
+      img.crossOrigin = 'Anonymous';
+      img.src = url;
+      img.onload = () => resolve(img);
+      img.onerror = reject;
+    });
+  };
+
+  // 绘制图形
+  const drawCanvas = () => {
+    const canvas = canvasRef.current;
+    if (!canvas) return;
+
+    const ctx = canvas.getContext('2d');
+    if (!ctx) return;
+
+    // 清空画布
+    ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+    // 按行分组
+    const grouped: Record<number, LockCabinetSlotVO[]> = {};
+    slotData.forEach(slot => {
+      const row = slot.row || 0;
+      if (!grouped[row]) grouped[row] = [];
+      grouped[row].push(slot);
+    });
+
+    const rows = Object.keys(grouped).map(Number).sort((a, b) => a - b);
+    const startY = 20;
+    const rowHeight = 120;
+    const rowGap = 20;
+    const boxWidth = 860;
+    const centerX = canvas.width / 2;
+    const boxStartX = centerX - boxWidth / 2;
+
+    rows.forEach((rowKey, rowIndex) => {
+      const rowSlots = grouped[rowKey];
+
+      // 绘制行容器
+      ctx.strokeStyle = 'black';
+      ctx.lineWidth = 2;
+      ctx.strokeRect(
+        boxStartX,
+        startY + rowIndex * (rowHeight + rowGap),
+        boxWidth,
+        rowHeight
+      );
+
+      // 绘制仓位
+      rowSlots.forEach((slot, slotIndex) => {
+        const { slotType, isOccupied, status } = slot;
+        let baseKey = '';
+
+        if (slotType === '0') {
+          baseKey = isOccupied === '1' ? 'icon.locker.normal' : 'icon.locker.out';
+        } else {
+          baseKey = isOccupied === '1' ? 'icon.padlock.normal' : 'icon.padlock.out';
+        }
+
+        const baseUrl = cachedResults[baseKey];
+        const baseImg = baseUrl ? cachedImages[baseUrl] : null;
+
+        const width = slotType === '0' ? 110 : 40;
+        const height = 90;
+        const padding = 20;
+        const totalSlots = rowSlots.length;
+        const spacing = totalSlots > 1
+          ? (boxWidth - 2 * padding - totalSlots * width) / (totalSlots - 1)
+          : 0;
+
+        const x = boxStartX + padding + slotIndex * (width + spacing);
+        const y = startY + rowIndex * (rowHeight + rowGap) + (rowHeight - height) / 2;
+
+        // 绘制仓位图标
+        if (baseImg) {
+          ctx.drawImage(baseImg, x, y, width, height);
+        } else {
+          // 如果没有图片,绘制占位矩形
+          ctx.fillStyle = '#f0f0f0';
+          ctx.fillRect(x, y, width, height);
+          ctx.strokeStyle = '#ccc';
+          ctx.lineWidth = 1;
+          ctx.strokeRect(x, y, width, height);
+        }
+
+        // 绘制异常图标
+        if (status === '1') {
+          const exUrl = cachedResults['icon.locker.exception'];
+          const exImg = exUrl ? cachedImages[exUrl] : null;
+          if (exImg) {
+            const exWidth = 30;
+            const exHeight = 30;
+            ctx.drawImage(
+              exImg,
+              x + (width - exWidth) / 2,
+              y + (height - exHeight) / 2,
+              exWidth,
+              exHeight
+            );
+          }
+        }
+
+        // 添加点击事件处理
+        canvas.addEventListener('click', (e) => {
+          const rect = canvas.getBoundingClientRect();
+          const clickX = e.clientX - rect.left;
+          const clickY = e.clientY - rect.top;
+
+          if (clickX >= x && clickX <= x + width && clickY >= y && clickY <= y + height) {
+            if (status === '1') {
+              setErrorInfo(slot.remark || '未知异常');
+              setDialogVisible(true);
+            } else {
+              console.log('点击仓位:', slot);
+            }
+          }
+        });
+      });
+    });
+  };
+
+  useEffect(() => {
+    if (cabinetId) {
+      getData();
+    }
+  }, [cabinetId]);
+
+  useEffect(() => {
+    if (slotData.length > 0 && Object.keys(cachedImages).length > 0) {
+      drawCanvas();
+    }
+  }, [slotData, cachedImages, cachedResults]);
+
+  return (
+    <div className="space-y-4">
+      {/* 时间卡片 */}
+      {updateTime && (
+        <div className="inline-flex items-center gap-3 px-5 py-3 bg-gradient-to-r from-gray-50 to-gray-100 rounded-lg shadow-sm">
+          <Clock className="w-5 h-5 text-blue-500" />
+          <div>
+            <div className="text-xs text-gray-500">最后更新</div>
+            <div className="text-base font-bold text-gray-800">{updateTime}</div>
+          </div>
+        </div>
+      )}
+
+      {/* 画布容器 */}
+      <div className="bg-white rounded-lg shadow-sm p-4 overflow-auto">
+        <canvas
+          ref={canvasRef}
+          width={900}
+          height={1000}
+          className="border border-gray-200"
+        />
+      </div>
+
+      {/* 异常信息对话框 */}
+      <Modal
+        title="异常信息"
+        open={dialogVisible}
+        onCancel={() => setDialogVisible(false)}
+        footer={[
+          <button
+            key="close"
+            onClick={() => setDialogVisible(false)}
+            className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
+          >
+            关闭
+          </button>
+        ]}
+      >
+        <div className="text-center font-bold">{errorInfo}</div>
+      </Modal>
+    </div>
+  );
+}
+

+ 291 - 0
src/components/lockCabinet/SlotForm.tsx

@@ -0,0 +1,291 @@
+import React, { useState, useImperativeHandle, forwardRef, useEffect } from 'react';
+import { Modal, Form, Input, Select, Radio, Spin, InputNumber } from 'antd';
+import { slotApi, LockCabinetSlotVO } from '../../api/lockCabinet/slots';
+import { lockCabinetApi } from '../../api/lockCabinet';
+import { hardwareApi } from '../../api/Hardware';
+import { toast } from 'sonner';
+import { DICT_TYPE, getStrDictOptions } from '../../utils/dict';
+
+interface SlotFormProps {
+  onSuccess?: () => void;
+  cabinetId?: string;
+}
+
+export interface SlotFormRef {
+  open: (type: string, id?: number) => void;
+}
+
+const SlotForm = forwardRef<SlotFormRef, SlotFormProps>(({ onSuccess, cabinetId }, ref) => {
+  const [dialogVisible, setDialogVisible] = useState(false);
+  const [dialogTitle, setDialogTitle] = useState('');
+  const [formLoading, setFormLoading] = useState(false);
+  const [formType, setFormType] = useState<'create' | 'update'>('create');
+  const [currentId, setCurrentId] = useState<number | undefined>();
+  const [form] = Form.useForm();
+  const [cabinetOptions, setCabinetOptions] = useState<{ label: string; value: number }[]>([]);
+  const [hardwareOptions, setHardwareOptions] = useState<{ label: string; value: number }[]>([]);
+  const [slotTypeOptions] = useState(() => getStrDictOptions(DICT_TYPE.SLOT_TYPE));
+  const [isOccupiedOptions] = useState(() => getStrDictOptions(DICT_TYPE.ISOCCUPIED_STATUS));
+  const [statusOptions] = useState(() => getStrDictOptions(DICT_TYPE.SLOT_STATUS));
+
+  // 获取机柜列表
+  const getCabinetList = async () => {
+    try {
+      const response = await lockCabinetApi.getIsLockCabinetPage({ pageNo: 1, pageSize: -1 });
+      const options = response.list.map(item => ({
+        label: item.cabinetName,
+        value: (item as any).id || item.cabinetId!,
+      }));
+      setCabinetOptions(options);
+    } catch (error) {
+      console.error('获取机柜列表失败:', error);
+    }
+  };
+
+  // 获取硬件列表
+  const getHardwareList = async () => {
+    try {
+      const response = await hardwareApi.listHardware({ pageNo: 1, pageSize: -1 });
+      const options = response.list.map(item => ({
+        label: item.hardwareName,
+        value: item.id!,
+      }));
+      setHardwareOptions(options);
+    } catch (error) {
+      console.error('获取硬件列表失败:', error);
+    }
+  };
+
+  // 当弹窗打开时重置表单
+  useEffect(() => {
+    if (dialogVisible) {
+      const timer = setTimeout(() => {
+        form.resetFields();
+      }, 0);
+      return () => clearTimeout(timer);
+    }
+  }, [dialogVisible, form]);
+
+  // 打开弹窗
+  const open = async (type: string, id?: number) => {
+    setDialogVisible(true);
+    setDialogTitle(type === 'create' ? '新增仓位' : '编辑仓位');
+    setFormType(type as 'create' | 'update');
+    setCurrentId(id);
+
+    // 加载选项数据
+    await Promise.all([getCabinetList(), getHardwareList()]);
+
+    // 修改时,先调详情接口获取数据详情
+    if (id) {
+      setFormLoading(true);
+      try {
+        const data = await slotApi.selectIsLockCabinetSlotsById(id);
+        // 从返回的数据中获取 id
+        const slotId = (data as any).id || data.slotId || id;
+        setCurrentId(slotId);
+        // 使用 setTimeout 确保 Form 组件已经渲染完成
+        setTimeout(() => {
+          form.setFieldsValue({
+            slotId: slotId,
+            cabinetId: data.cabinetId || (cabinetId ? Number(cabinetId) : undefined),
+            slotType: data.slotType,
+            row: data.row,
+            col: data.col,
+            isOccupied: data.isOccupied || '0',
+            hardwareId: data.hardwareId,
+            status: data.status || '0',
+            remark: data.remark || '',
+          });
+        }, 0);
+      } catch (error: any) {
+        toast.error(error.message || '获取仓位信息失败');
+      } finally {
+        setFormLoading(false);
+      }
+    } else {
+      // 新增时,如果传入了 cabinetId,自动设置
+      setTimeout(() => {
+        form.setFieldsValue({
+          cabinetId: cabinetId ? Number(cabinetId) : undefined,
+          isOccupied: '0',
+          status: '0',
+        });
+      }, 0);
+    }
+  };
+
+  useImperativeHandle(ref, () => ({
+    open,
+  }));
+
+  // 提交表单
+  const submitForm = async () => {
+    try {
+      const values = await form.validateFields();
+      setFormLoading(true);
+      
+      const data: LockCabinetSlotVO = {
+        ...values,
+      };
+
+      if (formType === 'create') {
+        // 创建时移除 slotId
+        delete data.slotId;
+        await slotApi.insertIsLockCabinetSlots(data);
+        toast.success('创建成功');
+        setDialogVisible(false);
+        setTimeout(() => {
+          onSuccess?.();
+        }, 100);
+      } else {
+        // 编辑时,必须使用从详情接口获取的 currentId
+        if (!currentId) {
+          toast.error('缺少仓位ID,无法更新');
+          setFormLoading(false);
+          return;
+        }
+        // 接口需要的是 id 而不是 slotId
+        delete data.slotId;
+        (data as any).id = currentId;
+        await slotApi.updateIsLockCabinetSlots(data);
+        toast.success('更新成功');
+        setDialogVisible(false);
+        setTimeout(() => {
+          onSuccess?.();
+        }, 100);
+      }
+    } catch (error: any) {
+      if (error.errorFields) {
+        // 表单验证错误
+        return;
+      }
+      toast.error(error.message || '操作失败');
+    } finally {
+      setFormLoading(false);
+    }
+  };
+
+  return (
+    <Modal
+      title={dialogTitle}
+      open={dialogVisible}
+      onCancel={() => setDialogVisible(false)}
+      onOk={submitForm}
+      confirmLoading={formLoading}
+      width={650}
+      destroyOnHidden
+    >
+      <Spin spinning={formLoading && formType === 'update'}>
+        <Form
+          form={form}
+          layout="horizontal"
+          labelCol={{ span: 6 }}
+          wrapperCol={{ span: 18 }}
+          initialValues={{
+            isOccupied: '0',
+            status: '0',
+          }}
+        >
+          <Form.Item
+            label="锁柜序号"
+            name="cabinetId"
+            rules={[{ required: true, message: '锁柜序号不能为空' }]}
+          >
+            <Select
+              placeholder="请选择锁柜"
+              options={cabinetOptions}
+              disabled={!!cabinetId}
+            />
+          </Form.Item>
+
+          <Form.Item
+            label="仓位类型"
+            name="slotType"
+            rules={[{ required: true, message: '仓位类型不能为空' }]}
+          >
+            <Radio.Group>
+              {slotTypeOptions.map(dict => (
+                <Radio key={dict.value} value={dict.value}>
+                  {dict.label}
+                </Radio>
+              ))}
+            </Radio.Group>
+          </Form.Item>
+
+          <Form.Item
+            label="行"
+            name="row"
+          >
+            <InputNumber placeholder="请输入行" style={{ width: '100%' }} min={0} />
+          </Form.Item>
+
+          <Form.Item
+            label="列"
+            name="col"
+          >
+            <InputNumber placeholder="请输入列" style={{ width: '100%' }} min={0} />
+          </Form.Item>
+
+          <Form.Item
+            label="是否被占用"
+            name="isOccupied"
+          >
+            <Radio.Group>
+              {isOccupiedOptions.map(dict => (
+                <Radio key={dict.value} value={dict.value}>
+                  {dict.label}
+                </Radio>
+              ))}
+            </Radio.Group>
+          </Form.Item>
+
+          <Form.Item
+            noStyle
+            shouldUpdate={(prevValues, currentValues) => prevValues.isOccupied !== currentValues.isOccupied}
+          >
+            {({ getFieldValue }) =>
+              getFieldValue('isOccupied') === '1' ? (
+                <Form.Item
+                  label="占用仓位的硬件ID"
+                  name="hardwareId"
+                >
+                  <Select
+                    placeholder="请选择硬件ID"
+                    allowClear
+                    options={hardwareOptions}
+                  />
+                </Form.Item>
+              ) : null
+            }
+          </Form.Item>
+
+          <Form.Item
+            label="状态"
+            name="status"
+          >
+            <Radio.Group>
+              {statusOptions.map(dict => (
+                <Radio key={dict.value} value={dict.value}>
+                  {dict.label}
+                </Radio>
+              ))}
+            </Radio.Group>
+          </Form.Item>
+
+          <Form.Item
+            label="备注"
+            name="remark"
+          >
+            <Input.TextArea placeholder="请输入备注" rows={4} />
+          </Form.Item>
+        </Form>
+      </Spin>
+    </Modal>
+  );
+});
+
+SlotForm.displayName = 'SlotForm';
+
+export default SlotForm;
+

+ 286 - 0
src/components/lockCabinet/SlotsList.tsx

@@ -0,0 +1,286 @@
+import React, { useState, useEffect, useRef } from 'react';
+import { Table, Button as AntButton, Modal, Select, Space, message } from 'antd';
+import { ExclamationCircleOutlined } from '@ant-design/icons';
+import { slotApi, LockCabinetSlotVO, SlotPageParam } from '../../api/lockCabinet/slots';
+import { DICT_TYPE, getIntDictOptions, getDictLabel } from '../../utils/dict';
+import { dateFormatter } from '../../utils/formatTime';
+import type { ColumnsType } from 'antd/es/table';
+import SlotForm, { SlotFormRef } from './SlotForm';
+
+interface SlotsListProps {
+  cabinetId: string;
+}
+
+export default function SlotsList({ cabinetId }: SlotsListProps) {
+  const [loading, setLoading] = useState(true);
+  const [list, setList] = useState<LockCabinetSlotVO[]>([]);
+  const [total, setTotal] = useState(0);
+  const [selectedIds, setSelectedIds] = useState<number[]>([]);
+  const [queryParams, setQueryParams] = useState<SlotPageParam>({
+    pageNo: 1,
+    pageSize: 10,
+    cabinetId: cabinetId ? Number(cabinetId) : undefined,
+    slotType: undefined,
+    status: undefined,
+  });
+  const formRef = useRef<SlotFormRef>(null);
+
+  // 获取仓位列表
+  const getList = async (params?: SlotPageParam) => {
+    const currentParams = params || queryParams;
+    const cleanParams: SlotPageParam = {
+      ...currentParams,
+      cabinetId: cabinetId ? Number(cabinetId) : undefined,
+    };
+    // 过滤掉 undefined 的参数
+    Object.keys(cleanParams).forEach(key => {
+      if (cleanParams[key] === undefined || cleanParams[key] === null || cleanParams[key] === '') {
+        delete cleanParams[key];
+      }
+    });
+    setLoading(true);
+    try {
+      const response = await slotApi.getIsLockCabinetSlotsPage(cleanParams);
+      setList(response.list || []);
+      setTotal(response.total || 0);
+    } catch (error: any) {
+      console.error('获取仓位列表失败:', error);
+      message.error(error.message || '获取仓位列表失败');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    if (cabinetId) {
+      getList();
+    }
+  }, [cabinetId]);
+
+  // 搜索
+  const handleQuery = () => {
+    const newParams = { ...queryParams, pageNo: 1 };
+    setQueryParams(newParams);
+    getList(newParams);
+  };
+
+  // 重置搜索
+  const resetQuery = () => {
+    const resetParams: SlotPageParam = {
+      pageNo: 1,
+      pageSize: 10,
+      cabinetId: cabinetId ? Number(cabinetId) : undefined,
+      slotType: undefined,
+      status: undefined,
+    };
+    setQueryParams(resetParams);
+    getList(resetParams);
+  };
+
+  // 打开表单
+  const openForm = (type: string, id?: number) => {
+    if (formRef.current) {
+      formRef.current.open(type, id);
+    } else {
+      message.error('表单组件未初始化,请刷新页面重试');
+    }
+  };
+
+  // 删除
+  const handleDelete = async (id?: number) => {
+    const ids = id ? [id] : selectedIds;
+    if (ids.length === 0) {
+      message.warning('请选择要删除的数据');
+      return;
+    }
+
+    Modal.confirm({
+      title: '确认删除',
+      icon: <ExclamationCircleOutlined />,
+      content: `确定要删除选中的 ${ids.length} 条数据吗?`,
+      onOk: async () => {
+        try {
+          await slotApi.deleteIsLockCabinetSlotsBySlotIds(ids.join(','));
+          message.success('删除成功');
+          getList();
+          setSelectedIds([]);
+        } catch (error: any) {
+          message.error(error.message || '删除失败');
+        }
+      },
+    });
+  };
+
+  // 表格列定义
+  const columns: ColumnsType<LockCabinetSlotVO> = [
+    {
+      title: '仓位类型',
+      dataIndex: 'slotType',
+      key: 'slotType',
+      render: (text) => getDictLabel(DICT_TYPE.SLOT_TYPE, text) || '-',
+    },
+    {
+      title: '行',
+      dataIndex: 'row',
+      key: 'row',
+    },
+    {
+      title: '列',
+      dataIndex: 'col',
+      key: 'col',
+    },
+    {
+      title: '是否被占用',
+      dataIndex: 'isOccupied',
+      key: 'isOccupied',
+      render: (text) => getDictLabel(DICT_TYPE.ISOCCUPIED_STATUS, text) || '-',
+    },
+    {
+      title: '占用仓位的硬件ID',
+      dataIndex: 'hardwareId',
+      key: 'hardwareId',
+      render: (text) => text || '-',
+    },
+    {
+      title: '状态',
+      dataIndex: 'status',
+      key: 'status',
+      render: (text) => {
+        if (text === null || text === undefined) return '-';
+        return getDictLabel(DICT_TYPE.SLOT_STATUS, text) || '-';
+      },
+    },
+    {
+      title: '备注',
+      dataIndex: 'remark',
+      key: 'remark',
+      render: (text) => text || '-',
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'createTime',
+      key: 'createTime',
+      render: (text) => text ? dateFormatter(text) : '-',
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: 150,
+      align: 'center',
+      render: (_: any, record: LockCabinetSlotVO) => (
+        <div className="flex items-center gap-2 justify-center">
+          <AntButton
+            type="link"
+            size="small"
+            onClick={() => openForm('update', (record as any).slotId || (record as any).id)}
+          >
+            编辑
+          </AntButton>
+          <AntButton
+            type="link"
+            danger
+            size="small"
+            onClick={() => handleDelete((record as any).slotId || (record as any).id)}
+          >
+            删除
+          </AntButton>
+        </div>
+      ),
+    },
+  ];
+
+  // 行选择配置
+  const rowSelection = {
+    selectedRowKeys: selectedIds,
+    onChange: (selectedRowKeys: React.Key[]) => {
+      setSelectedIds(selectedRowKeys as number[]);
+    },
+  };
+
+  const slotTypeOptions = getIntDictOptions(DICT_TYPE.SLOT_TYPE);
+  const statusOptions = getIntDictOptions(DICT_TYPE.SLOT_STATUS);
+
+  return (
+    <div className="space-y-4">
+      {/* 搜索栏 */}
+      <div className="bg-white p-4 rounded-lg shadow-sm">
+        <Space size="middle" wrap>
+          <div className="flex items-center gap-2">
+            <span className="text-sm font-medium">仓位类型:</span>
+            <Select
+              style={{ width: 200 }}
+              placeholder="请选择仓位类型"
+              allowClear
+              value={queryParams.slotType}
+              onChange={(value) => setQueryParams(prev => ({ ...prev, slotType: value }))}
+            >
+              {slotTypeOptions.map(option => (
+                <Select.Option key={option.value} value={option.value}>
+                  {option.label}
+                </Select.Option>
+              ))}
+            </Select>
+          </div>
+          {/* <div className="flex items-center gap-2">
+            <span className="text-sm font-medium">状态:</span>
+            <Select
+              style={{ width: 200 }}
+              placeholder="请选择状态"
+              allowClear
+              value={queryParams.status}
+              onChange={(value) => setQueryParams(prev => ({ ...prev, status: value }))}
+            >
+              {statusOptions.map(option => (
+                <Select.Option key={option.value} value={option.value}>
+                  {option.label}
+                </Select.Option>
+              ))}
+            </Select>
+          </div> */}
+          <AntButton type="primary" onClick={handleQuery}>搜索</AntButton>
+          <AntButton onClick={resetQuery}>重置</AntButton>
+          <AntButton
+            type="primary"
+            onClick={() => openForm('create')}
+          >
+            新增
+          </AntButton>
+          <AntButton
+            danger
+            disabled={selectedIds.length === 0}
+            onClick={() => handleDelete()}
+          >
+            批量删除
+          </AntButton>
+        </Space>
+      </div>
+
+      {/* 表格 */}
+      <div className="bg-white rounded-lg shadow-sm">
+        <Table
+          rowKey={(record) => (record as any).slotId || (record as any).id || `row-${Math.random()}`}
+          loading={loading}
+          dataSource={list}
+          rowSelection={rowSelection}
+          columns={columns}
+          pagination={{
+            current: queryParams.pageNo,
+            pageSize: queryParams.pageSize,
+            total: total,
+            showSizeChanger: true,
+            showTotal: (total) => `共 ${total} 条`,
+            onChange: (page, pageSize) => {
+              const newParams = { ...queryParams, pageNo: page, pageSize };
+              setQueryParams(newParams);
+              getList(newParams);
+            },
+          }}
+        />
+      </div>
+
+      {/* 表单弹窗 */}
+      <SlotForm ref={formRef} onSuccess={getList} cabinetId={cabinetId} />
+    </div>
+  );
+}
+

+ 450 - 0
src/components/lockCabinet/SystemLockCabinetManagement.tsx

@@ -0,0 +1,450 @@
+import React, { useState, useEffect, useRef } from 'react';
+import { Search, Plus, RefreshCw, Edit2, Trash2, Eye } from 'lucide-react';
+import { lockCabinetApi, LockCabinetVO, PageParam } from '../../api/lockCabinet';
+import { toast } from 'sonner';
+import { dateFormatter } from '../../utils/formatTime';
+import { DICT_TYPE, getDictLabel, getStrDictOptions } from '../../utils/dict';
+import { Modal, Table, Button as AntButton, Image, Input, Select, Space } from 'antd';
+import { ExclamationCircleOutlined, SearchOutlined, ReloadOutlined, PlusOutlined, DeleteOutlined } from '@ant-design/icons';
+import type { ColumnsType } from 'antd/es/table';
+import LockCabinetForm, { LockCabinetFormRef } from './LockCabinetForm';
+import { ImageWithFallback } from '../figma/ImageWithFallback';
+import { useNavigate } from 'react-router-dom';
+
+export default function SystemLockCabinetManagement() {
+  const navigate = useNavigate();
+  const [loading, setLoading] = useState(true);
+  const [list, setList] = useState<LockCabinetVO[]>([]);
+  const [total, setTotal] = useState(0);
+  const [selectedIds, setSelectedIds] = useState<number[]>([]);
+  const [queryParams, setQueryParams] = useState<PageParam>({
+    pageNo: 1,
+    pageSize: 10,
+    cabinetName: undefined,
+    isOnline: undefined,
+  });
+  const formRef = useRef<LockCabinetFormRef>(null);
+  const [isOnlineOptions] = useState(() => {
+    try {
+      const options = getStrDictOptions(DICT_TYPE.ISONLINE_STATUS);
+      console.log('是否在线选项:', options);
+      return options || [];
+    } catch (error) {
+      console.error('获取是否在线选项失败:', error);
+      return [];
+    }
+  });
+
+  // 获取机柜列表
+  const getList = async (params?: PageParam) => {
+    const currentParams = params || queryParams;
+    // 过滤掉 undefined 的参数
+    const cleanParams: PageParam = {};
+    Object.keys(currentParams).forEach(key => {
+      if (currentParams[key] !== undefined && currentParams[key] !== null && currentParams[key] !== '') {
+        cleanParams[key] = currentParams[key];
+      }
+    });
+    setLoading(true);
+    try {
+      console.log('SystemLockCabinetManagement: 开始获取机柜列表', cleanParams);
+      const response = await lockCabinetApi.getIsLockCabinetPage(cleanParams);
+      console.log('SystemLockCabinetManagement: API 响应', response);
+      
+      // 处理响应数据 - 兼容不同的响应格式
+      let data;
+      if (response && typeof response === 'object') {
+        // 如果响应有 data 属性,使用 data;否则直接使用 response
+        data = (response as any).data !== undefined ? (response as any).data : response;
+      } else {
+        data = response;
+      }
+      
+      // 确保 data 是对象
+      if (!data || typeof data !== 'object') {
+        console.warn('SystemLockCabinetManagement: 响应数据格式异常', data);
+        setList([]);
+        setTotal(0);
+        return;
+      }
+      
+      setList(data.list || []);
+      setTotal(data.total || 0);
+      console.log('SystemLockCabinetManagement: 设置列表数据', data.list, '总数', data.total);
+      // 调试:打印第一条数据的结构,检查 ID 字段名
+      if (data.list && data.list.length > 0) {
+        console.log('SystemLockCabinetManagement: 第一条数据示例', data.list[0]);
+      }
+    } catch (error: any) {
+      console.error('SystemLockCabinetManagement: 获取机柜列表失败', error);
+      console.error('错误详情:', {
+        message: error?.message,
+        response: error?.response,
+        status: error?.response?.status,
+        data: error?.response?.data,
+        code: error?.response?.data?.code,
+        config: error?.config,
+        url: error?.config?.url
+      });
+      setList([]);
+      setTotal(0);
+      
+      // 显示更详细的错误信息
+      let errorMessage = '获取机柜列表失败';
+      if (error?.response?.data?.message) {
+        errorMessage = error.response.data.message;
+      } else if (error?.message) {
+        errorMessage = error.message;
+      } else if (error?.response?.data) {
+        // 如果响应数据存在但没有 message,尝试显示整个响应
+        errorMessage = `请求失败: ${JSON.stringify(error.response.data)}`;
+      }
+      
+      // 只在非静默模式下显示错误(避免重复显示)
+      if (!error?.silent) {
+        toast.error(errorMessage);
+      }
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  // 组件挂载和分页参数变化时获取数据
+  useEffect(() => {
+    getList();
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [queryParams.pageNo, queryParams.pageSize]);
+
+  // 搜索
+  const handleQuery = () => {
+    const newParams = { ...queryParams, pageNo: 1 };
+    setQueryParams(newParams);
+    getList(newParams);
+  };
+
+  // 重置搜索
+  const resetQuery = () => {
+    const resetParams: PageParam = {
+      pageNo: 1,
+      pageSize: 10,
+      cabinetName: undefined,
+      isOnline: undefined,
+    };
+    setQueryParams(resetParams);
+    getList(resetParams);
+  };
+
+  // 打开表单
+  const openForm = (type: string, id?: number) => {
+    if (formRef.current) {
+      formRef.current.open(type, id);
+    } else {
+      toast.error('表单组件未初始化,请刷新页面重试');
+    }
+  };
+
+  // Ant Design Table 的行选择配置
+  const rowSelection = {
+    selectedRowKeys: selectedIds,
+    onChange: (selectedRowKeys: React.Key[]) => {
+      setSelectedIds(selectedRowKeys as number[]);
+    },
+  };
+
+  // 删除机柜
+  const handleDelete = async (id?: number) => {
+    const ids = id ? [id] : selectedIds;
+    if (ids.length === 0) {
+      toast.error('请选择要删除的数据');
+      return;
+    }
+
+    Modal.confirm({
+      title: '确认删除',
+      icon: <ExclamationCircleOutlined />,
+      content: (
+        <div>
+          <p>确定要删除选中的 {ids.length} 条机柜数据吗?</p>
+          <p style={{ color: '#ff4d4f', marginTop: '8px' }}>删除后无法恢复,请谨慎操作!</p>
+        </div>
+      ),
+      okText: '确定删除',
+      okType: 'danger',
+      cancelText: '取消',
+      onOk: async () => {
+        try {
+          await lockCabinetApi.deleteIsLockCabinetByCabinetIds(ids.join(','));
+          toast.success('删除成功');
+          setSelectedIds([]);
+          await getList();
+        } catch (error: any) {
+          toast.error(error.message || '删除失败');
+        }
+      },
+    });
+  };
+
+  // 查看详情
+  const lookDetail = (row: LockCabinetVO) => {
+    // 使用 id 或 cabinetId,优先使用 id
+    const cabinetId = (row as any).id || row.cabinetId;
+    if (cabinetId) {
+      // 直接使用 navigate 跳转,Dashboard 会检测路径变化
+      navigate(`/lock-cabinet/detail?cabinetId=${cabinetId}`);
+    }
+  };
+
+  return (
+    <div className="space-y-4">
+      {/* 搜索栏 */}
+      <div className="bg-white rounded-xl border border-gray-200/50 shadow-sm p-5">
+        <Space size="middle" wrap>
+          <div className="flex items-center gap-2">
+            <span className="text-sm font-medium text-gray-700 whitespace-nowrap">锁柜名称:</span>
+            <Input
+              placeholder="请输入锁柜名称"
+              value={queryParams.cabinetName || ''}
+              onChange={(e) => setQueryParams(prev => ({ ...prev, cabinetName: e.target.value }))}
+              onPressEnter={handleQuery}
+              style={{ width: 200 }}
+              allowClear
+            />
+          </div>
+
+          <div className="flex items-center gap-2">
+            <span className="text-sm font-medium text-gray-700 whitespace-nowrap">是否在线:</span>
+            <Select
+              placeholder="请选择是否在线"
+              value={queryParams.isOnline || undefined}
+              onChange={(value) => setQueryParams(prev => ({ ...prev, isOnline: value }))}
+              style={{ width: 200 }}
+              allowClear
+            >
+              {isOnlineOptions && isOnlineOptions.length > 0 ? (
+                isOnlineOptions.map(option => (
+                  <Select.Option key={option.value} value={String(option.value)}>
+                    {option.label}
+                  </Select.Option>
+                ))
+              ) : (
+                <>
+                  <Select.Option value="0">离线</Select.Option>
+                  <Select.Option value="1">在线</Select.Option>
+                </>
+              )}
+            </Select>
+          </div>
+
+          {/* 操作按钮组 */}
+          <AntButton
+            type="primary"
+            icon={<SearchOutlined />}
+            onClick={handleQuery}
+          >
+            搜索
+          </AntButton>
+          
+          <AntButton
+            icon={<ReloadOutlined />}
+            onClick={resetQuery}
+          >
+            重置
+          </AntButton>
+
+          <AntButton
+            type="primary"
+            icon={<PlusOutlined />}
+            onClick={() => openForm('create')}
+          >
+            新增
+          </AntButton>
+
+          <AntButton
+            danger
+            icon={<DeleteOutlined />}
+            onClick={handleDelete}
+            disabled={selectedIds.length === 0}
+          >
+            批量删除
+          </AntButton>
+        </Space>
+      </div>
+
+      {/* 表格 */}
+      <div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
+        <Table
+          rowKey={(record) => {
+            // 兼容不同的 ID 字段名
+            const id = record.cabinetId || (record as any).id || (record as any).cabinetId;
+            return id || `row-${Math.random()}`;
+          }}
+          loading={loading}
+          dataSource={list}
+          rowSelection={rowSelection}
+          columns={[
+            {
+              title: '锁柜名称',
+              dataIndex: 'cabinetName',
+              key: 'cabinetName',
+              width: 150,
+            },
+            {
+              title: '硬件ID',
+              dataIndex: 'hardwareId',
+              key: 'hardwareId',
+              width: 120,
+              render: (text) => text || '-',
+            },
+            {
+              title: '硬件序列号',
+              dataIndex: 'serialNumber',
+              key: 'serialNumber',
+              width: 150,
+              render: (text) => text || '-',
+            },
+            {
+              title: '岗位',
+              dataIndex: 'workstationName',
+              key: 'workstationName',
+              width: 150,
+              render: (text) => text || '-',
+            },
+            {
+              title: '图片',
+              dataIndex: 'cabinetPicture',
+              key: 'cabinetPicture',
+              width: 100,
+              render: (url: string) => {
+                if (!url) return '-';
+                return (
+                  <Image
+                    src={url}
+                    alt="图片"
+                    width={50}
+                    height={50}
+                    style={{ objectFit: 'cover', cursor: 'pointer' }}
+                    preview={{
+                      mask: '查看',
+                    }}
+                  />
+                );
+              },
+            },
+            {
+              title: '图标',
+              dataIndex: 'cabinetIcon',
+              key: 'cabinetIcon',
+              width: 100,
+              render: (url: string) => {
+                if (!url) return '-';
+                return (
+                  <Image
+                    src={url}
+                    alt="图标"
+                    width={50}
+                    height={50}
+                    style={{ objectFit: 'cover', cursor: 'pointer' }}
+                    preview={{
+                      mask: '查看',
+                    }}
+                  />
+                );
+              },
+            },
+            {
+              title: '是否在线',
+              dataIndex: 'isOnline',
+              key: 'isOnline',
+              width: 100,
+              render: (value: string) => getDictLabel(DICT_TYPE.ISONLINE_STATUS, value) || '-',
+            },
+            {
+              title: '状态',
+              dataIndex: 'status',
+              key: 'status',
+              width: 100,
+              render: (value: string) => {
+                if (value === null || value === undefined) return '-';
+                return getDictLabel(DICT_TYPE.CANBINET_STATUS, value) || '-';
+              },
+            },
+            {
+              title: '备注',
+              dataIndex: 'remark',
+              key: 'remark',
+              width: 150,
+              render: (text) => text || '-',
+            },
+            {
+              title: '创建时间',
+              dataIndex: 'createTime',
+              key: 'createTime',
+              width: 180,
+              render: (text) => text ? dateFormatter(text) : '-',
+            },
+            {
+              title: '详情',
+              key: 'detail',
+              width: 80,
+              align: 'center',
+              render: (_: any, record: LockCabinetVO) => (
+                <AntButton 
+                  type="link" 
+                  size="small" 
+                  onClick={() => lookDetail(record)}
+                >
+                  查看
+                </AntButton>
+              ),
+            },
+            {
+              title: '操作',
+              key: 'action',
+              width: 150,
+              align: 'center',
+              render: (_: any, record: LockCabinetVO) => (
+                <div className="flex items-center gap-2 justify-center">
+                  <AntButton
+                    type="link"
+                    size="small"
+                    icon={<Edit2 className="w-4 h-4" />}
+                    onClick={() => openForm('update', record.cabinetId || (record as any).id)}
+                  >
+                    编辑
+                  </AntButton>
+                  <AntButton
+                    type="link"
+                    size="small"
+                    danger
+                    icon={<Trash2 className="w-4 h-4" />}
+                    onClick={() => handleDelete(record.cabinetId || (record as any).id)}
+                  >
+                    删除
+                  </AntButton>
+                </div>
+              ),
+            },
+          ]}
+          pagination={{
+            current: queryParams.pageNo,
+            pageSize: queryParams.pageSize,
+            total: total,
+            showTotal: (total) => `共 ${total} 条记录`,
+            showSizeChanger: true,
+            showQuickJumper: true,
+            onChange: (page, pageSize) => {
+              setQueryParams(prev => ({ ...prev, pageNo: page, pageSize: pageSize || 10 }));
+            },
+          }}
+        />
+      </div>
+
+      {/* 表单弹窗 */}
+      <LockCabinetForm ref={formRef} onSuccess={() => {
+        // 编辑成功后刷新列表
+        getList();
+      }} />
+    </div>
+  );
+}
+

+ 183 - 0
src/components/lockCabinet/UploadImg.tsx

@@ -0,0 +1,183 @@
+import React, { useState, useRef, useEffect } from 'react';
+import { Upload, X, ZoomIn, Loader2 } from 'lucide-react';
+import { ImageWithFallback } from '../figma/ImageWithFallback';
+import { fileApi } from '../../api/file';
+import { toast } from 'sonner';
+
+interface UploadImgProps {
+  value?: string;
+  onChange?: (value: string) => void;
+  limit?: number;
+  height?: string;
+  width?: string;
+}
+
+export default function UploadImg({
+  value,
+  onChange,
+  limit = 1,
+  height = '64px',
+  width = '64px',
+}: UploadImgProps) {
+  const [previewUrl, setPreviewUrl] = useState<string | undefined>(value);
+  const [showPreview, setShowPreview] = useState(false);
+  const [uploading, setUploading] = useState(false);
+  const fileInputRef = useRef<HTMLInputElement>(null);
+
+  // 同步外部 value 变化
+  useEffect(() => {
+    if (value) {
+      // 如果 value 是 URL,直接使用
+      // 如果 value 是 data URI,直接使用
+      // 如果 value 是纯 base64 字符串,转换为 data URI 用于预览
+      if (value.startsWith('http://') || value.startsWith('https://')) {
+        setPreviewUrl(value);
+      } else if (value.startsWith('data:')) {
+        setPreviewUrl(value);
+      } else {
+        setPreviewUrl(`data:image/jpeg;base64,${value}`);
+      }
+    } else {
+      setPreviewUrl(undefined);
+    }
+  }, [value]);
+
+  // 处理文件选择
+  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0];
+    if (!file) return;
+
+    // 检查文件类型
+    if (!file.type.startsWith('image/')) {
+      toast.error('请选择图片文件');
+      return;
+    }
+
+    // 检查文件大小 (限制为5MB)
+    if (file.size > 5 * 1024 * 1024) {
+      toast.error('图片大小不能超过5MB');
+      return;
+    }
+
+    // 先创建预览(使用本地 URL)
+    const localPreviewUrl = URL.createObjectURL(file);
+    setPreviewUrl(localPreviewUrl);
+    setUploading(true);
+
+    try {
+      // 上传文件获取 URL
+      const fileUrl = await fileApi.upload(file);
+      // 释放本地预览 URL
+      URL.revokeObjectURL(localPreviewUrl);
+      // 设置服务器返回的 URL 作为预览
+      setPreviewUrl(fileUrl);
+      // 传递 URL 给父组件
+      onChange?.(fileUrl);
+    } catch (error: any) {
+      // 上传失败,清除预览
+      URL.revokeObjectURL(localPreviewUrl);
+      setPreviewUrl(undefined);
+      toast.error(error.message || '图片上传失败');
+      // 清空文件输入
+      if (fileInputRef.current) {
+        fileInputRef.current.value = '';
+      }
+    } finally {
+      setUploading(false);
+    }
+  };
+
+  // 删除图片
+  const handleRemove = (e: React.MouseEvent) => {
+    e.stopPropagation();
+    setPreviewUrl(undefined);
+    onChange?.('');
+    if (fileInputRef.current) {
+      fileInputRef.current.value = '';
+    }
+  };
+
+  // 打开文件选择器
+  const handleClick = () => {
+    fileInputRef.current?.click();
+  };
+
+  return (
+    <div>
+      <div
+        className="relative border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:border-blue-400 transition-colors flex items-center justify-center bg-gray-50"
+        style={{ height, width }}
+        onClick={handleClick}
+      >
+        {uploading ? (
+          <div className="flex flex-col items-center justify-center gap-2 text-gray-400">
+            <Loader2 className="w-6 h-6 animate-spin" />
+            <span className="text-xs">上传中...</span>
+          </div>
+        ) : previewUrl ? (
+          <div className="relative w-full h-full group">
+            <ImageWithFallback
+              src={previewUrl}
+              alt="预览"
+              className="w-full h-full object-cover rounded-lg"
+            />
+            <div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2 rounded-lg">
+              <button
+                onClick={(e) => {
+                  e.stopPropagation();
+                  setShowPreview(true);
+                }}
+                className="p-1 bg-white/80 rounded hover:bg-white transition-colors"
+                title="查看大图"
+              >
+                <ZoomIn className="w-4 h-4 text-gray-700" />
+              </button>
+              <button
+                onClick={handleRemove}
+                className="p-1 bg-white/80 rounded hover:bg-white transition-colors"
+                title="删除"
+              >
+                <X className="w-4 h-4 text-gray-700" />
+              </button>
+            </div>
+          </div>
+        ) : (
+          <div className="flex flex-col items-center justify-center gap-2 text-gray-400">
+            <Upload className="w-6 h-6" />
+            <span className="text-xs">上传图片</span>
+          </div>
+        )}
+        <input
+          ref={fileInputRef}
+          type="file"
+          accept="image/*"
+          style={{ display: 'none' }}
+          onChange={handleFileChange}
+        />
+      </div>
+
+      {/* 图片预览弹窗 */}
+      {showPreview && previewUrl && (
+        <div
+          className="fixed inset-0 z-50 flex items-center justify-center bg-black/80"
+          onClick={() => setShowPreview(false)}
+        >
+          <div className="relative max-w-4xl max-h-[90vh] p-4">
+            <button
+              onClick={() => setShowPreview(false)}
+              className="absolute top-2 right-2 p-2 bg-white/80 rounded-full hover:bg-white transition-colors"
+            >
+              <X className="w-5 h-5 text-gray-700" />
+            </button>
+            <ImageWithFallback
+              src={previewUrl}
+              alt="预览"
+              className="max-w-full max-h-[90vh] object-contain rounded-lg"
+            />
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}
+

+ 8 - 0
src/routes/index.tsx

@@ -28,6 +28,14 @@ export const router = createBrowserRouter([
       </ProtectedRoute>
     ),
   },
+  {
+    path: '/lock-cabinet/detail',
+    element: (
+      <ProtectedRoute>
+        <Dashboard />
+      </ProtectedRoute>
+    ),
+  },
   {
     path: '*',
     element: <Navigate to="/login" replace />,

+ 89 - 3
src/utils/axios.ts

@@ -74,10 +74,96 @@ axiosInstance.interceptors.response.use(
       if (data.code === 200 || data.code === 0) {
         return data.data !== undefined ? data.data : data;
       } else {
-        // 业务错误
+        // 业务错误 - 创建一个包含更多信息的错误对象
         const errorMessage = data.message || '请求失败';
-        toast.error(errorMessage);
-        return Promise.reject(new Error(errorMessage));
+        const error = new Error(errorMessage) as any;
+        error.response = {
+          data: data,
+          status: data.code,
+        };
+        error.config = config;
+        
+        // 如果 code 是 401,需要处理认证问题
+        if (data.code === 401) {
+          const originalRequest = config as InternalAxiosRequestConfig & { _retry?: boolean };
+          
+          // 如果是刷新token的请求失败,直接跳转登录
+          if (originalRequest.url?.includes('/refresh-token') || originalRequest._retry) {
+            clearAuth();
+            window.location.href = '/login';
+            return Promise.reject(error);
+          }
+
+          // 如果正在刷新token,将请求加入队列
+          if (isRefreshing) {
+            return new Promise((resolve, reject) => {
+              failedQueue.push({ resolve, reject });
+            })
+              .then((token) => {
+                if (originalRequest.headers) {
+                  originalRequest.headers.Authorization = `Bearer ${token}`;
+                }
+                return axiosInstance(originalRequest);
+              })
+              .catch((err) => {
+                return Promise.reject(err);
+              });
+          }
+
+          // 尝试刷新token
+          const refreshToken = getRefreshToken();
+          if (!refreshToken) {
+            clearAuth();
+            window.location.href = '/login';
+            return Promise.reject(error);
+          }
+
+          isRefreshing = true;
+          originalRequest._retry = true;
+
+          return loginApi.refreshToken(refreshToken)
+            .then((res: any) => {
+              const tokenData = res?.data || res;
+              const newAccessToken = tokenData?.accessToken || tokenData?.token;
+              const newRefreshToken = tokenData?.refreshToken;
+
+              if (newAccessToken) {
+                // 更新token
+                setToken({ accessToken: newAccessToken, token: newAccessToken });
+                setAccessToken(newAccessToken);
+                if (newRefreshToken) {
+                  setRefreshToken(newRefreshToken);
+                }
+
+                // 更新请求头
+                if (originalRequest.headers) {
+                  originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
+                }
+
+                // 处理队列中的请求
+                processQueue(null, newAccessToken);
+
+                // 重试原始请求
+                return axiosInstance(originalRequest);
+              } else {
+                throw new Error('刷新token失败:未返回新token');
+              }
+            })
+            .catch((refreshError: any) => {
+              // 刷新失败,清除所有认证信息并跳转登录
+              processQueue(refreshError, null);
+              clearAuth();
+              window.location.href = '/login';
+              return Promise.reject(error);
+            })
+            .finally(() => {
+              isRefreshing = false;
+            });
+        }
+        
+        // 不在这里显示 toast,让调用方决定是否显示
+        // toast.error(errorMessage);
+        return Promise.reject(error);
       }
     }
 

+ 44 - 0
src/utils/dict.ts

@@ -40,6 +40,43 @@ export const getDictOptions = (dictType: string): DictDataType[] => {
     ];
   }
 
+  // 是否在线状态
+  if (dictType === DICT_TYPE.ISONLINE_STATUS) {
+    return [
+      { dictType, label: '离线', value: '0', colorType: 'danger', cssClass: '' },
+      { dictType, label: '在线', value: '1', colorType: 'success', cssClass: '' },
+    ];
+  }
+
+  // 机柜状态
+  if (dictType === DICT_TYPE.CANBINET_STATUS) {
+    return [
+      { dictType, label: '异常', value: '0', colorType: 'danger', cssClass: '' },
+      { dictType, label: '正常', value: '1', colorType: 'success', cssClass: '' },
+    ];
+  }
+  // 仓位类型
+  if (dictType === DICT_TYPE.SLOT_TYPE) {
+    return [
+      { dictType, label: '锁柜', value: '0', colorType: 'primary', cssClass: '' },
+      { dictType, label: '挂锁', value: '1', colorType: 'info', cssClass: '' },
+    ];
+  }
+  // 仓位状态
+  if (dictType === DICT_TYPE.SLOT_STATUS) {
+    return [
+      { dictType, label: '正常', value: '0', colorType: 'success', cssClass: '' },
+      { dictType, label: '异常', value: '1', colorType: 'danger', cssClass: '' },
+    ];
+  }
+  // 是否被占用
+  if (dictType === DICT_TYPE.ISOCCUPIED_STATUS) {
+    return [
+      { dictType, label: '未占用', value: '0', colorType: 'default', cssClass: '' },
+      { dictType, label: '已占用', value: '1', colorType: 'warning', cssClass: '' },
+    ];
+  }
+
   // 其他类型返回空数组
   // 实际项目中可以在这里调用 API 获取字典数据
   return [];
@@ -162,5 +199,12 @@ export enum DICT_TYPE {
   SYSTEM_MAIL_SEND_STATUS = 'system_mail_send_status',
   SYSTEM_NOTIFY_TEMPLATE_TYPE = 'system_notify_template_type',
   SYSTEM_SOCIAL_TYPE = 'system_social_type',
+
+  // ========== 机柜管理 ==========
+  ISONLINE_STATUS = 'isonline_status', // 是否在线
+  CANBINET_STATUS = 'canbinet_status', // 机柜状态
+  SLOT_TYPE = 'slot_type', // 仓位类型
+  SLOT_STATUS = 'slot_status', // 仓位状态
+  ISOCCUPIED_STATUS = 'isoccupied_status', // 是否被占用
 }
 

+ 1 - 0
src/utils/permission.ts

@@ -189,6 +189,7 @@ export const mapMenuPathToKey = (path: string): string | null => {
     if (path.includes('/system/post') || path === '/system/post' || path.includes('/system/marsdept')) return 'positionManagement';
     if (path.includes('/system/role') || path === '/system/role') return 'roleManagement';
     if (path.includes('/system/dict') || path === '/system/dict') return 'dictionaryManagement';
+    if (path.includes('/system/role') || path === '/system/role') return 'roleManagement';
     return 'systemConfig';
   }