重构消息通知弹窗
This commit is contained in:
parent
6de57aff67
commit
f1f8b963f1
@ -1,15 +1,8 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState, useMemo, useRef } from 'react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import { PaginatedTable, type ColumnDef, type SearchFieldDef, type PaginatedTableRef } from '@/components/ui/paginated-table';
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table';
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@ -21,47 +14,18 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { Search, RefreshCw, Users, TrendingUp, Clock, LogOut } from 'lucide-react';
|
import { RefreshCw, Users, TrendingUp, Clock, LogOut } from 'lucide-react';
|
||||||
import { getOnlineUsers, getOnlineStatistics, kickUser } from './service';
|
import { getOnlineUsers, getOnlineStatistics, kickUser } from './service';
|
||||||
import type { OnlineUserResponse, OnlineStatistics } from './types';
|
import type { OnlineUserResponse, OnlineStatistics, OnlineUserQuery } from './types';
|
||||||
import type { Page } from '@/types/base';
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
const OnlineUserList: React.FC = () => {
|
const OnlineUserList: React.FC = () => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [loading, setLoading] = useState(false);
|
const tableRef = useRef<PaginatedTableRef<OnlineUserResponse>>(null);
|
||||||
const [data, setData] = useState<Page<OnlineUserResponse> | null>(null);
|
|
||||||
const [statistics, setStatistics] = useState<OnlineStatistics | null>(null);
|
const [statistics, setStatistics] = useState<OnlineStatistics | null>(null);
|
||||||
const [keyword, setKeyword] = useState('');
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [pageSize] = useState(10);
|
|
||||||
const [kickDialogOpen, setKickDialogOpen] = useState(false);
|
const [kickDialogOpen, setKickDialogOpen] = useState(false);
|
||||||
const [selectedUser, setSelectedUser] = useState<OnlineUserResponse | null>(null);
|
const [selectedUser, setSelectedUser] = useState<OnlineUserResponse | null>(null);
|
||||||
|
|
||||||
// 加载在线用户列表
|
|
||||||
const loadData = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const response = await getOnlineUsers({
|
|
||||||
keyword: keyword || undefined,
|
|
||||||
pageNum: currentPage,
|
|
||||||
pageSize,
|
|
||||||
sortField: 'loginTime',
|
|
||||||
sortOrder: 'desc',
|
|
||||||
});
|
|
||||||
setData(response);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载失败:', error);
|
|
||||||
toast({
|
|
||||||
title: '加载失败',
|
|
||||||
description: error instanceof Error ? error.message : '未知错误',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 加载统计数据
|
// 加载统计数据
|
||||||
const loadStatistics = async () => {
|
const loadStatistics = async () => {
|
||||||
try {
|
try {
|
||||||
@ -73,19 +37,22 @@ const OnlineUserList: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
|
||||||
loadStatistics();
|
loadStatistics();
|
||||||
}, [currentPage, pageSize]);
|
}, []);
|
||||||
|
|
||||||
// 搜索
|
// 包装 fetchFn,转换页码(后端从1开始)
|
||||||
const handleSearch = () => {
|
const fetchData = async (query: OnlineUserQuery) => {
|
||||||
setCurrentPage(1);
|
return await getOnlineUsers({
|
||||||
loadData();
|
...query,
|
||||||
|
pageNum: (query.pageNum || 0) + 1, // 转换为从1开始
|
||||||
|
sortField: 'loginTime',
|
||||||
|
sortOrder: 'desc',
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 刷新
|
// 刷新
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
loadData();
|
tableRef.current?.refresh();
|
||||||
loadStatistics();
|
loadStatistics();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -107,7 +74,7 @@ const OnlineUserList: React.FC = () => {
|
|||||||
});
|
});
|
||||||
setKickDialogOpen(false);
|
setKickDialogOpen(false);
|
||||||
setSelectedUser(null);
|
setSelectedUser(null);
|
||||||
loadData();
|
tableRef.current?.refresh();
|
||||||
loadStatistics();
|
loadStatistics();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('强制下线失败:', error);
|
console.error('强制下线失败:', error);
|
||||||
@ -133,10 +100,64 @@ const OnlineUserList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 格式化时间
|
// 搜索字段定义
|
||||||
const formatTime = (time: string): string => {
|
const searchFields: SearchFieldDef[] = useMemo(() => [
|
||||||
return dayjs(time).format('YYYY-MM-DD HH:mm:ss');
|
{ key: 'keyword', type: 'input', placeholder: '搜索用户名/昵称', width: 'w-[220px]' },
|
||||||
};
|
], []);
|
||||||
|
|
||||||
|
// 列定义
|
||||||
|
const columns: ColumnDef<OnlineUserResponse>[] = useMemo(() => [
|
||||||
|
{ key: 'username', title: '用户名', dataIndex: 'username', width: '120px' },
|
||||||
|
{ key: 'nickname', title: '昵称', dataIndex: 'nickname', width: '120px' },
|
||||||
|
{ key: 'departmentName', title: '部门', dataIndex: 'departmentName', width: '120px' },
|
||||||
|
{
|
||||||
|
key: 'loginTime',
|
||||||
|
title: '登录时间',
|
||||||
|
width: '180px',
|
||||||
|
render: (_, record) => dayjs(record.loginTime).format('YYYY-MM-DD HH:mm:ss'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'onlineDuration',
|
||||||
|
title: '在线时长',
|
||||||
|
width: '120px',
|
||||||
|
render: (_, record) => formatDuration(record.onlineDuration),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lastActiveTime',
|
||||||
|
title: '最后活跃',
|
||||||
|
width: '180px',
|
||||||
|
render: (_, record) => dayjs(record.lastActiveTime).format('YYYY-MM-DD HH:mm:ss'),
|
||||||
|
},
|
||||||
|
{ key: 'ipAddress', title: 'IP地址', dataIndex: 'ipAddress', width: '140px' },
|
||||||
|
{ key: 'browser', title: '浏览器', dataIndex: 'browser', width: '120px' },
|
||||||
|
{ key: 'os', title: '操作系统', dataIndex: 'os', width: '120px' },
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
title: '操作',
|
||||||
|
width: '120px',
|
||||||
|
sticky: true,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleKickClick(record)}
|
||||||
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4 mr-1" />
|
||||||
|
强制下线
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], []);
|
||||||
|
|
||||||
|
// 工具栏
|
||||||
|
const toolbar = (
|
||||||
|
<Button onClick={handleRefresh} variant="outline" size="sm">
|
||||||
|
<RefreshCw className="h-4 w-4 mr-1" />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
@ -185,120 +206,19 @@ const OnlineUserList: React.FC = () => {
|
|||||||
{/* 主内容卡片 */}
|
{/* 主内容卡片 */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle>在线用户列表</CardTitle>
|
<CardTitle>在线用户列表</CardTitle>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
placeholder="搜索用户名/昵称"
|
|
||||||
value={keyword}
|
|
||||||
onChange={(e) => setKeyword(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
|
||||||
className="w-64"
|
|
||||||
/>
|
|
||||||
<Button onClick={handleSearch} size="sm">
|
|
||||||
<Search className="h-4 w-4 mr-1" />
|
|
||||||
搜索
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Button onClick={handleRefresh} variant="outline" size="sm">
|
|
||||||
<RefreshCw className="h-4 w-4 mr-1" />
|
|
||||||
刷新
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<Separator />
|
||||||
<div className="rounded-md border">
|
<CardContent className="pt-6">
|
||||||
<Table minWidth="1400px">
|
<PaginatedTable<OnlineUserResponse, OnlineUserQuery>
|
||||||
<TableHeader>
|
ref={tableRef}
|
||||||
<TableRow>
|
fetchFn={fetchData}
|
||||||
<TableHead width="100px">用户名</TableHead>
|
columns={columns}
|
||||||
<TableHead width="100px">昵称</TableHead>
|
searchFields={searchFields}
|
||||||
<TableHead width="120px">部门</TableHead>
|
toolbar={toolbar}
|
||||||
<TableHead width="160px">登录时间</TableHead>
|
rowKey="userId"
|
||||||
<TableHead width="100px">在线时长</TableHead>
|
minWidth="1400px"
|
||||||
<TableHead width="160px">最后活跃</TableHead>
|
/>
|
||||||
<TableHead width="130px">IP地址</TableHead>
|
|
||||||
<TableHead width="110px">浏览器</TableHead>
|
|
||||||
<TableHead width="120px">操作系统</TableHead>
|
|
||||||
<TableHead width="120px" sticky>操作</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{loading ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={10} className="text-center py-8 text-muted-foreground">
|
|
||||||
加载中...
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : data?.content && data.content.length > 0 ? (
|
|
||||||
data.content.map((user) => (
|
|
||||||
<TableRow key={user.userId}>
|
|
||||||
<TableCell width="100px" className="font-medium">{user.username}</TableCell>
|
|
||||||
<TableCell width="100px">{user.nickname}</TableCell>
|
|
||||||
<TableCell width="120px">{user.departmentName || '-'}</TableCell>
|
|
||||||
<TableCell width="160px">{formatTime(user.loginTime)}</TableCell>
|
|
||||||
<TableCell width="100px">{formatDuration(user.onlineDuration)}</TableCell>
|
|
||||||
<TableCell width="160px">{formatTime(user.lastActiveTime)}</TableCell>
|
|
||||||
<TableCell width="130px">{user.ipAddress || '-'}</TableCell>
|
|
||||||
<TableCell width="110px">{user.browser || '-'}</TableCell>
|
|
||||||
<TableCell width="120px">{user.os || '-'}</TableCell>
|
|
||||||
<TableCell width="120px" sticky>
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleKickClick(user)}
|
|
||||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
|
||||||
>
|
|
||||||
<LogOut className="h-4 w-4 mr-1" />
|
|
||||||
强制下线
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={10} className="text-center py-8 text-muted-foreground">
|
|
||||||
暂无数据
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 分页 */}
|
|
||||||
{data && data.totalElements > 0 && (
|
|
||||||
<div className="flex items-center justify-between mt-4">
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
共 {data.totalElements} 条记录,第 {currentPage} / {data.totalPages} 页
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setCurrentPage(currentPage - 1)}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
>
|
|
||||||
上一页
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setCurrentPage(currentPage + 1)}
|
|
||||||
disabled={currentPage >= data.totalPages}
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@ -1,19 +1,16 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useMemo, useRef } from 'react';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { PaginatedTable, type ColumnDef, type SearchFieldDef, type PaginatedTableRef } from '@/components/ui/paginated-table';
|
||||||
import { DataTablePagination } from '@/components/ui/pagination';
|
|
||||||
import {
|
import {
|
||||||
Loader2, Plus, Search, Edit, Trash2, KeyRound, Tag as TagIcon,
|
Plus, Edit, Trash2, KeyRound, Tag as TagIcon,
|
||||||
ShieldCheck, Settings, Shield
|
ShieldCheck, Settings, Shield
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { getRoleList, deleteRole, getRoleMenusAndPermissions, assignMenusAndPermissions } from './service';
|
import { getRoleList, deleteRole, getRoleMenusAndPermissions, assignMenusAndPermissions } from './service';
|
||||||
import type { RoleResponse, RoleQuery } from './types';
|
import type { RoleResponse, RoleQuery } from './types';
|
||||||
import type { Page } from '@/types/base';
|
|
||||||
import { DEFAULT_PAGE_SIZE, DEFAULT_PAGE_NUM } from '@/utils/page';
|
|
||||||
import EditDialog from './components/EditDialog';
|
import EditDialog from './components/EditDialog';
|
||||||
import DeleteDialog from './components/DeleteDialog';
|
import DeleteDialog from './components/DeleteDialog';
|
||||||
import PermissionDialog from './components/PermissionDialog';
|
import PermissionDialog from './components/PermissionDialog';
|
||||||
@ -26,8 +23,8 @@ import dayjs from 'dayjs';
|
|||||||
*/
|
*/
|
||||||
const RolePage: React.FC = () => {
|
const RolePage: React.FC = () => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [loading, setLoading] = useState(false);
|
const tableRef = useRef<PaginatedTableRef<RoleResponse>>(null);
|
||||||
const [data, setData] = useState<Page<RoleResponse> | null>(null);
|
const [stats, setStats] = useState({ total: 0 });
|
||||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
const [editRecord, setEditRecord] = useState<RoleResponse>();
|
const [editRecord, setEditRecord] = useState<RoleResponse>();
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
@ -38,63 +35,30 @@ const RolePage: React.FC = () => {
|
|||||||
const [selectedRole, setSelectedRole] = useState<RoleResponse | null>(null);
|
const [selectedRole, setSelectedRole] = useState<RoleResponse | null>(null);
|
||||||
const [defaultMenuIds, setDefaultMenuIds] = useState<number[]>([]);
|
const [defaultMenuIds, setDefaultMenuIds] = useState<number[]>([]);
|
||||||
const [defaultPermissionIds, setDefaultPermissionIds] = useState<number[]>([]);
|
const [defaultPermissionIds, setDefaultPermissionIds] = useState<number[]>([]);
|
||||||
const [query, setQuery] = useState<RoleQuery>({
|
|
||||||
pageNum: DEFAULT_PAGE_NUM,
|
|
||||||
pageSize: DEFAULT_PAGE_SIZE,
|
|
||||||
code: '',
|
|
||||||
name: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
// 加载数据
|
// 包装 fetchFn,同时更新统计数据
|
||||||
const loadData = async () => {
|
const fetchData = async (query: RoleQuery) => {
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const result = await getRoleList(query);
|
const result = await getRoleList(query);
|
||||||
setData(result);
|
setStats({ total: result.totalElements || 0 });
|
||||||
} catch (error) {
|
return result;
|
||||||
console.error('加载角色列表失败:', error);
|
|
||||||
toast({
|
|
||||||
title: '加载失败',
|
|
||||||
description: error instanceof Error ? error.message : '未知错误',
|
|
||||||
variant: 'destructive'
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadData();
|
|
||||||
}, [query]);
|
|
||||||
|
|
||||||
// 搜索
|
|
||||||
const handleSearch = () => {
|
|
||||||
setQuery(prev => ({ ...prev, pageNum: 0 }));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 重置
|
|
||||||
const handleReset = () => {
|
|
||||||
setQuery({
|
|
||||||
pageNum: 0,
|
|
||||||
pageSize: DEFAULT_PAGE_SIZE,
|
|
||||||
code: '',
|
|
||||||
name: '',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 新建
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
setEditRecord(undefined);
|
setEditRecord(undefined);
|
||||||
setEditDialogOpen(true);
|
setEditDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 编辑
|
|
||||||
const handleEdit = (record: RoleResponse) => {
|
const handleEdit = (record: RoleResponse) => {
|
||||||
setEditRecord(record);
|
setEditRecord(record);
|
||||||
setEditDialogOpen(true);
|
setEditDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 删除
|
const handleSuccess = () => {
|
||||||
|
setEditDialogOpen(false);
|
||||||
|
setEditRecord(undefined);
|
||||||
|
tableRef.current?.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = (record: RoleResponse) => {
|
const handleDelete = (record: RoleResponse) => {
|
||||||
setDeleteRecord(record);
|
setDeleteRecord(record);
|
||||||
setDeleteDialogOpen(true);
|
setDeleteDialogOpen(true);
|
||||||
@ -108,7 +72,7 @@ const RolePage: React.FC = () => {
|
|||||||
title: '删除成功',
|
title: '删除成功',
|
||||||
description: `角色 "${deleteRecord.name}" 已删除`,
|
description: `角色 "${deleteRecord.name}" 已删除`,
|
||||||
});
|
});
|
||||||
loadData();
|
tableRef.current?.refresh();
|
||||||
setDeleteDialogOpen(false);
|
setDeleteDialogOpen(false);
|
||||||
setDeleteRecord(null);
|
setDeleteRecord(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -148,7 +112,7 @@ const RolePage: React.FC = () => {
|
|||||||
description: `已为角色 "${selectedRole.name}" 分配权限`,
|
description: `已为角色 "${selectedRole.name}" 分配权限`,
|
||||||
});
|
});
|
||||||
setPermissionDialogOpen(false);
|
setPermissionDialogOpen(false);
|
||||||
loadData();
|
tableRef.current?.refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('分配权限失败:', error);
|
console.error('分配权限失败:', error);
|
||||||
toast({
|
toast({
|
||||||
@ -165,125 +129,40 @@ const RolePage: React.FC = () => {
|
|||||||
setAssignTagDialogOpen(true);
|
setAssignTagDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 统计数据
|
// 搜索字段定义
|
||||||
const stats = useMemo(() => {
|
const searchFields: SearchFieldDef[] = useMemo(() => [
|
||||||
const total = data?.totalElements || 0;
|
{ key: 'code', type: 'input', placeholder: '搜索角色编码', width: 'w-[180px]' },
|
||||||
return { total };
|
{ key: 'name', type: 'input', placeholder: '搜索角色名称', width: 'w-[180px]' },
|
||||||
}, [data]);
|
], []);
|
||||||
|
|
||||||
const pageCount = data?.totalElements ? Math.ceil(data.totalElements / (query.pageSize || DEFAULT_PAGE_SIZE)) : 0;
|
// 列定义
|
||||||
|
const columns: ColumnDef<RoleResponse>[] = useMemo(() => [
|
||||||
return (
|
{ key: 'id', title: 'ID', dataIndex: 'id', width: '80px' },
|
||||||
<div className="p-6">
|
{
|
||||||
<div className="mb-6">
|
key: 'code',
|
||||||
<h1 className="text-3xl font-bold text-foreground">角色管理</h1>
|
title: '角色编码',
|
||||||
<p className="text-muted-foreground mt-2">
|
width: '150px',
|
||||||
管理系统角色,分配权限和标签。
|
render: (_, record) => <code className="text-sm">{record.code}</code>,
|
||||||
</p>
|
},
|
||||||
</div>
|
{ key: 'name', title: '角色名称', dataIndex: 'name', width: '150px' },
|
||||||
|
{
|
||||||
{/* 统计卡片 */}
|
key: 'isAdmin',
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
|
title: '类型',
|
||||||
<Card className="bg-gradient-to-br from-purple-500/10 to-purple-500/5 border-purple-500/20">
|
width: '120px',
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
render: (_, record) => record.isAdmin ? (
|
||||||
<CardTitle className="text-sm font-medium text-purple-700">总角色数</CardTitle>
|
|
||||||
<ShieldCheck className="h-4 w-4 text-purple-500" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{stats.total}</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">所有系统角色</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
|
||||||
<CardTitle>角色列表</CardTitle>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" onClick={() => setTagDialogOpen(true)}>
|
|
||||||
<Settings className="h-4 w-4 mr-2" />
|
|
||||||
标签管理
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleCreate}>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
新建角色
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{/* 搜索栏 */}
|
|
||||||
<div className="flex flex-wrap items-center gap-4 mb-4">
|
|
||||||
<div className="flex-1 max-w-xs">
|
|
||||||
<Input
|
|
||||||
placeholder="搜索角色编码"
|
|
||||||
value={query.code}
|
|
||||||
onChange={(e) => setQuery(prev => ({ ...prev, code: e.target.value }))}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 max-w-xs">
|
|
||||||
<Input
|
|
||||||
placeholder="搜索角色名称"
|
|
||||||
value={query.name}
|
|
||||||
onChange={(e) => setQuery(prev => ({ ...prev, name: e.target.value }))}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button onClick={handleSearch} className="h-9">
|
|
||||||
<Search className="h-4 w-4 mr-2" />
|
|
||||||
搜索
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" onClick={handleReset} className="h-9">
|
|
||||||
重置
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 表格 */}
|
|
||||||
<div className="rounded-md border">
|
|
||||||
<Table minWidth="1280px">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead width="150px">角色编码</TableHead>
|
|
||||||
<TableHead width="150px">角色名称</TableHead>
|
|
||||||
<TableHead width="100px">类型</TableHead>
|
|
||||||
<TableHead width="200px">标签</TableHead>
|
|
||||||
<TableHead width="100px">排序</TableHead>
|
|
||||||
<TableHead width="200px">描述</TableHead>
|
|
||||||
<TableHead width="180px">创建时间</TableHead>
|
|
||||||
<TableHead width="200px" sticky>操作</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{loading ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={8} className="h-24 text-center">
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin mr-2" />
|
|
||||||
<span className="text-sm text-muted-foreground">加载中...</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : data?.content && data.content.length > 0 ? (
|
|
||||||
data.content.map((record) => {
|
|
||||||
return (
|
|
||||||
<TableRow key={record.id} className="hover:bg-muted/50">
|
|
||||||
<TableCell width="150px" className="font-medium">
|
|
||||||
<code className="text-sm">{record.code}</code>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell width="150px">{record.name}</TableCell>
|
|
||||||
<TableCell width="100px">
|
|
||||||
{record.isAdmin ? (
|
|
||||||
<Badge variant="default" className="bg-amber-500 hover:bg-amber-600">
|
<Badge variant="default" className="bg-amber-500 hover:bg-amber-600">
|
||||||
<Shield className="h-3 w-3 mr-1" />
|
<Shield className="h-3 w-3 mr-1" />
|
||||||
管理员
|
管理员
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="outline">普通角色</Badge>
|
<Badge variant="outline">普通角色</Badge>
|
||||||
)}
|
),
|
||||||
</TableCell>
|
},
|
||||||
<TableCell width="200px">
|
{
|
||||||
|
key: 'tags',
|
||||||
|
title: '标签',
|
||||||
|
width: '200px',
|
||||||
|
render: (_, record) => (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{record.tags && record.tags.length > 0 ? (
|
{record.tags && record.tags.length > 0 ? (
|
||||||
record.tags.map(tag => (
|
record.tags.map(tag => (
|
||||||
@ -300,22 +179,33 @@ const RolePage: React.FC = () => {
|
|||||||
<span className="text-muted-foreground">-</span>
|
<span className="text-muted-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
),
|
||||||
<TableCell width="100px">{record.sort}</TableCell>
|
},
|
||||||
<TableCell width="200px" className="text-sm max-w-[200px] truncate" title={record.description}>
|
{ key: 'sort', title: '排序', dataIndex: 'sort', width: '80px' },
|
||||||
|
{
|
||||||
|
key: 'description',
|
||||||
|
title: '描述',
|
||||||
|
width: '200px',
|
||||||
|
render: (_, record) => (
|
||||||
|
<div className="truncate max-w-[200px]" title={record.description || ''}>
|
||||||
{record.description || '-'}
|
{record.description || '-'}
|
||||||
</TableCell>
|
</div>
|
||||||
<TableCell width="180px" className="text-sm">
|
),
|
||||||
{record.createTime ? dayjs(record.createTime).format('YYYY-MM-DD HH:mm') : '-'}
|
},
|
||||||
</TableCell>
|
{
|
||||||
<TableCell width="200px" sticky>
|
key: 'createTime',
|
||||||
<div className="flex justify-end gap-2">
|
title: '创建时间',
|
||||||
<Button
|
width: '180px',
|
||||||
variant="ghost"
|
render: (_, record) => record.createTime ? dayjs(record.createTime).format('YYYY-MM-DD HH:mm') : '-',
|
||||||
size="sm"
|
},
|
||||||
onClick={() => handleEdit(record)}
|
{
|
||||||
title="编辑"
|
key: 'actions',
|
||||||
>
|
title: '操作',
|
||||||
|
width: '200px',
|
||||||
|
sticky: true,
|
||||||
|
render: (_, record) => (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => handleEdit(record)} title="编辑">
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@ -348,37 +238,62 @@ const RolePage: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
),
|
||||||
</TableRow>
|
},
|
||||||
);
|
], []);
|
||||||
})
|
|
||||||
) : (
|
// 工具栏
|
||||||
<TableRow>
|
const toolbar = (
|
||||||
<TableCell colSpan={8} className="h-24 text-center">
|
<div className="flex gap-2">
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
<Button variant="outline" onClick={() => setTagDialogOpen(true)}>
|
||||||
<ShieldCheck className="w-16 h-16 mb-4 text-muted-foreground/50" />
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
<div className="text-lg font-semibold mb-2">暂无角色数据</div>
|
标签管理
|
||||||
<div className="text-sm">点击右上角"新建角色"开始创建角色。</div>
|
</Button>
|
||||||
|
<Button onClick={handleCreate}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
新建角色
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
);
|
||||||
</TableRow>
|
|
||||||
)}
|
return (
|
||||||
</TableBody>
|
<div className="p-6">
|
||||||
</Table>
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-foreground">角色管理</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
管理系统角色,分配权限和标签。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 分页 */}
|
{/* 统计卡片 */}
|
||||||
{pageCount > 1 && (
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
|
||||||
<DataTablePagination
|
<Card className="bg-gradient-to-br from-purple-500/10 to-purple-500/5 border-purple-500/20">
|
||||||
pageIndex={query.pageNum || 0}
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
pageSize={query.pageSize || DEFAULT_PAGE_SIZE}
|
<CardTitle className="text-sm font-medium text-purple-700">总角色数</CardTitle>
|
||||||
pageCount={pageCount}
|
<ShieldCheck className="h-4 w-4 text-purple-500" />
|
||||||
onPageChange={(page) => setQuery(prev => ({
|
</CardHeader>
|
||||||
...prev,
|
<CardContent>
|
||||||
pageNum: page - 1
|
<div className="text-2xl font-bold">{stats.total}</div>
|
||||||
}))}
|
<p className="text-xs text-muted-foreground mt-1">所有系统角色</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>角色列表</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<Separator />
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<PaginatedTable<RoleResponse, RoleQuery>
|
||||||
|
ref={tableRef}
|
||||||
|
fetchFn={fetchData}
|
||||||
|
columns={columns}
|
||||||
|
searchFields={searchFields}
|
||||||
|
toolbar={toolbar}
|
||||||
|
rowKey="id"
|
||||||
|
minWidth="1280px"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@ -387,7 +302,7 @@ const RolePage: React.FC = () => {
|
|||||||
open={editDialogOpen}
|
open={editDialogOpen}
|
||||||
record={editRecord}
|
record={editRecord}
|
||||||
onOpenChange={setEditDialogOpen}
|
onOpenChange={setEditDialogOpen}
|
||||||
onSuccess={loadData}
|
onSuccess={handleSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 删除确认对话框 */}
|
{/* 删除确认对话框 */}
|
||||||
@ -414,7 +329,7 @@ const RolePage: React.FC = () => {
|
|||||||
onOpenChange={setAssignTagDialogOpen}
|
onOpenChange={setAssignTagDialogOpen}
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
setAssignTagDialogOpen(false);
|
setAssignTagDialogOpen(false);
|
||||||
loadData();
|
tableRef.current?.refresh();
|
||||||
}}
|
}}
|
||||||
selectedTags={selectedRole.tags}
|
selectedTags={selectedRole.tags}
|
||||||
/>
|
/>
|
||||||
@ -427,7 +342,7 @@ const RolePage: React.FC = () => {
|
|||||||
onOpenChange={setTagDialogOpen}
|
onOpenChange={setTagDialogOpen}
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
setTagDialogOpen(false);
|
setTagDialogOpen(false);
|
||||||
loadData();
|
tableRef.current?.refresh();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,13 +1,11 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { PaginatedTable, type ColumnDef, type SearchFieldDef, type PaginatedTableRef } from '@/components/ui/paginated-table';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { DataTablePagination } from '@/components/ui/pagination';
|
|
||||||
import {
|
import {
|
||||||
Loader2, Plus, Search, Edit, Trash2, KeyRound, Users as UsersIcon,
|
Plus, Edit, Trash2, KeyRound, Users as UsersIcon,
|
||||||
UserCheck, UserX, Activity
|
UserCheck, UserX, Activity
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
@ -15,8 +13,6 @@ import { getUsers, deleteUser, resetPassword, assignRoles, getAllRoles } from '.
|
|||||||
import { getDepartmentTree } from '../../Department/List/service';
|
import { getDepartmentTree } from '../../Department/List/service';
|
||||||
import type { UserResponse, UserQuery, Role } from './types';
|
import type { UserResponse, UserQuery, Role } from './types';
|
||||||
import type { DepartmentResponse } from '../../Department/List/types';
|
import type { DepartmentResponse } from '../../Department/List/types';
|
||||||
import type { Page } from '@/types/base';
|
|
||||||
import { DEFAULT_PAGE_SIZE, DEFAULT_PAGE_NUM } from '@/utils/page';
|
|
||||||
import EditModal from './components/EditModal';
|
import EditModal from './components/EditModal';
|
||||||
import ResetPasswordDialog from './components/ResetPasswordDialog';
|
import ResetPasswordDialog from './components/ResetPasswordDialog';
|
||||||
import AssignRolesDialog from './components/AssignRolesDialog';
|
import AssignRolesDialog from './components/AssignRolesDialog';
|
||||||
@ -28,8 +24,8 @@ import dayjs from 'dayjs';
|
|||||||
*/
|
*/
|
||||||
const UserPage: React.FC = () => {
|
const UserPage: React.FC = () => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [loading, setLoading] = useState(false);
|
const tableRef = useRef<PaginatedTableRef<UserResponse>>(null);
|
||||||
const [data, setData] = useState<Page<UserResponse> | null>(null);
|
const [stats, setStats] = useState({ total: 0, enabledCount: 0, disabledCount: 0 });
|
||||||
const [departments, setDepartments] = useState<DepartmentResponse[]>([]);
|
const [departments, setDepartments] = useState<DepartmentResponse[]>([]);
|
||||||
const [allRoles, setAllRoles] = useState<Role[]>([]);
|
const [allRoles, setAllRoles] = useState<Role[]>([]);
|
||||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
@ -40,25 +36,17 @@ const UserPage: React.FC = () => {
|
|||||||
const [assignRolesRecord, setAssignRolesRecord] = useState<UserResponse | null>(null);
|
const [assignRolesRecord, setAssignRolesRecord] = useState<UserResponse | null>(null);
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [deleteRecord, setDeleteRecord] = useState<UserResponse | null>(null);
|
const [deleteRecord, setDeleteRecord] = useState<UserResponse | null>(null);
|
||||||
const [query, setQuery] = useState<UserQuery>({
|
|
||||||
pageNum: DEFAULT_PAGE_NUM,
|
|
||||||
pageSize: DEFAULT_PAGE_SIZE,
|
|
||||||
username: '',
|
|
||||||
email: '',
|
|
||||||
enabled: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 加载数据
|
// 包装 fetchFn,同时更新统计数据
|
||||||
const loadData = async () => {
|
const fetchData = async (query: UserQuery) => {
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const result = await getUsers(query);
|
const result = await getUsers(query);
|
||||||
setData(result);
|
const all = result.content || [];
|
||||||
} catch (error) {
|
setStats({
|
||||||
console.error('加载用户列表失败:', error);
|
total: result.totalElements || 0,
|
||||||
} finally {
|
enabledCount: all.filter(d => d.enabled).length,
|
||||||
setLoading(false);
|
disabledCount: all.filter(d => !d.enabled).length,
|
||||||
}
|
});
|
||||||
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 加载部门和角色
|
// 加载部门和角色
|
||||||
@ -79,38 +67,22 @@ const UserPage: React.FC = () => {
|
|||||||
loadDepartmentsAndRoles();
|
loadDepartmentsAndRoles();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadData();
|
|
||||||
}, [query]);
|
|
||||||
|
|
||||||
// 搜索
|
|
||||||
const handleSearch = () => {
|
|
||||||
setQuery(prev => ({ ...prev, pageNum: 0 }));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 重置
|
|
||||||
const handleReset = () => {
|
|
||||||
setQuery({
|
|
||||||
pageNum: 0,
|
|
||||||
pageSize: DEFAULT_PAGE_SIZE,
|
|
||||||
username: '',
|
|
||||||
email: '',
|
|
||||||
enabled: undefined,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 新建
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
setEditRecord(undefined);
|
setEditRecord(undefined);
|
||||||
setEditModalOpen(true);
|
setEditModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 编辑
|
|
||||||
const handleEdit = (record: UserResponse) => {
|
const handleEdit = (record: UserResponse) => {
|
||||||
setEditRecord(record);
|
setEditRecord(record);
|
||||||
setEditModalOpen(true);
|
setEditModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSuccess = () => {
|
||||||
|
setEditModalOpen(false);
|
||||||
|
setEditRecord(undefined);
|
||||||
|
tableRef.current?.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
// 重置密码
|
// 重置密码
|
||||||
const handleResetPassword = (record: UserResponse) => {
|
const handleResetPassword = (record: UserResponse) => {
|
||||||
setResetPasswordRecord(record);
|
setResetPasswordRecord(record);
|
||||||
@ -151,7 +123,7 @@ const UserPage: React.FC = () => {
|
|||||||
title: '分配成功',
|
title: '分配成功',
|
||||||
description: `已为用户 "${assignRolesRecord.username}" 分配角色`,
|
description: `已为用户 "${assignRolesRecord.username}" 分配角色`,
|
||||||
});
|
});
|
||||||
loadData();
|
tableRef.current?.refresh();
|
||||||
setAssignRolesDialogOpen(false);
|
setAssignRolesDialogOpen(false);
|
||||||
setAssignRolesRecord(null);
|
setAssignRolesRecord(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -178,7 +150,7 @@ const UserPage: React.FC = () => {
|
|||||||
title: '删除成功',
|
title: '删除成功',
|
||||||
description: `用户 "${deleteRecord.username}" 已删除`,
|
description: `用户 "${deleteRecord.username}" 已删除`,
|
||||||
});
|
});
|
||||||
loadData();
|
tableRef.current?.refresh();
|
||||||
setDeleteDialogOpen(false);
|
setDeleteDialogOpen(false);
|
||||||
setDeleteRecord(null);
|
setDeleteRecord(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -191,9 +163,53 @@ const UserPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 状态徽章
|
// 搜索字段定义
|
||||||
const getStatusBadge = (enabled: boolean) => {
|
const searchFields: SearchFieldDef[] = useMemo(() => [
|
||||||
return enabled ? (
|
{ key: 'username', type: 'input', placeholder: '搜索用户名', width: 'w-[180px]' },
|
||||||
|
{ key: 'email', type: 'input', placeholder: '搜索邮箱', width: 'w-[180px]' },
|
||||||
|
{
|
||||||
|
key: 'enabled',
|
||||||
|
type: 'select',
|
||||||
|
placeholder: '状态',
|
||||||
|
width: 'w-[120px]',
|
||||||
|
options: [
|
||||||
|
{ label: '启用', value: 'true' },
|
||||||
|
{ label: '禁用', value: 'false' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
], []);
|
||||||
|
|
||||||
|
// 列定义
|
||||||
|
const columns: ColumnDef<UserResponse>[] = useMemo(() => [
|
||||||
|
{ key: 'id', title: 'ID', dataIndex: 'id', width: '80px' },
|
||||||
|
{ key: 'username', title: '用户名', dataIndex: 'username', width: '120px' },
|
||||||
|
{ key: 'nickname', title: '昵称', dataIndex: 'nickname', width: '120px' },
|
||||||
|
{ key: 'email', title: '邮箱', dataIndex: 'email', width: '200px' },
|
||||||
|
{ key: 'phone', title: '手机号', dataIndex: 'phone', width: '120px' },
|
||||||
|
{ key: 'departmentName', title: '部门', dataIndex: 'departmentName', width: '120px' },
|
||||||
|
{
|
||||||
|
key: 'roles',
|
||||||
|
title: '角色',
|
||||||
|
width: '150px',
|
||||||
|
render: (_, record) => (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{record.roles && record.roles.length > 0 ? (
|
||||||
|
record.roles.map(role => (
|
||||||
|
<Badge key={role.id} variant="outline" className="text-xs">
|
||||||
|
{role.name}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'enabled',
|
||||||
|
title: '状态',
|
||||||
|
width: '100px',
|
||||||
|
render: (_, record) => record.enabled ? (
|
||||||
<Badge variant="success" className="inline-flex items-center gap-1">
|
<Badge variant="success" className="inline-flex items-center gap-1">
|
||||||
<UserCheck className="h-3 w-3" />
|
<UserCheck className="h-3 w-3" />
|
||||||
启用
|
启用
|
||||||
@ -203,18 +219,69 @@ const UserPage: React.FC = () => {
|
|||||||
<UserX className="h-3 w-3" />
|
<UserX className="h-3 w-3" />
|
||||||
禁用
|
禁用
|
||||||
</Badge>
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'createTime',
|
||||||
|
title: '创建时间',
|
||||||
|
width: '180px',
|
||||||
|
render: (_, record) => record.createTime ? dayjs(record.createTime).format('YYYY-MM-DD HH:mm') : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
title: '操作',
|
||||||
|
width: '200px',
|
||||||
|
sticky: true,
|
||||||
|
render: (_, record) => {
|
||||||
|
const isAdmin = record.username === 'admin';
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => handleEdit(record)} title="编辑">
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleResetPassword(record)}
|
||||||
|
title="重置密码"
|
||||||
|
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
||||||
|
>
|
||||||
|
<KeyRound className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAssignRoles(record)}
|
||||||
|
title="分配角色"
|
||||||
|
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
|
||||||
|
disabled={isAdmin}
|
||||||
|
>
|
||||||
|
<UsersIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{!isAdmin && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(record)}
|
||||||
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
},
|
||||||
|
},
|
||||||
|
], []);
|
||||||
|
|
||||||
// 统计数据
|
// 工具栏
|
||||||
const stats = useMemo(() => {
|
const toolbar = (
|
||||||
const total = data?.totalElements || 0;
|
<Button onClick={handleCreate}>
|
||||||
const enabledCount = data?.content?.filter(d => d.enabled).length || 0;
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
const disabledCount = data?.content?.filter(d => !d.enabled).length || 0;
|
新增用户
|
||||||
return { total, enabledCount, disabledCount };
|
</Button>
|
||||||
}, [data]);
|
);
|
||||||
|
|
||||||
const pageCount = data?.totalElements ? Math.ceil(data.totalElements / (query.pageSize || DEFAULT_PAGE_SIZE)) : 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
@ -260,186 +327,20 @@ const UserPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader>
|
||||||
<CardTitle>用户列表</CardTitle>
|
<CardTitle>用户列表</CardTitle>
|
||||||
<Button onClick={handleCreate}>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
新增用户
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<Separator />
|
||||||
{/* 搜索栏 */}
|
<CardContent className="pt-6">
|
||||||
<div className="flex flex-wrap items-center gap-4 mb-4">
|
<PaginatedTable<UserResponse, UserQuery>
|
||||||
<div className="flex-1 max-w-xs">
|
ref={tableRef}
|
||||||
<Input
|
fetchFn={fetchData}
|
||||||
placeholder="搜索用户名"
|
columns={columns}
|
||||||
value={query.username}
|
searchFields={searchFields}
|
||||||
onChange={(e) => setQuery(prev => ({ ...prev, username: e.target.value }))}
|
toolbar={toolbar}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
rowKey="id"
|
||||||
className="h-9"
|
minWidth="1340px"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div className="flex-1 max-w-xs">
|
|
||||||
<Input
|
|
||||||
placeholder="搜索邮箱"
|
|
||||||
value={query.email}
|
|
||||||
onChange={(e) => setQuery(prev => ({ ...prev, email: e.target.value }))}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
value={query.enabled === undefined ? 'all' : query.enabled ? 'true' : 'false'}
|
|
||||||
onValueChange={(value) => setQuery(prev => ({
|
|
||||||
...prev,
|
|
||||||
enabled: value === 'all' ? undefined : value === 'true'
|
|
||||||
}))}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[130px] h-9">
|
|
||||||
<SelectValue placeholder="全部状态" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">全部状态</SelectItem>
|
|
||||||
<SelectItem value="true">启用</SelectItem>
|
|
||||||
<SelectItem value="false">禁用</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Button onClick={handleSearch} className="h-9">
|
|
||||||
<Search className="h-4 w-4 mr-2" />
|
|
||||||
搜索
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" onClick={handleReset} className="h-9">
|
|
||||||
重置
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 表格 */}
|
|
||||||
<div className="rounded-md border">
|
|
||||||
<Table minWidth="1340px">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead width="120px">用户名</TableHead>
|
|
||||||
<TableHead width="150px">昵称</TableHead>
|
|
||||||
<TableHead width="200px">邮箱</TableHead>
|
|
||||||
<TableHead width="120px">手机号</TableHead>
|
|
||||||
<TableHead width="120px">部门</TableHead>
|
|
||||||
<TableHead width="150px">角色</TableHead>
|
|
||||||
<TableHead width="100px">状态</TableHead>
|
|
||||||
<TableHead width="180px">创建时间</TableHead>
|
|
||||||
<TableHead width="200px" sticky>操作</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{loading ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={9} className="h-24 text-center">
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin mr-2" />
|
|
||||||
<span className="text-sm text-muted-foreground">加载中...</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : data?.content && data.content.length > 0 ? (
|
|
||||||
data.content.map((record) => {
|
|
||||||
const isAdmin = record.username === 'admin';
|
|
||||||
return (
|
|
||||||
<TableRow key={record.id} className="hover:bg-muted/50">
|
|
||||||
<TableCell width="120px" className="font-medium">
|
|
||||||
{record.username}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell width="150px">{record.nickname || '-'}</TableCell>
|
|
||||||
<TableCell width="200px" className="text-sm">{record.email || '-'}</TableCell>
|
|
||||||
<TableCell width="120px" className="text-sm">{record.phone || '-'}</TableCell>
|
|
||||||
<TableCell width="120px">{record.departmentName || '-'}</TableCell>
|
|
||||||
<TableCell width="150px">
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{record.roles && record.roles.length > 0 ? (
|
|
||||||
record.roles.map(role => (
|
|
||||||
<Badge key={role.id} variant="outline" className="text-xs">
|
|
||||||
{role.name}
|
|
||||||
</Badge>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">-</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell width="100px">{getStatusBadge(record.enabled)}</TableCell>
|
|
||||||
<TableCell width="180px" className="text-sm">
|
|
||||||
{record.createTime ? dayjs(record.createTime).format('YYYY-MM-DD HH:mm') : '-'}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell width="200px" sticky>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleEdit(record)}
|
|
||||||
title="编辑"
|
|
||||||
>
|
|
||||||
<Edit className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleResetPassword(record)}
|
|
||||||
title="重置密码"
|
|
||||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
|
||||||
>
|
|
||||||
<KeyRound className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleAssignRoles(record)}
|
|
||||||
title="分配角色"
|
|
||||||
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
|
|
||||||
disabled={isAdmin}
|
|
||||||
>
|
|
||||||
<UsersIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
{!isAdmin && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDelete(record)}
|
|
||||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
|
||||||
title="删除"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={9} className="h-24 text-center">
|
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
|
||||||
<UsersIcon className="w-16 h-16 mb-4 text-muted-foreground/50" />
|
|
||||||
<div className="text-lg font-semibold mb-2">暂无用户数据</div>
|
|
||||||
<div className="text-sm">点击右上角"新增用户"开始创建用户。</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 分页 */}
|
|
||||||
{pageCount > 1 && (
|
|
||||||
<DataTablePagination
|
|
||||||
pageIndex={query.pageNum || 0}
|
|
||||||
pageSize={query.pageSize || DEFAULT_PAGE_SIZE}
|
|
||||||
pageCount={pageCount}
|
|
||||||
onPageChange={(page) => setQuery(prev => ({
|
|
||||||
...prev,
|
|
||||||
pageNum: page - 1
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@ -449,7 +350,7 @@ const UserPage: React.FC = () => {
|
|||||||
record={editRecord}
|
record={editRecord}
|
||||||
departments={departments}
|
departments={departments}
|
||||||
onOpenChange={setEditModalOpen}
|
onOpenChange={setEditModalOpen}
|
||||||
onSuccess={loadData}
|
onSuccess={handleSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 重置密码对话框 */}
|
{/* 重置密码对话框 */}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user