fix(audit): 修复审计日志时区差并支持供应商单号搜索与展示
- 解决审计日志 8 小时时区差:后端输出带时区 UTC ISO 字符串,前端 formatTime 增加 UTC 自动解析换算 - 审计日志支持单号检索:list_audit_logs 关联 DouyuTask 匹配供应商单号、外部单号、UID、昵称等 - 列表摘要与详情抽屉优化:摘要直观展示供应商单号与外部单号,抽屉增加专用供应商订单列表并支持一键复制 - 增加测试用例覆盖时间格式与单号反向检索
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user