fix(audit): 修复审计日志时区差并支持供应商单号搜索与展示

- 解决审计日志 8 小时时区差:后端输出带时区 UTC ISO 字符串,前端 formatTime 增加 UTC 自动解析换算
- 审计日志支持单号检索:list_audit_logs 关联 DouyuTask 匹配供应商单号、外部单号、UID、昵称等
- 列表摘要与详情抽屉优化:摘要直观展示供应商单号与外部单号,抽屉增加专用供应商订单列表并支持一键复制
- 增加测试用例覆盖时间格式与单号反向检索
This commit is contained in:
yml2213
2026-09-12 14:52:26 +08:00
parent 4518b726ac
commit 1ac6a13a2c
4 changed files with 441 additions and 78 deletions
+150 -20
View File
@@ -5,6 +5,7 @@ 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;
@@ -30,10 +31,6 @@ const ACTION_OPTIONS = [
const ACTION_LABELS = new Map(ACTION_OPTIONS.map((item) => [item.value, item.label]));
function formatTime(value: string): string {
return new Date(value).toLocaleString('zh-CN', { hour12: false });
}
function actionLabel(action: string): string {
return ACTION_LABELS.get(action) || action;
}
@@ -51,6 +48,29 @@ 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>;
@@ -58,6 +78,15 @@ function detailSummary(detail: string): string {
});
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)
@@ -76,6 +105,15 @@ function formatDetail(detail: string): string {
}
}
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);
@@ -131,21 +169,23 @@ export default function AuditLogsPage() {
<Card title="审计日志" extra={<Button icon={<ReloadOutlined />} onClick={() => void load()} loading={loading}>刷新</Button>}>
<Space wrap style={{ marginBottom: 16 }}>
<Input
allowClear placeholder="操作者" style={{ width: 150 }}
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: 210 }}
allowClear placeholder="操作类型" options={ACTION_OPTIONS} style={{ width: 190 }}
onChange={(value) => updateFilter({ action: value || undefined })}
/>
<Select
allowClear placeholder="结果" style={{ width: 120 }}
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: 220 }}
allowClear
placeholder="供应商单号 / 外部单号 / 账号 / 目标 / 摘要"
style={{ width: 320 }}
onSearch={(value) => updateFilter({ keyword: value || undefined })}
/>
</Space>
@@ -162,19 +202,109 @@ export default function AuditLogsPage() {
},
}}
/>
<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="操作">{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>
<Descriptions.Item label="详情"><pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{formatDetail(selected.detail)}</pre></Descriptions.Item>
</Descriptions>
)}
<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>
);
}
+16 -4
View File
@@ -7,12 +7,22 @@ dayjs.extend(timezone);
/**
* 将后端返回的 ISO 时间字符串格式化为本地可读格式。
* 后端统一输出带时区的 ISO 字符串(如 2026-06-22T15:51:26+00:00),
* 此函数自动转为本地时区并格式化为 "YYYY-MM-DD HH:mm:ss"。
* 兼容带时区(如 2026-06-22T15:51:26+00:00 / 2026-06-22T15:51:26Z)与
* 不带时区后缀的 UTC 时间字符串(如 2026-06-22T15:51:26 / 2026-06-22 15:51:26),
* 自动转为本地时区并格式化为 "YYYY-MM-DD HH:mm:ss"。
*/
function normalizeUtcDateStr(val: string): string {
let raw = val.trim();
if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(raw)) {
raw = raw.replace(' ', 'T') + 'Z';
}
return raw;
}
export function formatTime(val: string | null | undefined): string {
if (!val) return '-';
return dayjs(val).format('YYYY-MM-DD HH:mm:ss');
const d = dayjs(normalizeUtcDateStr(val));
return d.isValid() ? d.format('YYYY-MM-DD HH:mm:ss') : val;
}
/**
@@ -20,5 +30,7 @@ export function formatTime(val: string | null | undefined): string {
*/
export function formatTimeShort(val: string | null | undefined): string {
if (!val) return '-';
return dayjs(val).format('MM-DD HH:mm');
const d = dayjs(normalizeUtcDateStr(val));
return d.isValid() ? d.format('MM-DD HH:mm') : val;
}