子仓库提交

This commit is contained in:
2025-11-10 09:19:56 +08:00
parent 62f92213f7
commit 5feb24e4e2
733 changed files with 141413 additions and 0 deletions

View File

@@ -0,0 +1 @@
export * from './operationLogApi'

View File

@@ -0,0 +1,212 @@
/**
* filekorolheader: 操作日志API - 操作日志相关接口调用
* 功能:获取操作日志列表、统计、导出等功能
* 路径:/central-config/monitor/operation-log/components/operationLogApi
* 规范遵循crop-x/docs/开发项目规范.md使用SDK生成的API接口
*/
import {
listOperationLogsApiV1LogsOperationOperationLogsGet,
getOperationStatisticsApiV1LogsOperationOperationLogsStatisticsGet,
exportOperationLogsApiV1LogsOperationOperationLogsExportGet
} from '@/lib/api/sdk.gen';
// 操作日志接口
export interface OperationLog {
id: string;
created_at: string;
updated_at: string;
username: string;
user_id: string | null;
operation_type: string;
module: string;
action: string;
request_method: string;
request_url: string;
request_headers: any | null;
request_body: any | null;
request_params: any | null;
response_status: number;
response_body: any | null;
error_message: string | null;
processing_time: number;
}
// 分页参数接口
export interface OperationLogsQueryParams {
page?: number;
size?: number;
username?: string;
module?: string;
action?: string;
operation_type?: string;
response_status?: number;
start_time?: string;
end_time?: string;
sort_order?: 'asc' | 'desc';
order_by?: string;
}
// 分页状态接口
export interface PaginationState {
page: number;
size: number;
total: number;
totalPages?: number;
hasNext?: boolean;
hasPrev?: boolean;
}
// 统计数据接口
export interface OperationLogStatistics {
total_operations: number;
successful_operations: number;
failed_operations: number;
unique_users: number;
success_rate: number;
average_processing_time: number;
}
/**
* 获取操作日志列表
*/
export const fetchOperationLogs = async (params: OperationLogsQueryParams = {}) => {
try {
// Get token from localStorage
const storedUser = localStorage.getItem('user');
let headers = {};
if (storedUser) {
const userData = JSON.parse(storedUser);
if (userData.token) {
headers = {
'Authorization': `Bearer ${userData.token}`
};
}
}
const response = await listOperationLogsApiV1LogsOperationOperationLogsGet({
headers,
query: {
page: params.page || 1,
size: params.size || 10,
username: params.username,
module: params.module,
action: params.action,
operation_type: params.operation_type,
response_status: params.response_status,
start_time: params.start_time,
end_time: params.end_time,
sort_order: params.sort_order || 'desc',
order_by: params.order_by || 'created_at',
}
});
return {
data: response.data?.data || [],
page: response.data?.page || 1,
size: response.data?.size || 10,
total: response.data?.total || 0,
totalPages: response.data?.total_pages || 0,
hasNext: response.data?.has_next || false,
hasPrev: response.data?.has_prev || false,
};
} catch (error) {
console.error('Failed to fetch operation logs:', error);
throw error;
}
};
/**
* 获取操作统计信息
*/
export const fetchOperationStatistics = async () => {
try {
// Get token from localStorage
const storedUser = localStorage.getItem('user');
let headers = {};
if (storedUser) {
const userData = JSON.parse(storedUser);
if (userData.token) {
headers = {
'Authorization': `Bearer ${userData.token}`
};
}
}
const response = await getOperationStatisticsApiV1LogsOperationOperationLogsStatisticsGet({
headers
});
return response.data;
} catch (error) {
console.error('Failed to fetch operation statistics:', error);
throw error;
}
};
/**
* 导出操作日志
*/
export const exportOperationLogs = async (params: OperationLogsQueryParams = {}) => {
try {
// Get token from localStorage
const storedUser = localStorage.getItem('user');
let headers = {};
if (storedUser) {
const userData = JSON.parse(storedUser);
if (userData.token) {
headers = {
'Authorization': `Bearer ${userData.token}`
};
}
}
const response = await exportOperationLogsApiV1LogsOperationOperationLogsExportGet({
headers,
query: {
username: params.username,
module: params.module,
action: params.action,
operation_type: params.operation_type,
response_status: params.response_status,
start_time: params.start_time,
end_time: params.end_time,
}
});
return response.data;
} catch (error) {
console.error('Failed to export operation logs:', error);
throw error;
}
};
/**
* 转换操作日志数据 - 适配组件使用
*/
export const transformOperationLogData = (log: any): OperationLog => ({
id: log.id,
created_at: log.created_at,
updated_at: log.updated_at,
username: log.username,
user_id: log.user_id,
operation_type: log.operation_type,
module: log.module,
action: log.action,
request_method: log.request_method,
request_url: log.request_url,
request_headers: log.request_headers,
request_body: log.request_body,
request_params: log.request_params,
response_status: log.response_status,
response_body: log.response_body,
error_message: log.error_message,
processing_time: log.processing_time,
});
/**
* 批量转换操作日志数据
*/
export const transformOperationLogsList = (logs: any[]): OperationLog[] => {
return logs.map(transformOperationLogData);
};

View File

@@ -0,0 +1,378 @@
/**
* 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>
);
}