Files
smart-cropx-ui/src/app/(app)/central-config/monitor/operation-log/page.tsx
2025-11-10 09:19:56 +08:00

378 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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>
);
}