重构消息通知弹窗

This commit is contained in:
dengqichen 2025-11-28 17:06:57 +08:00
parent 6de57aff67
commit f1f8b963f1
3 changed files with 400 additions and 664 deletions

View File

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

View File

@ -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); const result = await getRoleList(query);
try { setStats({ total: result.totalElements || 0 });
const result = await getRoleList(query); return result;
setData(result);
} catch (error) {
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,13 +129,132 @@ 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(() => [
{ key: 'id', title: 'ID', dataIndex: 'id', width: '80px' },
{
key: 'code',
title: '角色编码',
width: '150px',
render: (_, record) => <code className="text-sm">{record.code}</code>,
},
{ key: 'name', title: '角色名称', dataIndex: 'name', width: '150px' },
{
key: 'isAdmin',
title: '类型',
width: '120px',
render: (_, record) => record.isAdmin ? (
<Badge variant="default" className="bg-amber-500 hover:bg-amber-600">
<Shield className="h-3 w-3 mr-1" />
</Badge>
) : (
<Badge variant="outline"></Badge>
),
},
{
key: 'tags',
title: '标签',
width: '200px',
render: (_, record) => (
<div className="flex flex-wrap gap-1">
{record.tags && record.tags.length > 0 ? (
record.tags.map(tag => (
<Badge
key={tag.id}
variant="outline"
style={{ backgroundColor: tag.color, color: '#fff' }}
className="text-xs"
>
{tag.name}
</Badge>
))
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
),
},
{ 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 || '-'}
</div>
),
},
{
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) => (
<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={() => handleAssignPermissions(record)}
title="分配权限"
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
>
<KeyRound className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleAssignTags(record)}
title="分配标签"
className="text-purple-600 hover:text-purple-700 hover:bg-purple-50"
>
<TagIcon className="h-4 w-4" />
</Button>
{!record.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 toolbar = (
<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>
);
return ( return (
<div className="p-6"> <div className="p-6">
@ -197,188 +280,20 @@ const RolePage: React.FC = () => {
</div> </div>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader>
<CardTitle></CardTitle> <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> </CardHeader>
<CardContent> <Separator />
{/* 搜索栏 */} <CardContent className="pt-6">
<div className="flex flex-wrap items-center gap-4 mb-4"> <PaginatedTable<RoleResponse, RoleQuery>
<div className="flex-1 max-w-xs"> ref={tableRef}
<Input fetchFn={fetchData}
placeholder="搜索角色编码" columns={columns}
value={query.code} searchFields={searchFields}
onChange={(e) => setQuery(prev => ({ ...prev, code: e.target.value }))} toolbar={toolbar}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()} rowKey="id"
className="h-9" minWidth="1280px"
/> />
</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">
<Shield className="h-3 w-3 mr-1" />
</Badge>
) : (
<Badge variant="outline"></Badge>
)}
</TableCell>
<TableCell width="200px">
<div className="flex flex-wrap gap-1">
{record.tags && record.tags.length > 0 ? (
record.tags.map(tag => (
<Badge
key={tag.id}
variant="outline"
style={{ backgroundColor: tag.color, color: '#fff' }}
className="text-xs"
>
{tag.name}
</Badge>
))
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
</TableCell>
<TableCell width="100px">{record.sort}</TableCell>
<TableCell width="200px" className="text-sm max-w-[200px] truncate" title={record.description}>
{record.description || '-'}
</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={() => handleAssignPermissions(record)}
title="分配权限"
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
>
<KeyRound className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleAssignTags(record)}
title="分配标签"
className="text-purple-600 hover:text-purple-700 hover:bg-purple-50"
>
<TagIcon className="h-4 w-4" />
</Button>
{!record.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={8} className="h-24 text-center">
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<ShieldCheck 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>
@ -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>

View File

@ -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); const result = await getUsers(query);
try { const all = result.content || [];
const result = await getUsers(query); setStats({
setData(result); total: result.totalElements || 0,
} catch (error) { enabledCount: all.filter(d => d.enabled).length,
console.error('加载用户列表失败:', error); disabledCount: all.filter(d => !d.enabled).length,
} finally { });
setLoading(false); 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,30 +163,125 @@ const UserPage: React.FC = () => {
} }
}; };
// 状态徽章 // 搜索字段定义
const getStatusBadge = (enabled: boolean) => { const searchFields: SearchFieldDef[] = useMemo(() => [
return enabled ? ( { key: 'username', type: 'input', placeholder: '搜索用户名', width: 'w-[180px]' },
<Badge variant="success" className="inline-flex items-center gap-1"> { key: 'email', type: 'input', placeholder: '搜索邮箱', width: 'w-[180px]' },
<UserCheck className="h-3 w-3" /> {
key: 'enabled',
</Badge> type: 'select',
) : ( placeholder: '状态',
<Badge variant="secondary" className="inline-flex items-center gap-1"> width: 'w-[120px]',
<UserX className="h-3 w-3" /> options: [
{ label: '启用', value: 'true' },
</Badge> { label: '禁用', value: 'false' },
); ],
}; },
], []);
// 统计数据 // 列定义
const stats = useMemo(() => { const columns: ColumnDef<UserResponse>[] = useMemo(() => [
const total = data?.totalElements || 0; { key: 'id', title: 'ID', dataIndex: 'id', width: '80px' },
const enabledCount = data?.content?.filter(d => d.enabled).length || 0; { key: 'username', title: '用户名', dataIndex: 'username', width: '120px' },
const disabledCount = data?.content?.filter(d => !d.enabled).length || 0; { key: 'nickname', title: '昵称', dataIndex: 'nickname', width: '120px' },
return { total, enabledCount, disabledCount }; { key: 'email', title: '邮箱', dataIndex: 'email', width: '200px' },
}, [data]); { 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">
<UserCheck className="h-3 w-3" />
</Badge>
) : (
<Badge variant="secondary" className="inline-flex items-center gap-1">
<UserX className="h-3 w-3" />
</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 pageCount = data?.totalElements ? Math.ceil(data.totalElements / (query.pageSize || DEFAULT_PAGE_SIZE)) : 0; // 工具栏
const toolbar = (
<Button onClick={handleCreate}>
<Plus className="h-4 w-4 mr-2" />
</Button>
);
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}
/> />
{/* 重置密码对话框 */} {/* 重置密码对话框 */}