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:
@@ -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 />)} />
|
||||
|
||||
@@ -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 || '' },
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user