feat(logManagement): implement log management service

This commit is contained in:
shiyu
2025-06-06 11:39:39 +08:00
parent a73752bcc8
commit a95651b04a
34 changed files with 1644 additions and 108 deletions

View File

@@ -88,3 +88,13 @@ export {
rebuildVectors
} from './vectorDbApi';
// 导出LogManagement API
export {
getLogs,
getLogById,
deleteLog,
batchDeleteLogs,
clearLogs,
getLogStatistics
} from './logManagementApi';

View File

@@ -0,0 +1,83 @@
import { fetchApi } from './fetchClient';
import {
type BaseResult,
type PaginatedResult,
type LogResponse,
type LogFilterRequest,
type ClearLogsRequest,
type BatchDeleteResult,
type LogStatistics
} from './types';
// 获取日志列表
export const getLogs = async (
filters: LogFilterRequest = {}
): Promise<PaginatedResult<LogResponse>> => {
const { page = 1, pageSize = 10, searchQuery, level, startDate, endDate } = filters;
const params = new URLSearchParams({
page: page.toString(),
pageSize: pageSize.toString(),
});
if (searchQuery) params.append('searchQuery', searchQuery);
if (level) params.append('level', level.toString());
if (startDate) params.append('startDate', startDate);
if (endDate) params.append('endDate', endDate);
const response = await fetchApi(`/management/log/get_logs?${params.toString()}`);
return response as PaginatedResult<LogResponse>;
};
// 根据ID获取单个日志
export const getLogById = async (id: number): Promise<BaseResult<LogResponse>> => {
return fetchApi<LogResponse>(
`/management/log/get_log/${id}`,
{ method: 'GET' }
);
};
// 删除日志
export const deleteLog = async (id: number): Promise<BaseResult<boolean>> => {
return fetchApi<boolean>(
'/management/log/delete_log',
{
method: 'POST',
body: JSON.stringify(id)
}
);
};
// 批量删除日志
export const batchDeleteLogs = async (
ids: number[]
): Promise<BaseResult<BatchDeleteResult>> => {
return fetchApi<BatchDeleteResult>(
'/management/log/batch_delete_logs',
{
method: 'POST',
body: JSON.stringify(ids)
}
);
};
// 清空日志
export const clearLogs = async (
request: ClearLogsRequest
): Promise<BaseResult<number>> => {
return fetchApi<number>(
'/management/log/clear_logs',
{
method: 'POST',
body: JSON.stringify(request)
}
);
};
// 获取日志统计信息
export const getLogStatistics = async (): Promise<BaseResult<LogStatistics>> => {
return fetchApi<LogStatistics>(
'/management/log/get_statistics',
{ method: 'GET' }
);
};

View File

@@ -315,3 +315,56 @@ export interface UserDetailResponse {
createdAt: Date;
statistics: UserStatistics;
}
// 日志级别枚举
export type LogLevel = 'Trace' | 'Debug' | 'Information' | 'Warning' | 'Error' | 'Critical';
export const LogLevel = {
Trace: 'Trace' as LogLevel,
Debug: 'Debug' as LogLevel,
Information: 'Information' as LogLevel,
Warning: 'Warning' as LogLevel,
Error: 'Error' as LogLevel,
Critical: 'Critical' as LogLevel
};
// 日志响应数据
export interface LogResponse {
id: number;
level: LogLevel | number; // 支持数字和字符串两种形式
message: string;
category: string;
eventId?: number;
timestamp: Date;
exception?: string;
requestPath?: string;
requestMethod?: string;
statusCode?: number;
ipAddress?: string;
userId?: string;
properties?: string;
}
// 日志筛选请求参数
export interface LogFilterRequest {
page?: number;
pageSize?: number;
searchQuery?: string;
level?: LogLevel | number; // 支持数字和字符串两种形式
startDate?: string;
endDate?: string;
}
// 清空日志请求
export interface ClearLogsRequest {
clearAll?: boolean;
beforeDate?: Date;
}
// 日志统计信息
export interface LogStatistics {
totalCount: number;
todayCount: number;
errorCount: number;
warningCount: number;
}

View File

@@ -0,0 +1,734 @@
import React, {useState, useEffect, useMemo} from 'react';
import {
Card,
Table,
Button,
Space,
Tag,
Typography,
Input,
Select,
DatePicker,
Row,
Col,
message,
Modal,
Tooltip,
Badge,
Popconfirm,
Drawer,
Alert,
Statistic
} from 'antd';
import {
SearchOutlined,
DeleteOutlined,
ClearOutlined,
ReloadOutlined,
ExclamationCircleOutlined,
InfoCircleOutlined,
WarningOutlined,
EyeOutlined,
FilterOutlined,
FileTextOutlined
} from '@ant-design/icons';
import type {ColumnsType} from 'antd/es/table';
import type {TableRowSelection} from 'antd/es/table/interface';
import {useOutletContext} from 'react-router';
import dayjs from 'dayjs';
import type {Dayjs} from 'dayjs';
import {getLogs, deleteLog, batchDeleteLogs, clearLogs, getLogById, getLogStatistics} from '../../../api';
import type {LogResponse, LogLevel, LogFilterRequest, LogStatistics} from '../../../api/types';
const {Title, Text, Paragraph} = Typography;
const {RangePicker} = DatePicker;
const {confirm} = Modal;
// 日志级别数字到字符串的映射
const LOG_LEVEL_MAP: Record<number, LogLevel> = {
0: 'Trace',
1: 'Debug',
2: 'Information',
3: 'Warning',
4: 'Error',
5: 'Critical'
};
// 日志级别颜色映射
const LOG_LEVEL_COLORS: Record<LogLevel, string> = {
Trace: 'default',
Debug: 'blue',
Information: 'green',
Warning: 'orange',
Error: 'red',
Critical: 'magenta'
};
// 日志级别图标映射
const LOG_LEVEL_ICONS: Record<LogLevel, React.ReactNode> = {
Trace: <FileTextOutlined/>,
Debug: <InfoCircleOutlined/>,
Information: <InfoCircleOutlined/>,
Warning: <WarningOutlined/>,
Error: <ExclamationCircleOutlined/>,
Critical: <ExclamationCircleOutlined/>
};
const AdminLogManagement: React.FC = () => {
const {isMobile} = useOutletContext<{ isMobile: boolean; isAdminPanel?: boolean }>();
// 状态管理
const [loading, setLoading] = useState(false);
const [logs, setLogs] = useState<LogResponse[]>([]);
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 20,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total: number, range: [number, number]) =>
`${range[0]}-${range[1]} 条,共 ${total}`,
});
// 筛选条件
const [filters, setFilters] = useState<LogFilterRequest>({});
const [searchText, setSearchText] = useState('');
const [selectedLevel, setSelectedLevel] = useState<LogLevel | undefined>();
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(null);
// 日志详情抽屉
const [detailDrawerOpen, setDetailDrawerOpen] = useState(false);
const [selectedLog, setSelectedLog] = useState<LogResponse | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
// 统计信息
const [logStats, setLogStats] = useState<LogStatistics>({
totalCount: 0,
todayCount: 0,
errorCount: 0,
warningCount: 0
});
// 获取日志统计
const fetchLogStatistics = async () => {
try {
const response = await getLogStatistics();
if (response.success && response.data) {
setLogStats(response.data);
}
} catch (error) {
console.error('Error fetching log statistics:', error);
message.error('获取日志统计失败');
}
};
// 获取日志列表
const fetchLogs = async (params: LogFilterRequest = {}) => {
setLoading(true);
try {
const response = await getLogs({
page: params.page || pagination.current,
pageSize: params.pageSize || pagination.pageSize,
searchQuery: params.searchQuery || searchText,
level: params.level || selectedLevel,
startDate: params.startDate,
endDate: params.endDate,
...params
});
if (response.success && response.data) {
setLogs(response.data);
setPagination(prev => ({
...prev,
current: response.page,
total: response.totalCount
}));
}
} catch (error) {
console.error('Error fetching logs:', error);
message.error('获取日志列表失败');
} finally {
setLoading(false);
}
};
// 查看日志详情
const handleViewDetail = async (logId: number) => {
setDetailLoading(true);
try {
const response = await getLogById(logId);
if (response.success && response.data) {
setSelectedLog(response.data);
setDetailDrawerOpen(true);
}
} catch (error) {
console.error('Error fetching log detail:', error);
message.error('获取日志详情失败');
} finally {
setDetailLoading(false);
}
};
// 删除单个日志
const handleDelete = async (id: number) => {
try {
const response = await deleteLog(id);
if (response.success) {
message.success('日志删除成功');
await Promise.all([fetchLogs(), fetchLogStatistics()]);
}
} catch (error) {
console.error('Error deleting log:', error);
message.error('删除日志失败');
}
};
// 批量删除日志
const handleBatchDelete = async () => {
if (selectedRowKeys.length === 0) {
message.warning('请选择要删除的日志');
return;
}
confirm({
title: '确认删除',
content: `确定要删除选中的 ${selectedRowKeys.length} 条日志吗?`,
icon: <ExclamationCircleOutlined/>,
okText: '确定',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
try {
const response = await batchDeleteLogs(selectedRowKeys as number[]);
if (response.success && response.data) {
message.success(`成功删除 ${response.data.successCount} 条日志`);
setSelectedRowKeys([]);
await Promise.all([fetchLogs(), fetchLogStatistics()]);
}
} catch (error) {
console.error('Error batch deleting logs:', error);
message.error('批量删除日志失败');
}
}
});
};
// 清空日志
const handleClearLogs = (type: 'all' | 'old') => {
const title = type === 'all' ? '清空所有日志' : '清空历史日志';
const content = type === 'all'
? '确定要清空所有日志吗?此操作不可恢复!'
: '确定要清空7天前的历史日志吗此操作不可恢复';
confirm({
title,
content,
icon: <ExclamationCircleOutlined/>,
okText: '确定',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
try {
const request = type === 'all'
? {clearAll: true}
: {beforeDate: dayjs().subtract(7, 'day').toDate()};
const response = await clearLogs(request);
if (response.success) {
message.success(`成功清空 ${response.data} 条日志`);
await Promise.all([fetchLogs(), fetchLogStatistics()]);
}
} catch (error) {
console.error('Error clearing logs:', error);
message.error('清空日志失败');
}
}
});
};
// 搜索和筛选
const handleSearch = () => {
const newFilters: LogFilterRequest = {
page: 1,
searchQuery: searchText,
level: selectedLevel,
};
if (dateRange) {
newFilters.startDate = dateRange[0].format('YYYY-MM-DD');
newFilters.endDate = dateRange[1].format('YYYY-MM-DD');
}
setFilters(newFilters);
fetchLogs(newFilters);
};
// 重置筛选
const handleResetFilters = () => {
setSearchText('');
setSelectedLevel(undefined);
setDateRange(null);
setFilters({});
setPagination(prev => ({...prev, current: 1}));
fetchLogs({page: 1});
};
// 获取日志级别字符串
const getLogLevelString = (level: number | string): LogLevel => {
if (typeof level === 'number') {
return LOG_LEVEL_MAP[level] || 'Information';
}
return level as LogLevel;
};
// 表格列配置
const columns = useMemo<ColumnsType<LogResponse>>(() => [
{
title: '时间',
dataIndex: 'timestamp',
key: 'timestamp',
width: 160,
render: (timestamp: Date) => (
<Text type="secondary">
{dayjs(timestamp).format('MM-DD HH:mm:ss')}
</Text>
),
sorter: true,
},
{
title: '级别',
dataIndex: 'level',
key: 'level',
width: 120,
render: (level: LogLevel | number) => {
const levelString = getLogLevelString(level);
return (
<Tag
color={LOG_LEVEL_COLORS[levelString]}
icon={LOG_LEVEL_ICONS[levelString]}
>
{levelString}
</Tag>
);
},
filters: [
{text: 'Trace', value: 0},
{text: 'Debug', value: 1},
{text: 'Information', value: 2},
{text: 'Warning', value: 3},
{text: 'Error', value: 4},
{text: 'Critical', value: 5},
],
},
{
title: '分类',
dataIndex: 'category',
key: 'category',
width: 150,
ellipsis: true,
render: (category: string) => (
<Tooltip title={category}>
<Text>{category}</Text>
</Tooltip>
),
},
{
title: '消息',
dataIndex: 'message',
key: 'message',
ellipsis: true,
render: (message: string) => (
<Tooltip title={message}>
<Text>{message}</Text>
</Tooltip>
),
},
{
title: '请求信息',
key: 'request',
width: 120,
responsive: ['lg'],
render: (_, record) => {
if (record.requestPath) {
return (
<Space direction="vertical" size={0}>
<Text type="secondary" style={{fontSize: '12px'}}>
{record.requestMethod} {record.statusCode}
</Text>
<Text type="secondary" style={{fontSize: '12px'}}>
{record.requestPath}
</Text>
</Space>
);
}
return '-';
},
},
{
title: 'IP地址',
dataIndex: 'ipAddress',
key: 'ipAddress',
width: 120,
responsive: ['xl'],
render: (ip: string) => ip || '-',
},
{
title: '操作',
key: 'action',
width: 120,
render: (_, record) => (
<Space>
<Tooltip title="查看详情">
<Button
type="text"
size="small"
icon={<EyeOutlined/>}
onClick={() => handleViewDetail(record.id)}
loading={detailLoading}
/>
</Tooltip>
<Popconfirm
title="确定删除此日志吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Tooltip title="删除">
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined/>}
/>
</Tooltip>
</Popconfirm>
</Space>
),
},
], [detailLoading]);
// 行选择配置
const rowSelection: TableRowSelection<LogResponse> = {
selectedRowKeys,
onChange: setSelectedRowKeys,
preserveSelectedRowKeys: true,
};
// 初始化加载
useEffect(() => {
Promise.all([fetchLogs(), fetchLogStatistics()]);
}, []);
return (
<div className="admin-log-management">
<Title level={2}></Title>
<Text type="secondary" style={{marginBottom: 24, display: 'block'}}>
</Text>
{/* 统计卡片 */}
<Row gutter={[16, 16]} style={{marginBottom: 24}}>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="总日志数"
value={logStats.totalCount}
prefix={<FileTextOutlined/>}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="今日日志"
value={logStats.todayCount}
prefix={<InfoCircleOutlined/>}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="错误日志"
value={logStats.errorCount}
prefix={<ExclamationCircleOutlined/>}
valueStyle={{color: '#cf1322'}}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="警告日志"
value={logStats.warningCount}
prefix={<WarningOutlined/>}
valueStyle={{color: '#fa8c16'}}
/>
</Card>
</Col>
</Row>
<Card>
{/* 筛选条件 */}
<Row gutter={[16, 16]} style={{marginBottom: 16}}>
<Col xs={24} sm={12} md={8}>
<Input
placeholder="搜索日志消息或分类"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onPressEnter={handleSearch}
prefix={<SearchOutlined/>}
allowClear
/>
</Col>
<Col xs={24} sm={12} md={6}>
<Select
placeholder="选择日志级别"
value={selectedLevel}
onChange={setSelectedLevel}
allowClear
style={{width: '100%'}}
>
<Select.Option value="Trace">Trace</Select.Option>
<Select.Option value="Debug">Debug</Select.Option>
<Select.Option value="Information">Information</Select.Option>
<Select.Option value="Warning">Warning</Select.Option>
<Select.Option value="Error">Error</Select.Option>
<Select.Option value="Critical">Critical</Select.Option>
</Select>
</Col>
<Col xs={24} sm={12} md={10}>
<Space size="small" style={{width: '100%', justifyContent: 'space-between'}}>
<RangePicker
value={dateRange}
onChange={(dates) => setDateRange(dates as [Dayjs, Dayjs] | null)}
placeholder={['开始日期', '结束日期']}
style={{flex: 1}}
/>
<Space>
<Button
type="primary"
icon={<FilterOutlined/>}
onClick={handleSearch}
>
</Button>
<Button
icon={<ReloadOutlined/>}
onClick={handleResetFilters}
>
</Button>
</Space>
</Space>
</Col>
</Row>
{/* 操作按钮 */}
<Row justify="space-between" style={{marginBottom: 16}}>
<Col>
<Space>
<Button
danger
icon={<DeleteOutlined/>}
onClick={handleBatchDelete}
disabled={selectedRowKeys.length === 0}
>
({selectedRowKeys.length})
</Button>
<Button
danger
icon={<ClearOutlined/>}
onClick={() => handleClearLogs('old')}
>
</Button>
<Button
danger
icon={<ClearOutlined/>}
onClick={() => handleClearLogs('all')}
>
</Button>
</Space>
</Col>
<Col>
<Button
icon={<ReloadOutlined/>}
onClick={() => fetchLogs()}
loading={loading}
>
</Button>
</Col>
</Row>
{/* 数据表格 */}
<Table
columns={columns}
dataSource={logs}
rowKey="id"
rowSelection={rowSelection}
pagination={{
...pagination,
onChange: (page, pageSize) => {
setPagination(prev => ({...prev, current: page, pageSize: pageSize || 20}));
fetchLogs({...filters, page, pageSize});
},
}}
loading={loading}
size={isMobile ? 'small' : 'middle'}
scroll={isMobile ? {x: 800} : undefined}
/>
</Card>
{/* 日志详情抽屉 */}
<Drawer
title="日志详情"
placement="right"
width={isMobile ? '100%' : 600}
open={detailDrawerOpen}
onClose={() => setDetailDrawerOpen(false)}
>
{selectedLog && (
<Space direction="vertical" size="large" style={{width: '100%'}}>
<Card title="基本信息" size="small">
<Row gutter={[16, 8]}>
<Col span={8}>
<Text strong>:</Text>
</Col>
<Col span={16}>
<Text>{dayjs(selectedLog.timestamp).format('YYYY-MM-DD HH:mm:ss')}</Text>
</Col>
<Col span={8}>
<Text strong>:</Text>
</Col>
<Col span={16}>
<Tag color={LOG_LEVEL_COLORS[getLogLevelString(selectedLog.level)]}
icon={LOG_LEVEL_ICONS[getLogLevelString(selectedLog.level)]}>
{getLogLevelString(selectedLog.level)}
</Tag>
</Col>
<Col span={8}>
<Text strong>:</Text>
</Col>
<Col span={16}>
<Text>{selectedLog.category}</Text>
</Col>
{selectedLog.eventId != null && (
<>
<Col span={8}>
<Text strong>ID:</Text>
</Col>
<Col span={16}>
<Text>{selectedLog.eventId}</Text>
</Col>
</>
)}
</Row>
</Card>
<Card title="消息内容" size="small">
<Paragraph>
<Text>{selectedLog.message}</Text>
</Paragraph>
</Card>
{selectedLog.exception && (
<Card title="异常信息" size="small">
<Alert
message="异常详情"
description={
<pre style={{whiteSpace: 'pre-wrap', fontSize: '12px'}}>
{selectedLog.exception}
</pre>
}
type="error"
showIcon
/>
</Card>
)}
{(selectedLog.requestPath || selectedLog.ipAddress) && (
<Card title="请求信息" size="small">
<Row gutter={[16, 8]}>
{selectedLog.requestMethod && (
<>
<Col span={8}>
<Text strong>:</Text>
</Col>
<Col span={16}>
<Tag>{selectedLog.requestMethod}</Tag>
</Col>
</>
)}
{selectedLog.requestPath && (
<>
<Col span={8}>
<Text strong>:</Text>
</Col>
<Col span={16}>
<Text code>{selectedLog.requestPath}</Text>
</Col>
</>
)}
{selectedLog.statusCode && (
<>
<Col span={8}>
<Text strong>:</Text>
</Col>
<Col span={16}>
<Badge
status={selectedLog.statusCode >= 400 ? 'error' : 'success'}
text={selectedLog.statusCode}
/>
</Col>
</>
)}
{selectedLog.ipAddress && (
<>
<Col span={8}>
<Text strong>IP地址:</Text>
</Col>
<Col span={16}>
<Text code>{selectedLog.ipAddress}</Text>
</Col>
</>
)}
{selectedLog.userId && (
<>
<Col span={8}>
<Text strong>ID:</Text>
</Col>
<Col span={16}>
<Text>{selectedLog.userId}</Text>
</Col>
</>
)}
</Row>
</Card>
)}
{selectedLog.properties && (
<Card title="附加属性" size="small">
<pre style={{whiteSpace: 'pre-wrap', fontSize: '12px'}}>
{selectedLog.properties}
</pre>
</Card>
)}
</Space>
)}
</Drawer>
</div>
);
};
export default AdminLogManagement;

View File

@@ -7,7 +7,8 @@ import {
SettingOutlined,
CompassOutlined,
DashboardOutlined,
UserOutlined
UserOutlined,
LogoutOutlined
} from '@ant-design/icons';
import AllImages from '../pages/allImages/Index';
@@ -22,6 +23,7 @@ import System from '../pages/admin/system/Index';
import UserManagement from '../pages/admin/users/Index';
import PictureManagement from '../pages/admin/pictures/Index';
import UserDetail from '../pages/admin/users/UserDetail';
import AdminLogManagement from '../pages/admin/log/Index';
export interface RouteConfig {
path: string;
@@ -168,6 +170,17 @@ const routes: RouteConfig[] = [
breadcrumb: {
title: '图片管理'
}
},
{
path: 'log',
key: 'admin-log',
icon: <LogoutOutlined />,
label: '日志中心',
element: <AdminLogManagement />,
area: 'admin',
breadcrumb: {
title: '日志中心'
}
},
{
path: 'system',