feat(account-events): 账号详情页与账号事件历史全链路支持

1. 新增账号事件数据表 AccountEvent 与迁移版本 20261002_0040_account_events,支持审计 Cookie 变更、自动重登、健康检查及任务执行事件;
2. 后端新增 account_events_service 并扩展 /api/accounts 路由,支持事件查询、过滤与上报;
3. 前端新增 AccountDetailPage 账号详情页组件,支持账号元信息展示、事件历史瀑布流与快捷重登操作;
4. 修复 CK 重登与自动化定时任务中事件关联逻辑,补充 test_account_events 单元测试;
5. 新增生产服务器高负载排查分析报告及相关设计文档。
This commit is contained in:
yml2213
2026-10-03 12:58:58 +08:00
parent 294c6cd058
commit 834bfdd1d0
29 changed files with 2321 additions and 1934 deletions
+2
View File
@@ -11,6 +11,7 @@ const LoginPage = lazy(() => import('./pages/LoginPage'));
const MainLayout = lazy(() => import('./layouts/MainLayout'));
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
const AccountsPage = lazy(() => import('./pages/AccountsPage'));
const AccountDetailPage = lazy(() => import('./pages/AccountDetailPage'));
const AccountCheckPage = lazy(() => import('./pages/AccountCheckPage'));
const AssignmentsPage = lazy(() => import('./pages/AssignmentsPage'));
const LoginTasksPage = lazy(() => import('./pages/LoginTasksPage'));
@@ -82,6 +83,7 @@ function AppContent() {
>
<Route index element={lazyRoute(<DashboardPage />)} />
<Route path="accounts" element={lazyRoute(<AccountsPage />)} />
<Route path="accounts/:id" element={lazyRoute(<AccountDetailPage />)} />
<Route path="account-check" element={lazyRoute(<AccountCheckPage />)} />
<Route path="assignments" element={lazyRoute(<AssignmentsPage />)} />
<Route path="login-tasks" element={lazyRoute(<LoginTasksPage />)} />
+9
View File
@@ -1,8 +1,10 @@
import api from './client';
import type {
AccountEventPage,
AccountItem,
AccountBulkSelection,
AccountBulkTagRequest,
AccountOverview,
AssignmentsSummary,
BasicSummary,
MessageCountResponse,
@@ -38,4 +40,11 @@ export const accountApi = {
api.delete<MessageDeletedResponse, MessageDeletedResponse>('/accounts/batch/delete', { params: { account_ids: account_ids.join(',') } }),
batchDeleteSelection: (data: AccountBulkSelection) =>
api.post<MessageDeletedResponse, MessageDeletedResponse>('/accounts/batch-delete', data),
// 详情页:状态卡 + 统计
overview: (id: number) => api.get<AccountOverview, AccountOverview>(`/accounts/${id}/overview`),
// 时间线(倒序分页),event_type 为空表示全部
events: (id: number, params: { page?: number; page_size?: number; event_type?: string }) =>
api.get<AccountEventPage, AccountEventPage>(`/accounts/${id}/events`, {
params: { page: 1, page_size: 50, ...params, event_type: params.event_type || '' },
}),
};
+76
View File
@@ -71,6 +71,82 @@ export interface BasicSummary {
tag_count?: number;
}
// ==================== 账号详情与事件历史 ====================
/** 事件类型(与后端 account_events_service 的常量保持一致)。 */
export type AccountEventType =
| 'imported'
| 'login_ok'
| 'ck_invalid'
| 'relogin_ok'
| 'relogin_failed'
| 'bind_ok'
| 'recharge'
| 'exchange'
| 'points_credit'
| 'elite_opened'
| 'esports_opened'
| 'assigned'
| 'tag_changed';
export interface AccountEventItem {
id: number;
event_type: AccountEventType | string;
occurred_at: string;
title: string;
/** 有符号:充值为正、兑换消耗为负 */
amount: number | null;
balance_after: number | null;
detail: Record<string, unknown> | null;
source: string;
}
export interface AccountEventPage {
items: AccountEventItem[];
total: number;
page: number;
page_size: number;
}
export interface AccountOverviewStats {
recharge_total: number;
exchange_count: number;
points_spent: number;
ck_invalid_count: number;
event_count: number;
login_count: number;
last_login_at: string | null;
last_ck_check_at: string | null;
}
export interface AccountOverview {
id: number;
username: string;
nickname: string;
uid: string;
tag: string;
remark: string;
assigned_to: number | null;
/** 导入时间 */
created_at: string | null;
points: number | null;
gold_balance: number | null;
exchange_balance: number | null;
game_name: string;
game_channel: string;
bind_status: string;
elite_opened: boolean | null;
elite_expire_time: number | null;
esports_points: number | null;
esports_bind_status: string;
esports_game_name: string;
esports_can_change_time: number | null;
auto_managed: boolean;
stats: AccountOverviewStats;
/** 开通时间来自事件表(上线后才开始记录,更早的不显示) */
opened_at: { elite: string | null; esports: string | null };
}
export interface TaskSummary {
total: number;
success: number;
@@ -0,0 +1,266 @@
import { useCallback, useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
Button, Card, Descriptions, Empty, Select, Space, Spin, Statistic, Table, Tag, Tooltip, Typography,
} from 'antd';
import type { TableProps } from 'antd';
import {
ArrowLeftOutlined, ReloadOutlined,
} from '@ant-design/icons';
import { accountApi, type AccountEventItem, type AccountOverview } from '../api/modules';
import { getErrorMessage } from '../utils/error';
import { message } from '../utils/antdMessage';
import { formatTime } from '../utils/time';
const { Text, Title } = Typography;
/**
* 事件类型 → 展示样式。
* 颜色用来一眼区分「钱进来 / 分出去 / 状态变化」三类,而不是按类型各给一个颜色。
*/
const EVENT_STYLE: Record<string, { label: string; color: string }> = {
imported: { label: '导入', color: 'default' },
login_ok: { label: '登录', color: 'blue' },
ck_invalid: { label: 'CK 失效', color: 'red' },
relogin_ok: { label: '重登成功', color: 'green' },
relogin_failed: { label: '重登转人工', color: 'orange' },
bind_ok: { label: '绑定', color: 'cyan' },
recharge: { label: '充值', color: 'green' },
exchange: { label: '兑换', color: 'purple' },
points_credit: { label: '积分到账', color: 'gold' },
elite_opened: { label: '精英宝典', color: 'magenta' },
esports_opened: { label: '电竞手册', color: 'volcano' },
assigned: { label: '分配', color: 'default' },
tag_changed: { label: '标签', color: 'default' },
};
function eventTag(type: string) {
const style = EVENT_STYLE[type] ?? { label: type, color: 'default' };
return <Tag color={style.color}>{style.label}</Tag>;
}
/** 金额展示:充值为正(绿色 ¥),兑换消耗为负(红色积分)。 */
function amountText(item: AccountEventItem) {
if (item.amount === null || item.amount === undefined) return null;
if (item.event_type === 'recharge') {
return <Text type="success">+¥{item.amount}</Text>;
}
if (item.event_type === 'exchange') {
return <Text type="danger">{item.amount} 分</Text>;
}
return (
<Text type={item.amount >= 0 ? 'success' : 'danger'}>
{item.amount > 0 ? '+' : ''}
{item.amount}
</Text>
);
}
export default function AccountDetailPage() {
const { id } = useParams<{ id: string }>();
const accountId = Number(id);
const navigate = useNavigate();
const [overview, setOverview] = useState<AccountOverview | null>(null);
const [loading, setLoading] = useState(true);
const [events, setEvents] = useState<AccountEventItem[]>([]);
const [eventTotal, setEventTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [eventType, setEventType] = useState('');
const [eventsLoading, setEventsLoading] = useState(false);
const loadOverview = useCallback(async () => {
if (!accountId) return;
setLoading(true);
try {
setOverview(await accountApi.overview(accountId));
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setLoading(false);
}
}, [accountId]);
const loadEvents = useCallback(async () => {
if (!accountId) return;
setEventsLoading(true);
try {
const data = await accountApi.events(accountId, {
page,
page_size: pageSize,
event_type: eventType,
});
setEvents(data.items);
setEventTotal(data.total);
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setEventsLoading(false);
}
}, [accountId, page, pageSize, eventType]);
useEffect(() => {
void loadOverview();
}, [loadOverview]);
useEffect(() => {
void loadEvents();
}, [loadEvents]);
const columns: TableProps<AccountEventItem>['columns'] = [
{
title: '时间',
dataIndex: 'occurred_at',
width: 165,
render: (val: string) => formatTime(val),
},
{
title: '类型',
dataIndex: 'event_type',
width: 100,
render: (val: string) => eventTag(val),
},
{ title: '说明', dataIndex: 'title', ellipsis: true },
{
title: '金额 / 变动',
dataIndex: 'amount',
width: 110,
align: 'right',
render: (_val, record) => amountText(record) ?? <Text type="secondary">-</Text>,
},
{
title: '变动后余额',
dataIndex: 'balance_after',
width: 110,
align: 'right',
render: (val: number | null) =>
val === null || val === undefined ? <Text type="secondary">-</Text> : val,
},
];
if (!accountId) {
return <Empty description="账号 ID 无效" />;
}
const stats = overview?.stats;
return (
<div style={{ padding: 4 }}>
<Space style={{ marginBottom: 12 }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/accounts')}>
返回账号列表
</Button>
<Button icon={<ReloadOutlined />} onClick={() => { void loadOverview(); void loadEvents(); }}>
刷新
</Button>
</Space>
<Spin spinning={loading}>
<Card
title={
<Space>
<Title level={5} style={{ margin: 0 }}>{overview?.username || '—'}</Title>
{overview?.tag && <Tag color="blue">{overview.tag}</Tag>}
{overview?.auto_managed === false && <Tag color="orange">不参与自动化</Tag>}
</Space>
}
style={{ marginBottom: 12 }}
>
<Descriptions column={3} size="small" bordered>
<Descriptions.Item label="斗鱼昵称">{overview?.nickname || '-'}</Descriptions.Item>
<Descriptions.Item label="斗鱼 UID">{overview?.uid || '-'}</Descriptions.Item>
<Descriptions.Item label="导入时间">{formatTime(overview?.created_at)}</Descriptions.Item>
<Descriptions.Item label="游戏角色">
{overview?.game_name || '-'}
{overview?.game_channel ? <Text type="secondary"> ({overview.game_channel})</Text> : null}
</Descriptions.Item>
<Descriptions.Item label="绑定状态">{overview?.bind_status || '-'}</Descriptions.Item>
<Descriptions.Item label="备注">{overview?.remark || '-'}</Descriptions.Item>
</Descriptions>
<div style={{ display: 'flex', gap: 32, marginTop: 16, flexWrap: 'wrap' }}>
<Statistic title="当前积分" value={overview?.points ?? '-'} />
<Statistic title="鱼翅余额" value={overview?.gold_balance ?? '-'} />
<Statistic title="兑换余额" value={overview?.exchange_balance ?? '-'} />
<Statistic
title="精英宝典"
value={overview?.elite_opened ? '已开通' : '未开通'}
valueStyle={{ color: overview?.elite_opened ? '#3f8600' : undefined, fontSize: 20 }}
/>
<Statistic
title="电竞手册"
value={overview?.esports_bind_status === 'esports_opened' ? '已开通' : (overview?.esports_bind_status || '未开通')}
valueStyle={{ fontSize: 20 }}
/>
</div>
<Descriptions column={3} size="small" style={{ marginTop: 16 }}>
<Descriptions.Item label="精英宝典开通时间">
{formatTime(overview?.opened_at?.elite)}
</Descriptions.Item>
<Descriptions.Item label="电竞手册开通时间">
{formatTime(overview?.opened_at?.esports)}
</Descriptions.Item>
<Descriptions.Item label="最近 CK 检测">
{formatTime(stats?.last_ck_check_at)}
</Descriptions.Item>
</Descriptions>
<div style={{ display: 'flex', gap: 32, marginTop: 12, flexWrap: 'wrap' }}>
<Statistic title="累计充值" value={stats?.recharge_total ?? 0} prefix="¥" />
<Statistic title="兑换次数" value={stats?.exchange_count ?? 0} />
<Statistic title="累计消耗积分" value={stats?.points_spent ?? 0} />
<Statistic title="CK 失效次数" value={stats?.ck_invalid_count ?? 0} />
<Statistic title="事件总数" value={stats?.event_count ?? 0} />
</div>
<Text type="secondary" style={{ display: 'block', marginTop: 12, fontSize: 12 }}>
开通时间与累计统计来自「账号事件」,从该功能上线起开始记录;
更早的历史请到「登录任务 / 精英宝典 / 电竞手册 / 和平小店」页查看。
</Text>
</Card>
</Spin>
<Card
title="账号时间线"
extra={
<Space>
<Select
size="small"
style={{ width: 140 }}
value={eventType}
onChange={(v) => { setEventType(v); setPage(1); }}
options={[
{ value: '', label: '全部事件' },
...Object.entries(EVENT_STYLE).map(([value, s]) => ({ value, label: s.label })),
]}
/>
<Tooltip title="从上线起累积,不回填历史">
<Text type="secondary" style={{ fontSize: 12 }}>共 {eventTotal} 条</Text>
</Tooltip>
</Space>
}
>
<Table
columns={columns}
dataSource={events}
rowKey="id"
size="small"
loading={eventsLoading}
pagination={{
current: page,
pageSize,
total: eventTotal,
size: 'small',
showSizeChanger: true,
pageSizeOptions: ['20', '50', '100'],
showTotal: (t) => `共 ${t} 条`,
onChange: (p, s) => { setPage(p); setPageSize(s); },
}}
locale={{ emptyText: <Empty description="暂无事件(功能上线后开始记录)" /> }}
/>
</Card>
</div>
);
}
+12 -1
View File
@@ -1,4 +1,5 @@
import { useEffect, useState, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert, Table, Button, Modal, Input, Select, Popconfirm, Typography, Tag, Space,
Row, Col, Card, Statistic, App,
@@ -19,6 +20,7 @@ const TAG_COLORS = [
];
export default function AccountsPage() {
const navigate = useNavigate();
// App.useApp() 的 modal 实例继承主题,静态 Modal.confirm 在暗色模式下是白底
const { modal } = App.useApp();
const [accounts, setAccounts] = useState<AccountItem[]>([]);
@@ -251,7 +253,16 @@ export default function AccountsPage() {
const columns: TableProps<AccountItem>['columns'] = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '用户名', dataIndex: 'username' },
{
title: '用户名',
dataIndex: 'username',
// 点进账号详情:那里有状态卡、累计统计与时间线
render: (username: string, record: AccountItem) => (
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => navigate(`/accounts/${record.id}`)}>
{username}
</Button>
),
},
{
title: '标签',
dataIndex: 'tag',