增加充值审计日志

This commit is contained in:
yml2213
2026-08-14 11:52:30 +08:00
parent 7fefe7e9f9
commit 7fe6228901
16 changed files with 675 additions and 3 deletions
+3
View File
@@ -25,6 +25,7 @@ const HuyaCookiePage = lazy(() => import('./pages/HuyaCookiePage'));
const HuyaRegisterPage = lazy(() => import('./pages/HuyaRegisterPage'));
const HuyaTasksPage = lazy(() => import('./pages/HuyaTasksPage'));
const YybRechargePage = lazy(() => import('./pages/YybRechargePage'));
const AuditLogsPage = lazy(() => import('./pages/AuditLogsPage'));
function RouteFallback() {
return (
@@ -43,6 +44,7 @@ function AppContent() {
const [authVersion, setAuthVersion] = useState(0);
const refreshAuth = useCallback(() => setAuthVersion((v) => v + 1), []);
const isLoggedIn = !!getUser();
const isSuperAdmin = getUser()?.role === 'super_admin';
const { isDark } = useTheme();
// localStorage 仅是前端缓存;每次加载应用时以服务端的实时权限为准。
@@ -93,6 +95,7 @@ function AppContent() {
<Route path="yyb/recharge" element={lazyRoute(<YybRechargePage />)} />
<Route path="proxy" element={lazyRoute(<ProxyPage />)} />
<Route path="users" element={lazyRoute(<UsersPage />)} />
<Route path="audit-logs" element={isSuperAdmin ? lazyRoute(<AuditLogsPage />) : <Navigate to="/" replace />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
+7
View File
@@ -0,0 +1,7 @@
import api from './client';
import type { AuditLogEntry, AuditLogQuery, PaginatedResponse } from './types';
export const auditApi = {
list: (params: AuditLogQuery) =>
api.get<PaginatedResponse<AuditLogEntry>, PaginatedResponse<AuditLogEntry>>('/audit-logs', { params }),
};
+1
View File
@@ -2,6 +2,7 @@ export * from './types';
export { accountCheckApi } from './accountCheck';
export { accountApi } from './accounts';
export { appApi } from './app';
export { auditApi } from './audit';
export { authApi } from './auth';
export { cookieApi } from './cookies';
export { dashboardApi } from './dashboard';
+20
View File
@@ -44,6 +44,26 @@ export interface PaginatedResponse<T> {
page_size: number;
}
export interface AuditLogEntry {
id: number;
user_id: number | null;
username: string;
action: string;
target: string;
detail: string;
success: boolean | null;
created_at: string;
}
export interface AuditLogQuery {
page: number;
page_size: number;
username?: string;
action?: string;
keyword?: string;
success?: boolean;
}
export interface BasicSummary {
total: number;
assigned_count: number;
+4 -1
View File
@@ -7,7 +7,7 @@ import {
MenuFoldOutlined, MenuUnfoldOutlined, SwapOutlined,
SunOutlined, MoonOutlined, DesktopOutlined, GiftOutlined, ShoppingCartOutlined, ApartmentOutlined, MobileOutlined,
BookOutlined, TrophyOutlined, ShopOutlined,
SafetyCertificateOutlined,
SafetyCertificateOutlined, FileSearchOutlined,
} from '@ant-design/icons';
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
import { getUser, clearAuth, type AuthUser } from '../store/auth';
@@ -115,6 +115,9 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
if (can('user:view')) {
systemItems.push({ key: '/users', label: '用户管理', icon: <TeamOutlined /> });
}
if (user.role === 'super_admin') {
systemItems.push({ key: '/audit-logs', label: '审计日志', icon: <FileSearchOutlined /> });
}
if (systemItems.length > 0) {
menuItems.push({ key: 'group-system', type: 'group', label: '系统', children: systemItems });
}
+156
View File
@@ -0,0 +1,156 @@
import { useCallback, useEffect, useState } from 'react';
import { Button, Card, Descriptions, Drawer, Input, Select, Space, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { ReloadOutlined } from '@ant-design/icons';
import { auditApi, type AuditLogEntry, type AuditLogQuery } from '../api/modules';
import { getErrorMessage } from '../utils/error';
import { message } from '../utils/antdMessage';
const { Text } = Typography;
const ACTION_OPTIONS = [
{ value: 'recharge:yyb:create', label: '应用宝创建任务' },
{ value: 'recharge:yyb:login', label: '应用宝发起登录' },
{ value: 'recharge:yyb:selection', label: '应用宝选择商品角色' },
{ value: 'recharge:yyb:payment', label: '应用宝生成付款码' },
{ value: 'recharge:yyb:payment_check', label: '应用宝检测到账' },
{ value: 'recharge:yyb:stop', label: '应用宝停止任务' },
{ value: 'recharge:douyu:create', label: '斗鱼创建直充批次' },
{ value: 'recharge:douyu:callback', label: '斗鱼供应商回调' },
{ value: 'recharge:douyu:stop', label: '斗鱼停止直充批次' },
{ value: 'recharge:douyu:config', label: '斗鱼直充配置' },
{ value: 'recharge:huya:create', label: '虎牙创建充值批次' },
{ value: 'recharge:huya:stop', label: '虎牙停止充值批次' },
{ value: 'recharge:huya:config', label: '虎牙充值配置' },
];
function formatTime(value: string): string {
return new Date(value).toLocaleString('zh-CN', { hour12: false });
}
function detailSummary(detail: string): string {
try {
const data = JSON.parse(detail) as Record<string, unknown>;
const rechargeAccounts = Array.isArray(data.recharge_accounts) ? data.recharge_accounts : [];
if (rechargeAccounts.length > 0) {
const names = rechargeAccounts.slice(0, 3).map((item) => {
const account = item as Record<string, unknown>;
return String(account.douyu_nickname || account.username || account.douyu_uid || '-');
});
return `充值账号: ${names.join('、')}${rechargeAccounts.length > 3 ? ' 等' : ''}`;
}
return Object.entries(data)
.filter(([key]) => key !== 'message')
.slice(0, 4)
.map(([key, value]) => `${key}: ${String(value)}`)
.join(' | ') || String(data.message || '');
} catch {
return detail;
}
}
function formatDetail(detail: string): string {
try {
return JSON.stringify(JSON.parse(detail), null, 2);
} catch {
return detail || '-';
}
}
export default function AuditLogsPage() {
const [rows, setRows] = useState<AuditLogEntry[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState<AuditLogQuery>({ page: 1, page_size: 50 });
const [selected, setSelected] = useState<AuditLogEntry | null>(null);
const load = useCallback(async (nextQuery = query) => {
setLoading(true);
try {
const data = await auditApi.list(nextQuery);
setRows(data.items);
setTotal(data.total);
} catch (error: unknown) {
message.error(getErrorMessage(error));
} finally {
setLoading(false);
}
}, [query]);
useEffect(() => {
void load();
}, [load]);
const updateFilter = (changes: Partial<AuditLogQuery>) => {
const next = { ...query, ...changes, page: 1 };
setQuery(next);
void load(next);
};
const columns: ColumnsType<AuditLogEntry> = [
{ title: '时间', dataIndex: 'created_at', width: 180, render: formatTime },
{ title: '操作者', dataIndex: 'username', width: 120, render: (value) => value || '-' },
{ title: '操作', dataIndex: 'action', width: 210 },
{ title: '目标', dataIndex: 'target', width: 210, ellipsis: true },
{
title: '结果', dataIndex: 'success', width: 88,
render: (value: boolean | null) => (
value === null ? <Tag></Tag> : <Tag color={value ? 'success' : 'error'}>{value ? '成功' : '失败'}</Tag>
),
},
{
title: '摘要', dataIndex: 'detail', ellipsis: true,
render: (value: string) => <Text ellipsis style={{ maxWidth: 460 }}>{detailSummary(value)}</Text>,
},
];
return (
<Card title="审计日志" extra={<Button icon={<ReloadOutlined />} onClick={() => void load()} loading={loading}></Button>}>
<Space wrap style={{ marginBottom: 16 }}>
<Input
allowClear placeholder="操作者" style={{ width: 150 }}
onPressEnter={(event) => updateFilter({ username: event.currentTarget.value || undefined })}
onBlur={(event) => updateFilter({ username: event.currentTarget.value || undefined })}
/>
<Select
allowClear placeholder="操作类型" options={ACTION_OPTIONS} style={{ width: 210 }}
onChange={(value) => updateFilter({ action: value || undefined })}
/>
<Select
allowClear placeholder="结果" style={{ width: 120 }}
options={[{ value: 'true', label: '成功' }, { value: 'false', label: '失败' }]}
onChange={(value) => updateFilter({ success: value === undefined ? undefined : value === 'true' })}
/>
<Input.Search
allowClear placeholder="目标或摘要" style={{ width: 220 }}
onSearch={(value) => updateFilter({ keyword: value || undefined })}
/>
</Space>
<Table
rowKey="id" columns={columns} dataSource={rows} loading={loading} size="middle"
onRow={(record) => ({ onClick: () => setSelected(record), style: { cursor: 'pointer' } })}
pagination={{
current: query.page, pageSize: query.page_size, total, showSizeChanger: true,
showTotal: (count) => `${count}`,
onChange: (page, pageSize) => {
const next = { ...query, page, page_size: pageSize };
setQuery(next);
void load(next);
},
}}
/>
<Drawer title="审计详情" open={!!selected} onClose={() => setSelected(null)} width={560}>
{selected && (
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="时间">{formatTime(selected.created_at)}</Descriptions.Item>
<Descriptions.Item label="操作者">{selected.username || '-'}</Descriptions.Item>
<Descriptions.Item label="操作">{selected.action}</Descriptions.Item>
<Descriptions.Item label="目标">{selected.target || '-'}</Descriptions.Item>
<Descriptions.Item label="结果">{selected.success === null ? '历史记录' : selected.success ? '成功' : '失败'}</Descriptions.Item>
<Descriptions.Item label="详情"><pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{formatDetail(selected.detail)}</pre></Descriptions.Item>
</Descriptions>
)}
</Drawer>
</Card>
);
}