166 lines
6.5 KiB
TypeScript
166 lines
6.5 KiB
TypeScript
'use client';
|
||
|
||
import { useState } from 'react';
|
||
import { PageHeader } from '@/components/layout/page-header';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { apiClient } from '@/lib/api/client';
|
||
import { formatDateTime } from '@/lib/utils/date';
|
||
import { Card, CardContent } from '@/components/ui/card';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||
import { Skeleton } from '@/components/ui/skeleton';
|
||
import { Search, ChevronLeft, ChevronRight } from 'lucide-react';
|
||
import type { PaginatedResponse } from '@/types/api';
|
||
|
||
interface AuditLog {
|
||
id: string;
|
||
adminId: string;
|
||
adminUsername: string;
|
||
action: string;
|
||
resource: string;
|
||
resourceId: string | null;
|
||
details: string | null;
|
||
ipAddress: string;
|
||
createdAt: string;
|
||
}
|
||
|
||
const actionLabels: Record<string, { label: string; className: string }> = {
|
||
CREATE: { label: '创建', className: 'bg-green-100 text-green-700' },
|
||
UPDATE: { label: '更新', className: 'bg-blue-100 text-blue-700' },
|
||
DELETE: { label: '删除', className: 'bg-red-100 text-red-700' },
|
||
LOGIN: { label: '登录', className: 'bg-purple-100 text-purple-700' },
|
||
LOGOUT: { label: '登出', className: 'bg-gray-100 text-gray-600' },
|
||
};
|
||
|
||
export default function AuditLogsPage() {
|
||
const [page, setPage] = useState(1);
|
||
const [action, setAction] = useState<string>('all');
|
||
const [keyword, setKeyword] = useState('');
|
||
const pageSize = 20;
|
||
|
||
const { data, isLoading, error } = useQuery({
|
||
queryKey: ['audit-logs', page, action, keyword],
|
||
queryFn: async () => {
|
||
const response = await apiClient.get('/audit', {
|
||
params: { page, pageSize, action: action === 'all' ? undefined : action, keyword: keyword || undefined },
|
||
});
|
||
return response.data.data as PaginatedResponse<AuditLog>;
|
||
},
|
||
});
|
||
|
||
const items = data?.items ?? [];
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<PageHeader title="审计日志" description="查看系统操作日志" />
|
||
|
||
<Card>
|
||
<CardContent className="p-4">
|
||
<div className="flex gap-4">
|
||
<div className="relative flex-1 max-w-sm">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||
<Input
|
||
placeholder="搜索管理员、资源..."
|
||
value={keyword}
|
||
onChange={(e) => setKeyword(e.target.value)}
|
||
className="pl-10"
|
||
/>
|
||
</div>
|
||
<Select value={action} onValueChange={setAction}>
|
||
<SelectTrigger className="w-[150px]">
|
||
<SelectValue placeholder="操作类型" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">全部</SelectItem>
|
||
<SelectItem value="CREATE">创建</SelectItem>
|
||
<SelectItem value="UPDATE">更新</SelectItem>
|
||
<SelectItem value="DELETE">删除</SelectItem>
|
||
<SelectItem value="LOGIN">登录</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<Button onClick={() => setPage(1)}>搜索</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardContent className="p-0">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>时间</TableHead>
|
||
<TableHead>管理员</TableHead>
|
||
<TableHead>操作</TableHead>
|
||
<TableHead>资源</TableHead>
|
||
<TableHead>资源ID</TableHead>
|
||
<TableHead>详情</TableHead>
|
||
<TableHead>IP地址</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{isLoading ? (
|
||
[...Array(10)].map((_, i) => (
|
||
<TableRow key={i}>
|
||
{[...Array(7)].map((_, j) => (
|
||
<TableCell key={j}>
|
||
<Skeleton className="h-4 w-full" />
|
||
</TableCell>
|
||
))}
|
||
</TableRow>
|
||
))
|
||
) : items.length === 0 ? (
|
||
<TableRow>
|
||
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||
暂无日志
|
||
</TableCell>
|
||
</TableRow>
|
||
) : (
|
||
items.map((log) => {
|
||
const actionInfo = actionLabels[log.action] || { label: log.action, className: '' };
|
||
return (
|
||
<TableRow key={log.id}>
|
||
<TableCell className="text-sm">{formatDateTime(log.createdAt)}</TableCell>
|
||
<TableCell>{log.adminUsername}</TableCell>
|
||
<TableCell>
|
||
<span
|
||
className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium ${actionInfo.className}`}
|
||
>
|
||
{actionInfo.label}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell>{log.resource}</TableCell>
|
||
<TableCell className="font-mono text-xs">{log.resourceId || '-'}</TableCell>
|
||
<TableCell className="max-w-[200px] truncate" title={log.details || ''}>
|
||
{log.details || '-'}
|
||
</TableCell>
|
||
<TableCell className="font-mono text-xs">{log.ipAddress}</TableCell>
|
||
</TableRow>
|
||
);
|
||
})
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
|
||
{data && data.totalPages > 1 && (
|
||
<div className="flex items-center justify-between p-4 border-t">
|
||
<p className="text-sm text-muted-foreground">
|
||
共 {data.total} 条,第 {page} / {data.totalPages} 页
|
||
</p>
|
||
<div className="flex items-center gap-2">
|
||
<Button variant="outline" size="sm" onClick={() => setPage(page - 1)} disabled={page <= 1}>
|
||
<ChevronLeft className="h-4 w-4" />
|
||
</Button>
|
||
<Button variant="outline" size="sm" onClick={() => setPage(page + 1)} disabled={page >= data.totalPages}>
|
||
<ChevronRight className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|