Files
live-hub-py/web/frontend/src/pages/HuyaCollabPage.tsx
T
yml2213 cad06ae789 feat(huya): 协作台账增加 WebSocket 实时同步
- 后端新增进程内事件总线与 /api/huya/external-orders/ws\n- 所有写接口成功后发布轻量失效事件\n- 前端自动重连、静默刷新、断线兜底轮询
2026-10-17 13:44:27 +08:00

2154 lines
92 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.
/**
* 虎牙外部单协作台账(`/huya/collab`)— 替代 Excel 多人协同表。
*
* 设计:`docs/设计-虎牙外部CK协作台账.md`。三条要点:
* 1. **一行 = 一单 = 一个外部虎牙 CK**,所有人共享同一张表,改动靠审计留痕;
* 2. **绑定账号 / 绑定角色**来自「验证 CK」的只读探针快照(人工不可编辑,每次验证覆盖);
* 3. **内联编辑必须带 `expected_version`**:别人先改过会 409,绝不静默覆盖(Excel 最痛的点)。
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert, App, AutoComplete, Button, Card, Checkbox, Col, Descriptions, Divider, Drawer, Dropdown,
Empty, Form, Image, Input, Modal, Popconfirm, Popover, Progress, Radio, Row, Select, Space, Statistic,
Switch, Table, Tag, Timeline, Tooltip, Typography, Upload,
} from 'antd';
import type { TableProps } from 'antd';
import {
CheckCircleOutlined, CloudUploadOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileExcelOutlined,
LinkOutlined, PlusOutlined, ReloadOutlined, SafetyCertificateOutlined, ShopOutlined,
StopOutlined, SwapOutlined, SyncOutlined, UndoOutlined,
} from '@ant-design/icons';
import {
fileApi,
huyaApi,
type HuyaCollabAuditItem,
type HuyaCollabCkExportResult,
type HuyaCollabCkPreview,
type HuyaCollabGift,
type HuyaCollabImageSlot,
type HuyaCollabMetric,
type HuyaCollabOrder,
type HuyaCollabOrderQuery,
type HuyaCollabOrderUpdate,
type HuyaCollabOwner,
type HuyaCollabShop,
type HuyaCollabStatus,
type HuyaCollabSummary,
} from '../api/modules';
import HuyaCollabShopModal from '../components/HuyaCollabShopModal';
import HuyaCollabShopPicker from '../components/HuyaCollabShopPicker';
import HuyaCollabHomeImageUploader from '../components/HuyaCollabHomeImageUploader';
import { message } from '../utils/antdMessage';
import { getErrorMessage } from '../utils/error';
import { copyTextToClipboard } from '../utils/clipboard';
import { usePermissions } from '../hooks/usePermissions';
import { useCollabRealtime, type CollabRealtimeEvent } from '../hooks/useCollabRealtime';
const { Text, Paragraph } = Typography;
function hasTextInputFocus(): boolean {
const active = document.activeElement;
return (
active instanceof HTMLElement &&
(active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)
);
}
const STATUS_META: Record<HuyaCollabStatus, { label: string; color: string }> = {
pending: { label: '待处理', color: 'default' },
// 人工核对过「探针读到的绑定角色」与「主页截图」一致
bound: { label: '已确认绑定', color: 'blue' },
recharged: { label: '已充值', color: 'geekblue' },
scripted: { label: '已上脚本', color: 'purple' },
// 抢购成功(枚举值仍是 done):从「已上脚本」查到兑换记录 → 自动进这一步
done: { label: '抢购成功', color: 'success' },
// 旁路终态:抢购后查兑换记录没查到这一单的礼包(枚举值仍是 failed)
failed: { label: '抢购失败', color: 'red' },
void: { label: '已作废', color: 'error' },
};
/** 判定结论的配色(生命周期时间线用):与「业务状态名」分开,这里说的是"兑换记录里查到没有" */
const VERDICT_META: Record<string, { label: string; color: string }> = {
success: { label: '兑换成功', color: 'success' },
failed: { label: '兑换失败', color: 'error' },
unknown: { label: '无法判定', color: 'default' },
};
/** 「改状态」能选的目标(顺序 = 业务流程顺序,回退要显式勾选) */
const STATUS_ORDER_OPTIONS: { value: HuyaCollabStatus; label: string }[] = [
{ value: 'pending', label: '待处理' },
{ value: 'bound', label: '已确认绑定' },
{ value: 'recharged', label: '已充值' },
{ value: 'scripted', label: '已上脚本' },
{ value: 'done', label: '抢购成功' },
{ value: 'failed', label: '抢购失败' },
{ value: 'void', label: '已作废' },
];
/** 「回退」能选的目标:只在流程链里选(要进「已作废 / 抢购失败」走「改状态」或「作废」入口) */
const FLOW_STATUS_OPTIONS = STATUS_ORDER_OPTIONS.filter(
(option) => option.value !== 'failed' && option.value !== 'void',
);
const CK_META: Record<string, { label: string; color: string }> = {
unverified: { label: '未验证', color: 'default' },
valid: { label: '有效', color: 'success' },
invalid: { label: '已失效', color: 'error' },
disabled: { label: '已停用', color: 'warning' },
};
const ACTION_LABELS: Record<string, string> = {
'huya:collab:create': '新建',
'huya:collab:update': '编辑',
'huya:collab:status': '状态变更',
'huya:collab:ck': 'CK 变更',
'huya:collab:ck_status': 'CK 验证',
'huya:collab:probe_snapshot': '绑定快照刷新',
'huya:collab:image': '图片变更',
'huya:collab:delete': '删除',
};
const SLOT_META: Record<HuyaCollabImageSlot, { label: string; keysKey: 'bind_images' | 'home_images' }> = {
// 绑定图不再人工上传:绑定关系由探针自动读(2026-10-16 用户拍板),只留老数据只读展示
bind_images: { label: '历史绑定图', keysKey: 'bind_images' },
home_images: { label: '主页截图', keysKey: 'home_images' },
};
const IMPORT_PREVIEW_ROWS = 20;
// 「记住最近选的礼包」的本地存储键(同一个礼包连开几单时不用反复选)
const LAST_GIFT_KEY = 'huya.collab.lastGift';
// 「礼包列表」的本地缓存键:一个活动期间就那一批礼包,没必要每贴一份 CK 都去虎牙拉一次;
// 要更新就点「强制刷新礼包」(活动换 SID 后必点一次)。
const GIFTS_CACHE_KEY = 'huya.collab.gifts';
const MAX_IMAGES_PER_SLOT = 20;
/** 读礼包缓存(读不到 / 解析坏 / 空列表都当没缓存) */
function readGiftCache(): { gifts: HuyaCollabGift[]; at: number } | null {
try {
const raw = window.localStorage.getItem(GIFTS_CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as { at?: number; gifts?: HuyaCollabGift[] };
const gifts = Array.isArray(parsed.gifts) ? parsed.gifts : [];
if (gifts.length === 0) return null;
return { gifts, at: Number(parsed.at) || 0 };
} catch {
return null;
}
}
/** 写礼包缓存:写不进去(隐私模式 / 配额满)也不影响功能,静静跳过 */
function writeGiftCache(gifts: HuyaCollabGift[]): void {
try {
window.localStorage.setItem(GIFTS_CACHE_KEY, JSON.stringify({ at: Date.now(), gifts }));
} catch {
// 忽略:缓存只是体验优化
}
}
function formatTime(value: string | null | undefined): string {
if (!value) return '-';
const time = new Date(value);
return Number.isNaN(time.getTime()) ? String(value) : time.toLocaleString();
}
/** 点开即改、失焦提交(回车=失焦提交,Esc=放弃) */
function EditableText({
value, disabled, placeholder, onCommit,
}: {
value: string;
disabled?: boolean;
placeholder?: string;
onCommit: (next: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
useEffect(() => {
setDraft(value);
setEditing(false);
}, [value]);
if (!editing || disabled) {
return (
<div
onClick={() => { if (!disabled) setEditing(true); }}
// wordBreak:礼品名 / 角色名里偶发的一长串不带空格的字符也不会顶穿这一列
style={{ cursor: disabled ? 'default' : 'text', minHeight: 22, wordBreak: 'break-word' }}
title={disabled ? undefined : '点击编辑'}
>
{value || <Text type="secondary">{placeholder || '—'}</Text>}
</div>
);
}
return (
<Input
autoFocus
size="small"
value={draft}
onChange={(event) => setDraft(event.target.value)}
onBlur={() => {
setEditing(false);
if (draft !== value) onCommit(draft);
}}
onPressEnter={(event) => event.currentTarget.blur()}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setDraft(value);
setEditing(false);
event.currentTarget.blur();
}
}}
/>
);
}
/** 两个图片槽位(object_key 数组,第一张是封面) */
function ImageSlotEditor({
order, slot, canEdit, busy, onUpload, onRemove, onSetCover,
}: {
order: HuyaCollabOrder;
slot: HuyaCollabImageSlot;
canEdit: boolean;
busy: boolean;
onUpload: (slot: HuyaCollabImageSlot, file: File) => void;
onRemove: (slot: HuyaCollabImageSlot, key: string) => void;
onSetCover: (slot: HuyaCollabImageSlot, key: string) => void;
}) {
const keys = slot === 'bind_images' ? order.bind_images : order.home_images;
const urls = slot === 'bind_images' ? order.bind_image_urls : order.home_image_urls;
const thumbs = slot === 'bind_images' ? order.bind_image_thumb_urls : order.home_image_thumb_urls;
const full = keys.length >= MAX_IMAGES_PER_SLOT;
return (
<Card size="small" title={SLOT_META[slot].label} style={{ marginBottom: 12 }}>
<Space direction="vertical" style={{ width: '100%' }} size={8}>
<Space wrap>
{keys.length === 0 && <Text type="secondary">还没有图</Text>}
{keys.length > 0 && (
<Image.PreviewGroup items={urls}>
{keys.map((key, index) => (
<Space key={key} direction="vertical" size={2} align="center">
<div style={{ position: 'relative' }}>
<Image
src={thumbs[index] || urls[index]}
width={64}
height={64}
style={{ objectFit: 'cover', borderRadius: 4 }}
alt={`${SLOT_META[slot].label}${index + 1}`}
/>
{index === 0 && (
<Tag color="blue" style={{ position: 'absolute', left: 0, top: 0, margin: 0 }}>
封面
</Tag>
)}
</div>
{canEdit && (
<Space size={2}>
{index !== 0 && (
<Tooltip title="设为封面">
<Button size="small" type="text" icon={<SwapOutlined />}
disabled={busy} onClick={() => onSetCover(slot, key)} />
</Tooltip>
)}
<Popconfirm title="从这一行移除这张图?" description="对象不会立刻删除,交给孤儿清理脚本"
onConfirm={() => onRemove(slot, key)} disabled={busy}>
<Tooltip title="移除">
<Button size="small" type="text" danger icon={<DeleteOutlined />} disabled={busy} />
</Tooltip>
</Popconfirm>
</Space>
)}
</Space>
))}
</Image.PreviewGroup>
)}
</Space>
{canEdit && (
<Upload
accept="image/*"
showUploadList={false}
disabled={busy || full}
beforeUpload={(file) => { onUpload(slot, file as File); return false; }}
>
<Button size="small" icon={<CloudUploadOutlined />} disabled={busy || full} loading={busy}>
{full ? `最多 ${MAX_IMAGES_PER_SLOT} 张` : '上传图片'}
</Button>
</Upload>
)}
</Space>
</Card>
);
}
export default function HuyaCollabPage() {
const { modal } = App.useApp();
const { can, user } = usePermissions();
const canEdit = can('huya:collab:edit');
const canManage = can('huya:collab:manage');
// 导出 CK 是把凭据带出系统,单独一个权限点(默认只给运营,见 permissions.py)
const canExportCk = can('huya:collab:ck_export');
const [rows, setRows] = useState<HuyaCollabOrder[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [summary, setSummary] = useState<HuyaCollabSummary | null>(null);
const [owners, setOwners] = useState<HuyaCollabOwner[]>([]);
const [shops, setShops] = useState<HuyaCollabShop[]>([]);
const [giftNames, setGiftNames] = useState<string[]>([]);
const [shopModalOpen, setShopModalOpen] = useState(false);
const [filters, setFilters] = useState<HuyaCollabOrderQuery>({ include_void: false });
const [realtimePending, setRealtimePending] = useState(false);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [detail, setDetail] = useState<HuyaCollabOrder | null>(null);
const [timeline, setTimeline] = useState<HuyaCollabAuditItem[]>([]);
const [timelineLoading, setTimelineLoading] = useState(false);
const [detailForm] = Form.useForm<HuyaCollabOrderUpdate>();
const [createOpen, setCreateOpen] = useState(false);
const [createForm] = Form.useForm<Record<string, unknown>>();
const [ckPreview, setCkPreview] = useState<HuyaCollabCkPreview | null>(null);
const [previewing, setPreviewing] = useState(false);
// 这次预览有没有带「拉礼包」(没有就是纯探针 + 用本地礼包缓存),只影响提示文案
const [previewPullsGifts, setPreviewPullsGifts] = useState(false);
const ckReady = !!ckPreview?.ok && !!ckPreview?.has_role;
// 礼包列表:优先用浏览器缓存那份 —— 有数据就不去虎牙拉,要更新点「强制刷新礼包」。
// 直接拿缓存做初值(而不是等 useEffect):贴第一份 CK 时就已经有数据了,不会白拉一次。
const [giftOptions, setGiftOptions] = useState<HuyaCollabGift[]>(
() => readGiftCache()?.gifts ?? [],
);
const [giftsCachedAt, setGiftsCachedAt] = useState<number | null>(
() => readGiftCache()?.at ?? null,
);
const giftsRef = useRef<HuyaCollabGift[]>(giftOptions);
// 记忆里的礼包不在「这次拉到的列表」里时的提示(会顺手清空,免得提交一个拉不到的礼包名)
const [giftHint, setGiftHint] = useState('');
const [importOpen, setImportOpen] = useState(false);
const [importText, setImportText] = useState('');
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState<{
created: number; updated: number; skipped: number;
errors: { line: number; order_no: string; message: string }[];
} | null>(null);
const [ckTarget, setCkTarget] = useState<{ order: HuyaCollabOrder; text: string } | null>(null);
// 单行状态弹窗:set=人工往前切(可跳步)/ rollback=回退 / void=作废
const [statusAction, setStatusAction] = useState<{
order: HuyaCollabOrder; mode: 'void' | 'rollback' | 'set'; target?: HuyaCollabStatus;
} | null>(null);
const [statusReason, setStatusReason] = useState('');
const [rollbackTarget, setRollbackTarget] = useState<HuyaCollabStatus>('pending');
// 批量改状态(勾选多行一次切)
const [batchStatusOpen, setBatchStatusOpen] = useState(false);
const [batchStatusTarget, setBatchStatusTarget] = useState<HuyaCollabStatus>('bound');
const [batchStatusReason, setBatchStatusReason] = useState('');
const [batchStatusRollback, setBatchStatusRollback] = useState(false);
// 积分 / 兑换记录(「账号全生命周期」)
const [querying, setQuerying] = useState(false);
const [metrics, setMetrics] = useState<HuyaCollabMetric[]>([]);
const [metricsLoading, setMetricsLoading] = useState(false);
// 勾选导出 CK(明文只在这一个弹窗里出现;复制走剪贴板)
const [ckExportOpen, setCkExportOpen] = useState(false);
const [ckExporting, setCkExporting] = useState(false);
const [ckExport, setCkExport] = useState<HuyaCollabCkExportResult | null>(null);
const [ckExportFormat, setCkExportFormat] = useState<'plain' | 'with_order'>('plain');
const load = useCallback(async (options?: { silent?: boolean; withMeta?: boolean }) => {
const silent = options?.silent === true;
const withMeta = options?.withMeta !== false;
if (!silent) setLoading(true);
try {
const [list, nextSummary, nextOwners, nextShops, nextGiftNames] = await Promise.all([
huyaApi.listCollabOrders({ ...filters, page, page_size: pageSize }),
huyaApi.collabOrdersSummary(),
withMeta ? huyaApi.collabOrderOwners() : Promise.resolve(null),
withMeta ? huyaApi.listCollabShops() : Promise.resolve(null),
withMeta ? huyaApi.collabOrderGifts() : Promise.resolve(null),
]);
setRows(list.items);
setTotal(list.total);
setSummary(nextSummary);
if (nextOwners) setOwners(nextOwners);
if (nextShops) setShops(nextShops);
if (nextGiftNames) setGiftNames(nextGiftNames);
// 选中是「当前页」的概念:刷新后剔除已经不在这页上的行,避免点到别人的脏 id。
setSelectedIds((ids) => ids.filter((id) => list.items.some((row) => row.id === id)));
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setLoading(false);
}
}, [filters, page, pageSize]);
useEffect(() => { void load(); }, [load]);
const realtimeEnabled = can('huya:collab:view');
const collabModalOpen = Boolean(
createOpen || detail || statusAction || batchStatusOpen || ckTarget || ckExportOpen || shopModalOpen,
);
const handleRealtimeInvalidate = useCallback((event: CollabRealtimeEvent) => {
if (!realtimeEnabled) return;
if (collabModalOpen || hasTextInputFocus()) {
setRealtimePending(true);
return;
}
void load({ silent: true, withMeta: event.scope !== 'orders' });
}, [realtimeEnabled, collabModalOpen, load]);
const {
connected: realtimeConnected,
reconnecting: realtimeReconnecting,
} = useCollabRealtime({
enabled: realtimeEnabled,
onInvalidate: handleRealtimeInvalidate,
});
useEffect(() => {
if (!realtimePending || collabModalOpen) return undefined;
let cancelled = false;
const flush = () => {
if (cancelled || collabModalOpen || hasTextInputFocus()) return;
setRealtimePending(false);
void load({ silent: true, withMeta: 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, collabModalOpen, load]);
// WebSocket 断开时用低频轮询兜底;连接正常时完全不轮询。
useEffect(() => {
if (!realtimeEnabled || realtimeConnected) return undefined;
const timer = window.setInterval(() => {
if (document.visibilityState !== 'visible' || collabModalOpen) return;
void load({ silent: true, withMeta: false });
}, 30_000);
return () => window.clearInterval(timer);
}, [realtimeEnabled, realtimeConnected, collabModalOpen, load]);
const applyUpdated = useCallback((updated: HuyaCollabOrder) => {
setRows((list) => list.map((row) => (row.id === updated.id ? updated : row)));
setDetail((current) => (current && current.id === updated.id ? updated : current));
}, []);
const previewTimer = useRef<number | null>(null);
// 预览请求序号:贴了新 CK 之后,旧请求迟到回来的结论一律丢掉(否则会显示上一份 CK 的"有效")
const previewSeq = useRef(0);
useEffect(() => {
giftsRef.current = giftOptions;
}, [giftOptions]);
const emptyPreview = useCallback(
(message: string): HuyaCollabCkPreview => ({
ok: false, message, uid: '', yyuid: '', hy_username: '', ck_status: 'invalid',
bind_game_account: '', bind_game_role: '', bind_game_channel: '',
has_role: false, gifts: [], gifts_message: '', uid_used_by: [],
}),
[],
);
/** 跑一次预览(探针 + 可选拉礼包),只读,不写库 */
const runCkPreview = useCallback(
async (text: string, withGifts: boolean) => {
const seq = previewSeq.current;
try {
const preview = await huyaApi.previewCollabCk(text, withGifts);
if (seq !== previewSeq.current) return; // 已经贴了新的 CK,旧结论丢掉
setCkPreview(preview);
if (preview.gifts.length > 0) {
// 真拉到列表了(首次或强制刷新):更新浏览器缓存,接下来几单直接用
setGiftOptions(preview.gifts);
setGiftsCachedAt(Date.now());
writeGiftCache(preview.gifts);
}
if (preview.ok && preview.has_role) {
message.success(`CK 有效:绑定角色 ${preview.bind_game_role}`);
// 「记住的礼包」如果不在当前列表里就清掉重选;列表为空时不动它(允许手填)
const options = preview.gifts.length > 0 ? preview.gifts : giftsRef.current;
const picked = String(createForm.getFieldValue('gift_name') ?? '').trim();
if (picked && options.length > 0 && !options.some((gift) => gift.name === picked)) {
createForm.setFieldValue('gift_name', undefined);
setGiftHint(`上次选的「${picked}」不在当前的礼包列表里,已清空,请重新点选`);
} else {
setGiftHint('');
}
}
} catch (e: unknown) {
if (seq !== previewSeq.current) return;
setCkPreview(emptyPreview(getErrorMessage(e)));
} finally {
if (seq === previewSeq.current) setPreviewing(false);
}
},
[emptyPreview, createForm],
);
/**
* 输入 CK 后防抖预览;改动了就先作废旧结论(避免拿旧的"有效"去提交)。
*
* ``forceGifts=true``(点了「强制刷新礼包」)才重新去虎牙拉列表;否则**本地有数据就用本地的** ——
* 一个活动期间礼包清单基本不变,每次贴 CK 都拉一遍纯属白挨接口。
*/
const scheduleCkPreview = useCallback(
(ckText: string, immediate = false, forceGifts = false) => {
if (previewTimer.current) window.clearTimeout(previewTimer.current);
const text = (ckText || '').trim();
previewSeq.current += 1; // 无论跑不跑,都让在途请求作废
if (!text) {
setCkPreview(null);
setPreviewing(false);
return;
}
setCkPreview(null);
setPreviewing(true);
// 有缓存(giftsRef 非空)就不带 with_gifts,只有没数据或用户强制刷新才真去拉
const withGifts = forceGifts || giftsRef.current.length === 0;
setPreviewPullsGifts(withGifts);
previewTimer.current = window.setTimeout(
() => void runCkPreview(text, withGifts),
immediate ? 0 : 600,
);
},
[runCkPreview],
);
/** 勾选多行一次改状态(往前可跳步;回退要在弹窗里勾「回退」) */
const submitBatchStatus = async () => {
setBusy(true);
try {
const result = await huyaApi.batchUpdateCollabOrderStatus({
ids: selectedIds,
status: batchStatusTarget,
reason: batchStatusReason,
rollback: batchStatusRollback,
});
const failures = result.results.filter((item) => !item.ok);
if (failures.length === 0) {
message.success(`已把 ${result.updated} 条改成「${result.target_label}」`);
} else {
modal.info({
title: `改状态完成:成功 ${result.updated} / 未通过 ${result.failed}(目标「${result.target_label}」)`,
width: 640,
content: (
<Space direction="vertical" style={{ width: '100%' }}>
{failures.map((item) => (
<Text key={item.id} type="danger">
{item.order_no}:{item.message}
</Text>
))}
</Space>
),
});
}
setBatchStatusOpen(false);
setSelectedIds([]);
void load();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setBusy(false);
}
};
/** 查这一单的积分 + 兑换记录(写一条生命周期快照;已上脚本的单会按结果自动改状态) */
const queryActivityOne = async (row: HuyaCollabOrder) => {
setQuerying(true);
try {
const result = await huyaApi.queryCollabOrderActivity(row.id);
message[result.verdict === 'success' ? 'success' : result.verdict === 'failed' ? 'warning' : 'info'](
`${row.order_no}:${result.verdict_label} · ${result.message}`
+ (result.auto_status ? `;状态已自动改为「${result.auto_status_label}」` : ''),
);
void load();
if (detail?.id === row.id) {
await openDetail(await huyaApi.getCollabOrder(row.id));
}
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setQuerying(false);
}
};
/** 勾选批量查询(串行带间隔,防风控) */
const queryActivitySelected = async () => {
if (selectedIds.length === 0) return;
setQuerying(true);
try {
const result = await huyaApi.batchQueryCollabOrderActivity(selectedIds);
modal.info({
title: `查询完成:成功 ${result.success} / 失败 ${result.failed} / 无法判定 ${result.unknown}`
+ (result.auto_changed ? `(${result.auto_changed} 条按结果自动改状态)` : ''),
width: 760,
content: (
<Space direction="vertical" style={{ width: '100%' }}>
{result.results.map((item) => (
<Text key={item.order_id} type={item.verdict === 'failed' ? 'danger' : undefined}>
{item.order_no}:{item.verdict_label} · {item.message}
{item.auto_status ? `;状态已改为「${item.auto_status_label}」` : ''}
</Text>
))}
</Space>
),
});
void load();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setQuerying(false);
}
};
/** 详情里的「账号全生命周期」时间线 */
const loadMetrics = async (orderId: number) => {
setMetrics([]);
setMetricsLoading(true);
try {
setMetrics(await huyaApi.collabOrderMetrics(orderId));
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setMetricsLoading(false);
}
};
/**
* 勾选导出 CK:先把明文拉回来(后端逐行写审计),再让用户复制。
*
* 明文只活在这次弹窗的 state 里:不进 localStorage、不写 URL、
* 关掉弹窗就清掉(`closeCkExport()`)。
*/
const openCkExport = async () => {
if (selectedIds.length === 0) return;
setCkExporting(true);
try {
const result = await huyaApi.exportCollabOrderCk(selectedIds);
setCkExport(result);
setCkExportOpen(true);
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setCkExporting(false);
}
};
const closeCkExport = () => {
setCkExportOpen(false);
setCkExport(null);
};
/** 导出文本:默认每行一份纯 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]);
// 已失效 / 已停用的 CK 照样导出(用户可能就是要看它),但得在界面上点名
const ckExportRisky = useMemo(
() => (ckExport?.items ?? []).filter(
(item) => item.ck_status === 'invalid' || item.ck_status === 'disabled',
),
[ckExport],
);
const copyCkExport = async () => {
if (!ckExport?.exported) return;
try {
await copyTextToClipboard(ckExportText);
message.success(`已复制 ${ckExport.exported} 份 CK 到剪贴板`);
} catch (e: unknown) {
// 复制失败别吞:明文就在下面的框里,让人手动选中复制
message.error(`复制失败:${getErrorMessage(e)}(可以在下面的框里手动选中复制)`);
}
};
const patchOrder = async (row: HuyaCollabOrder, payload: HuyaCollabOrderUpdate) => {
try {
const updated = await huyaApi.updateCollabOrder(row.id, { ...payload, expected_version: row.version });
applyUpdated(updated);
message.success('已保存');
} catch (e: unknown) {
message.error(getErrorMessage(e));
void load();
}
};
const openDetail = async (row: HuyaCollabOrder) => {
setDetail(row);
detailForm.setFieldsValue({
order_no: row.order_no,
shop: row.shop,
owner_id: row.owner_id,
gift_name: row.gift_name,
remark: row.remark,
});
setTimeline([]);
setTimelineLoading(true);
void loadMetrics(row.id);
try {
setTimeline(await huyaApi.collabOrderTimeline(row.id));
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setTimelineLoading(false);
}
};
/** 打开「同一买家的另一单」:先按 id 取最新数据,再复用详情抽屉 */
const openSiblingOrder = async (id: number) => {
try {
await openDetail(await huyaApi.getCollabOrder(id));
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
};
const verifyOne = async (row: HuyaCollabOrder) => {
setBusy(true);
try {
const updated = await huyaApi.verifyCollabOrder(row.id);
applyUpdated(updated);
if (updated.ck_status === 'valid') {
message.success(
updated.bind_game_role
? `CK 有效:绑定角色 ${updated.bind_game_role}`
: 'CK 有效',
);
} else {
message.warning(updated.ck_check_message || 'CK 验证未通过');
}
void load();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setBusy(false);
}
};
const verifySelected = async () => {
if (selectedIds.length === 0) return;
setBusy(true);
try {
const result = await huyaApi.verifyCollabOrders(selectedIds);
const failures = result.results.filter((item) => !item.ok);
if (failures.length === 0) {
message.success(`验证完成:${result.valid} 条全部有效`);
} else {
modal.info({
title: `验证完成:有效 ${result.valid} / 未通过 ${result.failed}`,
width: 640,
content: (
<Space direction="vertical" style={{ width: '100%' }}>
{failures.map((item) => (
<Text key={item.id} type="danger">
{item.order_no}:{item.message}
</Text>
))}
</Space>
),
});
}
setSelectedIds([]);
void load();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setBusy(false);
}
};
const advance = async (row: HuyaCollabOrder) => {
if (!row.next_status) return;
setBusy(true);
try {
applyUpdated(await huyaApi.updateCollabOrderStatus(row.id, {
status: row.next_status,
expected_version: row.version,
}));
message.success(`已标记为「${STATUS_META[row.next_status].label}」`);
void load();
} catch (e: unknown) {
message.error(getErrorMessage(e));
void load();
} finally {
setBusy(false);
}
};
/**
* 单行状态弹窗的提交(三种模式共用):
* - `void`:作废(必须填原因);
* - `rollback`:回退到选定状态(`rollback=true`,会清掉后面阶段的时间戳);
* - `set`:人工往前切,允许跳步(如 待处理 → 已上脚本),不用勾回退。
*/
const submitStatusAction = async () => {
if (!statusAction) return;
const { order, mode, target } = statusAction;
const nextStatus: HuyaCollabStatus = mode === 'void' ? 'void' : (target ?? rollbackTarget);
// 服务层对「作废」一律要求原因(不管从哪个入口进来的)
if (nextStatus === 'void' && !statusReason.trim()) {
message.warning('作废必须填原因');
return;
}
setBusy(true);
try {
applyUpdated(await huyaApi.updateCollabOrderStatus(order.id, {
status: nextStatus,
reason: statusReason,
rollback: mode === 'rollback',
expected_version: order.version,
}));
message.success(
mode === 'void'
? '已作废'
: mode === 'rollback'
? '已回退'
: `已改为「${STATUS_META[nextStatus].label}」`,
);
setStatusAction(null);
setStatusReason('');
void load();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setBusy(false);
}
};
const submitCk = async (action: 'paste' | 'rebuild') => {
if (!ckTarget) return;
if (action === 'paste' && !ckTarget.text.trim()) {
message.warning('请先贴一份 CK');
return;
}
setBusy(true);
try {
const updated = await huyaApi.updateCollabOrderCk(ckTarget.order.id, {
ck_text: ckTarget.text,
action,
expected_version: ckTarget.order.version,
});
applyUpdated(updated);
message.success(action === 'rebuild' ? '已从 App 原始凭据重建' : 'CK 已保存,待验证');
setCkTarget(null);
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setBusy(false);
}
};
const toggleCkDisabled = async (row: HuyaCollabOrder) => {
const next = row.ck_status === 'disabled' ? 'unverified' : 'disabled';
setBusy(true);
try {
const updated = await huyaApi.updateCollabOrderCkStatus(row.id, {
status: next,
message: next === 'disabled' ? '人工停用' : '人工恢复,待验证',
expected_version: row.version,
});
applyUpdated(updated);
message.success(next === 'disabled' ? '已停用这份 CK' : '已恢复(待验证)');
void load();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setBusy(false);
}
};
const removeOrder = async (row: HuyaCollabOrder) => {
setBusy(true);
try {
await huyaApi.deleteCollabOrder(row.id);
message.success(`已删除订单 ${row.order_no}`);
if (detail?.id === row.id) setDetail(null);
void load();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setBusy(false);
}
};
const commitSlot = async (row: HuyaCollabOrder, slot: HuyaCollabImageSlot, keys: string[]) => {
const updated = await huyaApi.updateCollabOrderImages(row.id, {
slot, images: keys, expected_version: row.version,
});
applyUpdated(updated);
};
const uploadImage = async (row: HuyaCollabOrder, slot: HuyaCollabImageSlot, file: File) => {
setBusy(true);
try {
const uploaded = await fileApi.upload(file, 'collab');
const current = slot === 'bind_images' ? row.bind_images : row.home_images;
await commitSlot(row, slot, [...current, uploaded.object_key]);
message.success('图片已上传');
} catch (e: unknown) {
message.error(getErrorMessage(e));
void load();
} finally {
setBusy(false);
}
};
const removeImage = async (row: HuyaCollabOrder, slot: HuyaCollabImageSlot, key: string) => {
setBusy(true);
try {
const current = slot === 'bind_images' ? row.bind_images : row.home_images;
await commitSlot(row, slot, current.filter((item) => item !== key));
message.success('已从这一行移除');
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setBusy(false);
}
};
const setCoverImage = async (row: HuyaCollabOrder, slot: HuyaCollabImageSlot, key: string) => {
setBusy(true);
try {
const current = slot === 'bind_images' ? row.bind_images : row.home_images;
await commitSlot(row, slot, [key, ...current.filter((item) => item !== key)]);
message.success('已设为封面');
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setBusy(false);
}
};
const submitCreate = async () => {
if (!ckReady) {
message.warning('这份 CK 没通过验证(或读不到绑定角色),不能提交');
return;
}
try {
const values = await createForm.validateFields();
const created = await huyaApi.createCollabOrder({
order_no: String(values.order_no ?? '').trim(),
shop: String(values.shop ?? ''),
ck_text: String(values.ck_text ?? ''),
// 「绑定斗鱼号」不再录入:绑了谁由探针读(bind_game_*),人工那一列已下线
gift_name: String(values.gift_name ?? ''),
home_images: (values.home_images as string[] | undefined) ?? [],
remark: String(values.remark ?? ''),
require_verified: true, // 服务端再校验一次:CK 必须读到绑定角色才让建
});
if (created.gift_name) window.localStorage.setItem(LAST_GIFT_KEY, created.gift_name);
message.success(`已新建 ${created.order_no}`);
setCreateOpen(false);
setCkPreview(null);
setGiftHint('');
createForm.resetFields();
void load();
} catch (e: unknown) {
if (e instanceof Error) message.error(getErrorMessage(e));
}
};
const submitImport = async () => {
if (!importText.trim()) {
message.warning('请先粘贴表格内容');
return;
}
setImporting(true);
try {
const result = await huyaApi.importCollabOrders(importText);
setImportResult(result);
message.success(`导入完成:新建 ${result.created} / 更新 ${result.updated} / 跳过 ${result.skipped}`);
void load();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setImporting(false);
}
};
/** 导入弹窗里的本地预览(后端还会再解析一次,这里只是让人确认列没串) */
const importPreview = useMemo(() => {
const lines = importText.split('\n').map((line) => line.replace(/\r$/, '')).filter((line) => line.trim());
const cells = lines.map((line) => (line.includes('\t') ? line.split('\t') : line.split(',')));
return {
header: cells[0] ?? [],
rows: cells.slice(1, IMPORT_PREVIEW_ROWS + 1),
total: Math.max(0, cells.length - 1),
};
}, [importText]);
const ownerOptions = owners.map((owner) => ({ value: owner.id, label: owner.username }));
// 筛选里的店铺也是点选(含停用店铺:历史订单还在用它们)
const finished = summary ? (summary.by_status.done ?? 0) : 0;
// 礼包来源提示:这次真去虎牙拉了就说拉了,用的是缓存就把缓存时间摆出来(让人知道要不要点强制刷新)
const giftCacheLabel = giftsCachedAt ? formatTime(new Date(giftsCachedAt).toISOString()) : '';
const giftSourceText = ckPreview?.gifts?.length
? ckPreview.gifts_message
: giftOptions.length > 0
? `礼包用本地缓存(${giftOptions.length} 个${giftCacheLabel ? ` · ${giftCacheLabel}` : ''}),要更新点「强制刷新礼包」`
: (ckPreview?.gifts_message || '');
const columns: TableProps<HuyaCollabOrder>['columns'] = [
{
title: '序号', width: 70, fixed: 'left', align: 'center',
render: (_, __, index) => (
// 跨页连续:第 2 页第 1 行 = pageSize + 1
<Text type="secondary">{(page - 1) * pageSize + index + 1}</Text>
),
},
{
title: '订单编号', dataIndex: 'order_no', width: 170, fixed: 'left',
render: (value: string, row) => (
<Space direction="vertical" size={0}>
<Text copyable={{ text: value }} strong>{value}</Text>
{row.status === 'void' && <Tag color="error">已作废:{row.void_reason || '-'}</Tag>}
</Space>
),
},
{
title: '店铺', dataIndex: 'shop', width: 120,
render: (value: string, row) => (
<Popover
trigger="click"
title="选择店铺"
placement="bottomLeft"
content={
<HuyaCollabShopPicker
value={value}
shops={shops}
allowCustom={canEdit}
allowClear={false}
onChange={(next) => { if (next !== value) void patchOrder(row, { shop: next }); }}
/>
}
>
<Button size="small" type={value ? 'text' : 'link'} disabled={!canEdit}>
{value || '+ 选店铺'}
</Button>
</Popover>
),
},
{
title: '负责人', dataIndex: 'owner_id', width: 130,
render: (value: number | null, row) => (
<Select
size="small" variant="borderless" style={{ width: '100%' }}
value={value ?? undefined} options={ownerOptions} disabled={!canEdit}
placeholder="+ 指派" allowClear showSearch optionFilterProp="label"
onChange={(next: number | undefined) => void patchOrder(row, { owner_id: next ?? null })}
/>
),
},
{
title: '绑定账号 / 角色', width: 210,
render: (_, row) => (
<Space direction="vertical" size={0}>
<Text strong>{row.bind_game_account || <Text type="secondary">未读到</Text>}</Text>
{row.bind_game_role
? <Tag color="green" style={{ marginTop: 2 }}>角色 {row.bind_game_role}</Tag>
: <Text type="secondary" style={{ fontSize: 12 }}>未读到绑定角色</Text>}
{row.bind_game_channel && (
<Text type="secondary" style={{ fontSize: 12 }}>{row.bind_game_channel}</Text>
)}
{!row.bind_snapshot_at && (
<Tooltip title="点这一行的「验证 CK」就会读到绑定账号与角色">
<Text type="secondary" style={{ fontSize: 12 }}>还没验证过</Text>
</Tooltip>
)}
</Space>
),
},
{
title: '同买家', width: 120,
render: (_, row) => {
const cluster = row.buyer_cluster;
if (!cluster || cluster.others.length === 0) {
return <Text type="secondary" style={{ fontSize: 12 }}>—</Text>;
}
return (
<Popover
trigger="click"
placement="bottomLeft"
title={
<Space direction="vertical" size={0}>
<Text strong style={{ fontSize: 13 }}>同一个买家,共 {cluster.total} 单</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{cluster.key}</Text>
</Space>
}
content={
<Space direction="vertical" size={8} style={{ maxWidth: 340 }}>
{cluster.others.map((item) => (
<Space key={item.id} size={6} wrap>
<Button size="small" type="link" style={{ padding: 0 }}
onClick={() => void openSiblingOrder(item.id)}>
{item.order_no}
</Button>
<Tag color={STATUS_META[item.status]?.color}>{item.status_label || item.status}</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>
{item.gift_name || '未填礼包'} · {item.shop || '未填店铺'}
</Text>
</Space>
))}
<Text type="secondary" style={{ fontSize: 12 }}>
识别口径:探针读到的「绑定账号 + 绑定角色」相同(不是人工绑定关系)
</Text>
</Space>
}
>
<Button size="small" type="link" style={{ padding: 0 }}>
<SwapOutlined /> 还有 {cluster.others.length} 单
</Button>
</Popover>
);
},
},
{
title: '主页截图', width: 170,
render: (_, row) => (
<Space size={8} wrap>
{(() => {
const keys = row.home_images;
const urls = row.home_image_urls;
const thumbs = row.home_image_thumb_urls;
if (keys.length === 0) {
return <Text type="secondary" style={{ fontSize: 12 }}>还没传 0</Text>;
}
return (
<Space size={4}>
<Image.PreviewGroup items={urls}>
<Image src={thumbs[0] || urls[0]} width={32} height={32}
style={{ objectFit: 'cover', borderRadius: 4 }} alt="主页截图封面" />
</Image.PreviewGroup>
<Text type="secondary" style={{ fontSize: 12 }}>{keys.length} 张</Text>
</Space>
);
})()}
{row.bind_images.length > 0 && (
<Tooltip title="绑定关系现在由「验证 CK」的探针自动读,绑定图已停用;这是老数据">
<Text type="secondary" style={{ fontSize: 12 }}>历史绑定图 {row.bind_images.length} 张</Text>
</Tooltip>
)}
</Space>
),
},
{
title: '礼包 / 备注', width: 160,
render: (_, row) => (
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<EditableText value={row.gift_name} disabled={!canEdit} placeholder="+ 礼包名"
onCommit={(next) => void patchOrder(row, { gift_name: next })} />
<EditableText value={row.remark} disabled={!canEdit} placeholder="+ 备注"
onCommit={(next) => void patchOrder(row, { remark: next })} />
</Space>
),
},
{
title: '状态', dataIndex: 'status', width: 130,
render: (value: HuyaCollabStatus, row) => (
<Space direction="vertical" size={2}>
<Tag color={STATUS_META[value]?.color}>{row.status_label || STATUS_META[value]?.label || value}</Tag>
{row.next_status && canEdit && (
<Popconfirm title={`标记为「${STATUS_META[row.next_status].label}」?`}
onConfirm={() => void advance(row)} disabled={busy}>
<Button size="small" type="link" style={{ padding: 0 }}>
→ {STATUS_META[row.next_status].label}
</Button>
</Popconfirm>
)}
</Space>
),
},
{
title: '虎牙号', width: 160,
render: (_, row) => (
<Space direction="vertical" size={0}>
<Text>{row.uid || <Text type="secondary">未贴 CK</Text>}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{row.hy_username || '-'}</Text>
</Space>
),
},
{
title: 'CK 状态', dataIndex: 'ck_status', width: 170,
render: (value: string, row) => (
<Space direction="vertical" size={2} style={{ width: '100%' }}>
<Tag color={CK_META[value]?.color}>{row.ck_status_label || CK_META[value]?.label || value}</Tag>
{row.ck_check_message && (
// 探针结论可能很长(账号 / 角色 / 平台·区服全在里面):**必须截断**,
// 否则长文案会顶穿这一列压到「最后改动」上(2026-10-16 用户反馈)。全文放 Tooltip。
<Tooltip title={`${row.ck_check_message}(${formatTime(row.ck_checked_at)})`}>
<Text
type="secondary"
style={{
display: 'block',
fontSize: 12,
maxWidth: 150,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{row.ck_check_message}
</Text>
</Tooltip>
)}
</Space>
),
},
{
title: '积分', width: 130,
render: (_, row) => {
if (!row.last_metric_at) {
return <Text type="secondary" style={{ fontSize: 12 }}>还没查</Text>;
}
const verdict = VERDICT_META[row.last_verdict];
return (
<Tooltip title={`${row.last_metric_message || ''}(${formatTime(row.last_metric_at)})`}>
<Space direction="vertical" size={0}>
<Text strong>{row.last_score ?? '-'}</Text>
{verdict && <Tag color={verdict.color} style={{ marginTop: 2 }}>{verdict.label}</Tag>}
<Text type="secondary" style={{ fontSize: 12 }}>{formatTime(row.last_metric_at)}</Text>
</Space>
</Tooltip>
);
},
},
{
title: '最后改动', width: 160,
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: 150, fixed: 'right', align: 'center',
render: (_, row) => (
<Space size={4}>
<Button size="small" icon={<EditOutlined />} onClick={() => void openDetail(row)}>详情</Button>
{canEdit && (
<Dropdown
menu={{
items: [
{ key: 'verify', label: '验证 CK(只读探针)', icon: <SafetyCertificateOutlined /> },
{ key: 'activity', label: '查积分 / 兑换记录', icon: <SyncOutlined />, disabled: !row.has_cookie },
{ key: 'ck', label: '贴 / 换 CK', icon: <LinkOutlined /> },
{ key: 'rebuild', label: '从 App 原始凭据重建', icon: <ReloadOutlined />, disabled: !row.has_raw_cookie },
{ key: 'disable', label: row.ck_status === 'disabled' ? '恢复这份 CK' : '停用这份 CK', icon: <StopOutlined /> },
{ type: 'divider' },
{ key: 'set', label: '改状态…', icon: <CheckCircleOutlined /> },
{ key: 'rollback', label: '回退状态…', icon: <UndoOutlined /> },
{ key: 'void', label: '作废…', icon: <StopOutlined />, danger: true },
...(canManage
? [{ key: 'delete', label: '删除这一行', icon: <DeleteOutlined />, danger: true }]
: []),
],
onClick: ({ key }) => {
if (key === 'verify') void verifyOne(row);
if (key === 'activity') void queryActivityOne(row);
if (key === 'ck') setCkTarget({ order: row, text: '' });
if (key === 'rebuild') setCkTarget({ order: row, text: '' });
if (key === 'disable') void toggleCkDisabled(row);
if (key === 'set') {
setRollbackTarget(row.next_status ?? row.status);
setStatusReason('');
setStatusAction({ order: row, mode: 'set' });
}
if (key === 'rollback') {
setRollbackTarget('pending');
setStatusReason('');
setStatusAction({ order: row, mode: 'rollback' });
}
if (key === 'void') {
setStatusReason('');
setStatusAction({ order: row, mode: 'void' });
}
if (key === 'delete') {
modal.confirm({
title: `删除订单 ${row.order_no}?`,
content: '只删这一行,对象存储里的图不会立刻删除(交给孤儿清理脚本)。',
okButtonProps: { danger: true },
onOk: () => removeOrder(row),
});
}
},
}}
>
<Button size="small" icon={<SwapOutlined />} disabled={busy} />
</Dropdown>
)}
</Space>
),
},
];
return (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{/* 卡片用 flex 平分:状态多了一个「抢购失败」,固定 span=4 会换行参差不齐 */}
<Row gutter={12}>
<Col flex="1"><Card size="small"><Statistic title="全部" value={summary?.total ?? 0} /></Card></Col>
<Col flex="1">
<Card size="small">
<Statistic title="待处理" value={summary?.by_status.pending ?? 0} valueStyle={{ color: '#faad14' }} />
</Card>
</Col>
<Col flex="1">
<Card size="small">
<Statistic title={STATUS_META.bound.label} value={summary?.by_status.bound ?? 0}
valueStyle={{ color: '#1677ff' }} />
</Card>
</Col>
<Col flex="1">
<Card size="small">
<Statistic title="已充值 / 已上脚本"
value={(summary?.by_status.recharged ?? 0) + (summary?.by_status.scripted ?? 0)}
valueStyle={{ color: '#722ed1' }} />
</Card>
</Col>
<Col flex="1">
<Card size="small">
<Statistic title={STATUS_META.done.label} value={finished} valueStyle={{ color: '#52c41a' }} />
<Progress percent={summary?.total ? Math.round((finished / summary.total) * 100) : 0} size="small" />
</Card>
</Col>
<Col flex="1">
<Card size="small">
<Statistic title={STATUS_META.failed.label} value={summary?.by_status.failed ?? 0}
valueStyle={{ color: '#ff4d4f' }} />
</Card>
</Col>
<Col flex="1">
<Card size="small">
<Statistic title="CK 失效" value={summary?.ck_invalid ?? 0} valueStyle={{ color: '#ff4d4f' }} />
</Card>
</Col>
</Row>
<Card size="small">
{/*
固定两行:第一行永远是筛选,第二行永远是动作按钮组 —— 不要用「筛选 + 动作同一个 wrap」
那种排法:不勾选时两组挤成一行,勾选后按钮多了「(N)」就会被顶到第二行,
列表跟着往下跳(2026-10-16 用户反馈「不要选中后两行」)。
*/}
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Space wrap style={{ width: '100%' }}>
<Space size={4}>
<Text type="secondary">店铺</Text>
<HuyaCollabShopPicker
value={filters.shop || ''}
shops={shops}
includeInactive
allowCustom={false}
clearLabel="全部"
onChange={(value) => { setPage(1); setFilters((prev) => ({ ...prev, shop: value })); }}
/>
</Space>
<Select allowClear showSearch optionFilterProp="label" style={{ width: 150 }} placeholder="负责人"
options={ownerOptions}
value={filters.owner_id ?? undefined}
onChange={(value?: number) => { setPage(1); setFilters((prev) => ({ ...prev, owner_id: value ?? null })); }} />
<Select allowClear style={{ width: 140 }} placeholder="业务状态"
options={Object.entries(STATUS_META).map(([key, meta]) => ({ value: key, label: meta.label }))}
value={filters.status || undefined}
onChange={(value?: string) => { setPage(1); setFilters((prev) => ({ ...prev, status: value })); }} />
<Select allowClear style={{ width: 130 }} placeholder="CK 状态"
options={Object.entries(CK_META).map(([key, meta]) => ({ value: key, label: meta.label }))}
value={filters.ck_status || undefined}
onChange={(value?: string) => { setPage(1); setFilters((prev) => ({ ...prev, ck_status: value })); }} />
<Select allowClear showSearch optionFilterProp="label" style={{ width: 180 }} placeholder="皮肤 / 礼包"
options={giftNames.map((name) => ({ value: name, label: name }))}
value={filters.gift_name || undefined}
onChange={(value?: string) => { setPage(1); setFilters((prev) => ({ ...prev, gift_name: value })); }} />
<Input.Search allowClear style={{ width: 340 }}
placeholder="搜订单号 / 店铺 / 绑定账号 / 角色 / 虎牙号 / 礼包 / 备注"
onSearch={(value) => { setPage(1); setFilters((prev) => ({ ...prev, keyword: value })); }} />
<Space size={4}>
<Text type="secondary">显示已作废</Text>
<Switch size="small" checked={!!filters.include_void}
onChange={(checked) => { setPage(1); setFilters((prev) => ({ ...prev, include_void: checked })); }} />
</Space>
</Space>
{/* 第二行:动作(固定在这一行;勾选后加「(N)」只是按钮变宽,不会把列表顶下去) */}
<Space wrap style={{ width: '100%' }}>
<Button icon={<ReloadOutlined />} onClick={() => void load()} disabled={loading}>刷新</Button>
{realtimeEnabled && (
<Tooltip title={realtimeConnected ? '协作台账实时同步已连接' : '实时连接已断开,正在自动重连'}>
<Tag color={realtimeConnected ? 'success' : realtimeReconnecting ? 'warning' : 'default'}>
{realtimeConnected ? '实时同步' : realtimeReconnecting ? '实时重连中' : '实时离线'}
</Tag>
</Tooltip>
)}
{realtimePending && <Tag color="processing">有更新待同步</Tag>}
{canEdit && (
<Button icon={<SafetyCertificateOutlined />} disabled={selectedIds.length === 0 || busy}
onClick={() => void verifySelected()}>
批量验证 CK{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
)}
{canEdit && (
// 勾选多行一次改状态(往前可跳步;回退在弹窗里勾「回退」)
<Dropdown
disabled={selectedIds.length === 0 || busy}
menu={{
items: STATUS_ORDER_OPTIONS.map((option) => ({
key: option.value,
label: option.label,
})),
onClick: ({ key }) => {
setBatchStatusTarget(key as HuyaCollabStatus);
setBatchStatusReason('');
setBatchStatusRollback(false);
setBatchStatusOpen(true);
},
}}
>
<Button icon={<SwapOutlined />} disabled={selectedIds.length === 0 || busy}>
批量改状态{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
</Dropdown>
)}
{canEdit && (
<Button icon={<SyncOutlined />} disabled={selectedIds.length === 0 || busy}
loading={querying}
onClick={() => void queryActivitySelected()}>
批量查积分/兑换{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
)}
{canExportCk && (
// 导出 CK 到剪贴板(勾选几行就导几行;明文只在这个弹窗里出现)
<Button icon={<CopyOutlined />} disabled={selectedIds.length === 0 || busy}
loading={ckExporting}
onClick={() => void openCkExport()}>
导出 CK{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
)}
{canEdit && (
<Button icon={<FileExcelOutlined />} onClick={() => { setImportResult(null); setImportOpen(true); }}>
粘贴导入
</Button>
)}
{canEdit && (
<Button icon={<ShopOutlined />} onClick={() => setShopModalOpen(true)}>店铺管理</Button>
)}
{canEdit && (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setCkPreview(null);
setPreviewing(false);
setGiftHint('');
previewSeq.current += 1; // 丢掉在途的预览结论
createForm.resetFields();
// 记住最近选的礼包:同一个礼包连开几单时不用反复选
const lastGift = window.localStorage.getItem(LAST_GIFT_KEY);
if (lastGift) createForm.setFieldsValue({ gift_name: lastGift });
setCreateOpen(true);
}}
>
新建
</Button>
)}
</Space>
</Space>
</Card>
<Table<HuyaCollabOrder>
rowKey="id"
size="small"
loading={loading}
columns={columns}
dataSource={rows}
scroll={{ x: 1800, y: 'calc(100vh - 460px)' }}
rowSelection={canEdit ? {
selectedRowKeys: selectedIds,
onChange: (keys) => setSelectedIds(keys.map(Number)),
} : undefined}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (count) => `共 ${count} 条`,
onChange: (nextPage, nextPageSize) => { setPage(nextPage); setPageSize(nextPageSize); },
}}
/>
<Drawer
width={680}
open={!!detail}
onClose={() => setDetail(null)}
title={detail ? `订单 ${detail.order_no}` : ''}
destroyOnHidden
>
{detail && (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Descriptions size="small" column={2} bordered
items={[
{ key: 'uid', label: '虎牙号', children: detail.uid || '-' },
{ key: 'username', label: '登录名 / 昵称', children: `${detail.hy_username || '-'} / ${detail.nickname || '-'}` },
{ key: 'ck', label: 'CK 状态',
children: (
<Space>
<Tag color={CK_META[detail.ck_status]?.color}>{detail.ck_status_label}</Tag>
{detail.has_cookie ? <Tag color="geekblue">已存 CK</Tag> : <Tag>无 CK</Tag>}
{detail.has_raw_cookie && <Tag color="cyan">含 App 原始凭据</Tag>}
</Space>
) },
{ key: 'checked', label: '最近验证', children: formatTime(detail.ck_checked_at) },
{ key: 'game_account', label: '绑定账号(探针)', children: detail.bind_game_account || '-' },
{ key: 'game_role', label: '绑定角色(探针)', children: detail.bind_game_role || '-' },
{ key: 'game_channel', label: '平台 / 区服', children: detail.bind_game_channel || '-' },
{ key: 'snapshot_at', label: '绑定快照时间', children: formatTime(detail.bind_snapshot_at) },
{ key: 'status', label: '业务状态',
children: <Tag color={STATUS_META[detail.status]?.color}>{detail.status_label}</Tag> },
{ key: 'bound_at', label: '绑定 / 充值时间',
children: `${formatTime(detail.bound_at)} / ${formatTime(detail.recharged_at)}` },
{ key: 'scripted_at', label: '上脚本 / 归档时间',
children: `${formatTime(detail.scripted_at)} / ${formatTime(detail.done_at)}` },
{ key: 'updated', label: '最后改动',
children: `${detail.updated_by_name || '-'} · ${formatTime(detail.updated_at)}` },
// 老数据可能还留着人工填的「绑定斗鱼号」:只读展示,不再给人填(绑定关系看探针)
...(detail.bind_douyu_account
? [{
key: 'bind_douyu_account',
label: '绑定斗鱼号(历史字段,不再录入)',
children: detail.bind_douyu_account,
}]
: []),
]} />
{detail.ck_check_message && (
<Text type="secondary">探针结论:{detail.ck_check_message}</Text>
)}
{detail.buyer_cluster && detail.buyer_cluster.others.length > 0 && (
// 同一个买家(绑定账号 + 绑定角色相同)名下的其它单:软识别,点一下直接切过去
<>
<Divider style={{ margin: '4px 0' }}>
<Text type="secondary" style={{ fontSize: 12 }}>
同一个买家的其它单({detail.buyer_cluster.key} · 共 {detail.buyer_cluster.total} 单)
</Text>
</Divider>
<Space direction="vertical" size={6} style={{ width: '100%' }}>
{detail.buyer_cluster.others.map((item) => (
<Space key={item.id} size={8} wrap>
<Button size="small" onClick={() => void openSiblingOrder(item.id)}>
{item.order_no}
</Button>
<Tag color={STATUS_META[item.status]?.color}>{item.status_label || item.status}</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>
{item.gift_name || '未填礼包'} · {item.shop || '未填店铺'}
{item.uid ? ` · 虎牙号 ${item.uid}` : ''}
</Text>
</Space>
))}
</Space>
</>
)}
{canEdit && (
<>
<Divider style={{ margin: '4px 0' }}>可编辑字段</Divider>
<Form
form={detailForm}
layout="vertical"
size="small"
initialValues={{
order_no: detail.order_no, shop: detail.shop, owner_id: detail.owner_id,
gift_name: detail.gift_name, remark: detail.remark,
}}
onFinish={(values) => void patchOrder(detail, values)}
>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="order_no" label="订单编号" rules={[{ required: true, message: '不能为空' }]}>
<Input maxLength={64} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="shop" label="店铺" tooltip="点一下选;点「新店」可以直接输个新名字">
<HuyaCollabShopPicker shops={shops} allowClear={false} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="owner_id" label="负责人">
<Select allowClear showSearch optionFilterProp="label" options={ownerOptions} placeholder="未指派" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="gift_name" label="礼包名"><Input maxLength={128} /></Form.Item>
</Col>
<Col span={24}>
<Form.Item name="remark" label="备注"><Input.TextArea rows={2} maxLength={512} /></Form.Item>
</Col>
</Row>
<Space>
<Button type="primary" htmlType="submit" disabled={busy}>保存字段</Button>
<Button icon={<SafetyCertificateOutlined />} disabled={busy || !detail.has_cookie}
onClick={() => void verifyOne(detail)}>验证 CK</Button>
<Button icon={<LinkOutlined />} onClick={() => setCkTarget({ order: detail, text: '' })}>贴 / 换 CK</Button>
<Button icon={<ReloadOutlined />} disabled={!detail.has_raw_cookie}
onClick={() => setCkTarget({ order: detail, text: '' })}>重建 CK</Button>
<Button icon={<StopOutlined />} onClick={() => void toggleCkDisabled(detail)}>
{detail.ck_status === 'disabled' ? '恢复 CK' : '停用 CK'}
</Button>
</Space>
</Form>
</>
)}
<Divider style={{ margin: '4px 0' }}>主页截图(第一张是封面)</Divider>
<ImageSlotEditor
order={detail}
slot="home_images"
canEdit={canEdit}
busy={busy}
onUpload={(targetSlot, file) => void uploadImage(detail, targetSlot, file)}
onRemove={(targetSlot, key) => void removeImage(detail, targetSlot, key)}
onSetCover={(targetSlot, key) => void setCoverImage(detail, targetSlot, key)}
/>
{detail.bind_images.length > 0 && (
// 老数据只读展示:绑定图已停用,但已经传过的图不该从界面上凭空消失
<>
<Divider style={{ margin: '4px 0' }}>
<Text type="secondary" style={{ fontSize: 12 }}>
历史绑定图(只读,已停用 —— 绑定关系改由「验证 CK」的探针读取)
</Text>
</Divider>
<Image.PreviewGroup items={detail.bind_image_urls}>
<Space wrap>
{detail.bind_images.map((key, index) => (
<Image
key={key}
src={detail.bind_image_thumb_urls[index] || detail.bind_image_urls[index]}
width={64}
height={64}
style={{ objectFit: 'cover', borderRadius: 4 }}
alt={`历史绑定图${index + 1}`}
/>
))}
</Space>
</Image.PreviewGroup>
</>
)}
{canEdit && (
<Space wrap>
{detail.next_status && (
<Button icon={<CheckCircleOutlined />} disabled={busy}
onClick={() => void advance(detail)}>
标记为「{STATUS_META[detail.next_status].label}」
</Button>
)}
<Button icon={<SwapOutlined />} disabled={busy}
onClick={() => {
// 默认给「下一步」,终点态(抢购成功 / 已作废 / 抢购失败)退回当前状态,让人自己挑
setRollbackTarget(detail.next_status ?? detail.status);
setStatusReason('');
setStatusAction({ order: detail, mode: 'set' });
}}>
改状态…
</Button>
<Button icon={<UndoOutlined />} disabled={busy}
onClick={() => { setRollbackTarget('pending'); setStatusReason(''); setStatusAction({ order: detail, mode: 'rollback' }); }}>
回退状态
</Button>
{detail.status !== 'void' && (
<Button danger icon={<StopOutlined />} disabled={busy}
onClick={() => { setStatusReason(''); setStatusAction({ order: detail, mode: 'void' }); }}>
作废
</Button>
)}
</Space>
)}
<Divider style={{ margin: '4px 0' }}>操作时间线</Divider>
{timeline.length === 0 && !timelineLoading && <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有操作记录" />}
<Timeline
items={timeline.map((item) => ({
color: item.success === false ? 'red' : 'blue',
children: (
<Space direction="vertical" size={0}>
<Text strong>
{ACTION_LABELS[item.action] || item.action}
<Text type="secondary" style={{ fontWeight: 400 }}> · {item.username || '-'}</Text>
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{formatTime(item.created_at)}</Text>
{item.detail && (
<Paragraph style={{ marginBottom: 0 }} ellipsis={{ rows: 2, expandable: true, symbol: '展开' }}>
<Text code style={{ fontSize: 12 }}>{item.detail}</Text>
</Paragraph>
)}
</Space>
),
}))}
/>
<Divider style={{ margin: '4px 0' }}>账号全生命周期(积分 / 兑换记录)</Divider>
<Space wrap size={8}>
{canEdit && (
<Button icon={<SyncOutlined />} disabled={busy || querying || !detail.has_cookie}
loading={querying}
onClick={() => void queryActivityOne(detail)}>
查一次积分 / 兑换记录
</Button>
)}
<Text type="secondary" style={{ fontSize: 12 }}>
每次查询都会追加一条快照(只追加、不修改);「已上脚本」的单会按结论自动改成「抢购成功」或「抢购失败」。
</Text>
</Space>
{!detail.has_cookie && (
<Text type="warning" style={{ fontSize: 12 }}>这单还没贴 CK,查不了积分 / 兑换记录。</Text>
)}
{metrics.length === 0 && !metricsLoading && (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有查询快照" />
)}
<Timeline
pending={metricsLoading ? '查询中…' : undefined}
items={metrics.map((item) => ({
color: item.verdict === 'success' ? 'green' : item.verdict === 'failed' ? 'red' : 'gray',
children: (
<Space direction="vertical" size={2} style={{ width: '100%' }}>
<Space size={6} wrap>
<Tag color={VERDICT_META[item.verdict]?.color}>{item.verdict_label}</Tag>
<Text strong>积分 {item.score ?? '-'}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
获得 {item.gained_score ?? '-'} / 消耗 {item.used_score ?? '-'}
{' · '}兑换记录 {item.records_total} 条(命中 {item.records_matched})
</Text>
</Space>
{item.message && (
<Text type={item.verdict === 'failed' ? 'danger' : 'secondary'} style={{ fontSize: 12 }}>
{item.message}
</Text>
)}
{item.matched_preview.length > 0 && (
<Space direction="vertical" size={0}>
{item.matched_preview.map((matched, index) => (
<Text key={`${matched.order_id}-${index}`} style={{ fontSize: 12 }}>
<Text code style={{ fontSize: 12 }}>{matched.prize_name || '(无礼包名)'}</Text>
{' '}
<Text type="secondary" style={{ fontSize: 12 }}>
{matched.status_label}
{matched.order_id ? ` · 兑换单 ${matched.order_id}` : ''}
{matched.exchange_time ? ` · ${formatTime(new Date(matched.exchange_time * 1000).toISOString())}` : ''}
</Text>
</Text>
))}
</Space>
)}
<Text type="secondary" style={{ fontSize: 12 }}>
{formatTime(item.created_at)} · {item.created_by_name || '系统'}
{' · '}{item.source === 'batch' ? '批量查询' : '手动查询'}
</Text>
</Space>
),
}))}
/>
</Space>
)}
</Drawer>
<Modal
title="新建协作单"
open={createOpen}
width={920}
onCancel={() => setCreateOpen(false)}
onOk={() => void submitCreate()}
confirmLoading={busy}
okText="新建"
okButtonProps={{ disabled: !ckReady || previewing }}
// 内容比一屏高:给个滚动区,别让底部按钮被顶出可视范围
styles={{ body: { maxHeight: '78vh', overflowY: 'auto', paddingRight: 12 } }}
destroyOnHidden
>
<Form
className="huya-collab-create"
form={createForm}
layout="vertical"
size="middle"
onValuesChange={(changed: Record<string, unknown>) => {
if (typeof changed.ck_text === 'string') scheduleCkPreview(changed.ck_text);
}}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="order_no" label="订单编号" rules={[{ required: true, message: '订单号不能为空' }]}>
<Input maxLength={64} placeholder="Excel B 列" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="负责人" tooltip="默认就是当前登录账号;要转给别人,在列表里改这一行的负责人">
<Text>{user?.username || '-'}(默认)</Text>
</Form.Item>
</Col>
</Row>
{/* 店铺独占一行:按钮要够宽才不会被挤到第二行(「新店」跟在同一行后面) */}
<Form.Item
name="shop"
label="店铺"
tooltip="必填:点一下选;点「新店」可以直接输个新名字(会自动进店铺字典)"
rules={[{ required: true, message: '请点一下选店铺' }]}
>
<HuyaCollabShopPicker shops={shops} allowClear={false} size="large" />
</Form.Item>
<Form.Item
name="ck_text"
label="外部 CK"
tooltip="必填:支持「纯CK」或「账号----密码----CK」。贴进来即自动探针验证 + 读绑定角色;礼包列表本地有缓存就不再重复拉,要更新点旁边的「强制刷新礼包」"
rules={[{ required: true, message: '请贴一份外部 CK' }]}
>
{/* 高度随内容走:短 CK 就 3 行,别固定 5 行把弹窗撑高 */}
<Input.TextArea
autoSize={{ minRows: 3, maxRows: 8 }}
placeholder="贴进来即自动验证 + 读绑定角色(礼包有缓存就不再拉)"
/>
</Form.Item>
{(previewing || ckPreview) && (
<div style={{ marginBottom: 12 }}>
{previewing ? (
<Alert
type="info"
showIcon
message={previewPullsGifts
? '正在验证 CK 并拉取礼包…'
: '正在验证 CK + 读绑定角色…(礼包用本地缓存,不重复拉)'}
/>
) : ckPreview?.ok && ckPreview.has_role ? (
<Alert
type="success"
showIcon
message={`CK 有效 · 绑定角色 ${ckPreview.bind_game_role}`}
description={
<>
<div>
{`订单编号里的账号:${ckPreview.bind_game_account || '-'}`
+ `(${ckPreview.bind_game_channel || '-'})`
+ `;虎牙号 ${ckPreview.uid}`
+ (giftSourceText ? `;${giftSourceText}` : '')}
</div>
{ckPreview.uid_used_by.length > 0 && (
// 放开 uid 唯一性后同一份 CK 可以服务多单,这里只提示已经挂在哪些单上
<div style={{ marginTop: 4 }}>
{`这份 CK 的虎牙号已经挂在:${ckPreview.uid_used_by
.map((item) => `${item.order_no}(${item.status_label || item.status})`)
.join('、')} —— 同一个号服务多单是允许的,照常提交`}
</div>
)}
</>
}
/>
) : (
<Alert
type="error"
showIcon
message="这份 CK 不能用,禁止提交"
description={`${ckPreview?.message || ''}${ckPreview?.ok && !ckPreview.has_role ? '(探针通了但读不到绑定角色)' : ''}`}
/>
)}
</div>
)}
<Row gutter={16}>
<Col span={14}>
<Form.Item
name="gift_name"
label="礼包名(用户购买的物品)"
tooltip="必填:先贴有效 CK,再从礼包列表里点选(列表有本地缓存,不用每次重拉;拉不到时可以直接手输)"
rules={[{ required: true, message: '请选一个礼包' }]}
extra={giftHint || (giftsCachedAt && giftOptions.length > 0
? `礼包列表缓存于 ${giftCacheLabel}(${giftOptions.length} 个)`
: undefined)}
validateStatus={giftHint ? 'warning' : undefined}
>
<AutoComplete
allowClear
placeholder={ckReady ? '从礼包里点选(可直接输)' : '先贴一份有效 CK'}
options={giftOptions.map((gift) => ({
value: gift.name,
label: [
gift.category_name ? `[${gift.category_name}]` : '',
gift.name,
gift.price ? `· ${gift.price} 积分` : '',
gift.remain_text ? `· 剩 ${gift.remain_text}` : '',
].filter(Boolean).join(' '),
}))}
filterOption={(input, option) =>
String(option?.value ?? '').toLowerCase().includes(input.toLowerCase())}
onChange={(value: string) => {
// 记住最近选的礼包:连开几单同一个礼包就不用反复点
setGiftHint('');
if (value) window.localStorage.setItem(LAST_GIFT_KEY, value);
}}
/>
</Form.Item>
</Col>
<Col span={10}>
<Form.Item
label="礼包列表"
tooltip="平时用本地缓存那份(一个活动期间基本不变);换了活动 / 想拿最新库存,点这里强制去虎牙重拉"
>
<Button
icon={<ReloadOutlined />}
disabled={!ckReady || previewing}
onClick={() => scheduleCkPreview(createForm.getFieldValue('ck_text') || '', true, true)}
>
强制刷新礼包{giftOptions.length ? `(${giftOptions.length})` : ''}
</Button>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={14}>
<Form.Item
name="home_images"
label="主页截图(必填,第一张作封面)"
tooltip="支持点击选择、拖拽或 Ctrl/Cmd+V 粘贴上传;第一张作封面,绑定图不用再传"
rules={[{
validator: (_rule, value: string[] | undefined) => (
value && value.length > 0
? Promise.resolve()
: Promise.reject(new Error('请至少上传 1 张主页截图'))
),
}]}
>
<HuyaCollabHomeImageUploader max={MAX_IMAGES_PER_SLOT} />
</Form.Item>
</Col>
<Col span={10}>
<Form.Item name="remark" label="备注(可选)">
<Input.TextArea rows={2} maxLength={512} />
</Form.Item>
</Col>
</Row>
</Form>
</Modal>
<Modal
title="粘贴导入(Excel 复制出来的 TSV)"
open={importOpen}
width={900}
onCancel={() => setImportOpen(false)}
onOk={() => void submitImport()}
confirmLoading={importing}
okText="导入"
destroyOnHidden
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Text type="secondary">
列顺序:店铺 / 订单编号 / 负责人 / 绑定斗鱼号 / 礼包 / 备注(首行表头可留)。
已存在的订单号按「更新」处理;单行出错不影响其他行。
</Text>
<Input.TextArea rows={8} value={importText} onChange={(event) => setImportText(event.target.value)}
placeholder={'佳佳\t2627501604891357\t张三\t用户123\t不朽伊人大礼包\t熟人'} />
{importPreview.rows.length > 0 && (
<>
<Text type="secondary">
预览(前 {importPreview.rows.length} 行,共 {importPreview.total} 行)
{importPreview.header.length > 1 ? '' : ':未识别到表头,按固定列序解析'}
</Text>
<Table
size="small"
rowKey={(_, index) => String(index)}
pagination={false}
scroll={{ x: 700, y: 200 }}
dataSource={importPreview.rows.map((cells, index) => ({ index, cells }))}
columns={['店铺', '订单编号', '负责人', '绑定斗鱼号', '礼包', '备注'].map((title, columnIndex) => ({
title, dataIndex: ['cells', columnIndex], width: 120, ellipsis: true,
render: (value: string | undefined) => value || <Text type="secondary">-</Text>,
}))}
/>
</>
)}
{importResult && (
<Space direction="vertical" size={2} style={{ width: '100%' }}>
<Text>
结果:新建 {importResult.created} / 更新 {importResult.updated} / 跳过 {importResult.skipped}
</Text>
{importResult.errors.map((error) => (
<Text key={`${error.line}-${error.order_no}`} type="danger">
第 {error.line} 行({error.order_no || '无订单号'}):{error.message}
</Text>
))}
</Space>
)}
</Space>
</Modal>
<Modal
title={ckTarget?.order ? `CK:订单 ${ckTarget.order.order_no}` : 'CK'}
open={!!ckTarget}
onCancel={() => setCkTarget(null)}
footer={[
<Button key="cancel" onClick={() => setCkTarget(null)}>取消</Button>,
<Button key="rebuild" icon={<ReloadOutlined />} disabled={busy || !ckTarget?.order.has_raw_cookie}
onClick={() => void submitCk('rebuild')}>用 App 原始凭据重建</Button>,
<Button key="paste" type="primary" disabled={busy} onClick={() => void submitCk('paste')}>保存并待验证</Button>,
]}
destroyOnHidden
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Text type="secondary">
贴一份新的外部 CK:支持「纯 CK」或「账号----密码----CK」。保存后状态回到「未验证」,
点「验证 CK」用只读探针确认(探针通不通由服务端说了算)。
</Text>
<Input.TextArea rows={6} value={ckTarget?.text ?? ''} autoFocus
onChange={(event) => setCkTarget((current) => (current ? { ...current, text: event.target.value } : current))}
placeholder="udb_uid=...;udb_passport=...;udb_cred=..." />
</Space>
</Modal>
<Modal
title={statusAction?.mode === 'void'
? `作废订单 ${statusAction.order.order_no}`
: statusAction?.mode === 'set'
? `改状态:订单 ${statusAction.order.order_no}`
: `回退订单 ${statusAction?.order.order_no ?? ''}`}
open={!!statusAction}
onCancel={() => setStatusAction(null)}
onOk={() => void submitStatusAction()}
confirmLoading={busy}
okButtonProps={{ danger: statusAction?.mode === 'void' }}
okText={statusAction?.mode === 'void' ? '作废' : statusAction?.mode === 'set' ? '改状态' : '回退'}
destroyOnHidden
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{statusAction && statusAction.mode !== 'void' && (
<Select
style={{ width: '100%' }}
value={rollbackTarget}
onChange={(value: HuyaCollabStatus) => setRollbackTarget(value)}
options={statusAction.mode === 'set' ? STATUS_ORDER_OPTIONS : FLOW_STATUS_OPTIONS}
/>
)}
<Input.TextArea rows={2} value={statusReason} onChange={(event) => setStatusReason(event.target.value)}
placeholder={
statusAction?.mode === 'void'
? '作废原因(必填)'
: rollbackTarget === 'void'
? '作废原因(必填)'
: statusAction?.mode === 'rollback'
? '回退说明(可选)'
: '改状态说明(可选)'
} />
{statusAction?.mode === 'set' && (
<Text type="secondary">
人工切换允许往前跳步(比如 待处理 → 已上脚本),不用一步一步点;
切到「已确认绑定」要求先有绑定角色 + 主页截图,切到「已作废」必须填原因。
</Text>
)}
{statusAction?.mode === 'rollback' && (
<Text type="secondary">回退会清掉后面阶段的时间戳,并在审计里留痕。</Text>
)}
</Space>
</Modal>
<Modal
title={`批量改状态(已选 ${selectedIds.length} 条)`}
open={batchStatusOpen}
onCancel={() => setBatchStatusOpen(false)}
onOk={() => void submitBatchStatus()}
confirmLoading={busy}
okText={`改成「${STATUS_META[batchStatusTarget]?.label ?? ''}」`}
destroyOnHidden
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Select
style={{ width: '100%' }}
value={batchStatusTarget}
onChange={(value: HuyaCollabStatus) => setBatchStatusTarget(value)}
options={STATUS_ORDER_OPTIONS}
/>
<Input.TextArea rows={2} value={batchStatusReason}
onChange={(event) => setBatchStatusReason(event.target.value)}
placeholder={batchStatusTarget === 'void' ? '作废原因(必填)' : '改状态说明(可选)'} />
<Checkbox checked={batchStatusRollback} onChange={(event) => setBatchStatusRollback(event.target.checked)}>
回退(目标不是「往前」时必须勾选,比如 已上脚本 → 待处理)
</Checkbox>
<Text type="secondary">
逐行独立提交:某一行前置校验没过(例如没主页截图进不了「已确认绑定」)只跳过那一行,
结果会在弹窗里逐条列出来。一次最多 50 行。
</Text>
</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 本体。"
/>
{ckExport && ckExport.skipped.length > 0 && (
<Text type="warning" style={{ fontSize: 12 }}>
跳过 {ckExport.skipped.length} 条:
{ckExport.skipped.map((item) => `${item.order_no}(${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})`).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>
<HuyaCollabShopModal
open={shopModalOpen}
shops={shops}
canEdit={canEdit}
canManage={canManage}
onClose={() => setShopModalOpen(false)}
onChanged={() => void load()}
/>
</Space>
);
}