fix(audit): 修复审计日志时区差并支持供应商单号搜索与展示
- 解决审计日志 8 小时时区差:后端输出带时区 UTC ISO 字符串,前端 formatTime 增加 UTC 自动解析换算 - 审计日志支持单号检索:list_audit_logs 关联 DouyuTask 匹配供应商单号、外部单号、UID、昵称等 - 列表摘要与详情抽屉优化:摘要直观展示供应商单号与外部单号,抽屉增加专用供应商订单列表并支持一键复制 - 增加测试用例覆盖时间格式与单号反向检索
This commit is contained in:
@@ -137,3 +137,107 @@ class TestAuditLog:
|
||||
assert detail["recharge_accounts"][0]["douyu_uid"] == "10001"
|
||||
assert detail["recharge_channel"] == "wechat_qr"
|
||||
assert detail["payment_method"] == "微信扫码支付"
|
||||
|
||||
def test_audit_log_created_at_has_utc_timezone_suffix(self):
|
||||
record_audit(
|
||||
self.session,
|
||||
self.admin,
|
||||
action="recharge:yyb:payment",
|
||||
target="yyb_task:1",
|
||||
detail={"test": 1},
|
||||
success=True,
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
result = list_audit_logs(
|
||||
page=1,
|
||||
page_size=10,
|
||||
username=None,
|
||||
action=None,
|
||||
keyword=None,
|
||||
success=None,
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
db=self.session,
|
||||
_=self.admin,
|
||||
)
|
||||
|
||||
created_at_str = result["items"][0]["created_at"]
|
||||
assert created_at_str is not None
|
||||
assert "+00:00" in created_at_str or created_at_str.endswith("Z")
|
||||
|
||||
def test_search_audit_logs_by_supplier_order_id_and_out_order_id(self):
|
||||
account = Account(
|
||||
username="test-user",
|
||||
password="password",
|
||||
email="test@example.com",
|
||||
email_password="pwd",
|
||||
uid="473705204",
|
||||
nickname="用户29160540",
|
||||
)
|
||||
self.session.add(account)
|
||||
self.session.commit()
|
||||
|
||||
task = DouyuTask(
|
||||
batch_id="01dee6f6a272",
|
||||
account_id=account.id,
|
||||
task_type="create_gold_qr",
|
||||
status="success",
|
||||
supplier_out_order_id="DYGF01DEE6F6A272T99458",
|
||||
result={
|
||||
"recharge_channel": "supplier_api",
|
||||
"out_order_id": "DYGF01DEE6F6A272T99458",
|
||||
"buy_num": 280,
|
||||
"supplier_order": {
|
||||
"order_id": "200000124398920260912141221",
|
||||
"order_status": 2,
|
||||
},
|
||||
},
|
||||
created_by=self.admin.id,
|
||||
)
|
||||
self.session.add(task)
|
||||
self.session.add(
|
||||
AuditLog(
|
||||
user_id=self.admin.id,
|
||||
username=self.admin.username,
|
||||
action="recharge:douyu:create",
|
||||
target="douyu_batch:01dee6f6a272",
|
||||
detail=json.dumps({"batch_id": "01dee6f6a272"}),
|
||||
success=True,
|
||||
)
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
# 1. 按供应商单号搜索
|
||||
result_by_order_id = list_audit_logs(
|
||||
page=1,
|
||||
page_size=10,
|
||||
username=None,
|
||||
action=None,
|
||||
keyword="200000124398920260912141221",
|
||||
success=None,
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
db=self.session,
|
||||
_=self.admin,
|
||||
)
|
||||
assert result_by_order_id["total"] == 1
|
||||
detail = json.loads(result_by_order_id["items"][0]["detail"])
|
||||
assert "200000124398920260912141221" in detail.get("supplier_order_ids", [])
|
||||
assert "DYGF01DEE6F6A272T99458" in detail.get("supplier_out_order_ids", [])
|
||||
assert detail.get("payment_method") == "API 直充支付"
|
||||
|
||||
# 2. 按外部单号搜索
|
||||
result_by_out_id = list_audit_logs(
|
||||
page=1,
|
||||
page_size=10,
|
||||
username=None,
|
||||
action=None,
|
||||
keyword="DYGF01DEE6F6A272T99458",
|
||||
success=None,
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
db=self.session,
|
||||
_=self.admin,
|
||||
)
|
||||
assert result_by_out_id["total"] == 1
|
||||
|
||||
+171
-54
@@ -3,9 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import String, cast, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
@@ -23,56 +24,135 @@ def require_super_admin(current: User = Depends(get_current_user)) -> User:
|
||||
|
||||
|
||||
def _audit_log_out(db: Session, row: AuditLog) -> dict:
|
||||
"""序列化审计记录,并为旧斗鱼直充批次补齐可安全展示的账号身份。"""
|
||||
"""序列化审计记录,并为斗鱼直充批次补齐账号身份与供应商订单信息。"""
|
||||
detail_text = row.detail or ""
|
||||
if row.action == "recharge:douyu:create" and row.target.startswith("douyu_batch:"):
|
||||
try:
|
||||
detail = json.loads(detail_text) if detail_text else {}
|
||||
except json.JSONDecodeError:
|
||||
detail = None
|
||||
if isinstance(detail, dict) and not detail.get("recharge_accounts"):
|
||||
batch_id = row.target.removeprefix("douyu_batch:")
|
||||
accounts = (
|
||||
db.query(DouyuTask, Account)
|
||||
.join(Account, Account.id == DouyuTask.account_id)
|
||||
.filter(
|
||||
DouyuTask.batch_id == batch_id,
|
||||
DouyuTask.task_type == "create_gold_qr",
|
||||
try:
|
||||
detail = json.loads(detail_text) if detail_text else {}
|
||||
except json.JSONDecodeError:
|
||||
detail = None
|
||||
|
||||
if (
|
||||
isinstance(detail, dict)
|
||||
and row.action == "recharge:douyu:create"
|
||||
and row.target.startswith("douyu_batch:")
|
||||
):
|
||||
batch_id = row.target.removeprefix("douyu_batch:")
|
||||
tasks_and_accounts = (
|
||||
db.query(DouyuTask, Account)
|
||||
.outerjoin(Account, Account.id == DouyuTask.account_id)
|
||||
.filter(
|
||||
DouyuTask.batch_id == batch_id,
|
||||
DouyuTask.task_type == "create_gold_qr",
|
||||
)
|
||||
.order_by(DouyuTask.id.asc())
|
||||
.limit(100)
|
||||
.all()
|
||||
)
|
||||
if tasks_and_accounts:
|
||||
task_info_by_id: dict[int, dict] = {}
|
||||
supplier_orders: list[dict] = []
|
||||
supplier_order_ids: list[str] = []
|
||||
supplier_out_order_ids: list[str] = []
|
||||
has_supplier = False
|
||||
|
||||
for task, account in tasks_and_accounts:
|
||||
task_result = task.result if isinstance(task.result, dict) else {}
|
||||
channel = str(task_result.get("recharge_channel") or "")
|
||||
supplier_order = (
|
||||
task_result.get("supplier_order")
|
||||
if isinstance(task_result.get("supplier_order"), dict)
|
||||
else {}
|
||||
)
|
||||
.order_by(DouyuTask.id.asc())
|
||||
.limit(100)
|
||||
.all()
|
||||
)
|
||||
if accounts:
|
||||
detail["recharge_accounts"] = [
|
||||
{
|
||||
"task_id": task.id,
|
||||
"username": account.username,
|
||||
"douyu_uid": account.uid or "",
|
||||
"douyu_nickname": account.nickname or "",
|
||||
}
|
||||
for task, account in accounts
|
||||
]
|
||||
detail["recharge_accounts_truncated"] = len(accounts) == 100
|
||||
if isinstance(detail, dict) and not detail.get("payment_method"):
|
||||
batch_id = row.target.removeprefix("douyu_batch:")
|
||||
task = (
|
||||
db.query(DouyuTask)
|
||||
.filter(
|
||||
DouyuTask.batch_id == batch_id,
|
||||
DouyuTask.task_type == "create_gold_qr",
|
||||
supplier_order_id = str(
|
||||
supplier_order.get("order_id")
|
||||
or supplier_order.get("orderId")
|
||||
or task_result.get("order_id")
|
||||
or ""
|
||||
).strip()
|
||||
out_order_id = str(
|
||||
task.supplier_out_order_id or task_result.get("out_order_id") or ""
|
||||
).strip()
|
||||
|
||||
if channel == "supplier_api" or supplier_order_id or out_order_id:
|
||||
has_supplier = True
|
||||
|
||||
info = {
|
||||
"task_id": task.id,
|
||||
"username": (account.username if account else "") or "",
|
||||
"douyu_uid": (account.uid if account else "") or "",
|
||||
"douyu_nickname": (account.nickname if account else "") or "",
|
||||
"status": task.status,
|
||||
"supplier_out_order_id": out_order_id,
|
||||
"supplier_order_id": supplier_order_id,
|
||||
"amount": task_result.get("buy_num")
|
||||
or task_result.get("pay_amount"),
|
||||
}
|
||||
task_info_by_id[task.id] = info
|
||||
|
||||
if out_order_id or supplier_order_id:
|
||||
supplier_orders.append(
|
||||
{
|
||||
"task_id": task.id,
|
||||
"supplier_out_order_id": out_order_id,
|
||||
"supplier_order_id": supplier_order_id,
|
||||
"douyu_nickname": info["douyu_nickname"]
|
||||
or info["username"],
|
||||
"douyu_uid": info["douyu_uid"],
|
||||
"status": task.status,
|
||||
"amount": info["amount"],
|
||||
}
|
||||
)
|
||||
if (
|
||||
supplier_order_id
|
||||
and supplier_order_id not in supplier_order_ids
|
||||
):
|
||||
supplier_order_ids.append(supplier_order_id)
|
||||
if out_order_id and out_order_id not in supplier_out_order_ids:
|
||||
supplier_out_order_ids.append(out_order_id)
|
||||
|
||||
if supplier_orders:
|
||||
detail["supplier_orders"] = supplier_orders
|
||||
detail["supplier_order_ids"] = supplier_order_ids
|
||||
detail["supplier_out_order_ids"] = supplier_out_order_ids
|
||||
|
||||
if not detail.get("recharge_accounts"):
|
||||
detail["recharge_accounts"] = list(task_info_by_id.values())
|
||||
detail["recharge_accounts_truncated"] = len(tasks_and_accounts) == 100
|
||||
else:
|
||||
for item in detail.get("recharge_accounts", []):
|
||||
if isinstance(item, dict) and "task_id" in item:
|
||||
tid = item.get("task_id")
|
||||
if tid in task_info_by_id:
|
||||
matched = task_info_by_id[tid]
|
||||
if matched["supplier_out_order_id"]:
|
||||
item["supplier_out_order_id"] = matched[
|
||||
"supplier_out_order_id"
|
||||
]
|
||||
if matched["supplier_order_id"]:
|
||||
item["supplier_order_id"] = matched["supplier_order_id"]
|
||||
if matched["status"]:
|
||||
item["status"] = matched["status"]
|
||||
if matched["amount"] is not None:
|
||||
item["amount"] = matched["amount"]
|
||||
|
||||
if not detail.get("payment_method") or not detail.get("recharge_channel"):
|
||||
detail["recharge_channel"] = (
|
||||
"supplier_api" if has_supplier else "wechat_qr"
|
||||
)
|
||||
.order_by(DouyuTask.id.asc())
|
||||
.first()
|
||||
)
|
||||
task_result = task.result if task and isinstance(task.result, dict) else {}
|
||||
recharge_channel = str(task_result.get("recharge_channel") or "wechat_qr")
|
||||
detail["recharge_channel"] = recharge_channel
|
||||
detail["payment_method"] = (
|
||||
"API 直充支付" if recharge_channel == "supplier_api" else "微信扫码支付"
|
||||
)
|
||||
if isinstance(detail, dict):
|
||||
detail_text = json.dumps(detail, ensure_ascii=False, separators=(",", ":"))
|
||||
detail["payment_method"] = (
|
||||
"API 直充支付" if has_supplier else "微信扫码支付"
|
||||
)
|
||||
|
||||
detail_text = json.dumps(detail, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
created_at = row.created_at
|
||||
if created_at is not None:
|
||||
if created_at.tzinfo is None:
|
||||
created_at = created_at.replace(tzinfo=UTC)
|
||||
created_at_out = created_at.isoformat()
|
||||
else:
|
||||
created_at_out = None
|
||||
|
||||
return {
|
||||
"id": row.id,
|
||||
"user_id": row.user_id,
|
||||
@@ -81,7 +161,7 @@ def _audit_log_out(db: Session, row: AuditLog) -> dict:
|
||||
"target": row.target or "",
|
||||
"detail": detail_text,
|
||||
"success": row.success,
|
||||
"created_at": row.created_at,
|
||||
"created_at": created_at_out,
|
||||
}
|
||||
|
||||
|
||||
@@ -105,16 +185,53 @@ def list_audit_logs(
|
||||
if action:
|
||||
query = query.filter(AuditLog.action == action.strip())
|
||||
if keyword:
|
||||
pattern = f"%{keyword.strip()}%"
|
||||
query = query.filter(
|
||||
(AuditLog.target.ilike(pattern)) | (AuditLog.detail.ilike(pattern))
|
||||
kw = keyword.strip()
|
||||
pattern = f"%{kw}%"
|
||||
conditions = [
|
||||
AuditLog.target.ilike(pattern),
|
||||
AuditLog.detail.ilike(pattern),
|
||||
AuditLog.username.ilike(pattern),
|
||||
]
|
||||
# 关联查询 DouyuTask,支持按外部单号、供应商单号、账号昵称、UID 直接检索对应的批次审计记录
|
||||
matching_tasks = (
|
||||
db.query(DouyuTask.batch_id)
|
||||
.outerjoin(Account, Account.id == DouyuTask.account_id)
|
||||
.filter(
|
||||
or_(
|
||||
DouyuTask.supplier_out_order_id.ilike(pattern),
|
||||
cast(DouyuTask.result, String).ilike(pattern),
|
||||
Account.uid.ilike(pattern),
|
||||
Account.nickname.ilike(pattern),
|
||||
Account.username.ilike(pattern),
|
||||
)
|
||||
)
|
||||
.distinct()
|
||||
.limit(100)
|
||||
.all()
|
||||
)
|
||||
batch_targets = [f"douyu_batch:{b[0]}" for b in matching_tasks if b[0]]
|
||||
if batch_targets:
|
||||
conditions.append(AuditLog.target.in_(batch_targets))
|
||||
|
||||
query = query.filter(or_(*conditions))
|
||||
|
||||
if success is not None:
|
||||
query = query.filter(AuditLog.success == success)
|
||||
if start_time:
|
||||
query = query.filter(AuditLog.created_at >= start_time)
|
||||
st = (
|
||||
start_time.astimezone(UTC).replace(tzinfo=None)
|
||||
if start_time.tzinfo
|
||||
else start_time
|
||||
)
|
||||
query = query.filter(AuditLog.created_at >= st)
|
||||
if end_time:
|
||||
query = query.filter(AuditLog.created_at <= end_time)
|
||||
et = (
|
||||
end_time.astimezone(UTC).replace(tzinfo=None)
|
||||
if end_time.tzinfo
|
||||
else end_time
|
||||
)
|
||||
query = query.filter(AuditLog.created_at <= et)
|
||||
|
||||
total = query.count()
|
||||
rows = (
|
||||
query.order_by(AuditLog.id.desc())
|
||||
|
||||
@@ -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