Files
live-hub-py/web/frontend/src/pages/DouyuCollabPage.tsx
T

1942 lines
66 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}