1942 lines
66 KiB
TypeScript
1942 lines
66 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import {
|
||
Alert,
|
||
App,
|
||
Badge,
|
||
Button,
|
||
Card,
|
||
Col,
|
||
Descriptions,
|
||
Drawer,
|
||
Dropdown,
|
||
Empty,
|
||
Image,
|
||
Input,
|
||
InputNumber,
|
||
Modal,
|
||
Radio,
|
||
Row,
|
||
Select,
|
||
Space,
|
||
Statistic,
|
||
Switch,
|
||
Table,
|
||
Tag,
|
||
Timeline,
|
||
Tooltip,
|
||
Typography,
|
||
type TableProps,
|
||
} from 'antd';
|
||
import {
|
||
CheckCircleOutlined,
|
||
CopyOutlined,
|
||
DeleteOutlined,
|
||
EditOutlined,
|
||
FormOutlined,
|
||
InboxOutlined,
|
||
LinkOutlined,
|
||
PlusOutlined,
|
||
ReloadOutlined,
|
||
SafetyCertificateOutlined,
|
||
ShopOutlined,
|
||
SyncOutlined,
|
||
TagOutlined,
|
||
UndoOutlined,
|
||
} from '@ant-design/icons';
|
||
import HuyaCollabHomeImageUploader from '../components/HuyaCollabHomeImageUploader';
|
||
import DouyuCollabShopModal from '../components/DouyuCollabShopModal';
|
||
import DouyuCollabShopPicker from '../components/DouyuCollabShopPicker';
|
||
import { douyuCollabApi } from '../api/modules';
|
||
import type {
|
||
DouyuCollabAccountLookup,
|
||
DouyuCollabCkExportResult,
|
||
DouyuCollabGoods,
|
||
DouyuCollabMetric,
|
||
DouyuCollabOrder,
|
||
DouyuCollabShop,
|
||
DouyuCollabStatus,
|
||
DouyuCollabSummary,
|
||
DouyuCollabTimeline,
|
||
} from '../api/douyuCollab';
|
||
import { usePermissions } from '../hooks/usePermissions';
|
||
import { useCollabRealtime, type CollabRealtimeEvent } from '../hooks/useCollabRealtime';
|
||
import { message } from '../utils/antdMessage';
|
||
import { getErrorMessage } from '../utils/error';
|
||
import { copyTextToClipboard } from '../utils/clipboard';
|
||
|
||
const { Text } = Typography;
|
||
|
||
function hasTextInputFocus(): boolean {
|
||
const active = document.activeElement;
|
||
return (
|
||
active instanceof HTMLElement &&
|
||
(active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)
|
||
);
|
||
}
|
||
|
||
/** 实时同步暂停开关按浏览器记住:刷新页面后仍保持暂停,直到手动恢复。 */
|
||
const REALTIME_PAUSED_STORAGE_KEY = 'douyu.collab.realtime-paused';
|
||
const REALTIME_WS_PATH = '/api/douyu/external-orders/ws';
|
||
|
||
function readRealtimePaused(): boolean {
|
||
try {
|
||
return window.localStorage.getItem(REALTIME_PAUSED_STORAGE_KEY) === '1';
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function cardStyle(active: boolean) {
|
||
return {
|
||
borderColor: active ? '#1677ff' : undefined,
|
||
cursor: 'pointer',
|
||
} as const;
|
||
}
|
||
|
||
const STATUS_META: Record<DouyuCollabStatus, { label: string; color: string }> = {
|
||
incomplete: { label: '待补资料', color: 'default' },
|
||
to_recharge: { label: '待充值', color: 'blue' },
|
||
recharged: { label: '已充值', color: 'cyan' },
|
||
scripted: { label: '已上脚本', color: 'purple' },
|
||
done: { label: '抢购成功', color: 'green' },
|
||
points_error: { label: '积分异常', color: 'orange' },
|
||
failed: { label: '抢购失败', color: 'red' },
|
||
void: { label: '已作废', color: 'default' },
|
||
};
|
||
|
||
const STATUS_ORDER: DouyuCollabStatus[] = [
|
||
'incomplete',
|
||
'to_recharge',
|
||
'recharged',
|
||
'scripted',
|
||
'done',
|
||
'points_error',
|
||
'failed',
|
||
'void',
|
||
];
|
||
|
||
const FLOW_ORDER: DouyuCollabStatus[] = [
|
||
'incomplete',
|
||
'to_recharge',
|
||
'recharged',
|
||
'scripted',
|
||
'done',
|
||
];
|
||
|
||
const CK_META: Record<string, { label: string; color: string }> = {
|
||
unverified: { label: '未检测', color: 'default' },
|
||
valid: { label: 'CK 正常', color: 'green' },
|
||
invalid: { label: 'CK 异常', color: 'red' },
|
||
error: { label: 'CK 异常', color: 'red' },
|
||
'': { label: '未检测', color: 'default' },
|
||
};
|
||
|
||
function formatTime(value?: string | null): string {
|
||
if (!value) return '-';
|
||
const date = new Date(value);
|
||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||
}
|
||
|
||
function pointsReasonMeta(reason: string): { label: string; color: string } {
|
||
if (reason === 'shortage') return { label: '积分不足', color: 'red' };
|
||
if (reason === 'excess') return { label: '积分超阈值', color: 'orange' };
|
||
if (reason === 'ok') return { label: '积分正常', color: 'green' };
|
||
return { label: '未核账', color: 'default' };
|
||
}
|
||
|
||
function goodsOptions(goods: DouyuCollabGoods[], accountSpecific = false) {
|
||
return goods
|
||
.filter((item) => item.commodity_id && item.name)
|
||
.map((item) => {
|
||
const time = item.is_limited ? ' · 23返场/限时' : '';
|
||
const accountState =
|
||
accountSpecific && item.status === '1'
|
||
? ' · 该账号已兑换'
|
||
: item.open_status === 0
|
||
? ' · 未开始'
|
||
: '';
|
||
return {
|
||
value: item.commodity_id,
|
||
label: `${item.name} · ${item.score ?? '-'} 积分${time}${accountState}`,
|
||
goods: item,
|
||
disabled: accountSpecific && item.status === '1',
|
||
};
|
||
});
|
||
}
|
||
|
||
interface OrderFormState {
|
||
order_no: string;
|
||
shop: string;
|
||
owner_id: number | null;
|
||
account_username: string;
|
||
gift_name: string;
|
||
commodity_id: string;
|
||
required_points: number | null;
|
||
home_images: string[];
|
||
remark: string;
|
||
}
|
||
|
||
const EMPTY_FORM: OrderFormState = {
|
||
order_no: '',
|
||
shop: '',
|
||
owner_id: null,
|
||
account_username: '',
|
||
gift_name: '',
|
||
commodity_id: '',
|
||
required_points: null,
|
||
home_images: [],
|
||
remark: '',
|
||
};
|
||
|
||
export default function DouyuCollabPage() {
|
||
const navigate = useNavigate();
|
||
const { modal } = App.useApp();
|
||
const { can, user } = usePermissions();
|
||
const canView = can('douyu:collab:view');
|
||
const canEdit = can('douyu:collab:edit');
|
||
const canManage = can('douyu:collab:manage');
|
||
const canExportCk = can('douyu:collab:ck_export');
|
||
// 单行复制是一键带走凭据,和虎牙台账保持一致:只给超级管理员(后端再校验)
|
||
const isAdmin = user?.role === 'super_admin';
|
||
|
||
const [orders, setOrders] = useState<DouyuCollabOrder[]>([]);
|
||
const [summary, setSummary] = useState<DouyuCollabSummary>({
|
||
total: 0,
|
||
by_status: {},
|
||
account_missing: 0,
|
||
ck_invalid: 0,
|
||
archived_total: 0,
|
||
});
|
||
const [owners, setOwners] = useState<Array<{ id: number; username: string }>>([]);
|
||
const [shopRecords, setShopRecords] = useState<DouyuCollabShop[]>([]);
|
||
const [gifts, setGifts] = useState<string[]>([]);
|
||
const [goods, setGoods] = useState<DouyuCollabGoods[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [goodsLoading, setGoodsLoading] = useState(false);
|
||
const [page, setPage] = useState(1);
|
||
const [pageSize, setPageSize] = useState(20);
|
||
const [total, setTotal] = useState(0);
|
||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||
|
||
const [keyword, setKeyword] = useState('');
|
||
const [statusFilter, setStatusFilter] = useState('');
|
||
const [shopFilter, setShopFilter] = useState('');
|
||
const [ownerFilter, setOwnerFilter] = useState<number | undefined>();
|
||
const [giftFilter, setGiftFilter] = useState('');
|
||
const [ckFilter, setCkFilter] = useState('');
|
||
const [archiveScope, setArchiveScope] = useState<'active' | 'archived' | 'all'>('active');
|
||
const [includeVoid, setIncludeVoid] = useState(false);
|
||
const [shopModalOpen, setShopModalOpen] = useState(false);
|
||
const [realtimePending, setRealtimePending] = useState(false);
|
||
const [realtimePaused, setRealtimePaused] = useState(readRealtimePaused);
|
||
|
||
const [createOpen, setCreateOpen] = useState(false);
|
||
const [editing, setEditing] = useState<DouyuCollabOrder | null>(null);
|
||
const [form, setForm] = useState<OrderFormState>(EMPTY_FORM);
|
||
const [lookup, setLookup] = useState<DouyuCollabAccountLookup | null>(null);
|
||
const lookupSeq = useRef(0);
|
||
const [lookupLoading, setLookupLoading] = useState(false);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
|
||
const [detail, setDetail] = useState<DouyuCollabOrder | null>(null);
|
||
const [detailOpen, setDetailOpen] = useState(false);
|
||
const [detailMetrics, setDetailMetrics] = useState<DouyuCollabMetric[]>([]);
|
||
const [detailTimeline, setDetailTimeline] = useState<DouyuCollabTimeline[]>([]);
|
||
const [detailLoading, setDetailLoading] = useState(false);
|
||
|
||
const [statusTarget, setStatusTarget] = useState<DouyuCollabOrder | null>(null);
|
||
const [batchStatusOpen, setBatchStatusOpen] = useState(false);
|
||
const [statusValue, setStatusValue] = useState<DouyuCollabStatus>('to_recharge');
|
||
const [statusReason, setStatusReason] = useState('');
|
||
const [statusBusy, setStatusBusy] = useState(false);
|
||
|
||
// 勾选导出 CK:明文只在弹窗 state 里,关闭即清空
|
||
const [ckExportOpen, setCkExportOpen] = useState(false);
|
||
const [ckExporting, setCkExporting] = useState(false);
|
||
const [ckCopyingId, setCkCopyingId] = useState<number | null>(null);
|
||
const [ckExport, setCkExport] = useState<DouyuCollabCkExportResult | null>(null);
|
||
const [ckExportFormat, setCkExportFormat] = useState<'plain' | 'with_order'>('plain');
|
||
|
||
const loadOrders = useCallback(async (options?: { silent?: boolean }) => {
|
||
const silent = options?.silent === true;
|
||
if (!silent) setLoading(true);
|
||
try {
|
||
const data = await douyuCollabApi.listOrders({
|
||
page,
|
||
page_size: pageSize,
|
||
keyword: keyword.trim() || undefined,
|
||
status: statusFilter || undefined,
|
||
shop: shopFilter || undefined,
|
||
owner_id: ownerFilter,
|
||
gift_name: giftFilter || undefined,
|
||
ck_status: ckFilter || undefined,
|
||
archive_scope: archiveScope,
|
||
include_void: includeVoid,
|
||
});
|
||
setOrders(data.items);
|
||
setTotal(data.total);
|
||
} catch (error) {
|
||
message.error(getErrorMessage(error));
|
||
} finally {
|
||
if (!silent) setLoading(false);
|
||
}
|
||
}, [
|
||
page,
|
||
pageSize,
|
||
keyword,
|
||
statusFilter,
|
||
shopFilter,
|
||
ownerFilter,
|
||
giftFilter,
|
||
ckFilter,
|
||
archiveScope,
|
||
includeVoid,
|
||
]);
|
||
|
||
const loadSummary = useCallback(async () => {
|
||
try {
|
||
setSummary(await douyuCollabApi.summary({ archive_scope: archiveScope, include_void: includeVoid }));
|
||
} catch {
|
||
// 统计失败不影响列表操作。
|
||
}
|
||
}, [archiveScope, includeVoid]);
|
||
|
||
const loadMeta = useCallback(async () => {
|
||
try {
|
||
const [ownerRows, giftRows, goodsRows, records] = await Promise.all([
|
||
douyuCollabApi.owners(),
|
||
douyuCollabApi.gifts(),
|
||
douyuCollabApi.goods(),
|
||
douyuCollabApi.shopRecords(),
|
||
]);
|
||
setOwners(ownerRows);
|
||
setGifts(giftRows);
|
||
setGoods(goodsRows);
|
||
setShopRecords(records);
|
||
} catch (error) {
|
||
message.error(getErrorMessage(error));
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void loadOrders();
|
||
}, [loadOrders]);
|
||
|
||
useEffect(() => {
|
||
void loadSummary();
|
||
}, [loadSummary]);
|
||
|
||
useEffect(() => {
|
||
void loadMeta();
|
||
}, [loadMeta]);
|
||
|
||
const collabModalOpen = Boolean(
|
||
createOpen ||
|
||
detailOpen ||
|
||
shopModalOpen ||
|
||
batchStatusOpen ||
|
||
statusTarget ||
|
||
ckExportOpen,
|
||
);
|
||
// 暂停后连 WebSocket 也不建:既不自动重拉,也不做断线兜底轮询。
|
||
const realtimeSyncEnabled = canView && !realtimePaused;
|
||
|
||
const reloadSilently = useCallback(
|
||
(withMeta: boolean) => {
|
||
void loadOrders({ silent: true });
|
||
void loadSummary();
|
||
if (withMeta) void loadMeta();
|
||
},
|
||
[loadOrders, loadSummary, loadMeta],
|
||
);
|
||
|
||
const toggleRealtimeSync = (nextEnabled: boolean) => {
|
||
const nextPaused = !nextEnabled;
|
||
setRealtimePaused(nextPaused);
|
||
try {
|
||
window.localStorage.setItem(REALTIME_PAUSED_STORAGE_KEY, nextPaused ? '1' : '0');
|
||
} catch {
|
||
// 隐私模式下 localStorage 不可写也不影响本次会话的开关。
|
||
}
|
||
if (nextPaused) {
|
||
setRealtimePending(false);
|
||
message.info('已暂停实时同步,点「刷新」手动获取最新数据');
|
||
return;
|
||
}
|
||
message.success('已恢复实时同步');
|
||
reloadSilently(true);
|
||
};
|
||
|
||
const handleRealtimeInvalidate = useCallback(
|
||
(event: CollabRealtimeEvent) => {
|
||
if (!realtimeSyncEnabled) return;
|
||
if (collabModalOpen || hasTextInputFocus()) {
|
||
setRealtimePending(true);
|
||
return;
|
||
}
|
||
reloadSilently(event.scope !== 'orders');
|
||
},
|
||
[realtimeSyncEnabled, collabModalOpen, reloadSilently],
|
||
);
|
||
|
||
const { connected: realtimeConnected, reconnecting: realtimeReconnecting } = useCollabRealtime({
|
||
enabled: realtimeSyncEnabled,
|
||
onInvalidate: handleRealtimeInvalidate,
|
||
path: REALTIME_WS_PATH,
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (!realtimePending || !realtimeSyncEnabled || collabModalOpen) return undefined;
|
||
let cancelled = false;
|
||
const flush = () => {
|
||
if (cancelled || collabModalOpen || hasTextInputFocus()) return;
|
||
setRealtimePending(false);
|
||
reloadSilently(true);
|
||
};
|
||
const timer = window.setTimeout(flush, 300);
|
||
const handleFocusOut = () => window.setTimeout(flush, 0);
|
||
window.addEventListener('focusout', handleFocusOut);
|
||
return () => {
|
||
cancelled = true;
|
||
window.clearTimeout(timer);
|
||
window.removeEventListener('focusout', handleFocusOut);
|
||
};
|
||
}, [realtimePending, realtimeSyncEnabled, collabModalOpen, reloadSilently]);
|
||
|
||
// WebSocket 断开时用低频轮询兜底;连接正常或手动暂停时完全不轮询。
|
||
useEffect(() => {
|
||
if (!realtimeSyncEnabled || realtimeConnected) return undefined;
|
||
const timer = window.setInterval(() => {
|
||
if (document.visibilityState !== 'visible' || collabModalOpen) return;
|
||
reloadSilently(false);
|
||
}, 30_000);
|
||
return () => window.clearInterval(timer);
|
||
}, [realtimeSyncEnabled, realtimeConnected, collabModalOpen, reloadSilently]);
|
||
|
||
const selectedCount = selectedRowKeys.length;
|
||
const selectedIds = useMemo(() => selectedRowKeys.map(Number), [selectedRowKeys]);
|
||
const selectedOrders = useMemo(
|
||
() => orders.filter((row) => selectedIds.includes(row.id)),
|
||
[orders, selectedIds],
|
||
);
|
||
const canArchiveSelected =
|
||
selectedOrders.length > 0 &&
|
||
selectedOrders.length === selectedIds.length &&
|
||
selectedOrders.every((row) => row.status === 'done' && !row.is_archived);
|
||
|
||
/** 导出文本:默认每行一份纯 CK,也可以带订单号方便多选对号。 */
|
||
const ckExportText = useMemo(() => {
|
||
if (!ckExport) return '';
|
||
return ckExport.items
|
||
.map((item) =>
|
||
ckExportFormat === 'with_order' ? `${item.order_no}\t${item.ck_text}` : item.ck_text,
|
||
)
|
||
.join('\n');
|
||
}, [ckExport, ckExportFormat]);
|
||
|
||
const ckExportRisky = useMemo(
|
||
() => (ckExport?.items ?? []).filter((item) => item.ck_status === 'invalid'),
|
||
[ckExport],
|
||
);
|
||
|
||
|
||
const refreshAll = () => {
|
||
void loadOrders();
|
||
void loadSummary();
|
||
void loadMeta();
|
||
};
|
||
|
||
const lookupUsername = useCallback(async (raw: string, options?: { notify?: boolean }) => {
|
||
const username = raw.trim();
|
||
if (!username) {
|
||
if (options?.notify !== false) message.warning('请先输入斗鱼用户名');
|
||
return;
|
||
}
|
||
const seq = ++lookupSeq.current;
|
||
setLookupLoading(true);
|
||
try {
|
||
const result = await douyuCollabApi.lookupAccount(username, true);
|
||
if (seq !== lookupSeq.current) return;
|
||
setLookup(result);
|
||
if (options?.notify === false) return;
|
||
if (result.found && result.ok) {
|
||
message.success(
|
||
`已找到账号:${result.account_nickname || result.account_username},角色 ${result.bind_game_role || '-'}`,
|
||
);
|
||
} else {
|
||
message.warning(result.message || '账号暂不可用');
|
||
}
|
||
} catch (error) {
|
||
if (seq === lookupSeq.current) message.error(getErrorMessage(error));
|
||
} finally {
|
||
if (seq === lookupSeq.current) setLookupLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
const openCreate = () => {
|
||
setEditing(null);
|
||
setForm({ ...EMPTY_FORM, owner_id: user?.id ?? null });
|
||
lookupSeq.current += 1;
|
||
setLookup(null);
|
||
setLookupLoading(false);
|
||
setCreateOpen(true);
|
||
};
|
||
|
||
const openEdit = (order: DouyuCollabOrder) => {
|
||
setEditing(order);
|
||
setForm({
|
||
order_no: order.order_no,
|
||
shop: order.shop,
|
||
owner_id: order.owner_id,
|
||
account_username: order.account_username,
|
||
gift_name: order.gift_name,
|
||
commodity_id: order.commodity_id,
|
||
required_points: order.required_points,
|
||
home_images: order.home_images,
|
||
remark: order.remark,
|
||
});
|
||
lookupSeq.current += 1;
|
||
setLookup(null);
|
||
setLookupLoading(false);
|
||
setCreateOpen(true);
|
||
};
|
||
|
||
const updateForm = <K extends keyof OrderFormState>(key: K, value: OrderFormState[K]) => {
|
||
if (key === 'account_username') {
|
||
// 账号名一变就作废旧查询结果,避免弹窗里显示上一个账号的角色/积分。
|
||
lookupSeq.current += 1;
|
||
setLookup(null);
|
||
setLookupLoading(false);
|
||
}
|
||
setForm((previous) => ({ ...previous, [key]: value }));
|
||
};
|
||
|
||
const handleLookup = () => {
|
||
void lookupUsername(form.account_username);
|
||
};
|
||
|
||
// 输入停下约 650ms 后自动查账号,右侧按钮保留给立即重试。
|
||
useEffect(() => {
|
||
if (!createOpen) return undefined;
|
||
const username = form.account_username.trim();
|
||
if (username.length < 2) return undefined;
|
||
const timer = window.setTimeout(() => {
|
||
void lookupUsername(username, { notify: false });
|
||
}, 650);
|
||
return () => window.clearTimeout(timer);
|
||
}, [createOpen, form.account_username, lookupUsername]);
|
||
|
||
const handleGiftChange = (commodityId: string) => {
|
||
const pool = lookup?.goods?.length ? lookup.goods : goods;
|
||
const item = pool.find((row) => row.commodity_id === commodityId);
|
||
if (!item) return;
|
||
setForm((previous) => ({
|
||
...previous,
|
||
commodity_id: item.commodity_id,
|
||
gift_name: item.name,
|
||
required_points: item.score,
|
||
}));
|
||
};
|
||
|
||
const submitCreate = async (draft: boolean) => {
|
||
if (!form.order_no.trim()) {
|
||
message.warning('订单编号不能为空');
|
||
return;
|
||
}
|
||
if (!draft && !form.shop.trim()) {
|
||
message.warning('请填写店铺');
|
||
return;
|
||
}
|
||
// 参考虎牙:资料没填全也允许直接点「新建」,自动按待补资料创建。
|
||
const accountVerified = Boolean(
|
||
lookup?.found &&
|
||
lookup.ok &&
|
||
lookup.account_username &&
|
||
lookup.account_username.trim() === form.account_username.trim(),
|
||
);
|
||
const createAsDraft =
|
||
draft ||
|
||
!accountVerified ||
|
||
!form.account_username.trim() ||
|
||
!form.gift_name.trim() ||
|
||
form.home_images.length === 0;
|
||
setSubmitting(true);
|
||
try {
|
||
if (editing) {
|
||
const updated = await douyuCollabApi.updateOrder(editing.id, {
|
||
order_no: form.order_no.trim(),
|
||
shop: form.shop.trim(),
|
||
owner_id: form.owner_id,
|
||
account_username: form.account_username.trim(),
|
||
gift_name: form.gift_name,
|
||
commodity_id: form.commodity_id,
|
||
required_points: form.required_points,
|
||
home_images: form.home_images,
|
||
remark: form.remark,
|
||
expected_version: editing.version,
|
||
});
|
||
if (updated.status === 'incomplete' && updated.missing_fields?.length) {
|
||
message.warning(`资料已保存,但仍缺:${updated.missing_fields.join('、')}`);
|
||
} else if (editing.status === 'incomplete') {
|
||
message.success('资料已补齐,协作单已自动进入待充值');
|
||
} else {
|
||
message.success('协作单已更新');
|
||
}
|
||
} else {
|
||
const created = await douyuCollabApi.createOrder({
|
||
order_no: form.order_no.trim(),
|
||
shop: form.shop.trim(),
|
||
owner_id: form.owner_id,
|
||
account_username: form.account_username.trim(),
|
||
gift_name: form.gift_name,
|
||
commodity_id: form.commodity_id,
|
||
required_points: form.required_points,
|
||
home_images: form.home_images,
|
||
remark: form.remark,
|
||
draft: createAsDraft,
|
||
require_verified: !createAsDraft,
|
||
});
|
||
message.success(
|
||
created.status === 'incomplete' ? '已创建待补资料协作单' : '协作单已创建',
|
||
);
|
||
}
|
||
setCreateOpen(false);
|
||
refreshAll();
|
||
} catch (error) {
|
||
message.error(getErrorMessage(error));
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const verifyOrder = async (order: DouyuCollabOrder) => {
|
||
try {
|
||
message.loading({ content: '正在校验账号…', key: 'dy-collab-verify' });
|
||
await douyuCollabApi.verifyOrder(order.id);
|
||
message.success({ content: '账号已校验', key: 'dy-collab-verify' });
|
||
refreshAll();
|
||
} catch (error) {
|
||
message.error({ content: getErrorMessage(error), key: 'dy-collab-verify' });
|
||
}
|
||
};
|
||
|
||
const queryOrder = async (order: DouyuCollabOrder) => {
|
||
try {
|
||
message.loading({ content: '正在查询鱼翅、积分与兑换记录…', key: 'dy-collab-query' });
|
||
const result = await douyuCollabApi.queryActivity(order.id);
|
||
message.success({
|
||
content: `${result.message}${result.points_reason ? `;${pointsReasonMeta(result.points_reason).label}` : ''}`,
|
||
key: 'dy-collab-query',
|
||
});
|
||
refreshAll();
|
||
if (detail?.id === order.id) void openDetail(order);
|
||
} catch (error) {
|
||
message.error({ content: getErrorMessage(error), key: 'dy-collab-query' });
|
||
}
|
||
};
|
||
|
||
const openDetail = async (order: DouyuCollabOrder) => {
|
||
setDetail(order);
|
||
setDetailOpen(true);
|
||
setDetailLoading(true);
|
||
try {
|
||
const [metrics, timeline, latest] = await Promise.all([
|
||
douyuCollabApi.metrics(order.id),
|
||
douyuCollabApi.timeline(order.id),
|
||
douyuCollabApi.getOrder(order.id),
|
||
]);
|
||
setDetailMetrics(metrics);
|
||
setDetailTimeline(timeline);
|
||
setDetail(latest);
|
||
} catch (error) {
|
||
message.error(getErrorMessage(error));
|
||
} finally {
|
||
setDetailLoading(false);
|
||
}
|
||
};
|
||
|
||
const openStatus = (order: DouyuCollabOrder) => {
|
||
setStatusTarget(order);
|
||
setBatchStatusOpen(false);
|
||
setStatusValue(order.next_status || order.status);
|
||
setStatusReason('');
|
||
};
|
||
|
||
const openBatchStatus = () => {
|
||
if (selectedIds.length === 0) {
|
||
message.warning('请先勾选协作单');
|
||
return;
|
||
}
|
||
setStatusTarget(null);
|
||
setBatchStatusOpen(true);
|
||
setStatusValue('recharged');
|
||
setStatusReason('');
|
||
};
|
||
|
||
const submitStatus = async () => {
|
||
setStatusBusy(true);
|
||
try {
|
||
if (batchStatusOpen) {
|
||
const result = await douyuCollabApi.batchStatus({
|
||
ids: selectedIds,
|
||
status: statusValue,
|
||
reason: statusReason,
|
||
});
|
||
message.success(`批量改状态完成:${result.results.filter((item) => item.success).length}/${result.results.length}`);
|
||
setBatchStatusOpen(false);
|
||
setSelectedRowKeys([]);
|
||
} else if (statusTarget) {
|
||
const currentIndex = FLOW_ORDER.indexOf(statusTarget.status);
|
||
const targetIndex = FLOW_ORDER.indexOf(statusValue);
|
||
await douyuCollabApi.setStatus(statusTarget.id, {
|
||
status: statusValue,
|
||
reason: statusReason,
|
||
rollback: targetIndex >= 0 && currentIndex >= 0 && targetIndex < currentIndex,
|
||
expected_version: statusTarget.version,
|
||
});
|
||
message.success('状态已更新');
|
||
setStatusTarget(null);
|
||
}
|
||
refreshAll();
|
||
} catch (error) {
|
||
message.error(getErrorMessage(error));
|
||
} finally {
|
||
setStatusBusy(false);
|
||
}
|
||
};
|
||
|
||
const batchQuery = async () => {
|
||
if (selectedIds.length === 0) {
|
||
message.warning('请先勾选协作单');
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
try {
|
||
const result = await douyuCollabApi.batchActivity(selectedIds);
|
||
const ok = result.results.filter((item) => item.success).length;
|
||
message.success(`查询完成:${ok}/${result.results.length}`);
|
||
refreshAll();
|
||
} catch (error) {
|
||
message.error(getErrorMessage(error));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const batchVerify = async () => {
|
||
if (selectedIds.length === 0) {
|
||
message.warning('请先勾选协作单');
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
try {
|
||
const result = await douyuCollabApi.batchVerify(selectedIds);
|
||
const ok = result.results.filter((item) => item.success).length;
|
||
const invalid = result.results.filter((item) => item.ck_status === 'invalid').length;
|
||
message.success(
|
||
`校验完成:${ok}/${result.results.length}${invalid ? `,其中 CK 异常 ${invalid} 单` : ''}`,
|
||
);
|
||
refreshAll();
|
||
} catch (error) {
|
||
message.error(getErrorMessage(error));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const openCkExport = async () => {
|
||
if (selectedIds.length === 0) {
|
||
message.warning('请先勾选协作单');
|
||
return;
|
||
}
|
||
setCkExporting(true);
|
||
try {
|
||
const result = await douyuCollabApi.exportOrderCk(selectedIds);
|
||
setCkExport(result);
|
||
setCkExportOpen(true);
|
||
} catch (error) {
|
||
message.error(getErrorMessage(error));
|
||
} finally {
|
||
setCkExporting(false);
|
||
}
|
||
};
|
||
|
||
const closeCkExport = () => {
|
||
setCkExportOpen(false);
|
||
setCkExport(null);
|
||
};
|
||
|
||
const copyCkExport = async () => {
|
||
if (!ckExport?.exported) return;
|
||
try {
|
||
await copyTextToClipboard(ckExportText);
|
||
message.success(`已复制 ${ckExport.exported} 份 CK 到剪贴板`);
|
||
} catch (error) {
|
||
message.error(`复制失败:${getErrorMessage(error)}(可以在下面的框里手动选中复制)`);
|
||
}
|
||
};
|
||
|
||
/** 单行复制:斗鱼协作单不存 CK,这里实时读取关联内部账号的 cookie。 */
|
||
const copyOneCk = async (row: DouyuCollabOrder) => {
|
||
setCkCopyingId(row.id);
|
||
try {
|
||
const result = await douyuCollabApi.copyOrderCk(row.id);
|
||
const item = result.items[0];
|
||
if (!item) {
|
||
message.warning(result.skipped[0]?.reason || '这行还没有可复制的 CK');
|
||
return;
|
||
}
|
||
await copyTextToClipboard(item.ck_text);
|
||
message.success(`已复制 ${row.order_no} 的 CK`);
|
||
} catch (error) {
|
||
message.error(`复制失败:${getErrorMessage(error)}`);
|
||
} finally {
|
||
setCkCopyingId(null);
|
||
}
|
||
};
|
||
|
||
const archiveOrders = async (order: DouyuCollabOrder) => {
|
||
try {
|
||
const result = await douyuCollabApi.archive([order.id], '');
|
||
message.success(`已归档 ${result.archived} 单`);
|
||
refreshAll();
|
||
} catch (error) {
|
||
message.error(getErrorMessage(error));
|
||
}
|
||
};
|
||
|
||
const unarchiveOrders = async (order: DouyuCollabOrder) => {
|
||
try {
|
||
const result = await douyuCollabApi.unarchive([order.id]);
|
||
message.success(`已取消归档 ${result.unarchived} 单`);
|
||
refreshAll();
|
||
} catch (error) {
|
||
message.error(getErrorMessage(error));
|
||
}
|
||
};
|
||
|
||
const batchArchive = () => {
|
||
if (!canArchiveSelected) {
|
||
message.warning('只有勾选的抢购成功订单才能批量归档');
|
||
return;
|
||
}
|
||
modal.confirm({
|
||
title: `归档选中的 ${selectedIds.length} 单?`,
|
||
content: '归档后这些订单在「查看归档」里只读展示,需要继续操作时可取消归档。',
|
||
onOk: async () => {
|
||
try {
|
||
const result = await douyuCollabApi.archive(selectedIds, '');
|
||
message.success(`已归档 ${result.archived} 单`);
|
||
setSelectedRowKeys([]);
|
||
refreshAll();
|
||
} catch (error) {
|
||
message.error(getErrorMessage(error));
|
||
}
|
||
},
|
||
});
|
||
};
|
||
|
||
const batchUnarchive = () => {
|
||
if (selectedIds.length === 0) {
|
||
message.warning('请先勾选已归档订单');
|
||
return;
|
||
}
|
||
modal.confirm({
|
||
title: `取消归档选中的 ${selectedIds.length} 单?`,
|
||
content: '取消归档后会回到主台账,可以继续编辑或改状态。',
|
||
onOk: async () => {
|
||
try {
|
||
const result = await douyuCollabApi.unarchive(selectedIds);
|
||
message.success(`已取消归档 ${result.unarchived} 单`);
|
||
setSelectedRowKeys([]);
|
||
refreshAll();
|
||
} catch (error) {
|
||
message.error(getErrorMessage(error));
|
||
}
|
||
},
|
||
});
|
||
};
|
||
|
||
const deleteOrder = (order: DouyuCollabOrder) => {
|
||
modal.confirm({
|
||
title: `删除协作单 ${order.order_no}?`,
|
||
content: '只删除这一行台账,不会删除内部斗鱼账号,也不会清理已上传图片。',
|
||
okButtonProps: { danger: true },
|
||
onOk: async () => {
|
||
try {
|
||
await douyuCollabApi.deleteOrder(order.id);
|
||
message.success('已删除');
|
||
setSelectedRowKeys((keys) => keys.filter((key) => Number(key) !== order.id));
|
||
refreshAll();
|
||
} catch (error) {
|
||
message.error(getErrorMessage(error));
|
||
}
|
||
},
|
||
});
|
||
};
|
||
|
||
const refreshGoods = async () => {
|
||
setGoodsLoading(true);
|
||
try {
|
||
const result = await douyuCollabApi.refreshGoods(lookup?.account_id || undefined);
|
||
setGoods(result.goods);
|
||
message.success(`已刷新 ${result.count} 个活动商品`);
|
||
} catch (error) {
|
||
message.error(getErrorMessage(error));
|
||
} finally {
|
||
setGoodsLoading(false);
|
||
}
|
||
};
|
||
|
||
const statusCards = [
|
||
{ key: '', label: '全部', value: summary.total },
|
||
...STATUS_ORDER.map((status) => ({
|
||
key: status,
|
||
label: STATUS_META[status].label,
|
||
value: summary.by_status[status] || 0,
|
||
})),
|
||
];
|
||
|
||
const columns: TableProps<DouyuCollabOrder>['columns'] = [
|
||
{
|
||
title: '序号',
|
||
width: 72,
|
||
fixed: 'left',
|
||
align: 'center',
|
||
render: (_, row) => <Text type="secondary">{row.id}</Text>,
|
||
},
|
||
{
|
||
title: '订单编号',
|
||
dataIndex: 'order_no',
|
||
width: 170,
|
||
fixed: 'left',
|
||
render: (value: string, row) => (
|
||
<Space direction="vertical" size={0}>
|
||
<Text strong copyable={{ text: value }}>
|
||
{value}
|
||
</Text>
|
||
{row.status === 'void' && <Tag color="error">已作废:{row.void_reason || '-'}</Tag>}
|
||
</Space>
|
||
),
|
||
},
|
||
{
|
||
title: '店铺',
|
||
dataIndex: 'shop',
|
||
width: 120,
|
||
render: (value: string) => value || <Text type="secondary">未填</Text>,
|
||
},
|
||
{
|
||
title: '负责人',
|
||
dataIndex: 'owner_id',
|
||
width: 120,
|
||
render: (value: number | null, row) => (
|
||
<Select
|
||
size="small"
|
||
variant="borderless"
|
||
style={{ width: '100%' }}
|
||
value={value ?? undefined}
|
||
options={owners.map((item) => ({ value: item.id, label: item.username }))}
|
||
placeholder="+ 指派"
|
||
allowClear
|
||
showSearch
|
||
optionFilterProp="label"
|
||
disabled={!canEdit || row.is_archived}
|
||
onChange={async (next) => {
|
||
try {
|
||
await douyuCollabApi.updateOrder(row.id, {
|
||
owner_id: next ?? null,
|
||
expected_version: row.version,
|
||
});
|
||
refreshAll();
|
||
} catch (error) {
|
||
message.error(getErrorMessage(error));
|
||
}
|
||
}}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
title: '斗鱼账号',
|
||
width: 210,
|
||
render: (_, row) => (
|
||
<Space direction="vertical" size={0}>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
style={{ padding: 0 }}
|
||
disabled={!row.account_id}
|
||
onClick={() => row.account_id && navigate(`/accounts/${row.account_id}`)}
|
||
>
|
||
{row.account_username || '未填'}
|
||
</Button>
|
||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||
{row.account_nickname || '-'}
|
||
{row.account_uid ? ` · UID ${row.account_uid}` : ''}
|
||
</Text>
|
||
<Space size={4} wrap>
|
||
{!row.account_exists ? (
|
||
<Tag color="red">账号不存在</Tag>
|
||
) : row.has_cookie ? (
|
||
<Tag color={CK_META[row.account_ck_status]?.color || 'default'}>
|
||
{CK_META[row.account_ck_status]?.label || 'CK 状态未知'}
|
||
</Tag>
|
||
) : (
|
||
<Tag color="red">无 CK</Tag>
|
||
)}
|
||
{row.account_cluster && row.account_cluster.total > 1 && (
|
||
<Tag color="blue">同账号 {row.account_cluster.total} 单</Tag>
|
||
)}
|
||
</Space>
|
||
</Space>
|
||
),
|
||
},
|
||
{
|
||
title: '绑定角色',
|
||
width: 180,
|
||
render: (_, row) => (
|
||
<Space direction="vertical" size={0}>
|
||
<Text>{row.bind_game_role || <Text type="secondary">未读到</Text>}</Text>
|
||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||
{row.bind_game_channel || '-'}
|
||
</Text>
|
||
{!row.bind_snapshot_at && (
|
||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||
还没校验
|
||
</Text>
|
||
)}
|
||
</Space>
|
||
),
|
||
},
|
||
{
|
||
title: '主页截图',
|
||
width: 110,
|
||
align: 'center',
|
||
render: (_, row) => {
|
||
const urls = row.home_image_urls || [];
|
||
if (urls.length === 0) {
|
||
return <Text type="secondary">未上传</Text>;
|
||
}
|
||
const first = row.home_image_thumb_urls?.[0] || urls[0];
|
||
return (
|
||
<Image.PreviewGroup items={urls}>
|
||
<Space size={4}>
|
||
<Image
|
||
src={first}
|
||
width={44}
|
||
height={44}
|
||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||
alt="主页截图"
|
||
/>
|
||
{urls.length > 1 && (
|
||
<Tooltip title={`共 ${urls.length} 张,点缩略图查看全部`}>
|
||
<Tag style={{ margin: 0 }}>+{urls.length - 1}</Tag>
|
||
</Tooltip>
|
||
)}
|
||
</Space>
|
||
</Image.PreviewGroup>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
title: '礼包 / 积分',
|
||
width: 200,
|
||
render: (_, row) => (
|
||
<Space direction="vertical" size={2}>
|
||
<Text>{row.gift_name || <Text type="secondary">未选礼包</Text>}</Text>
|
||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||
需要 {row.required_points ?? '-'} 积分
|
||
</Text>
|
||
{row.last_verdict && (
|
||
<Tag color={row.last_verdict === 'success' ? 'green' : row.last_verdict === 'failed' ? 'red' : 'default'}>
|
||
{row.last_verdict_label || row.last_verdict}
|
||
</Tag>
|
||
)}
|
||
</Space>
|
||
),
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
width: 130,
|
||
render: (value: DouyuCollabStatus, row) => (
|
||
<Space size={4} wrap>
|
||
<Tag color={STATUS_META[value]?.color}>{row.status_label || STATUS_META[value]?.label || value}</Tag>
|
||
{row.is_archived && <Tag>已归档</Tag>}
|
||
</Space>
|
||
),
|
||
},
|
||
{
|
||
title: '鱼翅 / 积分',
|
||
width: 170,
|
||
render: (_, row) => {
|
||
const meta = pointsReasonMeta(row.points_check_reason);
|
||
return (
|
||
<Space direction="vertical" size={1}>
|
||
<Text>鱼翅 {row.fish_fin_balance ?? '-'}</Text>
|
||
<Text>积分 {row.points_balance ?? '-'}</Text>
|
||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||
同账号需 {row.points_required_total ?? '-'},结余 {row.points_surplus ?? '-'}
|
||
</Text>
|
||
{row.points_check_reason && <Tag color={meta.color}>{meta.label}</Tag>}
|
||
</Space>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
title: '最近查询',
|
||
width: 160,
|
||
render: (_, row) =>
|
||
row.last_metric_at ? (
|
||
<Tooltip title={row.last_metric_message}>
|
||
<Space direction="vertical" size={0}>
|
||
<Text>{row.last_verdict_label || '已查询'}</Text>
|
||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||
{formatTime(row.last_metric_at)}
|
||
</Text>
|
||
</Space>
|
||
</Tooltip>
|
||
) : (
|
||
<Text type="secondary">还没查</Text>
|
||
),
|
||
},
|
||
{
|
||
title: '最后改动',
|
||
width: 150,
|
||
render: (_, row) => (
|
||
<Space direction="vertical" size={0}>
|
||
<Text>{row.updated_by_name || '-'}</Text>
|
||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||
{formatTime(row.updated_at)}
|
||
</Text>
|
||
</Space>
|
||
),
|
||
},
|
||
{
|
||
title: '操作',
|
||
width: 400,
|
||
fixed: 'right',
|
||
align: 'center',
|
||
render: (_, row) => (
|
||
<Space size={4} wrap>
|
||
{canEdit && !row.is_archived && row.status === 'incomplete' && (
|
||
<Button
|
||
type="primary"
|
||
size="small"
|
||
icon={<FormOutlined />}
|
||
onClick={() => openEdit(row)}
|
||
>
|
||
补资料
|
||
</Button>
|
||
)}
|
||
{isAdmin && !row.is_archived && (
|
||
<Tooltip title={row.has_cookie ? '复制关联账号的 CK 明文(仅管理员)' : '关联账号还没有 CK'}>
|
||
<Button
|
||
size="small"
|
||
icon={<CopyOutlined />}
|
||
disabled={!row.has_cookie || ckCopyingId === row.id}
|
||
loading={ckCopyingId === row.id}
|
||
onClick={() => void copyOneCk(row)}
|
||
>
|
||
复制CK
|
||
</Button>
|
||
</Tooltip>
|
||
)}
|
||
<Button size="small" icon={<EditOutlined />} onClick={() => void openDetail(row)}>
|
||
详情
|
||
</Button>
|
||
{canEdit && !row.is_archived && row.status !== 'void' && (
|
||
<>
|
||
<Tooltip title="重新读取 CK、角色、积分和鱼翅">
|
||
<Button size="small" icon={<SafetyCertificateOutlined />} onClick={() => void verifyOrder(row)}>
|
||
校验
|
||
</Button>
|
||
</Tooltip>
|
||
<Button size="small" icon={<SyncOutlined />} onClick={() => void queryOrder(row)}>
|
||
查询
|
||
</Button>
|
||
<Dropdown
|
||
menu={{
|
||
items: [
|
||
{ key: 'edit', label: '编辑资料', icon: <EditOutlined /> },
|
||
{ key: 'status', label: '改状态…', icon: <CheckCircleOutlined /> },
|
||
...(row.status === 'done'
|
||
? [{ key: 'archive', label: '归档…', icon: <InboxOutlined /> }]
|
||
: []),
|
||
...(canManage
|
||
? [{ type: 'divider' as const }, { key: 'delete', label: '删除这一行', icon: <DeleteOutlined />, danger: true }]
|
||
: []),
|
||
],
|
||
onClick: ({ key }) => {
|
||
if (key === 'edit') openEdit(row);
|
||
if (key === 'status') openStatus(row);
|
||
if (key === 'archive') void archiveOrders(row);
|
||
if (key === 'delete') deleteOrder(row);
|
||
},
|
||
}}
|
||
>
|
||
<Button size="small" icon={<TagOutlined />} />
|
||
</Dropdown>
|
||
</>
|
||
)}
|
||
{row.is_archived && canEdit && (
|
||
<Button size="small" icon={<UndoOutlined />} onClick={() => void unarchiveOrders(row)}>
|
||
取消归档
|
||
</Button>
|
||
)}
|
||
</Space>
|
||
),
|
||
},
|
||
];
|
||
|
||
const goodsPool = lookup?.goods?.length ? lookup.goods : goods;
|
||
const giftOptions = goodsOptions(goodsPool, Boolean(lookup?.goods?.length)).map((item) => ({
|
||
value: item.value,
|
||
label: item.label,
|
||
disabled: item.disabled,
|
||
}));
|
||
if (
|
||
form.commodity_id &&
|
||
!giftOptions.some((item) => item.value === form.commodity_id)
|
||
) {
|
||
giftOptions.unshift({
|
||
value: form.commodity_id,
|
||
label: `${form.gift_name || form.commodity_id} · 当前已选`,
|
||
disabled: false,
|
||
});
|
||
}
|
||
|
||
return (
|
||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||
<div>
|
||
<h2 style={{ margin: 0 }}>斗鱼外部单协作</h2>
|
||
<Text type="secondary">订单挂内部斗鱼账号;输入用户名即可读取 CK、角色、鱼翅与手册积分</Text>
|
||
</div>
|
||
|
||
<Row gutter={[8, 8]}>
|
||
{statusCards.map((item) => (
|
||
<Col flex="1 1 92px" key={item.key || 'all'}>
|
||
<Card
|
||
size="small"
|
||
hoverable
|
||
onClick={() => {
|
||
setStatusFilter(item.key);
|
||
setPage(1);
|
||
}}
|
||
style={cardStyle(statusFilter === item.key)}
|
||
>
|
||
<Statistic title={item.label} value={item.value} />
|
||
</Card>
|
||
</Col>
|
||
))}
|
||
<Col flex="1 1 92px">
|
||
<Card size="small" style={{ height: '100%' }}>
|
||
<Statistic
|
||
title="账号缺失"
|
||
value={summary.account_missing}
|
||
styles={{ content: { color: '#cf1322' } }}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
<Col flex="1 1 92px">
|
||
<Card
|
||
size="small"
|
||
hoverable
|
||
onClick={() => {
|
||
setCkFilter((previous) => (previous === 'invalid' ? '' : 'invalid'));
|
||
setPage(1);
|
||
}}
|
||
style={cardStyle(ckFilter === 'invalid')}
|
||
>
|
||
<Statistic title="CK 失效" value={summary.ck_invalid} styles={{ content: { color: '#ff4d4f' } }} />
|
||
</Card>
|
||
</Col>
|
||
{canView && (
|
||
<Col flex="none">
|
||
<Card
|
||
size="small"
|
||
style={{ width: 172, height: '100%' }}
|
||
styles={{
|
||
body: {
|
||
height: '100%',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
justifyContent: 'center',
|
||
padding: '10px 12px',
|
||
},
|
||
}}
|
||
>
|
||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||
<Space size={6} style={{ width: '100%', justifyContent: 'space-between' }}>
|
||
<Tooltip title="关闭后停止自动同步;需要最新数据时点「刷新」手动获取">
|
||
<Text strong style={{ fontSize: 13 }}>
|
||
实时同步
|
||
</Text>
|
||
</Tooltip>
|
||
<Switch
|
||
size="small"
|
||
checked={!realtimePaused}
|
||
onChange={toggleRealtimeSync}
|
||
aria-label="实时同步开关"
|
||
/>
|
||
</Space>
|
||
<Badge
|
||
status={
|
||
realtimePaused
|
||
? 'default'
|
||
: realtimeConnected
|
||
? 'success'
|
||
: realtimeReconnecting
|
||
? 'warning'
|
||
: 'error'
|
||
}
|
||
text={
|
||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||
{realtimePaused
|
||
? '已暂停 · 手动刷新'
|
||
: realtimeConnected
|
||
? '已连接 · 自动刷新'
|
||
: realtimeReconnecting
|
||
? '重连中…'
|
||
: '已离线 · 自动重试'}
|
||
</Text>
|
||
}
|
||
/>
|
||
</Space>
|
||
</Card>
|
||
</Col>
|
||
)}
|
||
</Row>
|
||
|
||
<Card size="small">
|
||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||
{/* 第一行固定筛选:店铺按钮 + 负责人 / 状态 / CK / 礼包 + 搜索 + 归档/作废开关 */}
|
||
<Space wrap style={{ width: '100%' }} align="center">
|
||
<Space size={4} align="center">
|
||
<Text type="secondary">店铺</Text>
|
||
<DouyuCollabShopPicker
|
||
value={shopFilter}
|
||
shops={shopRecords}
|
||
includeInactive
|
||
allowCustom={false}
|
||
clearLabel="全部"
|
||
onChange={(value) => {
|
||
setShopFilter(value);
|
||
setPage(1);
|
||
}}
|
||
/>
|
||
</Space>
|
||
<Select
|
||
allowClear
|
||
showSearch
|
||
optionFilterProp="label"
|
||
placeholder="负责人"
|
||
style={{ width: 150 }}
|
||
value={ownerFilter}
|
||
onChange={(value) => {
|
||
setOwnerFilter(value);
|
||
setPage(1);
|
||
}}
|
||
options={owners.map((item) => ({ value: item.id, label: item.username }))}
|
||
/>
|
||
<Select
|
||
allowClear
|
||
style={{ width: 140 }}
|
||
placeholder="业务状态"
|
||
value={statusFilter || undefined}
|
||
onChange={(value) => {
|
||
setStatusFilter(value || '');
|
||
setPage(1);
|
||
}}
|
||
options={STATUS_ORDER.map((status) => ({ value: status, label: STATUS_META[status].label }))}
|
||
/>
|
||
<Select
|
||
allowClear
|
||
style={{ width: 130 }}
|
||
placeholder="CK 状态"
|
||
value={ckFilter || undefined}
|
||
onChange={(value) => {
|
||
setCkFilter(value || '');
|
||
setPage(1);
|
||
}}
|
||
options={[
|
||
{ value: 'valid', label: CK_META.valid.label },
|
||
{ value: 'invalid', label: CK_META.invalid.label },
|
||
{ value: 'unverified', label: CK_META.unverified.label },
|
||
]}
|
||
/>
|
||
<Select
|
||
allowClear
|
||
showSearch
|
||
optionFilterProp="label"
|
||
placeholder="礼包"
|
||
style={{ width: 180 }}
|
||
value={giftFilter || undefined}
|
||
onChange={(value) => {
|
||
setGiftFilter(value || '');
|
||
setPage(1);
|
||
}}
|
||
options={gifts.map((item) => ({ value: item, label: item }))}
|
||
/>
|
||
<Input.Search
|
||
allowClear
|
||
placeholder="搜订单号 / 账号 / 角色 / 店铺 / 备注"
|
||
style={{ width: 320 }}
|
||
value={keyword}
|
||
onChange={(event) => {
|
||
setKeyword(event.target.value);
|
||
setPage(1);
|
||
}}
|
||
/>
|
||
<Space size={4}>
|
||
<Text type="secondary">显示已作废</Text>
|
||
<Switch
|
||
size="small"
|
||
checked={includeVoid}
|
||
onChange={(checked) => {
|
||
setIncludeVoid(checked);
|
||
setPage(1);
|
||
}}
|
||
/>
|
||
</Space>
|
||
<Space size={4}>
|
||
<Text type="secondary">查看归档</Text>
|
||
<Switch
|
||
size="small"
|
||
checked={archiveScope === 'archived'}
|
||
onChange={(checked) => {
|
||
setArchiveScope(checked ? 'archived' : 'active');
|
||
setSelectedRowKeys([]);
|
||
setPage(1);
|
||
}}
|
||
/>
|
||
<Tag>{summary.archived_total}</Tag>
|
||
</Space>
|
||
</Space>
|
||
|
||
{/* 第二行固定动作:勾选后按钮只变宽,不把列表顶下去 */}
|
||
<Space wrap style={{ width: '100%' }}>
|
||
<Button icon={<ReloadOutlined />} onClick={() => void loadOrders()} loading={loading}>
|
||
刷新
|
||
</Button>
|
||
{realtimePending && <Tag color="processing">有更新待同步</Tag>}
|
||
<Text type="secondary">已选 {selectedCount} 单</Text>
|
||
{canExportCk && archiveScope !== 'archived' && (
|
||
<Tooltip title="勾选几行就复制几份关联账号的 CK;明文只在弹窗里出现">
|
||
<Button
|
||
icon={<CopyOutlined />}
|
||
disabled={selectedCount === 0 || loading || ckExporting}
|
||
loading={ckExporting}
|
||
onClick={() => void openCkExport()}
|
||
>
|
||
批量复制 CK{selectedCount > 0 ? `(${selectedCount})` : ''}
|
||
</Button>
|
||
</Tooltip>
|
||
)}
|
||
{canEdit && archiveScope !== 'archived' && (
|
||
<>
|
||
<Button
|
||
icon={<SafetyCertificateOutlined />}
|
||
disabled={selectedCount === 0 || loading}
|
||
onClick={() => void batchVerify()}
|
||
>
|
||
批量验证 CK{selectedCount > 0 ? `(${selectedCount})` : ''}
|
||
</Button>
|
||
<Button icon={<CheckCircleOutlined />} disabled={selectedCount === 0} onClick={openBatchStatus}>
|
||
批量改状态{selectedCount > 0 ? `(${selectedCount})` : ''}
|
||
</Button>
|
||
<Button
|
||
icon={<SyncOutlined />}
|
||
disabled={selectedCount === 0 || loading}
|
||
onClick={() => void batchQuery()}
|
||
>
|
||
批量查鱼翅/积分{selectedCount > 0 ? `(${selectedCount})` : ''}
|
||
</Button>
|
||
<Button
|
||
icon={<InboxOutlined />}
|
||
disabled={!canArchiveSelected || loading}
|
||
onClick={batchArchive}
|
||
>
|
||
批量归档{selectedCount > 0 ? `(${selectedCount})` : ''}
|
||
</Button>
|
||
</>
|
||
)}
|
||
{canEdit && archiveScope === 'archived' && (
|
||
<Button
|
||
icon={<UndoOutlined />}
|
||
disabled={selectedCount === 0 || loading}
|
||
onClick={batchUnarchive}
|
||
>
|
||
批量取消归档{selectedCount > 0 ? `(${selectedCount})` : ''}
|
||
</Button>
|
||
)}
|
||
{canEdit && (
|
||
<Button icon={<ReloadOutlined />} onClick={() => void refreshGoods()} loading={goodsLoading}>
|
||
刷新活动商品
|
||
</Button>
|
||
)}
|
||
{canEdit && (
|
||
<Button icon={<ShopOutlined />} onClick={() => setShopModalOpen(true)}>
|
||
店铺管理
|
||
</Button>
|
||
)}
|
||
{canEdit && (
|
||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||
新建协作单
|
||
</Button>
|
||
)}
|
||
</Space>
|
||
</Space>
|
||
|
||
<Table<DouyuCollabOrder>
|
||
style={{ marginTop: 12 }}
|
||
rowKey="id"
|
||
size="small"
|
||
tableLayout="fixed"
|
||
loading={loading}
|
||
columns={columns}
|
||
dataSource={orders}
|
||
scroll={{ x: 2090 }}
|
||
rowSelection={{
|
||
selectedRowKeys,
|
||
onChange: (keys) => setSelectedRowKeys(keys),
|
||
preserveSelectedRowKeys: true,
|
||
}}
|
||
pagination={{
|
||
current: page,
|
||
pageSize,
|
||
total,
|
||
showSizeChanger: true,
|
||
showTotal: (count) => `共 ${count} 条`,
|
||
onChange: (nextPage, nextSize) => {
|
||
setPage(nextPage);
|
||
if (nextSize !== pageSize) {
|
||
setPageSize(nextSize);
|
||
setPage(1);
|
||
}
|
||
},
|
||
}}
|
||
/>
|
||
</Card>
|
||
|
||
<DouyuCollabShopModal
|
||
open={shopModalOpen}
|
||
shops={shopRecords}
|
||
canEdit={canEdit}
|
||
canManage={canManage}
|
||
onClose={() => setShopModalOpen(false)}
|
||
onChanged={refreshAll}
|
||
/>
|
||
|
||
<Modal
|
||
title={editing ? `编辑协作单 ${editing.order_no}` : '新建协作单'}
|
||
open={createOpen}
|
||
width={820}
|
||
onCancel={() => setCreateOpen(false)}
|
||
footer={
|
||
editing ? (
|
||
<Space>
|
||
<Button onClick={() => setCreateOpen(false)}>取消</Button>
|
||
<Button type="primary" loading={submitting} onClick={() => void submitCreate(false)}>
|
||
保存
|
||
</Button>
|
||
</Space>
|
||
) : (
|
||
<Space>
|
||
<Button onClick={() => setCreateOpen(false)}>取消</Button>
|
||
<Button loading={submitting} onClick={() => void submitCreate(true)}>
|
||
存草稿
|
||
</Button>
|
||
<Button type="primary" loading={submitting} onClick={() => void submitCreate(false)}>
|
||
新建
|
||
</Button>
|
||
</Space>
|
||
)
|
||
}
|
||
destroyOnHidden
|
||
styles={{ body: { maxHeight: '78vh', overflowY: 'auto', paddingRight: 12 } }}
|
||
>
|
||
{!editing && (
|
||
<Alert
|
||
style={{ marginBottom: 12 }}
|
||
type="info"
|
||
showIcon
|
||
message="只填订单号 + 店铺也能直接点「新建」"
|
||
description="资料没填全会自动按「待补资料」创建,之后用行内「补资料」补齐即可。"
|
||
/>
|
||
)}
|
||
{editing?.status === 'incomplete' && (
|
||
<Alert
|
||
style={{ marginBottom: 12 }}
|
||
type="warning"
|
||
showIcon
|
||
message="保存后会重新校验账号"
|
||
description="只有斗鱼账号、礼包、主页截图和绑定角色都齐全,才会自动进入「待充值」。绑定角色读不到时,请先确认账号 CK 是否可用。"
|
||
/>
|
||
)}
|
||
<Row gutter={16}>
|
||
<Col span={12}>
|
||
<div style={{ marginBottom: 12 }}>
|
||
<Text strong>订单编号</Text>
|
||
<Input
|
||
style={{ marginTop: 4 }}
|
||
value={form.order_no}
|
||
maxLength={64}
|
||
placeholder="外部订单编号"
|
||
onChange={(event) => updateForm('order_no', event.target.value)}
|
||
/>
|
||
</div>
|
||
</Col>
|
||
<Col span={12}>
|
||
<div style={{ marginBottom: 12 }}>
|
||
<Text strong>负责人</Text>
|
||
<Select
|
||
style={{ width: '100%', marginTop: 4 }}
|
||
allowClear
|
||
showSearch
|
||
optionFilterProp="label"
|
||
placeholder="选择负责人"
|
||
value={form.owner_id ?? undefined}
|
||
options={owners.map((item) => ({ value: item.id, label: item.username }))}
|
||
onChange={(value) => updateForm('owner_id', value ?? null)}
|
||
/>
|
||
</div>
|
||
</Col>
|
||
</Row>
|
||
|
||
<div style={{ marginBottom: 12 }}>
|
||
<Text strong>店铺</Text>
|
||
<div style={{ marginTop: 4 }}>
|
||
<DouyuCollabShopPicker
|
||
value={form.shop}
|
||
shops={shopRecords}
|
||
allowCustom
|
||
allowClear={false}
|
||
size="middle"
|
||
onChange={(value) => updateForm('shop', value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ marginBottom: 12 }}>
|
||
<Text strong>斗鱼用户名</Text>
|
||
<Space.Compact style={{ width: '100%', marginTop: 4 }}>
|
||
<Input
|
||
value={form.account_username}
|
||
placeholder="输入内部斗鱼账号用户名,停止输入后自动查询"
|
||
onChange={(event) => updateForm('account_username', event.target.value)}
|
||
/>
|
||
<Button loading={lookupLoading} onClick={() => void handleLookup()}>
|
||
查询账号
|
||
</Button>
|
||
</Space.Compact>
|
||
{lookup && (
|
||
<Alert
|
||
style={{ marginTop: 8 }}
|
||
showIcon
|
||
type={lookup.found && lookup.ok ? 'success' : 'warning'}
|
||
message={
|
||
lookup.found
|
||
? `${lookup.account_nickname || lookup.account_username} · ${lookup.bind_game_role || '未读到角色'} · 手册 ${lookup.manual_score ?? '-'} 积分 · 鱼翅 ${lookup.fish_fin_balance ?? '-'}`
|
||
: lookup.message || '账号不存在'
|
||
}
|
||
description={
|
||
lookup.orders_using_account.length > 0
|
||
? `这个账号已经挂在:${lookup.orders_using_account.map((item) => item.order_no).join('、')};允许多单,照常提交`
|
||
: lookup.message || undefined
|
||
}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
<Row gutter={16}>
|
||
<Col span={14}>
|
||
<div style={{ marginBottom: 12 }}>
|
||
<Text strong>礼包</Text>
|
||
<Select
|
||
style={{ width: '100%', marginTop: 4 }}
|
||
showSearch
|
||
allowClear
|
||
optionFilterProp="label"
|
||
value={form.commodity_id || undefined}
|
||
options={giftOptions}
|
||
placeholder={goodsPool.length ? '选择 23返场 / 常驻礼包' : '商品缓存为空,点右上角刷新活动商品'}
|
||
onChange={handleGiftChange}
|
||
/>
|
||
{form.gift_name && (
|
||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||
当前:{form.gift_name}
|
||
</Text>
|
||
)}
|
||
</div>
|
||
</Col>
|
||
<Col span={10}>
|
||
<div style={{ marginBottom: 12 }}>
|
||
<Text strong>所需积分</Text>
|
||
<InputNumber
|
||
style={{ width: '100%', marginTop: 4 }}
|
||
min={0}
|
||
value={form.required_points}
|
||
placeholder="选中礼包自动带出"
|
||
onChange={(value) => updateForm('required_points', value ?? null)}
|
||
/>
|
||
</div>
|
||
</Col>
|
||
</Row>
|
||
|
||
<div style={{ marginBottom: 12 }}>
|
||
<Text strong>主页截图(必填,第一张作封面)</Text>
|
||
<div style={{ marginTop: 4 }}>
|
||
<HuyaCollabHomeImageUploader
|
||
value={form.home_images}
|
||
onChange={(keys) => updateForm('home_images', keys)}
|
||
optional={false}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ marginBottom: 12 }}>
|
||
<Text strong>备注</Text>
|
||
<Input.TextArea
|
||
style={{ marginTop: 4 }}
|
||
rows={2}
|
||
maxLength={512}
|
||
value={form.remark}
|
||
onChange={(event) => updateForm('remark', event.target.value)}
|
||
/>
|
||
</div>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title={batchStatusOpen ? `批量改状态(${selectedCount} 单)` : `改状态:${statusTarget?.order_no || ''}`}
|
||
open={batchStatusOpen || Boolean(statusTarget)}
|
||
onCancel={() => {
|
||
setBatchStatusOpen(false);
|
||
setStatusTarget(null);
|
||
}}
|
||
onOk={() => void submitStatus()}
|
||
confirmLoading={statusBusy}
|
||
okText="确定"
|
||
>
|
||
<Space direction="vertical" style={{ width: '100%' }}>
|
||
<Select
|
||
style={{ width: '100%' }}
|
||
value={statusValue}
|
||
onChange={(value: DouyuCollabStatus) => setStatusValue(value)}
|
||
options={STATUS_ORDER.map((item) => ({
|
||
value: item,
|
||
label: STATUS_META[item].label,
|
||
}))}
|
||
/>
|
||
{statusValue === 'void' && (
|
||
<Alert type="warning" showIcon message="作废后不可恢复;请在备注里写作废原因。" />
|
||
)}
|
||
<Input.TextArea
|
||
rows={2}
|
||
value={statusReason}
|
||
placeholder="原因 / 备注(可选)"
|
||
onChange={(event) => setStatusReason(event.target.value)}
|
||
/>
|
||
</Space>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title={`导出 CK(勾选 ${ckExport?.total ?? 0} 条,可导出 ${ckExport?.exported ?? 0} 条)`}
|
||
open={ckExportOpen}
|
||
onCancel={closeCkExport}
|
||
width={760}
|
||
footer={[
|
||
<Button key="close" onClick={closeCkExport}>
|
||
关闭
|
||
</Button>,
|
||
<Button
|
||
key="copy"
|
||
type="primary"
|
||
icon={<CopyOutlined />}
|
||
disabled={!ckExport?.exported}
|
||
onClick={() => void copyCkExport()}
|
||
>
|
||
复制到剪贴板
|
||
</Button>,
|
||
]}
|
||
destroyOnHidden
|
||
>
|
||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||
<Alert
|
||
type="warning"
|
||
showIcon
|
||
message="CK 是账号凭据"
|
||
description="斗鱼协作单不存 CK,这里导出的是关联内部账号当前的 cookie。复制后请勿发群 / 截图外传;每次导出都会写审计(谁、什么时候、导出了哪几单),审计里不存 CK 本体。"
|
||
/>
|
||
{ckExport && ckExport.skipped.length > 0 && (
|
||
<Text type="warning" style={{ fontSize: 12 }}>
|
||
跳过 {ckExport.skipped.length} 条:
|
||
{ckExport.skipped
|
||
.map((item) =>
|
||
item.order_no ? `${item.order_no}(${item.reason})` : item.reason,
|
||
)
|
||
.join(';')}
|
||
</Text>
|
||
)}
|
||
{ckExportRisky.length > 0 && (
|
||
<Text type="danger" style={{ fontSize: 12 }}>
|
||
其中 {ckExportRisky.length} 条关联账号的 CK 已失效:
|
||
{ckExportRisky
|
||
.map((item) => `${item.order_no}(${item.ck_status_label || 'CK 异常'})`)
|
||
.join(';')}
|
||
</Text>
|
||
)}
|
||
<Radio.Group
|
||
value={ckExportFormat}
|
||
onChange={(event) => setCkExportFormat(event.target.value)}
|
||
>
|
||
<Radio value="plain">每行一份纯 CK(直接粘)</Radio>
|
||
<Radio value="with_order">订单号 + Tab + CK(多选时好对号)</Radio>
|
||
</Radio.Group>
|
||
<Input.TextArea
|
||
rows={10}
|
||
value={ckExportText}
|
||
readOnly
|
||
placeholder="没有可导出的 CK"
|
||
style={{
|
||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||
fontSize: 12,
|
||
}}
|
||
/>
|
||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||
共 {ckExport?.exported ?? 0} 份 · {ckExportText.length} 字符;
|
||
关掉这个弹窗就清掉,不会留在页面上。
|
||
</Text>
|
||
</Space>
|
||
</Modal>
|
||
|
||
<Drawer
|
||
title={detail ? `协作单 ${detail.order_no}` : '协作单详情'}
|
||
open={detailOpen}
|
||
onClose={() => setDetailOpen(false)}
|
||
width={760}
|
||
extra={
|
||
detail && canEdit ? (
|
||
<Space>
|
||
{detail.account_id && (
|
||
<Button icon={<LinkOutlined />} onClick={() => navigate(`/accounts/${detail.account_id}`)}>
|
||
账号详情
|
||
</Button>
|
||
)}
|
||
<Button icon={<EditOutlined />} onClick={() => openEdit(detail)}>
|
||
编辑
|
||
</Button>
|
||
<Button icon={<SafetyCertificateOutlined />} onClick={() => void verifyOrder(detail)}>
|
||
校验
|
||
</Button>
|
||
<Button icon={<SyncOutlined />} onClick={() => void queryOrder(detail)}>
|
||
查鱼翅/积分
|
||
</Button>
|
||
</Space>
|
||
) : undefined
|
||
}
|
||
>
|
||
{detail ? (
|
||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||
<Descriptions bordered size="small" column={2}>
|
||
<Descriptions.Item label="订单编号">{detail.order_no}</Descriptions.Item>
|
||
<Descriptions.Item label="店铺">{detail.shop || '-'}</Descriptions.Item>
|
||
<Descriptions.Item label="负责人">{detail.owner_name || '-'}</Descriptions.Item>
|
||
<Descriptions.Item label="状态">
|
||
<Tag color={STATUS_META[detail.status]?.color}>{detail.status_label}</Tag>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="斗鱼账号">{detail.account_username || '-'}</Descriptions.Item>
|
||
<Descriptions.Item label="账号昵称">{detail.account_nickname || '-'}</Descriptions.Item>
|
||
<Descriptions.Item label="绑定角色">{detail.bind_game_role || '-'}</Descriptions.Item>
|
||
<Descriptions.Item label="角色区服">{detail.bind_game_channel || '-'}</Descriptions.Item>
|
||
<Descriptions.Item label="礼包">
|
||
{detail.gift_name || '-'}({detail.required_points ?? '-'} 积分)
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="鱼翅 / 积分">
|
||
鱼翅 {detail.fish_fin_balance ?? '-'} / 积分 {detail.points_balance ?? '-'}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="同账号合计">
|
||
需 {detail.points_required_total ?? '-'},结余 {detail.points_surplus ?? '-'}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="积分判定">
|
||
{detail.points_check_message || '-'}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="备注" span={2}>
|
||
{detail.remark || '-'}
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
|
||
<div>
|
||
<Text strong>主页截图</Text>
|
||
<div style={{ marginTop: 8 }}>
|
||
{detail.home_image_urls.length ? (
|
||
<Image.PreviewGroup items={detail.home_image_urls}>
|
||
<Space wrap>
|
||
{detail.home_image_thumb_urls.map((url, index) => (
|
||
<Image key={url} src={url} width={88} height={88} style={{ objectFit: 'cover', borderRadius: 4 }} alt={`主页截图${index + 1}`} />
|
||
))}
|
||
</Space>
|
||
</Image.PreviewGroup>
|
||
) : (
|
||
<Text type="secondary">未上传</Text>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<Text strong>查询时间线</Text>
|
||
{detailLoading ? (
|
||
<Text type="secondary">加载中…</Text>
|
||
) : detailMetrics.length ? (
|
||
<Timeline
|
||
style={{ marginTop: 12 }}
|
||
items={detailMetrics.map((metric) => ({
|
||
color: metric.verdict === 'success' ? 'green' : metric.verdict === 'failed' ? 'red' : 'gray',
|
||
children: (
|
||
<Space direction="vertical" size={0}>
|
||
<Text>
|
||
{metric.verdict_label || '查询'} · {formatTime(metric.created_at)}
|
||
</Text>
|
||
<Text type="secondary">{metric.message}</Text>
|
||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||
鱼翅 {metric.fish_fin_balance ?? '-'} / 积分 {metric.points_balance ?? '-'} / 记录 {metric.records_matched}/{metric.records_total}
|
||
</Text>
|
||
</Space>
|
||
),
|
||
}))}
|
||
/>
|
||
) : (
|
||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没查询过" />
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<Text strong>操作记录</Text>
|
||
<Timeline
|
||
style={{ marginTop: 12 }}
|
||
items={detailTimeline.map((item) => ({
|
||
children: (
|
||
<Space direction="vertical" size={0}>
|
||
<Text>
|
||
{item.username || '-'} · {item.action}
|
||
</Text>
|
||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||
{formatTime(item.created_at)}
|
||
</Text>
|
||
{item.detail && (
|
||
<Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-all' }}>
|
||
{item.detail}
|
||
</Text>
|
||
)}
|
||
</Space>
|
||
),
|
||
}))}
|
||
/>
|
||
</div>
|
||
</Space>
|
||
) : null}
|
||
</Drawer>
|
||
</Space>
|
||
);
|
||
}
|