378 lines
12 KiB
TypeScript
378 lines
12 KiB
TypeScript
/**
|
||
* filekorolheader: 操作日志页面 - 用户操作行为监控页面
|
||
* 功能:操作日志查询、统计、导出、筛选
|
||
* 路径:/central-config/monitor/operation-log
|
||
* 规范:遵循crop-x/docs/开发项目规范.md,使用SearchFormPagination重构,事件驱动模式
|
||
*/
|
||
'use client';
|
||
|
||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||
import { toast } from 'sonner';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { Download, Eye, CheckCircle, XCircle, Clock, AlertTriangle } from 'lucide-react';
|
||
|
||
import { SearchFormPagination, type SearchFieldConfig, type TableColumnConfig } from '@/components/common/searchFormPagination';
|
||
import {
|
||
fetchOperationLogs,
|
||
transformOperationLogsList,
|
||
OperationLog,
|
||
PaginationState,
|
||
OperationLogsQueryParams,
|
||
exportOperationLogs
|
||
} from './components';
|
||
|
||
export default function OperationLogPage() {
|
||
const [logs, setLogs] = useState<OperationLog[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [pagination, setPagination] = useState<PaginationState>({
|
||
page: 1,
|
||
size: 10,
|
||
total: 0,
|
||
totalPages: 0,
|
||
hasNext: false,
|
||
hasPrev: false,
|
||
});
|
||
const [searchFilters, setSearchFilters] = useState<Record<string, string>>({
|
||
search: '',
|
||
module: 'all',
|
||
status: 'all'
|
||
});
|
||
const isFirstLoad = useRef(true);
|
||
|
||
// 搜索字段配置
|
||
const searchFields: SearchFieldConfig[] = [
|
||
{
|
||
key: 'search',
|
||
label: '搜索',
|
||
type: 'text',
|
||
placeholder: '搜索操作人、模块、操作...',
|
||
},
|
||
{
|
||
key: 'module',
|
||
label: '模块',
|
||
type: 'select',
|
||
defaultValue: 'all',
|
||
options: [
|
||
{ value: 'all', label: '全部模块' },
|
||
{ value: '用户管理', label: '用户管理' },
|
||
{ value: '企业管理', label: '企业管理' },
|
||
{ value: '系统配置', label: '系统配置' },
|
||
{ value: '数据管理', label: '数据管理' },
|
||
],
|
||
},
|
||
{
|
||
key: 'status',
|
||
label: '状态',
|
||
type: 'select',
|
||
defaultValue: 'all',
|
||
options: [
|
||
{ value: 'all', label: '全部状态' },
|
||
{ value: 'success', label: '成功' },
|
||
{ value: 'failed', label: '失败' },
|
||
],
|
||
},
|
||
];
|
||
|
||
// 表格列配置
|
||
const columns: TableColumnConfig[] = [
|
||
{
|
||
key: 'created_at',
|
||
label: '操作时间',
|
||
render: (value: string) => (
|
||
<div className="text-sm text-muted-foreground">
|
||
{value ? new Date(value).toLocaleString('zh-CN') : '-'}
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: 'username',
|
||
label: '操作人',
|
||
render: (value: string) => (
|
||
<div className="font-medium text-foreground">{value || '-'}</div>
|
||
),
|
||
},
|
||
{
|
||
key: 'module',
|
||
label: '模块',
|
||
render: (value: string) => (
|
||
<Badge variant="outline" className="bg-blue-50 dark:bg-blue-950 text-blue-600 dark:text-blue-400">
|
||
{value || '-'}
|
||
</Badge>
|
||
),
|
||
},
|
||
{
|
||
key: 'action',
|
||
label: '操作',
|
||
render: (value: string) => (
|
||
<div className="text-sm font-medium">{value || '-'}</div>
|
||
),
|
||
},
|
||
{
|
||
key: 'processing_time',
|
||
label: '耗时',
|
||
render: (value: number) => (
|
||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||
<Clock className="w-3 h-3" />
|
||
{value ? `${(value * 1000).toFixed(0)}ms` : '-'}
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: 'response_status',
|
||
label: '状态',
|
||
render: (value: number) => {
|
||
if (value >= 200 && value < 300) {
|
||
return (
|
||
<Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||
<CheckCircle className="w-3 h-3 mr-1" />
|
||
成功
|
||
</Badge>
|
||
);
|
||
} else {
|
||
return (
|
||
<Badge className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
|
||
<XCircle className="w-3 h-3 mr-1" />
|
||
失败
|
||
</Badge>
|
||
);
|
||
}
|
||
},
|
||
},
|
||
{
|
||
key: 'actions',
|
||
label: '操作',
|
||
render: (_: any, row: OperationLog) => (
|
||
<div className="flex gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => handleViewDetail(row)}
|
||
className="h-8 px-2"
|
||
title="查看详情"
|
||
>
|
||
<Eye className="w-3 h-3 mr-1" />
|
||
查看
|
||
</Button>
|
||
</div>
|
||
),
|
||
},
|
||
];
|
||
|
||
// 加载操作日志数据 - 事件驱动模式
|
||
const loadOperationLogs = useCallback(async (params?: {
|
||
filters?: Record<string, string>;
|
||
pagination?: { page: number; size: number };
|
||
resetPage?: boolean;
|
||
}) => {
|
||
try {
|
||
setLoading(true);
|
||
|
||
const queryParams: OperationLogsQueryParams = {
|
||
page: params?.resetPage ? 1 : (params?.pagination?.page || pagination.page),
|
||
size: params?.pagination?.size || pagination.size,
|
||
sort_order: 'desc',
|
||
order_by: 'created_at',
|
||
};
|
||
|
||
// 处理搜索条件
|
||
const searchKeyword = params?.filters?.search ?? searchFilters.search;
|
||
if (searchKeyword) {
|
||
queryParams.username = searchKeyword;
|
||
queryParams.module = searchKeyword;
|
||
queryParams.action = searchKeyword;
|
||
}
|
||
|
||
// 处理模块筛选
|
||
const module = params?.filters?.module ?? searchFilters.module;
|
||
if (module !== 'all') {
|
||
queryParams.module = module;
|
||
}
|
||
|
||
// 处理状态筛选
|
||
const status = params?.filters?.status ?? searchFilters.status;
|
||
if (status !== 'all') {
|
||
queryParams.response_status = status === 'success' ? 200 : 400;
|
||
}
|
||
|
||
const response = await fetchOperationLogs(queryParams);
|
||
const transformedLogs = transformOperationLogsList(response.data);
|
||
|
||
setLogs(transformedLogs);
|
||
setPagination({
|
||
page: response.page,
|
||
size: response.size,
|
||
total: response.total,
|
||
totalPages: response.totalPages,
|
||
hasNext: response.hasNext,
|
||
hasPrev: response.hasPrev,
|
||
});
|
||
} catch (error) {
|
||
console.error('Failed to load operation logs:', error);
|
||
const errorMessage = error instanceof Error ? error.message : '加载操作日志失败';
|
||
toast.error(errorMessage);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [pagination.page, pagination.size, searchFilters]);
|
||
|
||
// 初始化数据 - 只在组件挂载时执行一次
|
||
useEffect(() => {
|
||
if (isFirstLoad.current) {
|
||
isFirstLoad.current = false;
|
||
loadOperationLogs({ resetPage: true });
|
||
}
|
||
}, [loadOperationLogs]);
|
||
|
||
// 事件处理器 - 事件驱动模式
|
||
const handleSearch = useCallback((filters: Record<string, string>) => {
|
||
setSearchFilters(filters);
|
||
loadOperationLogs({
|
||
filters,
|
||
pagination: { page: 1, size: pagination.size }
|
||
});
|
||
}, [loadOperationLogs, pagination.size]);
|
||
|
||
const handlePageChange = useCallback((page: number) => {
|
||
setPagination(prev => ({ ...prev, page }));
|
||
loadOperationLogs({
|
||
filters: searchFilters,
|
||
pagination: { page, size: pagination.size }
|
||
});
|
||
}, [loadOperationLogs, searchFilters, pagination.size]);
|
||
|
||
const handleSizeChange = useCallback((size: number) => {
|
||
setPagination(prev => ({ ...prev, size, page: 1 }));
|
||
loadOperationLogs({
|
||
filters: searchFilters,
|
||
pagination: { page: 1, size }
|
||
});
|
||
}, [loadOperationLogs, searchFilters]);
|
||
|
||
// 计算统计数据
|
||
const stats = useMemo(() => {
|
||
const totalOperations = logs.length;
|
||
const successfulOperations = logs.filter(log => log.response_status >= 200 && log.response_status < 300).length;
|
||
const failedOperations = totalOperations - successfulOperations;
|
||
const averageProcessingTime = logs.length > 0
|
||
? logs.reduce((sum, log) => sum + log.processing_time, 0) / logs.length
|
||
: 0;
|
||
|
||
return {
|
||
totalOperations,
|
||
successfulOperations,
|
||
failedOperations,
|
||
averageProcessingTime
|
||
};
|
||
}, [logs]);
|
||
|
||
// 查看详情
|
||
const handleViewDetail = (log: OperationLog) => {
|
||
toast.info(`查看操作日志详情: ${log.username} - ${log.action}`);
|
||
};
|
||
|
||
// 导出日志
|
||
const handleExport = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const exportData = await exportOperationLogs({
|
||
username: searchFilters.search,
|
||
module: searchFilters.module === 'all' ? undefined : searchFilters.module,
|
||
response_status: searchFilters.status === 'all' ? undefined :
|
||
searchFilters.status === 'success' ? 200 : 400,
|
||
});
|
||
|
||
// 创建下载链接
|
||
const dataStr = JSON.stringify(exportData, null, 2);
|
||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||
const url = URL.createObjectURL(dataBlob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = `operation_logs_${new Date().getTime()}.json`;
|
||
link.click();
|
||
URL.revokeObjectURL(url);
|
||
|
||
toast.success('导出成功');
|
||
} catch (error) {
|
||
console.error('Failed to export operation logs:', error);
|
||
const errorMessage = error instanceof Error ? error.message : '导出失败';
|
||
toast.error(errorMessage);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* 页面标题 */}
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h2 className="text-green-800">操作日志</h2>
|
||
<p className="text-muted-foreground">详细追踪用户在系统中的关键操作行为</p>
|
||
</div>
|
||
<Button onClick={handleExport} disabled={loading}>
|
||
<Download className="w-4 h-4 mr-2" />
|
||
导出日志
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 统计卡片 */}
|
||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||
<div className="p-6 bg-card hover:bg-muted transition-colors border rounded-lg">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<div className="text-sm text-muted-foreground">总操作数</div>
|
||
<div className="text-2xl font-bold text-blue-600">{stats.totalOperations}</div>
|
||
</div>
|
||
<div className="text-xs text-muted-foreground">
|
||
系统总操作数
|
||
</div>
|
||
</div>
|
||
<div className="p-6 bg-card hover:bg-muted transition-colors border rounded-lg">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<div className="text-sm text-muted-foreground">成功操作</div>
|
||
<div className="text-2xl font-bold text-green-600">{stats.successfulOperations}</div>
|
||
</div>
|
||
<div className="text-xs text-green-600">
|
||
操作成功次数
|
||
</div>
|
||
</div>
|
||
<div className="p-6 bg-card hover:bg-muted transition-colors border rounded-lg">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<div className="text-sm text-muted-foreground">失败操作</div>
|
||
<div className="text-2xl font-bold text-red-600">{stats.failedOperations}</div>
|
||
</div>
|
||
<div className="text-xs text-red-600">
|
||
操作失败次数
|
||
</div>
|
||
</div>
|
||
<div className="p-6 bg-card hover:bg-muted transition-colors border rounded-lg">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<div className="text-sm text-muted-foreground">平均耗时</div>
|
||
<div className="text-2xl font-bold text-orange-600">{Math.round(stats.averageProcessingTime * 1000)}ms</div>
|
||
</div>
|
||
<div className="text-xs text-orange-600">
|
||
操作平均耗时
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 搜索、表格和分页 */}
|
||
<SearchFormPagination
|
||
formTitle="操作日志列表"
|
||
searchFields={searchFields}
|
||
columns={columns}
|
||
data={logs}
|
||
loading={loading}
|
||
error={null}
|
||
pagination={pagination}
|
||
onPageChange={handlePageChange}
|
||
onSizeChange={handleSizeChange}
|
||
onSearch={handleSearch}
|
||
emptyText="暂无操作日志数据"
|
||
showSizeSelector={true}
|
||
showPageInfo={true}
|
||
sizeOptions={[10, 20, 50, 100]}
|
||
/>
|
||
</div>
|
||
);
|
||
} |