- 后端新增进程内事件总线与 /api/huya/external-orders/ws\n- 所有写接口成功后发布轻量失效事件\n- 前端自动重连、静默刷新、断线兜底轮询
2154 lines
92 KiB
TypeScript
2154 lines
92 KiB
TypeScript
/**
|
||
* 虎牙外部单协作台账(`/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>
|
||
);
|
||
}
|