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

用户管理模块内容开发,表格创建

wyn 5 месяцев назад
Родитель
Сommit
cff037a911

+ 388 - 503
src/components/UserManagement.tsx

@@ -1,353 +1,297 @@
-import React, { useState } from 'react';
-import { Plus, Search, Edit2, Trash2, MoreVertical, ChevronRight, ChevronDown, Building2 } from 'lucide-react';
-
-interface TableRow {
-  id: number;
-  [key: string]: any;
-}
+import React, { useState, useEffect, useRef } from 'react';
+import { Plus, Search, Edit2, Trash2, MoreVertical, Upload, Download, Key, UserCog, Eye, RefreshCw } from 'lucide-react';
+import { userApi } from '../api/User';
+import { UserVO, WorkstationNode } from '../types';
+import { toast } from 'sonner';
+import DeptTree from './user/DeptTree';
+import UserForm, { UserFormRef } from './user/UserForm';
+import UserImportForm, { UserImportFormRef } from './user/UserImportForm';
+import UserAssignRoleForm, { UserAssignRoleFormRef } from './user/UserAssignRoleForm';
+import FaceOrFingerForm, { FaceOrFingerFormRef } from './user/FaceOrFingerForm';
+import { Switch } from './ui/switch';
+import { Button } from './ui/button';
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuTrigger,
+} from './ui/dropdown-menu';
 
 interface UserManagementProps {
   subMenu: string;
 }
 
 export default function UserManagement({ subMenu }: UserManagementProps) {
-  const [searchTerm, setSearchTerm] = useState('');
-  const [showAddModal, setShowAddModal] = useState(false);
-  const [editingItem, setEditingItem] = useState<TableRow | null>(null);
-  const [expandedDeptIds, setExpandedDeptIds] = useState<number[]>([1]); // 部门树展开的节点
-  const [selectedDeptId, setSelectedDeptId] = useState<number | null>(null); // 选中的部门ID
+  const [loading, setLoading] = useState(true);
+  const [list, setList] = useState<UserVO[]>([]);
+  const [total, setTotal] = useState(0);
+  const [queryParams, setQueryParams] = useState({
+    pageNo: 1,
+    pageSize: 10,
+    username: '',
+    mobile: '',
+    status: undefined as number | undefined,
+    workstationId: undefined as number | undefined,
+    createTime: [] as string[],
+  });
+  const [exportLoading, setExportLoading] = useState(false);
 
-  // 部门树形数据
-  const departmentTreeData = [
-    {
-      id: 1,
-      name: '总公司',
-      manager: '李总',
-      memberCount: 120,
-      children: [
-        {
-          id: 2,
-          name: '技术中心',
-          manager: '张三',
-          memberCount: 45,
-          parentId: 1,
-          children: [
-            {
-              id: 21,
-              name: '研发部',
-              manager: '王研',
-              memberCount: 25,
-              parentId: 2
-            },
-            {
-              id: 22,
-              name: '测试部',
-              manager: '赵测',
-              memberCount: 12,
-              parentId: 2
-            },
-            {
-              id: 23,
-              name: '运维部',
-              manager: '李运',
-              memberCount: 8,
-              parentId: 2
-            }
-          ]
-        },
-        {
-          id: 3,
-          name: '市场中心',
-          manager: '李四',
-          memberCount: 30,
-          parentId: 1,
-          children: [
-            {
-              id: 31,
-              name: '市场部',
-              manager: '钱市',
-              memberCount: 18,
-              parentId: 3
-            },
-            {
-              id: 32,
-              name: '销售部',
-              manager: '孙销',
-              memberCount: 12,
-              parentId: 3
-            }
-          ]
-        },
-        {
-          id: 4,
-          name: '行政中心',
-          manager: '王五',
-          memberCount: 25,
-          parentId: 1,
-          children: [
-            {
-              id: 41,
-              name: '人力资源部',
-              manager: '周人',
-              memberCount: 10,
-              parentId: 4
-            },
-            {
-              id: 42,
-              name: '财务部',
-              manager: '吴财',
-              memberCount: 8,
-              parentId: 4
-            },
-            {
-              id: 43,
-              name: '行政部',
-              manager: '郑行',
-              memberCount: 7,
-              parentId: 4
-            }
-          ]
-        },
-        {
-          id: 5,
-          name: '安全部',
-          manager: '赵六',
-          memberCount: 20,
-          parentId: 1
-        }
-      ]
+  // 子组件引用
+  const formRef = useRef<UserFormRef>(null);
+  const importFormRef = useRef<UserImportFormRef>(null);
+  const assignRoleFormRef = useRef<UserAssignRoleFormRef>(null);
+  const faceOrFingerFormRef = useRef<FaceOrFingerFormRef>(null);
+
+  // 获取用户列表
+  const getList = async () => {
+    setLoading(true);
+    try {
+      const response = await userApi.getUserPage(queryParams);
+      setList(response.list || []);
+      setTotal(response.total || 0);
+    } catch (error: any) {
+      toast.error(error.message || '获取用户列表失败');
+    } finally {
+      setLoading(false);
     }
-  ];
+  };
 
-  // 切换部门树展开/收起
-  const toggleDeptExpand = (id: number) => {
-    setExpandedDeptIds(prev =>
-      prev.includes(id) ? prev.filter(item => item !== id) : [...prev, id]
-    );
+  useEffect(() => {
+    getList();
+  }, [queryParams.pageNo, queryParams.pageSize, queryParams.workstationId]);
+
+  // 搜索
+  const handleQuery = () => {
+    setQueryParams(prev => ({ ...prev, pageNo: 1 }));
+    getList();
   };
 
-  // 用户列表数据
-  const userData: TableRow[] = [
-    { 
-      id: 1, 
-      username: 'admin', 
-      realName: '张三', 
-      phone: '13800138001', 
-      email: 'zhangsan@example.com', 
-      department: '技术部', 
-      role: '超级管理员',
-      status: '在线',
-      lastLogin: '2025-12-04 10:30',
-      createTime: '2025-01-01'
-    },
-    { 
-      id: 2, 
-      username: 'user001', 
-      realName: '李四', 
-      phone: '13800138002', 
-      email: 'lisi@example.com', 
-      department: '运维部', 
-      role: '部门管理员',
-      status: '离线',
-      lastLogin: '2025-12-03 18:45',
-      createTime: '2025-01-05'
-    },
-    { 
-      id: 3, 
-      username: 'user002', 
-      realName: '王五', 
-      phone: '13800138003', 
-      email: 'wangwu@example.com', 
-      department: '安全部', 
-      role: '操作员',
-      status: '在线',
-      lastLogin: '2025-12-04 09:15',
-      createTime: '2025-01-10'
-    },
-    { 
-      id: 4, 
-      username: 'user003', 
-      realName: '赵六', 
-      phone: '13800138004', 
-      email: 'zhaoliu@example.com', 
-      department: '测试部', 
-      role: '审计员',
-      status: '在线',
-      lastLogin: '2025-12-04 11:00',
-      createTime: '2025-01-15'
-    },
-    { 
-      id: 5, 
-      username: 'user004', 
-      realName: '孙七', 
-      phone: '13800138005', 
-      email: 'sunqi@example.com', 
-      department: '技术部', 
-      role: '操作员',
-      status: '离线',
-      lastLogin: '2025-12-03 17:30',
-      createTime: '2025-01-20'
-    },
-  ];
+  // 重置搜索
+  const resetQuery = () => {
+    setQueryParams({
+      pageNo: 1,
+      pageSize: 10,
+      username: '',
+      mobile: '',
+      status: undefined,
+      workstationId: undefined,
+      createTime: [],
+    });
+    setTimeout(() => getList(), 100);
+  };
 
-  // 用户列表数据和列配置
-  const data = userData;
-  const columns = [
-    { key: 'username', label: '用户名', width: '10%' },
-    { key: 'realName', label: '姓名', width: '8%' },
-    { key: 'phone', label: '手机号', width: '12%' },
-    { key: 'email', label: '邮箱', width: '15%' },
-    { key: 'department', label: '部门', width: '10%' },
-    { key: 'role', label: '角色', width: '12%' },
-    { key: 'status', label: '状态', width: '8%' },
-    { key: 'lastLogin', label: '最后登录', width: '15%' },
-  ];
+  // 处理部门节点点击
+  const handleDeptNodeClick = (node: WorkstationNode) => {
+    setQueryParams(prev => ({ ...prev, workstationId: node.id, pageNo: 1 }));
+  };
 
-  // 过滤数据
-  const filteredData = data.filter((item) =>
-    Object.values(item).some((value) =>
-      String(value).toLowerCase().includes(searchTerm.toLowerCase())
-    )
-  );
+  // 打开表单
+  const openForm = (type: string, id?: number) => {
+    formRef.current?.open(type, id);
+  };
+
+  // 用户导入
+  const handleImport = () => {
+    importFormRef.current?.open();
+  };
+
+  // 导出用户
+  const handleExport = async () => {
+    if (!confirm('确定要导出用户数据吗?')) {
+      return;
+    }
 
-  const handleDelete = (id: number) => {
-    if (confirm('确定要删除这条数据吗?')) {
-      console.log('删除:', id);
+    setExportLoading(true);
+    try {
+      const blob = await userApi.exportUser(queryParams);
+      const url = window.URL.createObjectURL(blob);
+      const link = document.createElement('a');
+      link.href = url;
+      link.download = '用户数据.xls';
+      document.body.appendChild(link);
+      link.click();
+      document.body.removeChild(link);
+      window.URL.revokeObjectURL(url);
+      toast.success('导出成功');
+    } catch (error: any) {
+      toast.error(error.message || '导出失败');
+    } finally {
+      setExportLoading(false);
     }
   };
 
-  const handleEdit = (item: TableRow) => {
-    setEditingItem(item);
-    setShowAddModal(true);
+  // 修改用户状态
+  const handleStatusChange = async (row: UserVO, newChecked: boolean) => {
+    const newStatus = newChecked ? 0 : 1;
+    const text = newStatus === 0 ? '启用' : '停用';
+    
+    // 使用更友好的确认框
+    const confirmed = window.confirm(`确认要${text}"${row.username}"用户吗?`);
+    if (!confirmed) {
+      // 用户取消,不更新状态,需要刷新列表以恢复Switch状态
+      await getList();
+      return;
+    }
+
+    try {
+      await userApi.updateUserStatus(row.id, newStatus);
+      toast.success('状态更新成功');
+      await getList();
+    } catch (error: any) {
+      toast.error(error.message || '状态更新失败');
+      // 刷新列表以恢复原状态
+      await getList();
+    }
   };
 
-  // 递归渲染部门树节点
-  const renderDepartmentTree = (departments: any[], level: number = 0) => {
-    return departments.map((dept) => (
-      <div key={dept.id}>
-        {/* 部门节点 */}
-        <div
-          onClick={() => {
-            if (dept.children && dept.children.length > 0) {
-              toggleDeptExpand(dept.id);
-            }
-            setSelectedDeptId(dept.id);
-          }}
-          className={`flex items-center gap-2 px-3 py-2.5 rounded-lg cursor-pointer transition-all group ${
-            selectedDeptId === dept.id
-              ? 'bg-blue-500 text-white shadow-md'
-              : 'hover:bg-blue-50 text-gray-700'
-          }`}
-          style={{ paddingLeft: `${level * 20 + 12}px` }}
-        >
-          {/* 展开/收起图标 */}
-          {dept.children && dept.children.length > 0 ? (
-            expandedDeptIds.includes(dept.id) ? (
-              <ChevronDown className={`w-4 h-4 flex-shrink-0 ${
-                selectedDeptId === dept.id ? 'text-white' : 'text-gray-600'
-              }`} />
-            ) : (
-              <ChevronRight className={`w-4 h-4 flex-shrink-0 ${
-                selectedDeptId === dept.id ? 'text-white' : 'text-gray-600'
-              }`} />
-            )
-          ) : (
-            <span className="w-4"></span>
-          )}
-          
-          {/* 部门图标 */}
-          <Building2 className={`w-4 h-4 flex-shrink-0 ${
-            selectedDeptId === dept.id ? 'text-white' : 'text-blue-500'
-          }`} />
-          
-          {/* 部门名称和人数 */}
-          <div className="flex-1 flex items-center justify-between min-w-0">
-            <span className="text-sm truncate">{dept.name}</span>
-            <span className={`text-xs px-2 py-0.5 rounded-full flex-shrink-0 ml-2 ${
-              selectedDeptId === dept.id
-                ? 'bg-white/20 text-white'
-                : 'bg-gray-100 text-gray-600 group-hover:bg-blue-100 group-hover:text-blue-700'
-            }`}>
-              {dept.memberCount}
-            </span>
-          </div>
-        </div>
-        
-        {/* 子部门 */}
-        {dept.children && dept.children.length > 0 && expandedDeptIds.includes(dept.id) && (
-          <div className="mt-1">
-            {renderDepartmentTree(dept.children, level + 1)}
-          </div>
-        )}
-      </div>
-    ));
+  // 删除用户
+  const handleDelete = async (id: number) => {
+    if (!confirm('确定要删除这条数据吗?')) {
+      return;
+    }
+
+    try {
+      await userApi.deleteUser(id);
+      toast.success('删除成功');
+      await getList();
+    } catch (error: any) {
+      toast.error(error.message || '删除失败');
+    }
+  };
+
+  // 重置密码
+  const handleResetPwd = async (row: UserVO) => {
+    const password = prompt(`请输入"${row.username}"的新密码`, '');
+    if (!password) {
+      return;
+    }
+
+    try {
+      await userApi.resetUserPassword(row.id, password);
+      toast.success(`修改成功,新密码是:${password}`);
+    } catch (error: any) {
+      toast.error(error.message || '重置密码失败');
+    }
+  };
+
+  // 分配角色
+  const handleRole = (row: UserVO) => {
+    assignRoleFormRef.current?.open(row);
+  };
+
+  // 打开指纹或人脸弹框
+  const openFaceOrFingerForm = (type: string, row: UserVO) => {
+    faceOrFingerFormRef.current?.open(type, row.id, row);
+  };
+
+  // 查看岗位
+  const handleLookWorkStation = (row: UserVO) => {
+    // 可以跳转到岗位管理页面,这里暂时用提示
+    toast.info('查看岗位功能,可跳转到岗位管理页面');
+  };
+
+  // 格式化日期
+  const formatDate = (dateStr?: string | Date) => {
+    if (!dateStr) return '';
+    const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr;
+    return date.toLocaleString('zh-CN');
   };
 
-  // 用户列表页面使用左右分栏布局
   return (
     <div className="flex gap-6 h-full">
-      {/* 左侧部门树 */}
+      {/* 左侧岗位树 */}
       <div className="w-80 flex-shrink-0">
         <div className="bg-white rounded-2xl border border-gray-200/50 shadow-sm h-full overflow-hidden flex flex-col">
-          {/* 部门树头部 */}
-          <div className="px-4 py-4 border-b border-gray-200/50 flex-shrink-0">
-            <div className="flex items-center justify-between mb-3">
-              <h3 className="text-sm text-gray-900">组织架构</h3>
-              <button className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors">
-                <Plus className="w-4 h-4 text-gray-600" />
-              </button>
-            </div>
-            <div className="relative">
-              <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
-              <input
-                type="text"
-                placeholder="搜索部门..."
-                className="w-full h-9 pl-9 pr-3 bg-gray-50 border border-gray-200 rounded-lg outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition-all text-sm"
-              />
-            </div>
-          </div>
-
-          {/* 部门树内容 */}
-          <div className="flex-1 overflow-y-auto p-3">
-            <div className="space-y-1">
-              {renderDepartmentTree(departmentTreeData)}
-            </div>
-          </div>
-
-          {/* 底部统计 */}
-          <div className="px-4 py-3 bg-gray-50/50 border-t border-gray-200/50 flex-shrink-0">
-            <div className="text-xs text-gray-600 text-center">
-              共 <span className="text-blue-600">120</span> 人
-            </div>
-          </div>
+          <DeptTree onNodeClick={handleDeptNodeClick} />
         </div>
       </div>
 
       {/* 右侧用户列表 */}
       <div className="flex-1 min-w-0">
         <div className="space-y-6">
+          {/* 搜索栏 */}
+          <div className="bg-white rounded-2xl border border-gray-200/50 shadow-sm p-4">
+            {/* 一行:检索条件和按钮 */}
+            <div className="flex items-center gap-4">
+              <div className="flex items-center gap-2">
+                <label className="text-sm text-gray-700 whitespace-nowrap">工号:</label>
+                <input
+                  type="text"
+                  value={queryParams.username}
+                  onChange={(e) => setQueryParams({ ...queryParams, username: e.target.value })}
+                  onKeyUp={(e) => e.key === 'Enter' && handleQuery()}
+                  placeholder="请输入工号"
+                  className="w-48 h-9 px-3 bg-gray-50 border border-gray-200 rounded-lg outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition-all text-sm"
+                />
+              </div>
+              <div className="flex items-center gap-2">
+                <label className="text-sm text-gray-700 whitespace-nowrap">手机号码:</label>
+                <input
+                  type="text"
+                  value={queryParams.mobile}
+                  onChange={(e) => setQueryParams({ ...queryParams, mobile: e.target.value })}
+                  onKeyUp={(e) => e.key === 'Enter' && handleQuery()}
+                  placeholder="请输入手机号码"
+                  className="w-48 h-9 px-3 bg-gray-50 border border-gray-200 rounded-lg outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition-all text-sm"
+                />
+              </div>
+              <Button 
+                onClick={handleQuery}
+                className="bg-blue-500 hover:bg-blue-600 text-white border-0"
+              >
+                <Search className="w-4 h-4 mr-2" />
+                搜索
+              </Button>
+              <Button 
+                variant="outline" 
+                onClick={resetQuery}
+                className="bg-gray-100 hover:bg-gray-200 text-gray-700 border-gray-300"
+              >
+                <RefreshCw className="w-4 h-4 mr-2" />
+                重置
+              </Button>
+              <Button
+                onClick={() => openForm('create')}
+                className="bg-green-500 hover:bg-green-600 text-white border-0"
+              >
+                <Plus className="w-4 h-4 mr-2" />
+                新增
+              </Button>
+              <Button
+                onClick={handleImport}
+                className="bg-orange-500 hover:bg-orange-600 text-white border-0"
+              >
+                <Upload className="w-4 h-4 mr-2" />
+                导入
+              </Button>
+              <Button
+                  onClick={handleExport}
+                  className="bg-green-500 hover:bg-indigo-700 text-white border-1 disabled:bg-gray-400 disabled:hover:bg-gray-400"
+              >
+                <Download className="w-4 h-4 mr-2" />
+                导出
+              </Button>
+              {/*<Button*/}
+              {/*  onClick={handleExport}*/}
+              {/*  disabled={exportLoading}*/}
+              {/*  className="bg-indigo-600 hover:bg-indigo-700 text-white border-0 disabled:bg-gray-400 disabled:hover:bg-gray-400"*/}
+              {/*>*/}
+              {/*  <Download className="w-4 h-4 mr-2" />*/}
+              {/*  {exportLoading ? '导出中...' : '导出'}*/}
+              {/*</Button>*/}
+            </div>
+          </div>
+
           {/* 表格容器 */}
           <div className="bg-white rounded-2xl border border-gray-200/50 shadow-sm overflow-hidden">
-            {/* 工具栏 */}
+            {/* 工具栏 - 显示已筛选岗位 */}
+            {queryParams.workstationId && (
             <div className="p-4 border-b border-gray-200/50">
-              <div className="flex items-center justify-between">
                 <div className="flex items-center gap-3">
-                  <div className="relative w-80">
-                    <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
-                    <input
-                      type="text"
-                      placeholder="搜索用户..."
-                      value={searchTerm}
-                      onChange={(e) => setSearchTerm(e.target.value)}
-                      className="w-full h-10 pl-10 pr-4 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all text-sm"
-                    />
-                  </div>
-                  {selectedDeptId && (
                     <div className="flex items-center gap-2 px-3 py-2 bg-blue-50 text-blue-700 rounded-lg text-sm">
-                      <Building2 className="w-4 h-4" />
-                      <span>已筛选部门</span>
+                    <span>已筛选岗位</span>
                       <button
-                        onClick={() => setSelectedDeptId(null)}
+                      onClick={() => setQueryParams({ ...queryParams, workstationId: undefined, pageNo: 1 })}
                         className="ml-1 hover:bg-blue-100 rounded p-0.5"
                       >
                         <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -355,21 +299,9 @@ export default function UserManagement({ subMenu }: UserManagementProps) {
                         </svg>
                       </button>
                     </div>
-                  )}
                 </div>
-
-                <button
-                  onClick={() => {
-                    setEditingItem(null);
-                    setShowAddModal(true);
-                  }}
-                  className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-xl hover:shadow-lg hover:shadow-blue-400/40 transition-all duration-300"
-                >
-                  <Plus className="w-4 h-4" strokeWidth={2.5} />
-                  <span className="text-sm">新增用户</span>
-                </button>
               </div>
-            </div>
+            )}
 
             {/* 表格 */}
             <div className="overflow-x-auto">
@@ -379,72 +311,138 @@ export default function UserManagement({ subMenu }: UserManagementProps) {
                     <th className="px-6 py-4 text-left text-xs text-gray-600 uppercase tracking-wider" style={{ width: '5%' }}>
                       序号
                     </th>
-                    {columns.map((column) => (
-                      <th
-                        key={column.key}
-                        className="px-6 py-4 text-left text-xs text-gray-600 uppercase tracking-wider"
-                        style={{ width: column.width }}
-                      >
-                        {column.label}
+                    <th className="px-6 py-4 text-left text-xs text-gray-600 uppercase tracking-wider" style={{ width: '8%' }}>
+                      用户编号
+                    </th>
+                    <th className="px-6 py-4 text-left text-xs text-gray-600 uppercase tracking-wider" style={{ width: '10%' }}>
+                      工号
+                    </th>
+                    <th className="px-6 py-4 text-left text-xs text-gray-600 uppercase tracking-wider" style={{ width: '10%' }}>
+                      用户昵称
+                    </th>
+                    <th className="px-6 py-4 text-left text-xs text-gray-600 uppercase tracking-wider" style={{ width: '12%' }}>
+                      手机号码
+                    </th>
+                    <th className="px-6 py-4 text-left text-xs text-gray-600 uppercase tracking-wider" style={{ width: '10%' }}>
+                      岗位
+                    </th>
+                    <th className="px-6 py-4 text-left text-xs text-gray-600 uppercase tracking-wider" style={{ width: '8%' }}>
+                      指纹
+                    </th>
+                    <th className="px-6 py-4 text-left text-xs text-gray-600 uppercase tracking-wider" style={{ width: '8%' }}>
+                      人脸
+                    </th>
+                    <th className="px-6 py-4 text-left text-xs text-gray-600 uppercase tracking-wider" style={{ width: '8%' }}>
+                      状态
+                    </th>
+                    <th className="px-6 py-4 text-left text-xs text-gray-600 uppercase tracking-wider" style={{ width: '12%' }}>
+                      创建时间
                       </th>
-                    ))}
                     <th className="px-6 py-4 text-center text-xs text-gray-600 uppercase tracking-wider" style={{ width: '10%' }}>
                       操作
                     </th>
                   </tr>
                 </thead>
                 <tbody className="divide-y divide-gray-100">
-                  {filteredData.map((row, index) => (
+                  {loading ? (
+                    <tr>
+                      <td colSpan={11} className="px-6 py-8 text-center">
+                        <div className="flex items-center justify-center">
+                          <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
+                        </div>
+                      </td>
+                    </tr>
+                  ) : list.length === 0 ? (
+                    <tr>
+                      <td colSpan={11} className="px-6 py-8 text-center text-gray-500">
+                        暂无数据
+                      </td>
+                    </tr>
+                  ) : (
+                    list.map((row, index) => (
                     <tr
                       key={row.id}
                       className="hover:bg-blue-50/30 transition-colors"
                     >
                       <td className="px-6 py-4 text-sm text-gray-900">
-                        {index + 1}
-                      </td>
-                      {columns.map((column) => (
-                        <td key={column.key} className="px-6 py-4 text-sm text-gray-900">
-                          {column.key === 'status' ? (
-                            <span
-                              className={`inline-flex px-3 py-1 rounded-lg text-xs ${
-                                row[column.key] === '在线'
-                                  ? 'bg-green-100 text-green-700'
-                                  : 'bg-gray-100 text-gray-700'
-                              }`}
-                            >
-                              {row[column.key]}
-                            </span>
-                          ) : (
-                            row[column.key]
-                          )}
+                          {(queryParams.pageNo - 1) * queryParams.pageSize + index + 1}
                         </td>
-                      ))}
-                      <td className="px-6 py-4">
-                        <div className="flex items-center justify-center gap-2">
+                        <td className="px-6 py-4 text-sm text-gray-900">{row.id}</td>
+                        <td className="px-6 py-4 text-sm text-gray-900">{row.username}</td>
+                        <td className="px-6 py-4 text-sm text-gray-900">{row.nickname}</td>
+                        <td className="px-6 py-4 text-sm text-gray-900">{row.mobile || '-'}</td>
+                        <td className="px-6 py-4 text-sm text-gray-900">
                           <button
-                            onClick={() => handleEdit(row)}
-                            className="p-2 text-blue-600 hover:bg-blue-100 rounded-lg transition-colors"
-                            title="编辑"
+                            onClick={() => handleLookWorkStation(row)}
+                            className="text-blue-600 hover:text-blue-800 hover:underline"
                           >
-                            <Edit2 className="w-4 h-4" />
+                            {row.workstationName || '查看'}
                           </button>
+                        </td>
+                        <td className="px-6 py-4 text-sm text-gray-900">
                           <button
-                            onClick={() => handleDelete(row.id)}
-                            className="p-2 text-red-600 hover:bg-red-100 rounded-lg transition-colors"
-                            title="删除"
+                            onClick={() => openFaceOrFingerForm('finger', row)}
+                            className="text-blue-600 hover:text-blue-800 hover:underline"
                           >
-                            <Trash2 className="w-4 h-4" />
+                            查看
                           </button>
+                        </td>
+                        <td className="px-6 py-4 text-sm text-gray-900">
                           <button
-                            className="p-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
-                            title="更多"
+                            onClick={() => openFaceOrFingerForm('face', row)}
+                            className="text-blue-600 hover:text-blue-800 hover:underline"
                           >
-                            <MoreVertical className="w-4 h-4" />
+                            查看
                           </button>
+                        </td>
+                        <td className="px-6 py-4 text-sm">
+                          <Switch
+                            checked={row.status === 0}
+                            onCheckedChange={(checked) => handleStatusChange(row, checked)}
+                            className="data-[state=checked]:!bg-blue-500 data-[state=unchecked]:!bg-gray-400"
+                          />
+                        </td>
+                        <td className="px-6 py-4 text-sm text-gray-900">
+                          {formatDate(row.createTime)}
+                        </td>
+                        <td className="px-6 py-4">
+                          <div className="flex items-center justify-center gap-2">
+                            <button
+                              onClick={() => openForm('update', row.id)}
+                              className="p-2 text-blue-600 hover:bg-blue-100 rounded-lg transition-colors"
+                              title="编辑"
+                            >
+                              <Edit2 className="w-4 h-4" />
+                            </button>
+                            <DropdownMenu>
+                              <DropdownMenuTrigger asChild>
+                                <button
+                                  className="p-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors relative z-10"
+                                  title="更多"
+                                >
+                                  <MoreVertical className="w-4 h-4" />
+                                </button>
+                              </DropdownMenuTrigger>
+                              <DropdownMenuContent align="end" className="!z-[9999]">
+                                <DropdownMenuItem onClick={() => handleDelete(row.id)}>
+                                  <Trash2 className="w-4 h-4 mr-2" />
+                                  删除
+                                </DropdownMenuItem>
+                                <DropdownMenuItem onClick={() => handleResetPwd(row)}>
+                                  <Key className="w-4 h-4 mr-2" />
+                                  重置密码
+                                </DropdownMenuItem>
+                                <DropdownMenuItem onClick={() => handleRole(row)}>
+                                  <UserCog className="w-4 h-4 mr-2" />
+                                  分配角色
+                                </DropdownMenuItem>
+                              </DropdownMenuContent>
+                            </DropdownMenu>
                         </div>
                       </td>
                     </tr>
-                  ))}
+                    ))
+                  )}
                 </tbody>
               </table>
             </div>
@@ -453,24 +451,28 @@ export default function UserManagement({ subMenu }: UserManagementProps) {
             <div className="px-6 py-4 bg-gray-50/50 border-t border-gray-200">
               <div className="flex items-center justify-between">
                 <div className="text-sm text-gray-600">
-                  共 <span className="text-blue-600">{filteredData.length}</span> 条记录
+                  共 <span className="text-blue-600">{total}</span> 条记录
                 </div>
                 <div className="flex gap-2">
-                  <button className="px-4 py-2 text-sm text-gray-600 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
+                  <Button
+                    variant="outline"
+                    size="sm"
+                    onClick={() => setQueryParams({ ...queryParams, pageNo: queryParams.pageNo - 1 })}
+                    disabled={queryParams.pageNo <= 1}
+                  >
                     上一页
-                  </button>
-                  <button className="px-4 py-2 text-sm text-white bg-blue-500 rounded-lg hover:bg-blue-600 transition-colors">
-                    1
-                  </button>
-                  <button className="px-4 py-2 text-sm text-gray-600 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
-                    2
-                  </button>
-                  <button className="px-4 py-2 text-sm text-gray-600 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
-                    3
-                  </button>
-                  <button className="px-4 py-2 text-sm text-gray-600 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
+                  </Button>
+                  <span className="px-4 py-2 text-sm text-gray-600 flex items-center">
+                    {queryParams.pageNo} / {Math.ceil(total / queryParams.pageSize) || 1}
+                  </span>
+                  <Button
+                    variant="outline"
+                    size="sm"
+                    onClick={() => setQueryParams({ ...queryParams, pageNo: queryParams.pageNo + 1 })}
+                    disabled={queryParams.pageNo >= Math.ceil(total / queryParams.pageSize)}
+                  >
                     下一页
-                  </button>
+                  </Button>
                 </div>
               </div>
             </div>
@@ -478,128 +480,11 @@ export default function UserManagement({ subMenu }: UserManagementProps) {
         </div>
       </div>
 
-      {/* 新增/编辑用户弹窗 */}
-      {showAddModal && (
-        <div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 animate-in fade-in duration-200">
-          <div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl mx-4 max-h-[90vh] overflow-y-auto animate-in zoom-in duration-200">
-            {/* 弹窗标题 */}
-            <div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between sticky top-0 bg-white z-10">
-              <h3 className="text-lg text-gray-900">
-                {editingItem ? '编辑用户' : '新增用户'}
-              </h3>
-              <button
-                onClick={() => setShowAddModal(false)}
-                className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
-              >
-                <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
-                </svg>
-              </button>
-            </div>
-
-            {/* 弹窗内容 */}
-            <div className="px-6 py-6">
-              <div className="grid grid-cols-2 gap-4">
-                <div>
-                  <label className="block text-sm text-gray-700 mb-2">用户名 *</label>
-                  <input
-                    type="text"
-                    className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all text-sm"
-                    placeholder="请输入用户名"
-                    defaultValue={editingItem?.username || ''}
-                  />
-                </div>
-                <div>
-                  <label className="block text-sm text-gray-700 mb-2">真实姓名 *</label>
-                  <input
-                    type="text"
-                    className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all text-sm"
-                    placeholder="请输入真实姓名"
-                    defaultValue={editingItem?.realName || ''}
-                  />
-                </div>
-                <div>
-                  <label className="block text-sm text-gray-700 mb-2">手机号 *</label>
-                  <input
-                    type="tel"
-                    className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all text-sm"
-                    placeholder="请输入手机号"
-                    defaultValue={editingItem?.phone || ''}
-                  />
-                </div>
-                <div>
-                  <label className="block text-sm text-gray-700 mb-2">邮箱 *</label>
-                  <input
-                    type="email"
-                    className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all text-sm"
-                    placeholder="请输入邮箱"
-                    defaultValue={editingItem?.email || ''}
-                  />
-                </div>
-                <div>
-                  <label className="block text-sm text-gray-700 mb-2">所属部门 *</label>
-                  <select className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all text-sm">
-                    <option value="">请选择部门</option>
-                    <option value="技术部">技术部</option>
-                    <option value="运维部">运维部</option>
-                    <option value="安全部">安全部</option>
-                    <option value="测试部">测试部</option>
-                  </select>
-                </div>
-                <div>
-                  <label className="block text-sm text-gray-700 mb-2">角色 *</label>
-                  <select className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all text-sm">
-                    <option value="">请选择角色</option>
-                    <option value="超级管理员">超级管理员</option>
-                    <option value="部门管理员">部门管理员</option>
-                    <option value="操作员">操作员</option>
-                    <option value="审计员">审计员</option>
-                  </select>
-                </div>
-                {!editingItem && (
-                  <>
-                    <div>
-                      <label className="block text-sm text-gray-700 mb-2">密码 *</label>
-                      <input
-                        type="password"
-                        className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all text-sm"
-                        placeholder="请输入密码"
-                      />
-                    </div>
-                    <div>
-                      <label className="block text-sm text-gray-700 mb-2">确认密码 *</label>
-                      <input
-                        type="password"
-                        className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all text-sm"
-                        placeholder="请再次输入密码"
-                      />
-                    </div>
-                  </>
-                )}
-              </div>
-            </div>
-
-            {/* 弹窗底部 */}
-            <div className="px-6 py-4 bg-gray-50/50 border-t border-gray-200 flex justify-end gap-3 rounded-b-2xl">
-              <button
-                onClick={() => setShowAddModal(false)}
-                className="px-5 py-2.5 text-sm text-gray-600 bg-white border border-gray-200 rounded-xl hover:bg-gray-50 transition-colors"
-              >
-                取消
-              </button>
-              <button
-                onClick={() => {
-                  console.log('保存:', editingItem);
-                  setShowAddModal(false);
-                }}
-                className="px-5 py-2.5 text-sm text-white bg-gradient-to-r from-blue-500 to-blue-600 rounded-xl hover:shadow-lg hover:shadow-blue-400/40 transition-all"
-              >
-                {editingItem ? '保存修改' : '确定'}
-              </button>
-            </div>
-          </div>
-        </div>
-      )}
+      {/* 子组件 */}
+      <UserForm ref={formRef} onSuccess={getList} />
+      <UserImportForm ref={importFormRef} onSuccess={getList} />
+      <UserAssignRoleForm ref={assignRoleFormRef} onSuccess={getList} />
+      <FaceOrFingerForm ref={faceOrFingerFormRef} onSuccess={getList} />
     </div>
   );
 }

+ 150 - 0
src/components/user/DeptTree.tsx

@@ -0,0 +1,150 @@
+import React, { useState, useEffect, useRef } from 'react';
+import { Search, ChevronRight, ChevronDown, Building2 } from 'lucide-react';
+import { systemApi } from '../../api/System';
+import { WorkstationNode } from '../../types';
+
+interface DeptTreeProps {
+  onNodeClick: (node: WorkstationNode) => void;
+}
+
+export default function DeptTree({ onNodeClick }: DeptTreeProps) {
+  const [deptName, setDeptName] = useState('');
+  const [deptList, setDeptList] = useState<WorkstationNode[]>([]);
+  const [expandedIds, setExpandedIds] = useState<number[]>([]);
+  const [selectedId, setSelectedId] = useState<number | null>(null);
+
+  // 将扁平数据转换为树形结构
+  const handleTree = (list: any[], idKey = 'id', parentKey = 'parentId', childrenKey = 'children'): WorkstationNode[] => {
+    const map = new Map<number, any>();
+    const roots: WorkstationNode[] = [];
+
+    // 创建映射
+    list.forEach(item => {
+      map.set(item[idKey], { ...item, [childrenKey]: [] });
+    });
+
+    // 构建树
+    list.forEach(item => {
+      const node = map.get(item[idKey]);
+      if (item[parentKey] && map.has(item[parentKey])) {
+        const parent = map.get(item[parentKey]);
+        if (!parent[childrenKey]) {
+          parent[childrenKey] = [];
+        }
+        parent[childrenKey].push(node);
+      } else {
+        roots.push(node);
+      }
+    });
+
+    return roots;
+  };
+
+  // 获取岗位树
+  const getTree = async () => {
+    try {
+      const res = await systemApi.listMarsDept({ pageNo: 1, pageSize: -1 });
+      const treeData = handleTree(res.list || [], 'id', 'parentId', 'children');
+      setDeptList(treeData);
+      // 默认展开第一层
+      if (treeData.length > 0) {
+        setExpandedIds([treeData[0].id]);
+      }
+    } catch (error) {
+      console.error('获取岗位树失败:', error);
+    }
+  };
+
+  // 过滤节点
+  const filterNode = (name: string, data: WorkstationNode): boolean => {
+    if (!name) return true;
+    return data.workstationName.includes(name);
+  };
+
+  // 递归渲染树节点
+  const renderTreeNode = (nodes: WorkstationNode[], level: number = 0, searchTerm: string = ''): React.ReactNode[] => {
+    return nodes
+      .filter(node => filterNode(searchTerm, node))
+      .map(node => {
+        const isExpanded = expandedIds.includes(node.id);
+        const isSelected = selectedId === node.id;
+        const hasChildren = node.children && node.children.length > 0;
+
+        return (
+          <div key={node.id}>
+            <div
+              onClick={() => {
+                if (hasChildren) {
+                  setExpandedIds(prev =>
+                    prev.includes(node.id) ? prev.filter(id => id !== node.id) : [...prev, node.id]
+                  );
+                }
+                setSelectedId(node.id);
+                onNodeClick(node);
+              }}
+              className={`flex items-center gap-2 px-3 py-2.5 rounded-lg cursor-pointer transition-all group ${
+                isSelected
+                  ? 'bg-blue-500 text-white shadow-md'
+                  : 'hover:bg-blue-50 text-gray-700'
+              }`}
+              style={{ paddingLeft: `${level * 20 + 12}px` }}
+            >
+              {hasChildren ? (
+                isExpanded ? (
+                  <ChevronDown className={`w-4 h-4 flex-shrink-0 ${
+                    isSelected ? 'text-white' : 'text-gray-600'
+                  }`} />
+                ) : (
+                  <ChevronRight className={`w-4 h-4 flex-shrink-0 ${
+                    isSelected ? 'text-white' : 'text-gray-600'
+                  }`} />
+                )
+              ) : (
+                <span className="w-4"></span>
+              )}
+              
+              <Building2 className={`w-4 h-4 flex-shrink-0 ${
+                isSelected ? 'text-white' : 'text-blue-500'
+              }`} />
+              
+              <span className="text-sm truncate">{node.workstationName}</span>
+            </div>
+            
+            {hasChildren && isExpanded && (
+              <div className="mt-1">
+                {renderTreeNode(node.children!, level + 1, searchTerm)}
+              </div>
+            )}
+          </div>
+        );
+      });
+  };
+
+  useEffect(() => {
+    getTree();
+  }, []);
+
+  return (
+    <div className="h-full flex flex-col">
+      <div className="px-4 py-4 border-b border-gray-200/50 flex-shrink-0">
+        <div className="relative">
+          <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
+          <input
+            type="text"
+            value={deptName}
+            onChange={(e) => setDeptName(e.target.value)}
+            placeholder="请输入岗位名称"
+            className="w-full h-9 pl-9 pr-3 bg-gray-50 border border-gray-200 rounded-lg outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition-all text-sm"
+          />
+        </div>
+      </div>
+      
+      <div className="flex-1 overflow-y-auto p-3">
+        <div className="space-y-1">
+          {renderTreeNode(deptList, 0, deptName)}
+        </div>
+      </div>
+    </div>
+  );
+}
+

+ 264 - 0
src/components/user/FaceOrFingerForm.tsx

@@ -0,0 +1,264 @@
+import React, { useState, useEffect, useImperativeHandle, forwardRef } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../ui/dialog';
+import { Button } from '../ui/button';
+import { Trash2, ZoomIn } from 'lucide-react';
+import { userApi } from '../../api/User';
+import { UserCharacteristic } from '../../types';
+import { toast } from 'sonner';
+
+interface FaceOrFingerFormProps {
+  onSuccess?: () => void;
+}
+
+export interface FaceOrFingerFormRef {
+  open: (type: string, id: number, row: any) => void;
+}
+
+const FaceOrFingerForm = forwardRef<FaceOrFingerFormRef, FaceOrFingerFormProps>(({ onSuccess }, ref) => {
+  const [dialogVisible, setDialogVisible] = useState(false);
+  const [dialogTitle, setDialogTitle] = useState('');
+  const [formLoading, setFormLoading] = useState(false);
+  const [tableData, setTableData] = useState<UserCharacteristic[]>([]);
+  const [selectedRows, setSelectedRows] = useState<number[]>([]);
+  const [total, setTotal] = useState(0);
+  const [queryParams, setQueryParams] = useState({
+    pageNo: 1,
+    pageSize: 10,
+  });
+  const [userId, setUserId] = useState<number>();
+  const [userType, setUserType] = useState<string>();
+
+  // 打开弹窗
+  const open = async (type: string, id: number, row: any) => {
+    setDialogVisible(true);
+    setDialogTitle(type === 'finger' ? '人员指纹数据' : '人员面部数据');
+    setUserId(row.id);
+    setUserType(type === 'finger' ? '1' : '2');
+    setQueryParams({ pageNo: 1, pageSize: 10 });
+    setTotal(0);
+    setSelectedRows([]);
+    await getFaceOrFingerList();
+  };
+
+  useImperativeHandle(ref, () => ({
+    open,
+  }));
+
+  // 获取指纹或人脸列表
+  const getFaceOrFingerList = async () => {
+    if (!userId || !userType) return;
+
+    setFormLoading(true);
+    try {
+      const response = await userApi.getUserType({
+        pageNo: queryParams.pageNo,
+        pageSize: queryParams.pageSize,
+        userId: userId,
+        type: userType,
+      });
+      setTableData(response.list || []);
+      setTotal(response.total || 0);
+    } catch (error: any) {
+      toast.error(error.message || '获取数据失败');
+    } finally {
+      setFormLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    if (dialogVisible && userId && userType) {
+      getFaceOrFingerList();
+    }
+  }, [queryParams.pageNo, queryParams.pageSize, dialogVisible]);
+
+  // 删除指纹或人脸
+  const handleDelete = async (id?: number) => {
+    const idsToDelete = id ? [id] : selectedRows;
+    
+    if (idsToDelete.length === 0) {
+      toast.warning('请选择要删除的数据');
+      return;
+    }
+
+    if (!confirm(`确定要删除选中的 ${idsToDelete.length} 条数据吗?`)) {
+      return;
+    }
+
+    try {
+      // 逐个删除
+      for (const deleteId of idsToDelete) {
+        await userApi.deleteUserFaceOrFinger(deleteId);
+      }
+      toast.success('删除成功');
+      await getFaceOrFingerList();
+      setSelectedRows([]);
+      onSuccess?.();
+    } catch (error: any) {
+      toast.error(error.message || '删除失败');
+    }
+  };
+
+  // 格式化日期
+  const formatDate = (dateStr?: string) => {
+    if (!dateStr) return '';
+    const date = new Date(dateStr);
+    return date.toLocaleString('zh-CN');
+  };
+
+  return (
+    <Dialog open={dialogVisible} onOpenChange={setDialogVisible}>
+      <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
+        <DialogHeader>
+          <DialogTitle>{dialogTitle}</DialogTitle>
+        </DialogHeader>
+        
+        <div className="py-4">
+          <div className="flex justify-end mb-4">
+            <Button
+              variant="destructive"
+              onClick={() => handleDelete()}
+              disabled={selectedRows.length === 0 || formLoading}
+            >
+              <Trash2 className="w-4 h-4 mr-2" />
+              删除
+            </Button>
+          </div>
+
+          {formLoading && (
+            <div className="flex items-center justify-center py-8">
+              <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
+            </div>
+          )}
+
+          {!formLoading && (
+            <div className="overflow-x-auto">
+              <table className="w-full border-collapse">
+                <thead>
+                  <tr className="bg-gray-50 border-b">
+                    <th className="px-4 py-2 text-left text-xs font-medium text-gray-600">
+                      <input
+                        type="checkbox"
+                        checked={selectedRows.length === tableData.length && tableData.length > 0}
+                        onChange={(e) => {
+                          if (e.target.checked) {
+                            setSelectedRows(tableData.map(item => item.id));
+                          } else {
+                            setSelectedRows([]);
+                          }
+                        }}
+                      />
+                    </th>
+                    <th className="px-4 py-2 text-left text-xs font-medium text-gray-600">序号</th>
+                    <th className="px-4 py-2 text-left text-xs font-medium text-gray-600">
+                      {dialogTitle === '人员指纹数据' ? '指纹' : '人脸'}
+                    </th>
+                    <th className="px-4 py-2 text-left text-xs font-medium text-gray-600">创建时间</th>
+                    <th className="px-4 py-2 text-left text-xs font-medium text-gray-600">操作</th>
+                  </tr>
+                </thead>
+                <tbody>
+                  {tableData.map((row, index) => (
+                    <tr key={row.id} className="border-b hover:bg-gray-50">
+                      <td className="px-4 py-2">
+                        <input
+                          type="checkbox"
+                          checked={selectedRows.includes(row.id)}
+                          onChange={(e) => {
+                            if (e.target.checked) {
+                              setSelectedRows([...selectedRows, row.id]);
+                            } else {
+                              setSelectedRows(selectedRows.filter(id => id !== row.id));
+                            }
+                          }}
+                        />
+                      </td>
+                      <td className="px-4 py-2 text-sm text-gray-900">{index + 1}</td>
+                      <td className="px-4 py-2">
+                        <div className="relative inline-block group">
+                          <img
+                            src={row.imageUrl}
+                            alt={dialogTitle === '人员指纹数据' ? '指纹' : '人脸'}
+                            className="w-16 h-16 object-cover rounded border border-gray-200 cursor-pointer"
+                            onClick={() => {
+                              // 打开图片预览
+                              window.open(row.imageUrl, '_blank');
+                            }}
+                          />
+                          <div className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded cursor-pointer">
+                            <ZoomIn className="w-6 h-6 text-white" />
+                          </div>
+                        </div>
+                      </td>
+                      <td className="px-4 py-2 text-sm text-gray-900">{formatDate(row.createTime)}</td>
+                      <td className="px-4 py-2">
+                        <Button
+                          variant="ghost"
+                          size="sm"
+                          onClick={() => handleDelete(row.id)}
+                        >
+                          <Trash2 className="w-4 h-4 mr-1" />
+                          删除
+                        </Button>
+                      </td>
+                    </tr>
+                  ))}
+                  {tableData.length === 0 && (
+                    <tr>
+                      <td colSpan={5} className="px-4 py-8 text-center text-gray-500">
+                        暂无数据
+                      </td>
+                    </tr>
+                  )}
+                </tbody>
+              </table>
+            </div>
+          )}
+
+          {/* 分页 */}
+          {total > 0 && (
+            <div className="flex items-center justify-between mt-4 pt-4 border-t">
+              <div className="text-sm text-gray-600">
+                共 <span className="text-blue-600">{total}</span> 条记录
+              </div>
+              <div className="flex gap-2">
+                <Button
+                  variant="outline"
+                  size="sm"
+                  onClick={() => setQueryParams({ ...queryParams, pageNo: queryParams.pageNo - 1 })}
+                  disabled={queryParams.pageNo <= 1}
+                >
+                  上一页
+                </Button>
+                <span className="px-4 py-2 text-sm text-gray-600">
+                  {queryParams.pageNo} / {Math.ceil(total / queryParams.pageSize)}
+                </span>
+                <Button
+                  variant="outline"
+                  size="sm"
+                  onClick={() => setQueryParams({ ...queryParams, pageNo: queryParams.pageNo + 1 })}
+                  disabled={queryParams.pageNo >= Math.ceil(total / queryParams.pageSize)}
+                >
+                  下一页
+                </Button>
+              </div>
+            </div>
+          )}
+        </div>
+
+        <DialogFooter>
+          <Button
+            variant="outline"
+            onClick={() => setDialogVisible(false)}
+          >
+            关闭
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+});
+
+FaceOrFingerForm.displayName = 'FaceOrFingerForm';
+
+export default FaceOrFingerForm;
+

+ 169 - 0
src/components/user/UserAssignRoleForm.tsx

@@ -0,0 +1,169 @@
+import React, { useState, useEffect, useImperativeHandle, forwardRef } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../ui/dialog';
+import { Button } from '../ui/button';
+import { Input } from '../ui/input';
+import { Label } from '../ui/label';
+import { permissionApi } from '../../api/Permission';
+import { systemApi } from '../../api/System';
+import { UserVO, RoleVO } from '../../types';
+import { toast } from 'sonner';
+
+interface UserAssignRoleFormProps {
+  onSuccess?: () => void;
+}
+
+export interface UserAssignRoleFormRef {
+  open: (row: UserVO) => void;
+}
+
+const UserAssignRoleForm = forwardRef<UserAssignRoleFormRef, UserAssignRoleFormProps>(({ onSuccess }, ref) => {
+  const [dialogVisible, setDialogVisible] = useState(false);
+  const [formLoading, setFormLoading] = useState(false);
+  const [formData, setFormData] = useState<{
+    id: number;
+    nickname: string;
+    username: string;
+    roleIds: number[];
+  }>({
+    id: -1,
+    nickname: '',
+    username: '',
+    roleIds: [],
+  });
+  const [roleList, setRoleList] = useState<RoleVO[]>([]);
+
+  // 打开弹窗
+  const open = async (row: UserVO) => {
+    setDialogVisible(true);
+    setFormData({
+      id: row.id,
+      nickname: row.nickname,
+      username: row.username,
+      roleIds: [],
+    });
+
+    // 加载角色列表
+    try {
+      const roles = await systemApi.getSimpleRoleList();
+      setRoleList(roles || []);
+    } catch (error) {
+      console.error('加载角色列表失败:', error);
+    }
+
+    // 获取用户角色列表
+    setFormLoading(true);
+    try {
+      const roleIds = await permissionApi.getUserRoleList(row.id);
+      setFormData(prev => ({ ...prev, roleIds: roleIds || [] }));
+    } catch (error) {
+      console.error('获取用户角色失败:', error);
+    } finally {
+      setFormLoading(false);
+    }
+  };
+
+  useImperativeHandle(ref, () => ({
+    open,
+  }));
+
+  // 提交表单
+  const submitForm = async () => {
+    setFormLoading(true);
+    try {
+      await permissionApi.assignUserRole({
+        userId: formData.id,
+        roleIds: formData.roleIds,
+      });
+      toast.success('分配成功');
+      setDialogVisible(false);
+      onSuccess?.();
+    } catch (error: any) {
+      toast.error(error.message || '分配失败');
+    } finally {
+      setFormLoading(false);
+    }
+  };
+
+  return (
+    <Dialog open={dialogVisible} onOpenChange={setDialogVisible}>
+      <DialogContent className="max-w-md">
+        <DialogHeader>
+          <DialogTitle>分配角色</DialogTitle>
+        </DialogHeader>
+        
+        {formLoading && (
+          <div className="flex items-center justify-center py-8">
+            <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
+          </div>
+        )}
+
+        {!formLoading && (
+          <div className="space-y-4 py-4">
+            <div>
+              <Label htmlFor="username">用户名称</Label>
+              <Input
+                id="username"
+                value={formData.username}
+                disabled
+                className="bg-gray-50"
+              />
+            </div>
+
+            <div>
+              <Label htmlFor="nickname">用户昵称</Label>
+              <Input
+                id="nickname"
+                value={formData.nickname}
+                disabled
+                className="bg-gray-50"
+              />
+            </div>
+
+            <div>
+              <Label htmlFor="roleIds">角色</Label>
+              <select
+                id="roleIds"
+                multiple
+                value={formData.roleIds.map(String)}
+                onChange={(e) => {
+                  const selected = Array.from(e.target.selectedOptions, option => Number(option.value));
+                  setFormData({ ...formData, roleIds: selected });
+                }}
+                className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all text-sm"
+                size={5}
+              >
+                {roleList.map(role => (
+                  <option key={role.id} value={role.id}>
+                    {role.name}
+                  </option>
+                ))}
+              </select>
+              <p className="text-xs text-gray-500 mt-1">按住 Ctrl/Cmd 键可多选</p>
+            </div>
+          </div>
+        )}
+
+        <DialogFooter>
+          <Button
+            variant="outline"
+            onClick={() => setDialogVisible(false)}
+            disabled={formLoading}
+          >
+            取消
+          </Button>
+          <Button
+            onClick={submitForm}
+            disabled={formLoading}
+          >
+            {formLoading ? '提交中...' : '确定'}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+});
+
+UserAssignRoleForm.displayName = 'UserAssignRoleForm';
+
+export default UserAssignRoleForm;
+

+ 328 - 0
src/components/user/UserForm.tsx

@@ -0,0 +1,328 @@
+import React, { useState, useEffect, useImperativeHandle, forwardRef } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../ui/dialog';
+import { Button } from '../ui/button';
+import { Input } from '../ui/input';
+import { Label } from '../ui/label';
+import { Textarea } from '../ui/textarea';
+import { userApi } from '../../api/User';
+import { systemApi } from '../../api/System';
+import { UserVO, WorkstationNode } from '../../types';
+import { toast } from 'sonner';
+
+interface UserFormProps {
+  onSuccess?: () => void;
+}
+
+export interface UserFormRef {
+  open: (type: string, id?: number) => void;
+}
+
+const UserForm = forwardRef<UserFormRef, UserFormProps>(({ onSuccess }, ref) => {
+  const [dialogVisible, setDialogVisible] = useState(false);
+  const [dialogTitle, setDialogTitle] = useState('');
+  const [formLoading, setFormLoading] = useState(false);
+  const [formType, setFormType] = useState<'create' | 'update'>('create');
+  const [formData, setFormData] = useState<Partial<UserVO>>({
+    nickname: '',
+    mobile: '',
+    email: '',
+    username: '',
+    password: '',
+    sex: undefined,
+    remark: '',
+    workstationIds: [],
+    status: 0, // 0-启用
+  });
+  const [workstationList, setWorkstationList] = useState<WorkstationNode[]>([]);
+
+  // 将扁平数据转换为树形结构
+  const handleTree = (list: any[], idKey = 'id', parentKey = 'parentId', childrenKey = 'children'): WorkstationNode[] => {
+    const map = new Map<number, any>();
+    const roots: WorkstationNode[] = [];
+
+    list.forEach(item => {
+      map.set(item[idKey], { ...item, [childrenKey]: [] });
+    });
+
+    list.forEach(item => {
+      const node = map.get(item[idKey]);
+      if (item[parentKey] && map.has(item[parentKey])) {
+        const parent = map.get(item[parentKey]);
+        if (!parent[childrenKey]) {
+          parent[childrenKey] = [];
+        }
+        parent[childrenKey].push(node);
+      } else {
+        roots.push(node);
+      }
+    });
+
+    return roots;
+  };
+
+  // 打开弹窗
+  const open = async (type: string, id?: number) => {
+    setDialogVisible(true);
+    setDialogTitle(type === 'create' ? '新增用户' : '编辑用户');
+    setFormType(type as 'create' | 'update');
+    resetForm();
+
+    // 加载岗位列表
+    try {
+      const data = await systemApi.listMarsDept({ pageNo: 1, pageSize: -1 });
+      const treeData = handleTree(data.list || [], 'id', 'parentId', 'children');
+      setWorkstationList(treeData);
+    } catch (error) {
+      console.error('加载岗位列表失败:', error);
+    }
+
+    // 修改时,设置数据
+    if (id) {
+      setFormLoading(true);
+      try {
+        const userData = await userApi.getUser(id);
+        setFormData(userData);
+      } catch (error) {
+        toast.error('获取用户信息失败');
+      } finally {
+        setFormLoading(false);
+      }
+    }
+  };
+
+  useImperativeHandle(ref, () => ({
+    open,
+  }));
+
+  // 重置表单
+  const resetForm = () => {
+    setFormData({
+      nickname: '',
+      mobile: '',
+      email: '',
+      username: '',
+      password: '',
+      sex: undefined,
+      remark: '',
+      workstationIds: [],
+      status: 0,
+    });
+  };
+
+  // 提交表单
+  const submitForm = async () => {
+    // 基本验证
+    if (!formData.nickname) {
+      toast.error('用户昵称不能为空');
+      return;
+    }
+    if (formType === 'create') {
+      if (!formData.username) {
+        toast.error('工号不能为空');
+        return;
+      }
+      if (!formData.password) {
+        toast.error('用户密码不能为空');
+        return;
+      }
+    }
+    if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
+      toast.error('请输入正确的邮箱地址');
+      return;
+    }
+    if (formData.mobile && !/^(?:(?:\+|00)86)?1(?:3[\d]|4[5-79]|5[0-35-9]|6[5-7]|7[0-8]|8[\d]|9[189])\d{8}$/.test(formData.mobile)) {
+      toast.error('请输入正确的手机号码');
+      return;
+    }
+
+    setFormLoading(true);
+    try {
+      if (formType === 'create') {
+        await userApi.createUser(formData);
+        toast.success('创建成功');
+      } else {
+        await userApi.updateUser(formData);
+        toast.success('更新成功');
+      }
+      setDialogVisible(false);
+      onSuccess?.();
+    } catch (error: any) {
+      toast.error(error.message || '操作失败');
+    } finally {
+      setFormLoading(false);
+    }
+  };
+
+  // 递归渲染岗位树选择(简化版,使用多选)
+  const renderWorkstationOptions = (nodes: WorkstationNode[], level: number = 0): React.ReactNode[] => {
+    return nodes.map(node => (
+      <option key={node.id} value={node.id}>
+        {'  '.repeat(level)}{node.workstationName}
+        {node.children && node.children.length > 0 && renderWorkstationOptions(node.children, level + 1)}
+      </option>
+    ));
+  };
+
+  // 扁平化岗位列表用于选择
+  const flattenWorkstations = (nodes: WorkstationNode[]): WorkstationNode[] => {
+    const result: WorkstationNode[] = [];
+    const traverse = (items: WorkstationNode[]) => {
+      items.forEach(item => {
+        result.push(item);
+        if (item.children) {
+          traverse(item.children);
+        }
+      });
+    };
+    traverse(nodes);
+    return result;
+  };
+
+  const flatWorkstations = flattenWorkstations(workstationList);
+
+  return (
+    <Dialog open={dialogVisible} onOpenChange={setDialogVisible}>
+      <DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
+        <DialogHeader>
+          <DialogTitle>{dialogTitle}</DialogTitle>
+        </DialogHeader>
+        
+        {formLoading && (
+          <div className="flex items-center justify-center py-8">
+            <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
+          </div>
+        )}
+
+        {!formLoading && (
+          <div className="grid grid-cols-2 gap-4 py-4">
+            <div>
+              <Label htmlFor="nickname">用户昵称 *</Label>
+              <Input
+                id="nickname"
+                value={formData.nickname || ''}
+                onChange={(e) => setFormData({ ...formData, nickname: e.target.value })}
+                placeholder="请输入用户昵称"
+              />
+            </div>
+            
+            <div>
+              <Label htmlFor="workstationIds">岗位</Label>
+              <select
+                id="workstationIds"
+                multiple
+                value={formData.workstationIds?.map(String) || []}
+                onChange={(e) => {
+                  const selected = Array.from(e.target.selectedOptions, option => Number(option.value));
+                  setFormData({ ...formData, workstationIds: selected });
+                }}
+                className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all text-sm"
+                size={3}
+              >
+                {flatWorkstations.map(ws => (
+                  <option key={ws.id} value={ws.id}>
+                    {ws.workstationName}
+                  </option>
+                ))}
+              </select>
+              <p className="text-xs text-gray-500 mt-1">按住 Ctrl/Cmd 键可多选</p>
+            </div>
+
+            <div>
+              <Label htmlFor="mobile">手机号码</Label>
+              <Input
+                id="mobile"
+                value={formData.mobile || ''}
+                onChange={(e) => setFormData({ ...formData, mobile: e.target.value })}
+                placeholder="请输入手机号码"
+                maxLength={11}
+              />
+            </div>
+
+            <div>
+              <Label htmlFor="email">邮箱</Label>
+              <Input
+                id="email"
+                type="email"
+                value={formData.email || ''}
+                onChange={(e) => setFormData({ ...formData, email: e.target.value })}
+                placeholder="请输入邮箱"
+                maxLength={50}
+              />
+            </div>
+
+            {formType === 'create' && (
+              <>
+                <div>
+                  <Label htmlFor="username">工号 *</Label>
+                  <Input
+                    id="username"
+                    value={formData.username || ''}
+                    onChange={(e) => setFormData({ ...formData, username: e.target.value })}
+                    placeholder="请输入工号"
+                  />
+                </div>
+
+                <div>
+                  <Label htmlFor="password">用户密码 *</Label>
+                  <Input
+                    id="password"
+                    type="password"
+                    value={formData.password || ''}
+                    onChange={(e) => setFormData({ ...formData, password: e.target.value })}
+                    placeholder="请输入用户密码"
+                  />
+                </div>
+              </>
+            )}
+
+            <div>
+              <Label htmlFor="sex">用户性别</Label>
+              <select
+                id="sex"
+                value={formData.sex ?? ''}
+                onChange={(e) => setFormData({ ...formData, sex: e.target.value ? Number(e.target.value) : undefined })}
+                className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all text-sm"
+              >
+                <option value="">请选择</option>
+                <option value="1">男</option>
+                <option value="2">女</option>
+              </select>
+            </div>
+
+            <div>
+              <Label htmlFor="remark">备注</Label>
+              <Textarea
+                id="remark"
+                value={formData.remark || ''}
+                onChange={(e) => setFormData({ ...formData, remark: e.target.value })}
+                placeholder="请输入内容"
+                rows={3}
+              />
+            </div>
+          </div>
+        )}
+
+        <DialogFooter>
+          <Button
+            variant="outline"
+            onClick={() => setDialogVisible(false)}
+            disabled={formLoading}
+          >
+            取消
+          </Button>
+          <Button
+            onClick={submitForm}
+            disabled={formLoading}
+          >
+            {formLoading ? '提交中...' : '确定'}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+});
+
+UserForm.displayName = 'UserForm';
+
+export default UserForm;
+

+ 194 - 0
src/components/user/UserImportForm.tsx

@@ -0,0 +1,194 @@
+import React, { useState, useImperativeHandle, forwardRef } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../ui/dialog';
+import { Button } from '../ui/button';
+import { Upload, Download, FileSpreadsheet } from 'lucide-react';
+import { userApi } from '../../api/User';
+import { toast } from 'sonner';
+
+interface UserImportFormProps {
+  onSuccess?: () => void;
+}
+
+export interface UserImportFormRef {
+  open: () => void;
+}
+
+const UserImportForm = forwardRef<UserImportFormRef, UserImportFormProps>(({ onSuccess }, ref) => {
+  const [dialogVisible, setDialogVisible] = useState(false);
+  const [formLoading, setFormLoading] = useState(false);
+  const [file, setFile] = useState<File | null>(null);
+  const [updateSupport, setUpdateSupport] = useState(false);
+
+  // 打开弹窗
+  const open = () => {
+    setDialogVisible(true);
+    setFile(null);
+    setUpdateSupport(false);
+  };
+
+  useImperativeHandle(ref, () => ({
+    open,
+  }));
+
+  // 下载模板
+  const importTemplate = async () => {
+    try {
+      const blob = await userApi.importUserTemplate();
+      const url = window.URL.createObjectURL(blob);
+      const link = document.createElement('a');
+      link.href = url;
+      link.download = '用户导入模版.xls';
+      document.body.appendChild(link);
+      link.click();
+      document.body.removeChild(link);
+      window.URL.revokeObjectURL(url);
+      toast.success('模板下载成功');
+    } catch (error: any) {
+      toast.error(error.message || '下载模板失败');
+    }
+  };
+
+  // 提交表单
+  const submitForm = async () => {
+    if (!file) {
+      toast.error('请上传文件');
+      return;
+    }
+
+    setFormLoading(true);
+    try {
+      const response = await userApi.importUser(file, updateSupport ? 1 : 0);
+      
+      // 拼接提示语
+      const data = response;
+      let text = `上传成功数量:${data.createUsernames?.length || 0}; `;
+      if (data.createUsernames?.length > 0) {
+        text += data.createUsernames.map((u: string) => `< ${u} >`).join(' ');
+      }
+      text += `更新成功数量:${data.updateUsernames?.length || 0}; `;
+      if (data.updateUsernames?.length > 0) {
+        text += data.updateUsernames.map((u: string) => `< ${u} >`).join(' ');
+      }
+      text += `更新失败数量:${Object.keys(data.failureUsernames || {}).length}; `;
+      if (data.failureUsernames) {
+        text += Object.entries(data.failureUsernames)
+          .map(([username, reason]) => `< ${username}: ${reason} >`)
+          .join(' ');
+      }
+      
+      toast.success(text, { duration: 5000 });
+      setDialogVisible(false);
+      onSuccess?.();
+    } catch (error: any) {
+      toast.error(error.message || '上传失败,请您重新上传!');
+    } finally {
+      setFormLoading(false);
+    }
+  };
+
+  // 处理文件选择
+  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const selectedFile = e.target.files?.[0];
+    if (selectedFile) {
+      const validTypes = ['.xlsx', '.xls'];
+      const fileExtension = selectedFile.name.substring(selectedFile.name.lastIndexOf('.')).toLowerCase();
+      if (!validTypes.includes(fileExtension)) {
+        toast.error('仅允许导入 xls、xlsx 格式文件');
+        return;
+      }
+      setFile(selectedFile);
+    }
+  };
+
+  return (
+    <Dialog open={dialogVisible} onOpenChange={setDialogVisible}>
+      <DialogContent className="max-w-md">
+        <DialogHeader>
+          <DialogTitle>用户导入</DialogTitle>
+        </DialogHeader>
+        
+        <div className="py-4 space-y-4">
+          <div
+            className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
+              file
+                ? 'border-blue-500 bg-blue-50'
+                : 'border-gray-300 hover:border-gray-400'
+            }`}
+          >
+            <input
+              type="file"
+              id="file-upload"
+              accept=".xlsx,.xls"
+              onChange={handleFileChange}
+              className="hidden"
+              disabled={formLoading}
+            />
+            <label
+              htmlFor="file-upload"
+              className="cursor-pointer flex flex-col items-center gap-2"
+            >
+              <Upload className="w-12 h-12 text-gray-400" />
+              <div className="text-sm text-gray-600">
+                {file ? (
+                  <div className="flex items-center gap-2">
+                    <FileSpreadsheet className="w-5 h-5 text-blue-500" />
+                    <span className="text-blue-600">{file.name}</span>
+                  </div>
+                ) : (
+                  <>
+                    将文件拖到此处,或<em className="text-blue-500">点击上传</em>
+                  </>
+                )}
+              </div>
+            </label>
+          </div>
+
+          <div className="flex items-center gap-2">
+            <input
+              type="checkbox"
+              id="updateSupport"
+              checked={updateSupport}
+              onChange={(e) => setUpdateSupport(e.target.checked)}
+              className="w-4 h-4"
+            />
+            <label htmlFor="updateSupport" className="text-sm text-gray-700">
+              是否更新已经存在的用户数据
+            </label>
+          </div>
+
+          <div className="text-xs text-gray-500 text-center">
+            <span>仅允许导入 xls、xlsx 格式文件。</span>
+            <button
+              type="button"
+              onClick={importTemplate}
+              className="text-blue-500 hover:text-blue-600 ml-1 underline"
+            >
+              下载模板
+            </button>
+          </div>
+        </div>
+
+        <DialogFooter>
+          <Button
+            variant="outline"
+            onClick={() => setDialogVisible(false)}
+            disabled={formLoading}
+          >
+            取消
+          </Button>
+          <Button
+            onClick={submitForm}
+            disabled={formLoading || !file}
+          >
+            {formLoading ? '上传中...' : '确定'}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+});
+
+UserImportForm.displayName = 'UserImportForm';
+
+export default UserImportForm;
+