- 解决审计日志 8 小时时区差:后端输出带时区 UTC ISO 字符串,前端 formatTime 增加 UTC 自动解析换算 - 审计日志支持单号检索:list_audit_logs 关联 DouyuTask 匹配供应商单号、外部单号、UID、昵称等 - 列表摘要与详情抽屉优化:摘要直观展示供应商单号与外部单号,抽屉增加专用供应商订单列表并支持一键复制 - 增加测试用例覆盖时间格式与单号反向检索
311 lines
14 KiB
TypeScript
311 lines
14 KiB
TypeScript
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';
|
|
import { formatTime } from '../utils/time';
|
|
|
|
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: '虎牙充值配置' },
|
|
{ value: 'cookie:check', label: '检测账号 CK' },
|
|
{ value: 'cookie:relogin', label: '创建 CK 重登批次' },
|
|
{ value: 'cookie:relogin_invalid', label: '批量重登失效 CK' },
|
|
{ value: 'cookie:relogin_stop', label: '停止 CK 重登批次' },
|
|
];
|
|
|
|
const ACTION_LABELS = new Map(ACTION_OPTIONS.map((item) => [item.value, item.label]));
|
|
|
|
function actionLabel(action: string): string {
|
|
return ACTION_LABELS.get(action) || action;
|
|
}
|
|
|
|
function paymentMethod(detail: string): string {
|
|
try {
|
|
const data = JSON.parse(detail) as Record<string, unknown>;
|
|
return typeof data.payment_method === 'string' ? data.payment_method : '-';
|
|
} catch {
|
|
return '-';
|
|
}
|
|
}
|
|
|
|
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 : [];
|
|
const supplierOrders = Array.isArray(data.supplier_orders) ? data.supplier_orders : [];
|
|
const supplierOrderIds = Array.isArray(data.supplier_order_ids) ? data.supplier_order_ids : [];
|
|
const supplierOutOrderIds = Array.isArray(data.supplier_out_order_ids) ? data.supplier_out_order_ids : [];
|
|
|
|
// 优先直观展示充值账号与供应商单号
|
|
if (supplierOrderIds.length > 0 || supplierOutOrderIds.length > 0 || supplierOrders.length > 0) {
|
|
const names = rechargeAccounts.slice(0, 2).map((item) => {
|
|
const account = item as Record<string, unknown>;
|
|
return String(account.douyu_nickname || account.username || account.douyu_uid || '-');
|
|
});
|
|
const accText = names.length > 0
|
|
? `充值账号: ${names.join('、')}${rechargeAccounts.length > 2 ? ` 等(${rechargeAccounts.length})` : ''}`
|
|
: '';
|
|
const orderId = String(supplierOrderIds[0] || (supplierOrders[0] as Record<string, unknown>)?.supplier_order_id || '').trim();
|
|
const outId = String(supplierOutOrderIds[0] || (supplierOrders[0] as Record<string, unknown>)?.supplier_out_order_id || '').trim();
|
|
|
|
const orderParts: string[] = [];
|
|
if (orderId) orderParts.push(`供应商单号: ${orderId}`);
|
|
if (outId && outId !== orderId) orderParts.push(`外部单号: ${outId}`);
|
|
|
|
return [accText, orderParts.join(' | ')].filter(Boolean).join(' | ');
|
|
}
|
|
|
|
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 ? ' 等' : ''}`;
|
|
}
|
|
|
|
if (data.out_order_id || data.order_id) {
|
|
const parts: string[] = [];
|
|
if (data.order_id) parts.push(`供应商单号: ${String(data.order_id)}`);
|
|
if (data.out_order_id) parts.push(`外部单号: ${String(data.out_order_id)}`);
|
|
if (data.message) parts.push(String(data.message));
|
|
return parts.join(' | ');
|
|
}
|
|
|
|
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 || '-';
|
|
}
|
|
}
|
|
|
|
function parseDetailData(detail: string): Record<string, unknown> | null {
|
|
try {
|
|
const data = JSON.parse(detail);
|
|
return typeof data === 'object' && data !== null ? (data as Record<string, unknown>) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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: 180, render: actionLabel },
|
|
{ title: '目标', dataIndex: 'target', width: 210, ellipsis: true },
|
|
{
|
|
title: '充值方式', dataIndex: 'detail', width: 130,
|
|
render: (detail: string) => paymentMethod(detail),
|
|
},
|
|
{
|
|
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: 140 }}
|
|
onPressEnter={(event) => updateFilter({ username: event.currentTarget.value || undefined })}
|
|
onBlur={(event) => updateFilter({ username: event.currentTarget.value || undefined })}
|
|
/>
|
|
<Select
|
|
allowClear placeholder="操作类型" options={ACTION_OPTIONS} style={{ width: 190 }}
|
|
onChange={(value) => updateFilter({ action: value || undefined })}
|
|
/>
|
|
<Select
|
|
allowClear placeholder="结果" style={{ width: 110 }}
|
|
options={[{ value: 'true', label: '成功' }, { value: 'false', label: '失败' }]}
|
|
onChange={(value) => updateFilter({ success: value === undefined ? undefined : value === 'true' })}
|
|
/>
|
|
<Input.Search
|
|
allowClear
|
|
placeholder="供应商单号 / 外部单号 / 账号 / 目标 / 摘要"
|
|
style={{ width: 320 }}
|
|
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={640}>
|
|
{selected && (() => {
|
|
const detailData = parseDetailData(selected.detail);
|
|
const supplierOrders = Array.isArray(detailData?.supplier_orders)
|
|
? (detailData?.supplier_orders as Array<Record<string, unknown>>)
|
|
: [];
|
|
const supplierOrderIds = Array.isArray(detailData?.supplier_order_ids)
|
|
? (detailData?.supplier_order_ids as string[])
|
|
: [];
|
|
const supplierOutOrderIds = Array.isArray(detailData?.supplier_out_order_ids)
|
|
? (detailData?.supplier_out_order_ids as string[])
|
|
: [];
|
|
|
|
return (
|
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
|
<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="操作">{actionLabel(selected.action)}</Descriptions.Item>
|
|
<Descriptions.Item label="目标">{selected.target || '-'}</Descriptions.Item>
|
|
<Descriptions.Item label="充值方式">{paymentMethod(selected.detail)}</Descriptions.Item>
|
|
<Descriptions.Item label="结果">{selected.success === null ? '历史记录' : selected.success ? '成功' : '失败'}</Descriptions.Item>
|
|
{supplierOrderIds.length > 0 && (
|
|
<Descriptions.Item label="供应商单号">
|
|
{supplierOrderIds.map((id) => (
|
|
<div key={id}><Text copyable strong>{id}</Text></div>
|
|
))}
|
|
</Descriptions.Item>
|
|
)}
|
|
{supplierOutOrderIds.length > 0 && (
|
|
<Descriptions.Item label="外部单号">
|
|
{supplierOutOrderIds.map((id) => (
|
|
<div key={id}><Text copyable type="secondary">{id}</Text></div>
|
|
))}
|
|
</Descriptions.Item>
|
|
)}
|
|
</Descriptions>
|
|
|
|
{supplierOrders.length > 0 && (
|
|
<Card size="small" title="供应商直充订单列表">
|
|
<Table<Record<string, unknown>>
|
|
size="small"
|
|
pagination={false}
|
|
rowKey={(r) => String(r.task_id || r.supplier_out_order_id || Math.random())}
|
|
dataSource={supplierOrders}
|
|
columns={[
|
|
{
|
|
title: '充值账号',
|
|
key: 'account',
|
|
render: (_: unknown, r: Record<string, unknown>) => (
|
|
<div>
|
|
<div>{String(r.douyu_nickname || r.username || '-')}</div>
|
|
{Boolean(r.douyu_uid) && <Text type="secondary" style={{ fontSize: 12 }}>UID: {String(r.douyu_uid)}</Text>}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
title: '数量',
|
|
dataIndex: 'amount',
|
|
key: 'amount',
|
|
width: 90,
|
|
render: (v: unknown) => (v ? `${String(v)} 鱼翅` : '-'),
|
|
},
|
|
{
|
|
title: '供应商单号',
|
|
dataIndex: 'supplier_order_id',
|
|
key: 'supplier_order_id',
|
|
render: (v: unknown) => (v ? <Text copyable strong>{String(v)}</Text> : '-'),
|
|
},
|
|
{
|
|
title: '外部单号',
|
|
dataIndex: 'supplier_out_order_id',
|
|
key: 'supplier_out_order_id',
|
|
render: (v: unknown) => (v ? <Text copyable style={{ fontSize: 12 }}>{String(v)}</Text> : '-'),
|
|
},
|
|
{
|
|
title: '状态',
|
|
dataIndex: 'status',
|
|
key: 'status',
|
|
width: 80,
|
|
render: (v: unknown) => {
|
|
const s = String(v || '');
|
|
if (s === 'success') return <Tag color="success">成功</Tag>;
|
|
if (s === 'failed') return <Tag color="error">失败</Tag>;
|
|
if (s === 'running') return <Tag color="processing">处理中</Tag>;
|
|
return <Tag>{s || '-'}</Tag>;
|
|
},
|
|
},
|
|
]}
|
|
/>
|
|
</Card>
|
|
)}
|
|
|
|
<Card size="small" title="完整详情 (JSON)">
|
|
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: 260, overflowY: 'auto' }}>
|
|
{formatDetail(selected.detail)}
|
|
</pre>
|
|
</Card>
|
|
</Space>
|
|
);
|
|
})()}
|
|
</Drawer>
|
|
</Card>
|
|
);
|
|
}
|
|
|