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

3817 lines
162 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 { useLocation, useNavigate } from 'react-router-dom';
import {
Alert, App, Badge, Button, Card, Checkbox, Col, Descriptions, Divider, Drawer, Dropdown,
Empty, Form, Image, Input, InputNumber, Modal, Popconfirm, Popover, Progress, Radio, Row, Select, Space, Statistic,
Switch, Table, Tag, Timeline, Tooltip, Typography, Upload,
} from 'antd';
import type { TableProps } from 'antd';
import {
AuditOutlined, CheckCircleOutlined, CloudUploadOutlined, CopyOutlined, DeleteOutlined, EditOutlined,
FileAddOutlined, FileExcelOutlined, GiftOutlined, InboxOutlined, LinkOutlined, PlusOutlined, ReloadOutlined,
SafetyCertificateOutlined, SettingOutlined, ShopOutlined, StopOutlined, SwapOutlined, SyncOutlined, UndoOutlined,
} from '@ant-design/icons';
import {
fileApi,
huyaApi,
type HuyaCollabActivityScope,
type HuyaCollabAuditItem,
type HuyaCollabCkExportResult,
type HuyaCollabCkPreview,
type HuyaCollabFrequentGift,
type HuyaCollabGift,
type HuyaCollabGiftCatalog,
type HuyaCollabImageSlot,
type HuyaCollabMetric,
type HuyaCollabOrder,
type HuyaCollabOrderQuery,
type HuyaCollabOrderUpdate,
type HuyaCollabOwner,
type HuyaCollabShop,
type HuyaCollabStatus,
type HuyaCollabSummary,
type HuyaConfig,
} from '../api/modules';
import HuyaConfigFields from '../components/HuyaConfigFields';
import HuyaCollabShopModal from '../components/HuyaCollabShopModal';
import HuyaCollabShopPicker from '../components/HuyaCollabShopPicker';
import HuyaCollabHomeImageUploader from '../components/HuyaCollabHomeImageUploader';
import HuyaCollabGiftModal from '../components/HuyaCollabGiftModal';
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 REALTIME_PAUSED_STORAGE_KEY = 'huya.collab.realtime-paused';
function readRealtimePaused(): boolean {
try {
return window.localStorage.getItem(REALTIME_PAUSED_STORAGE_KEY) === '1';
} catch {
return false;
}
}
const STATUS_META: Record<HuyaCollabStatus, { label: string; color: string }> = {
// 只登记了订单号 + 店铺,客户资料(CK / 礼包 / 主页截图)到了再补
incomplete: { label: '待补资料', color: 'gold' },
// 资料齐全且 CK 有效;等充值
to_recharge: { label: '待充值', color: 'orange' },
recharged: { label: '已充值', color: 'geekblue' },
scripted: { label: '已上脚本', color: 'purple' },
// 抢购成功(枚举值仍是 done):从「已上脚本」查到兑换记录 → 自动进这一步
done: { label: '抢购成功', color: 'success' },
// 充值完成但积分不足/超出阈值
points_error: { label: '积分异常', color: 'volcano' },
// 旁路终态:抢购后查兑换记录没查到这一单的礼包(枚举值仍是 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: 'to_recharge', label: '待充值' },
{ value: 'recharged', label: '已充值(核账)' },
{ value: 'scripted', label: '已上脚本' },
{ value: 'done', label: '抢购成功' },
{ value: 'failed', label: '抢购失败' },
{ value: 'void', label: '已作废' },
];
/** 「回退」能选的目标:流程链(含「待补资料」;要进「已作废 / 抢购失败」走「改状态」或「作废」入口) */
const FLOW_STATUS_OPTIONS: { value: HuyaCollabStatus; label: string }[] = [
{ value: 'incomplete', label: '待补资料' },
{ value: 'to_recharge', label: '待充值' },
{ value: 'recharged', label: '已充值(核账)' },
{ value: 'scripted', label: '已上脚本' },
{ value: 'done', label: '抢购成功' },
];
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' },
};
/** 表头排序字段(后端白名单):seq = 固定创建序号;点击一次正序,再点一次反序。 */
type CollabSortKey =
| 'seq'
| 'order_no'
| 'shop'
| 'owner'
| 'bind'
| 'gift_name'
| 'status'
| 'uid'
| 'ck_status'
| 'updated_at';
type CollabSortOrder = 'ascend' | 'descend';
const COLLAB_SORT_KEYS: ReadonlySet<string> = new Set<CollabSortKey>([
'seq',
'order_no',
'shop',
'owner',
'bind',
'gift_name',
'status',
'uid',
'ck_status',
'updated_at',
]);
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:recharge_invalidate': '核账快照失效',
'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;
// 「礼包列表」的本地缓存键:一个活动期间就那一批礼包,没必要每贴一份 CK 都去虎牙拉一次;
// v3 = 2026-11-11 起自动合并积分中心全部页签,换键避免继续用只含「精英专享」的旧缓存。
const GIFTS_CACHE_KEY = 'huya.collab.gifts.v3';
const RECHARGE_THRESHOLD_KEY = 'huya.collab.rechargeThreshold';
const RECHARGE_THRESHOLD_DEFAULT = 100;
function readRechargeThreshold(): number {
try {
const raw = window.localStorage.getItem(RECHARGE_THRESHOLD_KEY);
if (raw === null || raw === '') return RECHARGE_THRESHOLD_DEFAULT;
const value = Number(raw);
return Number.isFinite(value) && value >= 0 ? value : RECHARGE_THRESHOLD_DEFAULT;
} catch {
return RECHARGE_THRESHOLD_DEFAULT;
}
}
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 {
// 忽略:缓存只是体验优化
}
}
// 「新建协作单」草稿:填一半关掉 / 刷新页面后再打开不能清空(2026-10-17 用户需求)。
// 按登录用户分键,避免同一台电脑换账号后看到上一个人的 CK。
const CREATE_DRAFT_KEY_PREFIX = 'huya.collab.createDraft';
// 「只填订单号 + 店铺直接建单」的首次提示只弹一次(按浏览器记,不再打扰)
const CREATE_DRAFT_HINT_KEY = 'huya.collab.draftHintShown';
interface CreateDraft {
order_no: string;
shop: string;
ck_text: string;
gift_name: string;
required_points: number | null;
remark: string;
home_images: string[];
saved_at: number;
}
function createDraftKey(userId?: number | null): string {
return userId ? `${CREATE_DRAFT_KEY_PREFIX}.${userId}` : '';
}
/** 读草稿(读不到 / 解析坏都当没有) */
function readCreateDraft(userId?: number | null): CreateDraft | null {
const key = createDraftKey(userId);
if (!key) return null;
try {
const raw = window.localStorage.getItem(key);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<CreateDraft>;
const savedAt = Number(parsed.saved_at) || 0;
// 草稿只留 7 天:里面可能有一份 CK,不能无限期躺在浏览器里
if (!savedAt || Date.now() - savedAt > 7 * 24 * 60 * 60 * 1000) {
window.localStorage.removeItem(key);
return null;
}
return {
order_no: typeof parsed.order_no === 'string' ? parsed.order_no : '',
shop: typeof parsed.shop === 'string' ? parsed.shop : '',
ck_text: typeof parsed.ck_text === 'string' ? parsed.ck_text : '',
gift_name: typeof parsed.gift_name === 'string' ? parsed.gift_name : '',
required_points: typeof parsed.required_points === 'number' ? parsed.required_points : null,
remark: typeof parsed.remark === 'string' ? parsed.remark : '',
home_images: Array.isArray(parsed.home_images)
? parsed.home_images.filter((item): item is string => typeof item === 'string')
: [],
saved_at: Number(parsed.saved_at) || 0,
};
} catch {
return null;
}
}
function clearCreateDraft(userId?: number | null): void {
const key = createDraftKey(userId);
if (!key) return;
try {
window.localStorage.removeItem(key);
} catch {
// 忽略:清不掉也不影响当前表单
}
}
/** 「资料没填全时直接建单 → 待补资料」的首次提示是否已经展示过 */
function draftHintShown(): boolean {
try {
return window.localStorage.getItem(CREATE_DRAFT_HINT_KEY) === '1';
} catch {
return true; // 存不了就不要再反复弹
}
}
function markDraftHintShown(): void {
try {
window.localStorage.setItem(CREATE_DRAFT_HINT_KEY, '1');
} catch {
// 忽略:提示只是体验
}
}
function hasDraftContent(draft: Omit<CreateDraft, 'saved_at'>): boolean {
// 礼包名不算「填过东西」:它现在默认空白、也不从草稿恢复(见下面 createForm 那段的说明),
// 否则一份「只被上次自动填了礼包」的老草稿会一直弹「已恢复上次未提交的草稿」。
return Boolean(
draft.order_no.trim()
|| draft.shop.trim()
|| draft.ck_text.trim()
|| draft.remark.trim()
|| draft.home_images.length,
);
}
// 「补资料」草稿:按登录用户 + 订单 id 分开存。填一半关掉 / 刷新后,
// 再点这一行的「补资料」会原样恢复(含刚贴的 CK 和刚上传的截图 key)。
const SUPPLEMENT_DRAFT_KEY_PREFIX = 'huya.collab.supplementDraft';
interface SupplementDraft {
shop: string;
ck_text: string;
gift_name: string;
required_points: number | null;
remark: string;
home_images: string[];
saved_at: number;
}
interface SupplementFormValues {
shop: string;
ck_text?: string;
gift_name?: string;
required_points?: number | null;
remark?: string;
home_images?: string[];
}
function supplementDraftKey(userId?: number | null, orderId?: number): string {
return userId && orderId ? `${SUPPLEMENT_DRAFT_KEY_PREFIX}.${userId}.${orderId}` : '';
}
function readSupplementDraft(userId: number | null | undefined, orderId: number): SupplementDraft | null {
const key = supplementDraftKey(userId, orderId);
if (!key) return null;
try {
const raw = window.localStorage.getItem(key);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<SupplementDraft>;
const savedAt = Number(parsed.saved_at) || 0;
if (!savedAt || Date.now() - savedAt > 7 * 24 * 60 * 60 * 1000) {
window.localStorage.removeItem(key);
return null;
}
return {
shop: typeof parsed.shop === 'string' ? parsed.shop : '',
ck_text: typeof parsed.ck_text === 'string' ? parsed.ck_text : '',
gift_name: typeof parsed.gift_name === 'string' ? parsed.gift_name : '',
required_points: typeof parsed.required_points === 'number' ? parsed.required_points : null,
remark: typeof parsed.remark === 'string' ? parsed.remark : '',
home_images: Array.isArray(parsed.home_images)
? parsed.home_images.filter((item): item is string => typeof item === 'string')
: [],
saved_at: savedAt,
};
} catch {
return null;
}
}
function clearSupplementDraft(userId: number | null | undefined, orderId: number): void {
const key = supplementDraftKey(userId, orderId);
if (!key) return;
try {
window.localStorage.removeItem(key);
} 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 location = useLocation();
const navigate = useNavigate();
const canEdit = can('huya:collab:edit');
const canManage = can('huya:collab:manage');
const isAdmin = user?.role === 'super_admin';
// 单行复制与批量导出共用这个权限点(默认运营和客服都有)
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 [giftCatalog, setGiftCatalog] = useState<HuyaCollabGiftCatalog[]>([]);
const [giftNames, setGiftNames] = useState<string[]>([]);
// 「常用礼包」:台账里用得最多的几个(新建/补资料的礼包名快捷按钮,见 2026-10-18 需求)
const [frequentGifts, setFrequentGifts] = useState<HuyaCollabFrequentGift[]>([]);
const [shopModalOpen, setShopModalOpen] = useState(false);
const [giftModalOpen, setGiftModalOpen] = useState(false);
const [pointsConfigOpen, setPointsConfigOpen] = useState(false);
const [pointsConfigLoading, setPointsConfigLoading] = useState(false);
const [pointsConfigSaving, setPointsConfigSaving] = useState(false);
const [pointsConfigForm] = Form.useForm<HuyaConfig>();
const [filters, setFilters] = useState<HuyaCollabOrderQuery>({
include_void: false,
archive_scope: 'active',
});
// 默认按固定创建序号正序:老单在上、新单永远追加在最后。
const [sortKey, setSortKey] = useState<CollabSortKey>('seq');
const [sortOrder, setSortOrder] = useState<CollabSortOrder>('ascend');
const [realtimePending, setRealtimePending] = useState(false);
const [realtimePaused, setRealtimePaused] = useState(readRealtimePaused);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const selectedHasVoid = selectedIds.length > 0
&& rows.some((row) => selectedIds.includes(row.id) && row.status === 'void');
// 点顶部统计卡筛选后滚到列表,避免用户还要自己往下找
const listAnchorRef = useRef<HTMLDivElement | null>(null);
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 [draftRestoredAt, setDraftRestoredAt] = useState<number | null>(null);
const draftSaveTimer = useRef<number | null>(null);
// 「补资料」:对待补资料的单补 CK / 礼包 / 主页截图 / 备注,草稿按订单存在本机
const [supplementTarget, setSupplementTarget] = useState<HuyaCollabOrder | null>(null);
const [supplementForm] = Form.useForm<SupplementFormValues>();
const [supplementDraftAt, setSupplementDraftAt] = useState<number | null>(null);
const supplementDraftTimer = useRef<number | null>(null);
const createGiftValue = Form.useWatch('gift_name', createForm) as string | undefined;
const supplementGiftValue = Form.useWatch('gift_name', supplementForm) as string | undefined;
const supplementCkText = Form.useWatch('ck_text', supplementForm) as string | undefined;
const supplementHomeImages = Form.useWatch('home_images', supplementForm) as string[] | undefined;
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' | 'force'; target?: HuyaCollabStatus;
} | null>(null);
const [statusReason, setStatusReason] = useState('');
const [rollbackTarget, setRollbackTarget] = useState<HuyaCollabStatus>('to_recharge');
// 批量改状态(勾选多行一次切)
const [batchStatusOpen, setBatchStatusOpen] = useState(false);
const [batchStatusTarget, setBatchStatusTarget] = useState<HuyaCollabStatus>('to_recharge');
const [batchStatusReason, setBatchStatusReason] = useState('');
const [batchStatusRollback, setBatchStatusRollback] = useState(false);
const [batchStatusForce, setBatchStatusForce] = useState(false);
// 充值核账弹窗:按「绑定账号 + 绑定角色」整组核账,阈值默认 100
const [rechargeOpen, setRechargeOpen] = useState(false);
const [rechargeIds, setRechargeIds] = useState<number[]>([]);
const [rechargeThreshold, setRechargeThreshold] = useState(readRechargeThreshold);
// 归档弹窗:ids=null 表示按当前筛选结果批量归档
const [archiveOpen, setArchiveOpen] = useState(false);
const [archiveIds, setArchiveIds] = useState<number[] | null>(null);
const [archiveFiltered, setArchiveFiltered] = useState(false);
const [archiveExpectedCount, setArchiveExpectedCount] = useState(0);
const [archiveReason, setArchiveReason] = useState('');
const [archiving, setArchiving] = 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,
nextGiftCatalog,
nextGiftNames,
nextFrequentGifts,
] = await Promise.all([
huyaApi.listCollabOrders({
...filters,
sort_by: sortKey,
sort_order: sortOrder === 'descend' ? 'desc' : 'asc',
page,
page_size: pageSize,
}),
huyaApi.collabOrdersSummary({
include_void: filters.include_void,
archive_scope: filters.archive_scope ?? 'active',
}),
withMeta ? huyaApi.collabOrderOwners() : Promise.resolve(null),
withMeta ? huyaApi.listCollabShops() : Promise.resolve(null),
withMeta ? huyaApi.listCollabGiftCatalog() : Promise.resolve(null),
withMeta ? huyaApi.collabOrderGifts() : Promise.resolve(null),
withMeta ? huyaApi.collabFrequentGifts() : Promise.resolve(null),
]);
setRows(list.items);
setTotal(list.total);
setSummary(nextSummary);
if (nextOwners) setOwners(nextOwners);
if (nextShops) setShops(nextShops);
if (nextGiftCatalog) setGiftCatalog(nextGiftCatalog);
if (nextGiftNames) setGiftNames(nextGiftNames);
if (nextFrequentGifts) setFrequentGifts(nextFrequentGifts);
// 选中是「当前页」的概念:刷新后剔除已经不在这页上的行,避免点到别人的脏 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, sortKey, sortOrder]);
useEffect(() => { void load(); }, [load]);
const openPointsConfig = async () => {
setPointsConfigOpen(true);
setPointsConfigLoading(true);
try {
pointsConfigForm.setFieldsValue(await huyaApi.getConfig());
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setPointsConfigLoading(false);
}
};
const savePointsConfig = async () => {
setPointsConfigSaving(true);
try {
const values = await pointsConfigForm.validateFields();
await huyaApi.updateConfig({
points_page_code: values.points_page_code,
points_source_id: values.points_source_id,
points_scene: values.points_scene,
points_page_id: values.points_page_id,
points_tab_ids: values.points_tab_ids,
});
message.success('积分中心配置已保存,下次刷新礼包时生效');
setPointsConfigOpen(false);
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setPointsConfigSaving(false);
}
};
const realtimeEnabled = can('huya:collab:view');
// 暂停后连 WebSocket 也不建:既不自动重拉,也不做断线兜底轮询。
const realtimeSyncEnabled = realtimeEnabled && !realtimePaused;
const collabModalOpen = Boolean(
createOpen || supplementTarget || detail || statusAction || batchStatusOpen || rechargeOpen || archiveOpen || ckTarget || ckExportOpen || shopModalOpen || giftModalOpen || pointsConfigOpen,
);
const toggleRealtimeSync = (nextEnabled: boolean) => {
const nextPaused = !nextEnabled;
setRealtimePaused(nextPaused);
try {
window.localStorage.setItem(REALTIME_PAUSED_STORAGE_KEY, nextPaused ? '1' : '0');
} catch {
// 隐私模式下 localStorage 不可写也不影响本次会话的开关。
}
if (nextPaused) {
setRealtimePending(false);
message.info('已暂停实时同步,点「刷新」手动获取最新数据');
return;
}
message.success('已恢复实时同步');
// 暂停期间可能漏过变更,恢复连接后立即补拉一次。
void load({ silent: true, withMeta: true });
};
const handleRealtimeInvalidate = useCallback((event: CollabRealtimeEvent) => {
if (!realtimeSyncEnabled) return;
if (collabModalOpen || hasTextInputFocus()) {
setRealtimePending(true);
return;
}
void load({ silent: true, withMeta: event.scope !== 'orders' });
}, [realtimeSyncEnabled, collabModalOpen, load]);
const {
connected: realtimeConnected,
reconnecting: realtimeReconnecting,
} = useCollabRealtime({
enabled: realtimeSyncEnabled,
onInvalidate: handleRealtimeInvalidate,
});
useEffect(() => {
if (!realtimePending || !realtimeSyncEnabled || 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, realtimeSyncEnabled, collabModalOpen, load]);
// WebSocket 断开时用低频轮询兜底;连接正常或手动暂停时完全不轮询。
useEffect(() => {
if (!realtimeSyncEnabled || 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);
}, [realtimeSyncEnabled, 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: '', nickname: '', 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.nickname || '虎牙昵称未读到'} · 角色 ${preview.bind_game_role}`,
);
// 「记住的礼包」如果不在实时列表、也不在标准字典里就清掉重选。
const options = preview.gifts.length > 0 ? preview.gifts : giftsRef.current;
const available = new Set([
...options.map((gift) => gift.name),
...giftCatalog.filter((gift) => gift.is_active).map((gift) => gift.name),
]);
const picked = String(createForm.getFieldValue('gift_name') ?? '').trim();
if (picked && available.size > 0 && !available.has(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, giftCatalog],
);
/**
* 输入 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],
);
useEffect(() => {
const state = location.state as { transferCk?: string } | null;
const transferCk = state?.transferCk?.trim();
if (!transferCk) return;
setCreateOpen(true);
setCkPreview(null);
setPreviewing(false);
setGiftHint('');
setDraftRestoredAt(null);
previewSeq.current += 1;
createForm.resetFields();
createForm.setFieldsValue({ ck_text: transferCk });
scheduleCkPreview(transferCk, true);
navigate(location.pathname, { replace: true, state: null });
}, [createForm, location.pathname, location.state, navigate, scheduleCkPreview]);
const isArchiveView = filters.archive_scope === 'archived';
const selectedRows = useMemo(
() => rows.filter((row) => selectedIds.includes(row.id)),
[rows, selectedIds],
);
const selectedArchivable = selectedRows.length > 0
&& selectedRows.every((row) => row.status === 'done' && !row.is_archived);
const canArchiveFiltered = canEdit && !isArchiveView && filters.status === 'done' && total > 0;
const toggleArchiveView = (checked: boolean) => {
setPage(1);
setSelectedIds([]);
setFilters((prev) => ({
...prev,
archive_scope: checked ? 'archived' : 'active',
// 归档视图目前只有「抢购成功」;保留原来的状态筛选会看起来像空表。
status: undefined,
}));
};
const openArchiveModal = (
ids: number[] | null,
options: { filtered?: boolean; count?: number } = {},
) => {
setArchiveIds(ids);
setArchiveFiltered(options.filtered === true);
setArchiveExpectedCount(options.count ?? ids?.length ?? 0);
setArchiveReason('');
setArchiveOpen(true);
};
const submitArchive = async () => {
if (!archiveFiltered && (!archiveIds || archiveIds.length === 0)) return;
setArchiving(true);
try {
const result = await huyaApi.archiveCollabOrders(
archiveFiltered
? {
archive_filtered: true,
expected_count: archiveExpectedCount,
reason: archiveReason,
shop: filters.shop || '',
owner_id: filters.owner_id ?? null,
status: filters.status || '',
ck_status: filters.ck_status || '',
gift_name: filters.gift_name || '',
keyword: filters.keyword || '',
include_void: !!filters.include_void,
}
: { ids: archiveIds ?? [], reason: archiveReason },
);
const failures = result.results.filter((item) => !item.ok);
if (result.archived > 0) {
message.success(`已归档 ${result.archived} 条抢购成功订单`);
} else {
message.warning('没有可归档的订单');
}
if (failures.length > 0) {
modal.info({
title: `归档完成:成功 ${result.archived} / 跳过 ${result.skipped}`,
width: 640,
content: (
<Space direction="vertical" style={{ width: '100%' }}>
{failures.map((item) => (
<Text key={item.id} type="secondary">
{item.order_no}:{item.message}
</Text>
))}
</Space>
),
});
}
setArchiveOpen(false);
setArchiveIds(null);
setArchiveFiltered(false);
setArchiveReason('');
setSelectedIds([]);
setPage(1);
void load();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setArchiving(false);
}
};
const unarchiveMany = async (ids: number[]) => {
if (ids.length === 0) return;
setBusy(true);
try {
const result = await huyaApi.unarchiveCollabOrders(ids);
if (result.unarchived > 0) {
message.success(`已取消归档 ${result.unarchived} 条`);
} else {
message.warning('没有可取消归档的订单');
}
setSelectedIds([]);
if (detail && ids.includes(detail.id)) setDetail(null);
void load();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setBusy(false);
}
};
const confirmUnarchive = (ids: number[]) => {
if (ids.length === 0) return;
modal.confirm({
title: ids.length === 1 ? '取消归档这一单?' : `取消归档 ${ids.length} 条?`,
content: '取消后会恢复到活跃台账,之前的归档备注仍保留在审计时间线里。',
okText: '取消归档',
onOk: () => unarchiveMany(ids),
});
};
/** 勾选多行一次改状态(往前可跳步;回退要在弹窗里勾「回退」) */
const submitBatchStatus = async () => {
if (batchStatusTarget === 'recharged') {
setBatchStatusOpen(false);
openRecharge(selectedIds);
return;
}
if (batchStatusForce && !batchStatusReason.trim()) {
message.warning('强制恢复已作废订单必须填写原因');
return;
}
setBusy(true);
try {
const result = await huyaApi.batchUpdateCollabOrderStatus({
ids: selectedIds,
status: batchStatusTarget,
reason: batchStatusReason,
rollback: batchStatusForce ? false : batchStatusRollback,
force: batchStatusForce,
});
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);
}
};
/**
* 勾选批量查询(串行带间隔,防风控)。
*
* `scope` 决定查哪一半 —— 界面上是**两个独立按钮**(2026-10-17 用户要求):
* 「查积分」只打积分接口,不会产生判定、也**不会自动改状态**;
* 「查兑换记录」只打兑换记录接口,判定与自动改状态照旧。
*/
const queryActivitySelected = async (scope: HuyaCollabActivityScope) => {
if (selectedIds.length === 0) return;
setQuerying(true);
try {
const result = await huyaApi.batchQueryCollabOrderActivity(selectedIds, scope);
const scoreScope = scope === 'score';
const missed = result.results.filter((item) => !item.ok).length;
modal.info({
title: scoreScope
? `查积分完成:${result.total} 条` + (missed ? `(${missed} 条没拿到积分)` : '')
: `查兑换记录完成:成功 ${result.success} / 失败 ${result.failed} / 无法判定 ${result.unknown}`
+ (result.auto_changed ? `(${result.auto_changed} 条按结果自动改状态)` : ''),
width: 760,
content: (
<Space direction="vertical" style={{ width: '100%' }}>
{scoreScope && (
<Text type="secondary" style={{ fontSize: 12 }}>
这一趟只查积分,没有查兑换记录,所以不会改业务状态(判定那栏保持上一次的结论)。
</Text>
)}
{result.results.map((item) => (
<Text
key={item.order_id}
type={!item.ok ? 'danger' : item.verdict === 'failed' ? 'danger' : undefined}
>
{item.order_no}:
{/* 只查积分时 verdict 是空的(未判定),没必要每行都挂一个「未判定」标签 */}
{scoreScope ? item.message : `${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,
required_points: row.required_points,
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 openRecharge = (ids: number[]) => {
if (ids.length === 0) return;
setRechargeIds(ids);
setRechargeThreshold(readRechargeThreshold());
setRechargeOpen(true);
};
const submitRecharge = async () => {
if (rechargeIds.length === 0) return;
setBusy(true);
try {
const threshold = Math.max(0, Number(rechargeThreshold) || 0);
try {
window.localStorage.setItem(RECHARGE_THRESHOLD_KEY, String(threshold));
} catch {
// 隐私模式 / 配额满:记不住阈值不影响本次核账
}
const result = await huyaApi.rechargeCollabOrders({
ids: rechargeIds,
threshold,
});
const lines = result.groups.map((group) => ({
key: group.group_key,
ok: Boolean(group.status),
text: group.status
? `${group.group_key}:${group.message}`
: `${group.group_key}:${group.message}`,
}));
if (lines.length === 0 && result.failures.length === 0) {
message.warning('没有可核账的订单');
} else {
modal.info({
title: `核账完成:更新 ${result.updated} 条,失败 ${result.failed} 条${
result.confirmed_groups
? `;二次确认 ${result.confirmed_groups} 组(等待 ${result.confirmation_delay_seconds} 秒)`
: ''
}`,
width: 680,
content: (
<Space direction="vertical" style={{ width: '100%' }}>
{lines.map((line) => (
<Text key={line.key} type={line.ok ? undefined : 'danger'}>
{line.text}
</Text>
))}
{result.failures.map((item) => (
<Text key={`failure-${item.id}`} type="danger">
{item.order_no}:{item.message}
</Text>
))}
</Space>
),
});
}
setRechargeOpen(false);
setSelectedIds([]);
if (detail && rechargeIds.includes(detail.id)) {
void openDetail(detail);
}
void load();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setBusy(false);
}
};
const advance = async (row: HuyaCollabOrder) => {
if (!row.next_status) return;
if (row.next_status === 'recharged') {
openRecharge([row.id]);
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`:人工往前切,允许跳步(如 待补资料 → 已上脚本),不用勾回退。
* - `force`:超级管理员从「已作废」强制恢复(必须填原因)。
*/
const submitStatusAction = async () => {
if (!statusAction) return;
const { order, mode, target } = statusAction;
const nextStatus: HuyaCollabStatus = mode === 'void' ? 'void' : (target ?? rollbackTarget);
if (nextStatus === 'recharged') {
setStatusAction(null);
setStatusReason('');
openRecharge([order.id]);
return;
}
// 服务层对「作废」一律要求原因(不管从哪个入口进来的)
if (nextStatus === 'void' && !statusReason.trim()) {
message.warning('作废必须填原因');
return;
}
if (mode === 'force' && !statusReason.trim()) {
message.warning('强制恢复已作废订单必须填写原因');
return;
}
setBusy(true);
try {
applyUpdated(await huyaApi.updateCollabOrderStatus(order.id, {
status: nextStatus,
reason: statusReason,
rollback: mode === 'rollback',
force: mode === 'force',
expected_version: order.version,
}));
message.success(
mode === 'void'
? '已作废'
: mode === 'force'
? '已强制恢复'
: 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);
}
};
/** 单行「复制 CK」与批量导出共用权限点,后端会再次校验。 */
const copyOneCk = async (row: HuyaCollabOrder) => {
setBusy(true);
try {
const result = await huyaApi.copyCollabOrderCk(row.id);
const item = result.items[0];
if (!item) {
message.warning(result.skipped[0]?.reason || '这行还没有可复制的 CK');
return;
}
await copyTextToClipboard(item.ck_text);
message.success(`已复制 ${row.order_no} 的 CK`);
} catch (e: unknown) {
message.error(`复制失败:${getErrorMessage(e)}`);
} finally {
setBusy(false);
}
};
/** 把「补资料」表单写进本机草稿:仍按用户 + 订单分键,最多留 7 天。 */
const writeSupplementDraft = useCallback((values: SupplementFormValues, target: HuyaCollabOrder) => {
const userId = user?.id;
const key = supplementDraftKey(userId, target.id);
if (!key) return;
const images = Array.isArray(values.home_images)
? values.home_images.filter((item): item is string => typeof item === 'string')
: target.home_images;
const payload: Omit<SupplementDraft, 'saved_at'> = {
shop: String(values.shop ?? ''),
ck_text: String(values.ck_text ?? ''),
gift_name: String(values.gift_name ?? ''),
required_points: typeof values.required_points === 'number' ? values.required_points : null,
remark: String(values.remark ?? ''),
home_images: images,
};
const changed = Boolean(
payload.ck_text.trim()
|| payload.shop !== target.shop
|| payload.gift_name !== target.gift_name
|| payload.required_points !== target.required_points
|| payload.remark !== target.remark
|| payload.home_images.join('\n') !== target.home_images.join('\n'),
);
try {
if (!changed) {
window.localStorage.removeItem(key);
return;
}
window.localStorage.setItem(key, JSON.stringify({ ...payload, saved_at: Date.now() }));
} catch {
// 隐私模式 / 配额满:暂存失败不影响提交
}
}, [user?.id]);
const persistSupplementDraft = useCallback((values: SupplementFormValues, target: HuyaCollabOrder) => {
if (supplementDraftTimer.current) window.clearTimeout(supplementDraftTimer.current);
supplementDraftTimer.current = window.setTimeout(() => writeSupplementDraft(values, target), 400);
}, [writeSupplementDraft]);
const openSupplement = (row: HuyaCollabOrder) => {
if (supplementDraftTimer.current) {
window.clearTimeout(supplementDraftTimer.current);
supplementDraftTimer.current = null;
}
const draft = readSupplementDraft(user?.id, row.id);
const base: SupplementFormValues = {
shop: row.shop,
ck_text: '',
gift_name: row.gift_name,
required_points: row.required_points,
remark: row.remark,
home_images: row.home_images,
};
supplementForm.setFieldsValue(draft ? { ...base, ...draft } : base);
setSupplementDraftAt(draft?.saved_at ?? null);
setSupplementTarget(row);
};
const closeSupplement = useCallback(() => {
if (supplementDraftTimer.current) {
window.clearTimeout(supplementDraftTimer.current);
supplementDraftTimer.current = null;
}
if (supplementTarget) {
writeSupplementDraft(
supplementForm.getFieldsValue(true) as SupplementFormValues,
supplementTarget,
);
}
setSupplementTarget(null);
setSupplementDraftAt(null);
supplementForm.resetFields();
}, [supplementForm, supplementTarget, writeSupplementDraft]);
const clearSupplement = () => {
if (!supplementTarget) return;
if (supplementDraftTimer.current) {
window.clearTimeout(supplementDraftTimer.current);
supplementDraftTimer.current = null;
}
clearSupplementDraft(user?.id, supplementTarget.id);
setSupplementDraftAt(null);
supplementForm.setFieldsValue({
shop: supplementTarget.shop,
ck_text: '',
gift_name: supplementTarget.gift_name,
required_points: supplementTarget.required_points,
remark: supplementTarget.remark,
home_images: supplementTarget.home_images,
});
};
/**
* 保存「补资料」:字段 / 新 CK / 截图分别走各自接口(每个都有乐观锁),
* 新 CK 保存后立即跑一次只读探针;用户点「保存并进入待充值」时再校验资料完整性。
*/
const submitSupplement = async (markPending = false) => {
if (!supplementTarget) return;
let values: SupplementFormValues;
try {
values = await supplementForm.validateFields();
} catch {
return;
}
setBusy(true);
try {
let current = supplementTarget;
const payload: HuyaCollabOrderUpdate = {};
const shop = String(values.shop ?? '').trim();
const giftName = String(values.gift_name ?? '').trim();
const requiredPoints = typeof values.required_points === 'number' ? values.required_points : null;
const remark = String(values.remark ?? '');
if (shop !== current.shop) payload.shop = shop;
if (giftName !== current.gift_name) payload.gift_name = giftName;
if (requiredPoints !== current.required_points) payload.required_points = requiredPoints;
if (remark !== current.remark) payload.remark = remark;
if (Object.keys(payload).length > 0) {
current = await huyaApi.updateCollabOrder(current.id, {
...payload,
expected_version: current.version,
});
}
const ckText = String(values.ck_text ?? '').trim();
if (ckText) {
current = await huyaApi.updateCollabOrderCk(current.id, {
ck_text: ckText,
action: 'paste',
expected_version: current.version,
});
current = await huyaApi.verifyCollabOrder(current.id);
}
const images = values.home_images ?? current.home_images;
if (images.join('\n') !== current.home_images.join('\n')) {
current = await huyaApi.updateCollabOrderImages(current.id, {
slot: 'home_images',
images,
expected_version: current.version,
});
}
// 没有换 CK、但准备检查资料是否齐全时,先跑一次只读探针;避免拿一份没验证的旧 CK 往前推
if (markPending && !ckText && current.has_cookie && current.ck_status !== 'valid') {
current = await huyaApi.verifyCollabOrder(current.id);
}
if (markPending && current.ck_status !== 'valid') {
applyUpdated(current);
setSupplementTarget(current);
supplementForm.setFieldValue('ck_text', '');
void load();
message.warning(
'资料已保存,但这份 CK 还没验证通过,暂时不能进入「待充值」;请先补 / 换一份有效 CK',
);
return;
}
applyUpdated(current);
clearSupplementDraft(user?.id, supplementTarget.id);
setSupplementDraftAt(null);
supplementForm.resetFields();
setSupplementTarget(null);
message.success(
markPending
? current.status === 'to_recharge'
? `资料已补齐,${current.order_no} 已进入「待充值」`
: `资料已保存;绑定账号/角色、礼包、主页截图齐全后会自动进入「待充值」`
: `已保存 ${current.order_no} 的资料`,
);
void load();
} catch (e: unknown) {
message.error(getErrorMessage(e));
void load();
} 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 writeCreateDraft = useCallback((values: Record<string, unknown>) => {
const userId = user?.id;
const key = createDraftKey(userId);
if (!key) return;
const payload = {
order_no: String(values.order_no ?? ''),
shop: String(values.shop ?? ''),
ck_text: String(values.ck_text ?? ''),
gift_name: String(values.gift_name ?? ''),
required_points: typeof values.required_points === 'number' ? values.required_points : null,
remark: String(values.remark ?? ''),
home_images: Array.isArray(values.home_images)
? (values.home_images as unknown[]).filter((item): item is string => typeof item === 'string')
: [],
};
if (!hasDraftContent(payload)) {
clearCreateDraft(userId);
return;
}
try {
window.localStorage.setItem(key, JSON.stringify({ ...payload, saved_at: Date.now() }));
} catch {
// 隐私模式 / 配额满:暂存失败不影响提交
}
}, [user?.id]);
/** 新建弹窗表单变化后写本地草稿(防抖 400ms) */
const persistCreateDraft = useCallback((values: Record<string, unknown>) => {
if (draftSaveTimer.current) window.clearTimeout(draftSaveTimer.current);
draftSaveTimer.current = window.setTimeout(() => writeCreateDraft(values), 400);
}, [writeCreateDraft]);
/** 关闭弹窗时立刻落盘一次,避免 400ms 内重新打开读不到最新输入 */
const closeCreateModal = useCallback(() => {
if (draftSaveTimer.current) {
window.clearTimeout(draftSaveTimer.current);
draftSaveTimer.current = null;
}
writeCreateDraft(createForm.getFieldsValue(true) as Record<string, unknown>);
setCreateOpen(false);
}, [createForm, writeCreateDraft]);
useEffect(() => () => {
if (draftSaveTimer.current) window.clearTimeout(draftSaveTimer.current);
if (supplementDraftTimer.current) window.clearTimeout(supplementDraftTimer.current);
}, []);
/** 清掉草稿并恢复空表单(「清空草稿」入口用) */
const discardCreateDraft = useCallback(() => {
if (draftSaveTimer.current) {
window.clearTimeout(draftSaveTimer.current);
draftSaveTimer.current = null;
}
clearCreateDraft(user?.id);
setDraftRestoredAt(null);
setCkPreview(null);
setPreviewing(false);
setGiftHint('');
previewSeq.current += 1;
createForm.resetFields();
}, [createForm, user?.id]);
const submitCreate = async () => {
try {
// 只校验订单号 + 店铺:CK / 礼包 / 主页截图即使空着也能一键建单(自动进「待补资料」)
const values = await createForm.validateFields();
const orderNo = String(values.order_no ?? '').trim();
const ckText = String(values.ck_text ?? '').trim();
const giftName = String(values.gift_name ?? '').trim();
const requiredPoints = typeof values.required_points === 'number' ? values.required_points : null;
const homeImages = (values.home_images as string[] | undefined) ?? [];
const missing: string[] = [];
if (!ckText) missing.push('外部 CK');
if (!giftName) missing.push('礼包名');
if (homeImages.length === 0) missing.push('主页截图');
// 资料齐全且 CK 已验证通过才走完整建单;否则一律按「待补资料」保存
const draft = missing.length > 0 || !ckReady;
const created = await huyaApi.createCollabOrder({
order_no: orderNo,
shop: String(values.shop ?? ''),
ck_text: ckText,
// 「绑定斗鱼号」不再录入:绑了谁由探针读(bind_game_*),人工那一列已下线
gift_name: giftName,
required_points: requiredPoints,
home_images: homeImages,
remark: String(values.remark ?? ''),
draft,
require_verified: !draft, // 草稿单不跑探针;完整建单仍要求 CK 读到绑定角色
});
if (draft) {
const reason = missing.length > 0
? `还缺 ${missing.join('、')}`
: (previewing ? 'CK 还在验证中' : 'CK 还没验证通过');
if (!draftHintShown()) {
markDraftHintShown();
// 首次建草稿单给一次明确提示:以后直接点「新建」就行,不用先勾任何东西
message.info(
`已按「待补资料」创建 ${created.order_no}:${reason}。`
+ '之后在详情里补齐,资料齐全并验证 CK 后会自动进入「待充值」。',
8,
);
} else {
message.success(`已新建 ${created.order_no}(待补资料,资料可稍后补)`);
}
} else {
message.success(`已新建 ${created.order_no}`);
}
setCreateOpen(false);
setCkPreview(null);
setGiftHint('');
setDraftRestoredAt(null);
if (draftSaveTimer.current) {
window.clearTimeout(draftSaveTimer.current);
draftSaveTimer.current = null;
}
clearCreateDraft(user?.id);
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 scrollToList = () => {
window.setTimeout(() => {
listAnchorRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 0);
};
const applyStatusCard = (status?: HuyaCollabStatus) => {
setPage(1);
setFilters((prev) => ({ ...prev, status, ck_status: undefined }));
scrollToList();
};
const applyCkInvalidCard = () => {
setPage(1);
setFilters((prev) => ({ ...prev, status: undefined, ck_status: 'invalid' }));
scrollToList();
};
const statusCardActive = (status: HuyaCollabStatus) =>
filters.status === status && !filters.ck_status;
const allCardActive = !filters.status && !filters.ck_status;
const ckInvalidCardActive = filters.ck_status === 'invalid';
const cardStyle = (active: boolean) => ({
cursor: 'pointer',
borderColor: active ? '#1677ff' : undefined,
boxShadow: active ? '0 0 0 2px rgba(22, 119, 255, 0.16)' : undefined,
});
// 礼包来源提示:这次真去虎牙拉了就说拉了,用的是缓存就把缓存时间摆出来(让人知道要不要点强制刷新)
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 hasSelectableGifts = giftOptions.length > 0
|| giftCatalog.some((gift) => gift.is_active);
/** 礼包下拉:实时活动礼包 + 管理员维护的标准字典,二者按名称去重。 */
const giftSelectOptions = (current?: string | null) => {
const value = String(current ?? '').trim();
const seen = new Set<string>();
const options = giftOptions
.filter((gift) => {
if (!gift.name || seen.has(gift.name)) return false;
seen.add(gift.name);
return true;
})
.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(' '),
}));
for (const gift of giftCatalog) {
if (!gift.is_active || !gift.name || seen.has(gift.name)) continue;
seen.add(gift.name);
options.push({
value: gift.name,
label: `${gift.name} · ${gift.points} 积分 · 标准字典`,
});
}
if (value && !seen.has(value)) {
options.unshift({ value, label: `${value}(历史值)` });
}
return options;
};
/** 礼包名对应的积分:实时价格优先,其次标准字典;都没有时返回 null。 */
const giftRequiredPoints = (name?: string | null): number | null => {
const value = String(name ?? '').trim();
if (!value) return null;
return giftOptions.find((gift) => gift.name === value)?.price
?? giftCatalog.find((gift) => gift.name === value)?.points
?? null;
};
/**
* 「常用礼包」快捷按钮(2026-10-18 用户要求):
* 礼包名**默认空白**(不再自动带上次选的那个,避免没注意时建错单),
* 想少点两下就点这里的备选。
*
* 只列**当前礼包缓存里还有的**那几个:活动换 SID 之后老礼包基本都下架了,
* 点一个拉不到的名字等于白点(见设计文档 §11.4)。缓存为空时整行不显示。
*/
const frequentGiftChoices = (): HuyaCollabFrequentGift[] => {
const available = new Set([
...giftOptions.map((gift) => gift.name),
...giftCatalog.filter((gift) => gift.is_active).map((gift) => gift.name),
]);
if (available.size === 0) return [];
return frequentGifts.filter((item) => available.has(item.name)).slice(0, 6);
};
/** 按钮上的短标签:礼包名很长(「[限量返场] 怪诞乔克服装礼包」),去掉类目前缀再截断 */
const giftChipLabel = (name: string): string => {
const short = name.replace(/^\[[^\]]*\]\s*/, '').trim() || name;
return short.length > 10 ? `${short.slice(0, 10)}…` : short;
};
/**
* 表格里选中礼包后的显示:
* 第一行保留活动 / 类目前缀(如「[限量返场]」),第二行完整显示礼包名。
* 之前整串单行塞进 160px,前缀就把真正的礼包名挤成省略号了。
*/
const renderGiftSelectedLabel = (name: string) => {
const gift = giftOptions.find((item) => item.name === name);
if (!gift) {
return (
<span style={{ whiteSpace: 'normal', wordBreak: 'break-word', lineHeight: '18px' }}>
{name}
</span>
);
}
return (
<span style={{ display: 'flex', flexDirection: 'column', minWidth: 0, lineHeight: 1.3 }}>
{gift.category_name && (
<Text type="secondary" style={{ fontSize: 11, lineHeight: '14px' }}>
[{gift.category_name}]
</Text>
)}
<Text strong style={{
display: 'block', fontSize: 13, lineHeight: '18px',
whiteSpace: 'normal', wordBreak: 'break-word',
}}>
{gift.name}
</Text>
</span>
);
};
const renderFrequentGiftChips = (
current: string | undefined,
pick: (name: string) => void,
) => {
const choices = frequentGiftChoices();
if (choices.length === 0) return null;
return (
<Space size={6} wrap style={{ marginBottom: 6 }}>
<Text type="secondary" style={{ fontSize: 12 }}>常用礼包:</Text>
{choices.map((item) => (
<Tooltip key={item.name} title={`${item.name}(用过 ${item.used} 次)`}>
<Button
size="small"
type={current === item.name ? 'primary' : 'default'}
onClick={() => pick(item.name)}
>
{giftChipLabel(item.name)}
</Button>
</Tooltip>
))}
</Space>
);
};
// 表头排序:一次正序、再点反序(Table 上限制 sortDirections,不会出现「第三次取消」)。
const sortProps = (key: CollabSortKey) => ({
key,
sorter: true,
sortOrder: sortKey === key ? sortOrder : null,
showSorterTooltip: { title: '点击正序,再点一次反序' },
});
const columns: TableProps<HuyaCollabOrder>['columns'] = [
{
title: '序号', width: 76, fixed: 'left', align: 'center',
...sortProps('seq'),
render: (_, row) => (
// 序号 = 创建时分配的固定 id:不管切换排序、筛选还是翻页都不会重排。
<Tooltip title="创建序号,固定不变;新单会追加在最后">
<Text type="secondary">{row.id}</Text>
</Tooltip>
),
},
{
title: '订单编号', dataIndex: 'order_no', width: 170, fixed: 'left',
...sortProps('order_no'),
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,
...sortProps('shop'),
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 || row.is_archived}>
{value || '+ 选店铺'}
</Button>
</Popover>
),
},
{
title: '负责人', dataIndex: 'owner_id', width: 130,
...sortProps('owner'),
render: (value: number | null, row) => (
<Select
size="small" variant="borderless" style={{ width: '100%' }}
value={value ?? undefined} options={ownerOptions} disabled={!canEdit || row.is_archived}
placeholder="+ 指派" allowClear showSearch optionFilterProp="label"
onChange={(next: number | undefined) => void patchOrder(row, { owner_id: next ?? null })}
/>
),
},
{
title: '游戏账号 / 角色 / 虎牙昵称', width: 280,
...sortProps('bind'),
render: (_, row) => (
<Space direction="vertical" size={1} style={{ width: '100%' }}>
<Space size={4} wrap={false} style={{ maxWidth: '100%' }}>
<Text strong ellipsis style={{ maxWidth: 112 }}>
{row.bind_game_account || '游戏账号未读到'}
</Text>
<Text type="secondary">/</Text>
<Text
type={row.bind_game_role ? undefined : 'secondary'}
ellipsis
style={{
color: row.bind_game_role ? '#389e0d' : undefined,
maxWidth: 140,
}}
>
{row.bind_game_role ? `角色 ${row.bind_game_role}` : '角色未读到'}
</Text>
</Space>
<Text type="secondary" style={{ fontSize: 12 }} ellipsis>
虎牙昵称:{row.nickname || '未读取(重新验证 CK 后补全)'}
</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: 190,
...sortProps('gift_name'),
render: (_, row) => (
<Space direction="vertical" size={0} style={{ width: '100%' }}>
{/* 礼包名只能从缓存列表点选:手输会出现“看起来一样、接口拉不到”的脏值 */}
<Select
size="small"
variant="borderless"
style={{ width: '100%' }}
showSearch
optionFilterProp="label"
options={giftSelectOptions(row.gift_name)}
value={row.gift_name || undefined}
disabled={!canEdit || row.is_archived}
placeholder="+ 点选礼包"
popupMatchSelectWidth={false}
// 前缀单独一行、礼包名完整换行,别让单行省略号把名字吃掉
labelRender={() => renderGiftSelectedLabel(row.gift_name)}
styles={{ content: { whiteSpace: 'normal', textOverflow: 'clip', lineHeight: 1.3 } }}
notFoundContent={hasSelectableGifts ? '没有匹配的礼包' : '实时礼包为空,标准字典也还没有配置'}
onChange={(next: string) => void patchOrder(row, {
gift_name: next,
required_points: giftRequiredPoints(next),
})}
/>
<EditableText value={row.remark} disabled={!canEdit || row.is_archived} placeholder="+ 备注"
onCommit={(next) => void patchOrder(row, { remark: next })} />
</Space>
),
},
{
title: '状态', dataIndex: 'status', width: 130,
...sortProps('status'),
render: (value: HuyaCollabStatus, row) => (
<Space size={4} wrap>
<Tag color={STATUS_META[value]?.color}>{row.status_label || STATUS_META[value]?.label || value}</Tag>
{row.is_archived && (
<Tooltip
title={`归档于 ${formatTime(row.archived_at)} · ${row.archived_by_name || '-'}`
+ (row.archive_reason ? `\n备注:${row.archive_reason}` : '')}
>
<Tag color="default">已归档</Tag>
</Tooltip>
)}
</Space>
),
},
{
title: '虎牙号', width: 160,
...sortProps('uid'),
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,
...sortProps('ck_status'),
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];
// 积分与判定可能是**两次不同的查询**(查积分 / 查兑换记录是两个按钮):
// 徽标保留上一次的判定结论,各自把自己的时间与结论写清楚,别让人以为徽标也是刚刚那次的。
const tip = [
`最新查询(${formatTime(row.last_metric_at)}):${row.last_metric_message || '—'}`,
verdict
? `判定(${formatTime(row.last_verdict_at)}):${row.last_verdict_message || '—'}`
: '',
].filter(Boolean).join('\n');
return (
<Tooltip title={<span style={{ whiteSpace: 'pre-line' }}>{tip}</span>}>
<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,
...sortProps('updated_at'),
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: 300, fixed: 'right', align: 'center',
render: (_, row) => (
<Space size={4} wrap>
{canEdit && row.status === 'incomplete' && !row.is_archived && (
<Button size="small" type="primary" ghost icon={<FileAddOutlined />}
onClick={() => openSupplement(row)}>
补资料
</Button>
)}
{canExportCk && !row.is_archived && (
<Tooltip title={row.has_cookie ? '复制这一行的 CK 明文' : '这行还没贴 CK'}>
<Button size="small" icon={<CopyOutlined />}
disabled={busy || !row.has_cookie}
onClick={() => void copyOneCk(row)}>
复制CK
</Button>
</Tooltip>
)}
<Button size="small" icon={<EditOutlined />} onClick={() => void openDetail(row)}>详情</Button>
{canEdit && row.is_archived && (
<Button size="small" icon={<UndoOutlined />} disabled={busy}
onClick={() => confirmUnarchive([row.id])}>
取消归档
</Button>
)}
{canEdit && !row.is_archived && (
<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' },
...(row.status === 'void'
? (isAdmin
? [{ key: 'force', label: '管理员强制恢复…', icon: <UndoOutlined />, danger: true }]
: [])
: [
{ key: 'set', label: '改状态…', icon: <CheckCircleOutlined /> },
{ key: 'rollback', label: '回退状态…', icon: <UndoOutlined /> },
{ key: 'void', label: '作废…', icon: <StopOutlined />, danger: true },
]),
...(row.status === 'done'
? [
{ type: 'divider' as const },
{ key: 'archive', label: '归档…', icon: <InboxOutlined /> },
]
: []),
...(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 === 'archive') openArchiveModal([row.id], { count: 1 });
if (key === 'set') {
setRollbackTarget(row.next_status ?? row.status);
setStatusReason('');
setStatusAction({ order: row, mode: 'set' });
}
if (key === 'rollback') {
setRollbackTarget('to_recharge');
setStatusReason('');
setStatusAction({ order: row, mode: 'rollback' });
}
if (key === 'force') {
setRollbackTarget('to_recharge');
setStatusReason('');
setStatusAction({ order: row, mode: 'force' });
}
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 会参差不齐 */}
<Row gutter={[8, 8]}>
<Col flex="1 1 104px">
<Card size="small" hoverable style={cardStyle(allCardActive)} onClick={() => applyStatusCard(undefined)}>
<Statistic title="全部" value={summary?.total ?? 0} />
</Card>
</Col>
{([
['incomplete', '#fa8c16'],
['to_recharge', '#faad14'],
['recharged', '#2f54eb'],
['scripted', '#722ed1'],
['done', '#52c41a'],
['failed', '#ff4d4f'],
['points_error', '#fa541c'],
] as const).map(([status, color]) => (
<Col key={status} flex="1 1 104px">
<Card
size="small"
hoverable
style={cardStyle(statusCardActive(status))}
onClick={() => applyStatusCard(status)}
>
<Statistic
title={STATUS_META[status].label}
value={status === 'done' ? finished : (summary?.by_status[status] ?? 0)}
valueStyle={{ color }}
/>
{status === 'done' && (
<Progress percent={summary?.total ? Math.round((finished / summary.total) * 100) : 0} size="small" />
)}
</Card>
</Col>
))}
<Col flex="1 1 104px">
<Card size="small" hoverable style={cardStyle(ckInvalidCardActive)} onClick={applyCkInvalidCard}>
<Statistic title="CK 失效" value={summary?.ck_invalid ?? 0} valueStyle={{ color: '#ff4d4f' }} />
</Card>
</Col>
{realtimeEnabled && (
<Col flex="none">
<Card
size="small"
style={{ width: 172, height: '100%' }}
styles={{ body: { height: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '10px 12px' } }}
>
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<Space size={6} style={{ width: '100%', justifyContent: 'space-between' }}>
<Tooltip title="关闭后停止自动同步;需要最新数据时点「刷新」手动获取">
<Text strong style={{ fontSize: 13 }}>实时同步</Text>
</Tooltip>
<Switch
size="small"
checked={!realtimePaused}
onChange={toggleRealtimeSync}
aria-label="实时同步开关"
/>
</Space>
<Badge
status={realtimePaused ? 'default' : realtimeConnected ? 'success' : realtimeReconnecting ? 'warning' : 'error'}
text={
<Text type="secondary" style={{ fontSize: 12 }}>
{realtimePaused
? '已暂停 · 手动刷新'
: realtimeConnected
? '已连接 · 自动刷新'
: realtimeReconnecting
? '重连中…'
: '已离线 · 自动重试'}
</Text>
}
/>
</Space>
</Card>
</Col>
)}
</Row>
<div ref={listAnchorRef} style={{ scrollMarginTop: 8 }} />
<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 size={4}>
<Text type="secondary">查看归档</Text>
<Switch size="small" checked={isArchiveView} onChange={toggleArchiveView} />
<Tag>{summary?.archived_total ?? 0}</Tag>
</Space>
</Space>
{/* 第二行:动作(固定在这一行;勾选后加「(N)」只是按钮变宽,不会把列表顶下去) */}
<Space wrap style={{ width: '100%' }}>
<Button icon={<ReloadOutlined />} onClick={() => void load()} disabled={loading}>刷新</Button>
{realtimePending && <Tag color="processing">有更新待同步</Tag>}
{canEdit && !isArchiveView && (
<Button
icon={<InboxOutlined />}
disabled={!selectedArchivable || busy}
onClick={() => openArchiveModal(selectedIds, { count: selectedRows.length })}
>
批量归档{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
)}
{canArchiveFiltered && (
<Button
icon={<InboxOutlined />}
disabled={busy}
onClick={() => openArchiveModal(null, { filtered: true, count: total })}
>
归档当前筛选结果({total})
</Button>
)}
{canEdit && isArchiveView && (
<Button
icon={<UndoOutlined />}
disabled={selectedIds.length === 0 || busy}
onClick={() => confirmUnarchive(selectedIds)}
>
批量取消归档{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
)}
{canEdit && !isArchiveView && (
<Button icon={<SafetyCertificateOutlined />} disabled={selectedIds.length === 0 || busy}
onClick={() => void verifySelected()}>
批量验证 CK{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
)}
{canEdit && !isArchiveView && (
// 勾选多行一次改状态(往前可跳步;回退在弹窗里勾「回退」)
<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);
setBatchStatusForce(false);
setBatchStatusOpen(true);
},
}}
>
<Button icon={<SwapOutlined />} disabled={selectedIds.length === 0 || busy}>
批量改状态{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
</Dropdown>
)}
{canEdit && !isArchiveView && (
<Tooltip title="自动刷新 CK 与绑定快照,再按「绑定账号 + 绑定角色」整组核账">
<Button icon={<AuditOutlined />} disabled={selectedIds.length === 0 || busy}
onClick={() => openRecharge(selectedIds)}>
充值核账{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
</Tooltip>
)}
{canEdit && !isArchiveView && (
// 查积分 / 查兑换记录**拆成两个按钮**(2026-10-17):查积分只写积分快照,
// 既不判定也不自动改状态;只有查兑换记录才产出判定并可能自动改状态。
// 合成一个按钮时「只想要积分」也会顺带把状态改了,两边互相干扰。
<>
<Button icon={<SyncOutlined />} disabled={selectedIds.length === 0 || busy}
loading={querying}
onClick={() => void queryActivitySelected('score')}>
批量查积分{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
<Button icon={<GiftOutlined />} disabled={selectedIds.length === 0 || busy}
loading={querying}
onClick={() => void queryActivitySelected('records')}>
批量查兑换记录{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
</>
)}
{canExportCk && !isArchiveView && (
// 导出 CK 到剪贴板(勾选几行就导几行;明文只在这个弹窗里出现)
<Button icon={<CopyOutlined />} disabled={selectedIds.length === 0 || busy}
loading={ckExporting}
onClick={() => void openCkExport()}>
导出 CK{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
)}
{canEdit && !isArchiveView && (
<Button icon={<FileExcelOutlined />} onClick={() => { setImportResult(null); setImportOpen(true); }}>
粘贴导入
</Button>
)}
{canEdit && !isArchiveView && (
<Button icon={<ShopOutlined />} onClick={() => setShopModalOpen(true)}>店铺管理</Button>
)}
{canManage && !isArchiveView && (
<Button icon={<GiftOutlined />} onClick={() => setGiftModalOpen(true)}>礼包管理</Button>
)}
{canManage && !isArchiveView && (
<Button icon={<SettingOutlined />} onClick={() => void openPointsConfig()}>
积分中心配置
</Button>
)}
{canEdit && !isArchiveView && (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setCkPreview(null);
setPreviewing(false);
setGiftHint('');
previewSeq.current += 1; // 丢掉在途的预览结论
createForm.resetFields();
const draft = readCreateDraft(user?.id);
if (draft && hasDraftContent(draft)) {
// 上次填了一半:订单号 / 店铺 / CK / 截图 / 备注都原样恢复,并重新验证暂存的 CK。
// **礼包名一律留空**(2026-10-18 用户要求):礼包默认空白,不再带上次选的那个;
// 反正上面有「常用礼包」备选,点一下就有,不值得为它记住上一次的选择。
createForm.setFieldsValue({ ...draft, gift_name: '', required_points: null });
setGiftHint('');
setDraftRestoredAt(draft.saved_at || Date.now());
setCreateOpen(true);
if (draft.ck_text.trim()) scheduleCkPreview(draft.ck_text, true);
return;
}
setDraftRestoredAt(null);
// 礼包名**不预填**(2026-10-18 用户要求):以前会自动带上"上次选的",
// 每开一单都顶着上一次的礼包,容易在没注意时建错单。
// 想少点两下就用上面的「常用礼包」快捷按钮,点了才算选了。
setGiftHint('');
setCreateOpen(true);
}}
>
新建
</Button>
)}
</Space>
{isArchiveView && (
<Alert
type="info"
showIcon
message={`正在查看已归档台账(${summary?.total ?? 0} 条,只读)`}
description="归档行仍可搜索、看详情和审计;需要修改或重新操作时,先点「取消归档」恢复回活跃台账。"
/>
)}
</Space>
</Card>
<Table<HuyaCollabOrder>
rowKey="id"
size="small"
loading={loading}
columns={columns}
dataSource={rows}
scroll={{ x: 2240, y: 'calc(100vh - 460px)' }}
sortDirections={['ascend', 'descend']}
onChange={(_pagination, _filters, sorter, extra) => {
if (extra.action !== 'sort') return;
const current = Array.isArray(sorter) ? sorter[0] : sorter;
const nextOrder = current?.order;
const nextKey = String(current?.columnKey ?? '');
// 只在白名单命中时切换;排序状态变了必须回第一页,否则会停在一个空页。
if (
(nextOrder === 'ascend' || nextOrder === 'descend')
&& COLLAB_SORT_KEYS.has(nextKey)
) {
setSortKey(nextKey as CollabSortKey);
setSortOrder(nextOrder);
} else {
setSortKey('seq');
setSortOrder('ascend');
}
setPage(1);
}}
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
? <Space>订单 {detail.order_no}{detail.is_archived && <Tag color="default">已归档</Tag>}</Space>
: ''}
destroyOnHidden
>
{detail && (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{detail.is_archived && (
<Alert
type="info"
showIcon
message={`这单已归档 · ${formatTime(detail.archived_at)} · ${detail.archived_by_name || '-'}`}
description={detail.archive_reason
? `归档备注:${detail.archive_reason}。归档期间只读;需要继续操作请先取消归档。`
: '归档期间只读;需要继续操作请先取消归档。'}
action={canEdit ? (
<Button size="small" icon={<UndoOutlined />} disabled={busy}
onClick={() => confirmUnarchive([detail.id])}>
取消归档
</Button>
) : undefined}
/>
)}
{detail.status === 'incomplete' && (
<Alert
type="warning"
showIcon
message="这单还在「待补资料」"
description={(() => {
const missing = [
!detail.has_cookie ? '外部 CK' : '',
!detail.bind_game_account ? '绑定账号(先验证 CK)' : '',
!detail.bind_game_role ? '绑定角色(先验证 CK)' : '',
!detail.gift_name ? '礼包名' : '',
detail.home_images.length === 0 ? '主页截图' : '',
].filter(Boolean);
return missing.length > 0
? `还缺:${missing.join('、')}。资料齐全且 CK 有效后会自动进入「待充值」。`
: '资料已齐,验证 CK 后会自动进入「待充值」。';
})()}
/>
)}
{detail.status === 'points_error' && (
<Alert
type="error"
showIcon
message={detail.points_check_message || '积分异常:账号积分与礼包所需积分不匹配'}
description={detail.points_checked_at
? `最近核账 ${formatTime(detail.points_checked_at)};如已补足/调整积分,重新勾选后点「充值核账」。`
: '请重新勾选后点「充值核账」。'}
/>
)}
<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: 'gift', label: '礼包 / 所需积分', children: `${detail.gift_name || '-'} / ${detail.required_points ?? '未记录'}` },
{ key: 'points_check', label: '最近核账(账号积分 / 礼包合计 / 结余 / 阈值)',
children: detail.points_checked_at
? `${detail.points_balance ?? '-'} / ${detail.points_required_total ?? '-'} / ${detail.points_surplus ?? '-'} / ${detail.points_threshold ?? '-'}(${formatTime(detail.points_checked_at)})`
: '-' },
{ key: 'status', label: '业务状态',
children: <Tag color={STATUS_META[detail.status]?.color}>{detail.status_label}</Tag> },
...(detail.is_archived
? [
{ key: 'archived_at', label: '归档时间', children: formatTime(detail.archived_at) },
{ key: 'archived_by', label: '归档人 / 备注',
children: `${detail.archived_by_name || '-'}${detail.archive_reason ? ` · ${detail.archive_reason}` : ''}` },
]
: []),
{ 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 && !detail.is_archived && (
<>
<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, required_points: detail.required_points, 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="礼包名"
tooltip="从实时活动礼包或管理员维护的标准字典里点选,可输入关键字搜索">
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="输入关键字搜索,点选礼包"
options={giftSelectOptions(detail.gift_name)}
notFoundContent={hasSelectableGifts
? '没有匹配的礼包'
: '实时礼包为空,标准字典也还没有配置'}
onChange={(value?: string) => {
detailForm.setFieldValue('required_points', giftRequiredPoints(value));
}}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="required_points" label="礼包所需积分"
tooltip="选中礼包时自动带出积分中心价格;也可按实际活动手工修正,核账时按这个值合计">
<InputNumber min={0} max={1_000_000} precision={0} style={{ width: '100%' }}
addonAfter="积分" placeholder="未记录时核账会按礼包名自动补" />
</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 && !detail.is_archived}
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 && !detail.is_archived && (
<Space wrap>
{detail.status === 'void' ? (
isAdmin && (
<Button danger icon={<UndoOutlined />} disabled={busy}
onClick={() => {
setRollbackTarget('to_recharge');
setStatusReason('');
setStatusAction({ order: detail, mode: 'force' });
}}>
管理员强制恢复…
</Button>
)
) : (
<>
{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('to_recharge'); setStatusReason(''); setStatusAction({ order: detail, mode: 'rollback' }); }}>
回退状态
</Button>
<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 && !detail.is_archived && (
<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) => {
// 一次查询只查一半时,另一半**压根没查** —— 别把「没查」显示成「积分 -」「0 条」
const scope = item.scope || 'both';
const hasScore = scope !== 'records';
const hasRecords = scope !== 'score';
return {
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>
{hasScore ? (
<>
<Text strong>积分 {item.score ?? '-'}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
获得 {item.gained_score ?? '-'} / 消耗 {item.used_score ?? '-'}
</Text>
</>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>本次没查积分</Text>
)}
{hasRecords ? (
<Text type="secondary" style={{ fontSize: 12 }}>
兑换记录 {item.records_total} 条(命中 {item.records_matched})
</Text>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>本次没查兑换记录</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' ? '批量查询' : '手动查询'}
{' · '}{scope === 'both' ? '积分 + 兑换记录' : scope === 'score' ? '只查积分' : '只查兑换记录'}
</Text>
</Space>
),
};
})}
/>
</Space>
)}
</Drawer>
<Modal
title="新建协作单"
open={createOpen}
width={920}
onCancel={closeCreateModal}
onOk={() => void submitCreate()}
confirmLoading={busy}
okText="新建"
// 一键建单:不再要求先勾「先建单」或等 CK 验证通过;资料没填全自动存成「待补资料」
// 内容比一屏高:给个滚动区,别让底部按钮被顶出可视范围
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>, allValues: Record<string, unknown>) => {
if (typeof changed.ck_text === 'string') scheduleCkPreview(changed.ck_text);
persistCreateDraft(allValues);
}}
>
{draftRestoredAt && (
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message={`已恢复上次未提交的草稿(${formatTime(new Date(draftRestoredAt).toISOString())} 暂存)`}
action={(
<Button size="small" type="link" onClick={discardCreateDraft}>
清空草稿
</Button>
)}
/>
)}
<Text type="secondary" style={{ display: 'block', marginBottom: 12, fontSize: 12 }}>
只填订单号 + 店铺也能直接点右下角「新建」:资料没填全会自动按「待补资料」创建,
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」。贴进来即自动探针验证 + 读绑定角色;不贴也可以先建单,之后在详情里补。礼包列表本地有缓存就不再重复拉,要更新点旁边的「强制刷新礼包」"
>
{/* 高度随内容走:短 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.nickname || '虎牙昵称未读到'} · 角色 ${ckPreview.bind_game_role}`}
description={
<>
<div>
{`游戏账号:${ckPreview.bind_game_account || '-'}`
+ `(${ckPreview.bind_game_channel || '-'})`
+ `;虎牙昵称:${ckPreview.nickname || '-'}`
+ `;虎牙号:${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="warning"
showIcon
message="这份 CK 没验证通过;点「新建」会先按「待补资料」保存"
description={`${ckPreview?.message || ''}${ckPreview?.ok && !ckPreview.has_role ? '(探针通了但读不到绑定角色)' : ''} 之后补 / 换 CK,再在详情里验证即可。`}
/>
)}
</div>
)}
<Row gutter={16}>
<Col span={14}>
{renderFrequentGiftChips(createGiftValue, (name) => {
createForm.setFieldsValue({
gift_name: name,
required_points: giftRequiredPoints(name),
});
setGiftHint('');
})}
<Form.Item
name="gift_name"
label="礼包名(用户购买的物品,可稍后补)"
tooltip="默认空白,不会自动带上次选的礼包。可从实时活动礼包或管理员维护的标准字典里点选;不选也能先建单,之后点「补资料」再选。"
extra={giftHint || [
giftsCachedAt && giftOptions.length > 0
? `实时礼包缓存于 ${giftCacheLabel}(${giftOptions.length} 个)`
: '',
giftCatalog.length > 0
? `标准字典 ${giftCatalog.filter((gift) => gift.is_active).length} 个可用`
: '',
].filter(Boolean).join(';') || undefined}
validateStatus={giftHint ? 'warning' : undefined}
>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder={hasSelectableGifts ? '输入关键字搜索,点选礼包' : '请先在上方「礼包管理」配置标准礼包'}
options={giftSelectOptions(createGiftValue)}
disabled={!hasSelectableGifts && !createGiftValue}
notFoundContent={hasSelectableGifts
? '没有匹配的礼包'
: '实时礼包为空,标准字典也还没有配置'}
onChange={(value?: string) => {
// 选了就清掉「上次那个礼包不在本次列表里」的提示,并带出积分价格
setGiftHint('');
createForm.setFieldValue('required_points', giftRequiredPoints(value));
}}
/>
</Form.Item>
<Form.Item
name="required_points"
label="礼包所需积分(可稍后补)"
tooltip="选择礼包时自动带出积分中心价格;核账时按每单这个值合计。留空也能核账,后端会按礼包名补一次"
>
<InputNumber min={0} max={1_000_000} precision={0} style={{ width: '100%' }}
addonAfter="积分" placeholder="选中礼包自动带出" />
</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 粘贴上传;第一张作封面,绑定图不用再传。不传也能先建单,之后在详情里补。"
>
<HuyaCollabHomeImageUploader max={MAX_IMAGES_PER_SLOT} optional />
</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={supplementTarget ? `补资料 · ${supplementTarget.order_no}` : '补资料'}
open={!!supplementTarget}
width={760}
onCancel={closeSupplement}
destroyOnHidden
styles={{ body: { maxHeight: '74vh', overflowY: 'auto', paddingRight: 12 } }}
footer={[
<Button key="cancel" onClick={closeSupplement} disabled={busy}>
取消
</Button>,
<Button key="save" onClick={() => void submitSupplement(false)} loading={busy}>
保存资料
</Button>,
<Button key="pending" type="primary" onClick={() => void submitSupplement(true)} loading={busy}>
保存并检查资料
</Button>,
]}
>
{supplementTarget && (
<>
{supplementDraftAt && (
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message={`已恢复上次没填完的补资料草稿(${formatTime(new Date(supplementDraftAt).toISOString())} 暂存)`}
action={<Button size="small" type="link" onClick={clearSupplement}>清空草稿</Button>}
/>
)}
<Alert
type="warning"
showIcon
style={{ marginBottom: 12 }}
message="还缺什么就补什么;填到一半可以直接关掉,下次再点「补资料」会从这里继续"
description={(() => {
const hasCk = supplementTarget.has_cookie || Boolean(supplementCkText?.trim());
const images = supplementHomeImages ?? supplementTarget.home_images;
const missing = [
!hasCk ? '外部 CK' : '',
!supplementTarget.bind_game_account ? '绑定账号(保存后验证 CK)' : '',
!supplementTarget.bind_game_role ? '绑定角色(保存后验证 CK)' : '',
!supplementGiftValue ? '礼包名' : '',
images.length === 0 ? '主页截图' : '',
].filter(Boolean);
return missing.length > 0
? `当前还缺:${missing.join('、')}`
: '资料已齐,保存并验证 CK 后会自动进入「待充值」。';
})()}
/>
<Form
form={supplementForm}
layout="vertical"
size="middle"
onValuesChange={(_changed, allValues) => {
persistSupplementDraft(allValues as SupplementFormValues, supplementTarget);
}}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="shop" label="店铺"
rules={[{ required: true, message: '请点一下选店铺' }]}
tooltip="点一下选;历史订单的店铺即使已停用也会保留">
<HuyaCollabShopPicker shops={shops} allowClear={false} size="large" />
</Form.Item>
</Col>
<Col span={12}>
{renderFrequentGiftChips(
supplementGiftValue ?? supplementTarget.gift_name,
(name) => supplementForm.setFieldsValue({
gift_name: name,
required_points: giftRequiredPoints(name),
}),
)}
<Form.Item name="gift_name" label="礼包名"
tooltip="从实时活动礼包或管理员维护的标准字典里点选,可输入关键字搜索">
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder={hasSelectableGifts ? '输入关键字搜索,点选礼包' : '请先让管理员配置标准礼包'}
options={giftSelectOptions(supplementGiftValue ?? supplementTarget.gift_name)}
disabled={!hasSelectableGifts && !supplementGiftValue}
notFoundContent={hasSelectableGifts
? '没有匹配的礼包'
: '实时礼包为空,标准字典也还没有配置'}
onChange={(value?: string) => {
supplementForm.setFieldValue('required_points', giftRequiredPoints(value));
}}
/>
</Form.Item>
<Form.Item name="required_points" label="礼包所需积分"
tooltip="选中礼包时自动带出积分中心价格;核账时按每单这个值合计">
<InputNumber min={0} max={1_000_000} precision={0} style={{ width: '100%' }}
addonAfter="积分" placeholder="选中礼包自动带出" />
</Form.Item>
</Col>
</Row>
<Form.Item
name="ck_text"
label="外部 CK(不换就留空)"
tooltip="支持「纯CK」或「账号----密码----CK」。贴新 CK 会覆盖并立即验证;这行已经存过 CK 时,不换就留空"
extra={supplementTarget.has_cookie
? `这行已存 CK(当前:${supplementTarget.ck_status_label};最近验证 ${formatTime(supplementTarget.ck_checked_at)})`
: '这行还没有 CK,可以现在补'}
>
<Input.TextArea
autoSize={{ minRows: 3, maxRows: 7 }}
placeholder={supplementTarget.has_cookie
? '已存 CK;要换就贴一份新的,不换留空'
: '粘贴外部 CK,保存后自动验证'}
/>
</Form.Item>
<Row gutter={16}>
<Col span={14}>
<Form.Item name="home_images" label="主页截图(第一张作封面)"
tooltip="支持点击选择、拖拽或 Ctrl/Cmd+V 粘贴;补资料时也可以只补截图">
<HuyaCollabHomeImageUploader max={MAX_IMAGES_PER_SLOT} optional />
</Form.Item>
</Col>
<Col span={10}>
<Form.Item name="remark" label="备注(可选)">
<Input.TextArea rows={3} 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={`充值核账(已选 ${rechargeIds.length} 条)`}
open={rechargeOpen}
onCancel={() => setRechargeOpen(false)}
onOk={() => void submitRecharge()}
confirmLoading={busy}
okText="核账并落状态"
destroyOnHidden
>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="按「绑定账号 + 绑定角色」整组核账"
description="提交时自动刷新 CK 和绑定快照。若角色已在核账前换绑,会保存新快照并提示确认后重试;CK 失效或积分查不到时整组保持原状态。"
/>
<Space size={8} align="center">
<Text>允许结余阈值</Text>
<InputNumber
min={0}
max={1_000_000}
precision={0}
value={rechargeThreshold}
onChange={(value) => setRechargeThreshold(Number(value) || 0)}
addonAfter="积分"
style={{ width: 160 }}
/>
</Space>
<Text type="secondary" style={{ fontSize: 12 }}>
结余 = 账号积分合计 - 礼包积分合计。结余小于 0 或大于阈值 → 整组「积分异常」;
其余 → 整组「已充值」。阈值默认 100。
</Text>
</Space>
</Modal>
<Modal
title={statusAction?.mode === 'void'
? `作废订单 ${statusAction.order.order_no}`
: statusAction?.mode === 'force'
? `管理员强制恢复订单 ${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' || statusAction?.mode === 'force' }}
okText={statusAction?.mode === 'void'
? '作废'
: statusAction?.mode === 'force'
? '强制恢复'
: statusAction?.mode === 'set' ? '改状态' : '回退'}
destroyOnHidden
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{statusAction?.mode === 'force' && (
<Alert
type="warning"
showIcon
message="仅在确认属于误作废时使用"
description="强制恢复只跳过「已作废不可逆」限制;资料完整性、待充值条件等校验仍会执行。操作人和原因都会写入审计。"
/>
)}
{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'
? '作废原因(必填)'
: statusAction?.mode === 'force'
? '强制恢复原因(必填)'
: rollbackTarget === 'void'
? '作废原因(必填)'
: statusAction?.mode === 'rollback'
? '回退说明(可选)'
: '改状态说明(可选)'
} />
{statusAction?.mode === 'set' && (
<Text type="secondary">
人工切换允许往前跳步(比如 待补资料 → 已上脚本),不用一步一步点;
选「已充值」会转为整组核账;切到「已作废」必须填原因。
</Text>
)}
{statusAction?.mode === 'rollback' && (
<Text type="secondary">回退会清掉后面阶段的时间戳,并在审计里留痕。</Text>
)}
{statusAction?.mode === 'force' && (
<Text type="secondary">
恢复后原「作废原因」会保留在审计时间线里;该操作仅限超级管理员。
</Text>
)}
</Space>
</Modal>
<Modal
title={archiveFiltered
? `归档当前筛选结果(${archiveExpectedCount} 条)`
: `归档抢购成功订单(${archiveIds?.length ?? 0} 条)`}
open={archiveOpen}
onCancel={() => setArchiveOpen(false)}
onOk={() => void submitArchive()}
confirmLoading={archiving}
okText={`归档 ${archiveFiltered ? archiveExpectedCount : (archiveIds?.length ?? 0)} 条`}
destroyOnHidden
>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="归档只是从活跃台账隐藏,不会删除数据"
description="业务状态仍保持「抢购成功」;归档后只读,可在「查看归档」里搜索,取消归档即可恢复。"
/>
<Input.TextArea
rows={3}
maxLength={256}
showCount
value={archiveReason}
onChange={(event) => setArchiveReason(event.target.value)}
placeholder="归档备注(可选):例如 已完成对账、活动结束、客户已验收…"
/>
<Text type="secondary" style={{ fontSize: 12 }}>
只有「抢购成功」的活跃订单可以归档;失败的订单请先处理或作废。
</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'
? '作废原因(必填)'
: batchStatusForce ? '强制恢复原因(必填)' : '改状态说明(可选)'} />
<Checkbox checked={batchStatusRollback} onChange={(event) => setBatchStatusRollback(event.target.checked)}>
回退(目标不是「往前」时必须勾选,比如 已上脚本 → 待充值)
</Checkbox>
{isAdmin && selectedHasVoid && (
<Checkbox checked={batchStatusForce} onChange={(event) => setBatchStatusForce(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>
<Modal
title="积分中心配置"
open={pointsConfigOpen}
onCancel={() => setPointsConfigOpen(false)}
onOk={() => void savePointsConfig()}
confirmLoading={pointsConfigSaving}
okText="保存"
width={680}
destroyOnHidden
>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="页签默认自动发现,通常只需在活动入口变化时改前三个参数"
description="保存后回到新建/补资料里点一次「强制刷新礼包」即可拉取最新商品;pageId 和页签 IDs 留空表示自动。"
/>
<Form form={pointsConfigForm} layout="vertical" disabled={pointsConfigLoading}>
<HuyaConfigFields scope="points" />
</Form>
</Space>
</Modal>
<HuyaCollabShopModal
open={shopModalOpen}
shops={shops}
canEdit={canEdit}
canManage={canManage}
onClose={() => setShopModalOpen(false)}
onChanged={() => void load()}
/>
<HuyaCollabGiftModal
open={giftModalOpen}
gifts={giftCatalog}
canManage={canManage}
onClose={() => setGiftModalOpen(false)}
onChanged={() => void load()}
/>
</Space>
);
}