/** * 虎牙外部单协作台账(`/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 = { // 只登记了订单号 + 店铺,客户资料(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 = { 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 = { 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 = new Set([ 'seq', 'order_no', 'shop', 'owner', 'bind', 'gift_name', 'status', 'uid', 'ck_status', 'updated_at', ]); const ACTION_LABELS: Record = { '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 = { // 绑定图不再人工上传:绑定关系由探针自动读(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; 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): 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; 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 (
{ if (!disabled) setEditing(true); }} // wordBreak:礼品名 / 角色名里偶发的一长串不带空格的字符也不会顶穿这一列 style={{ cursor: disabled ? 'default' : 'text', minHeight: 22, wordBreak: 'break-word' }} title={disabled ? undefined : '点击编辑'} > {value || {placeholder || '—'}}
); } return ( 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 ( {keys.length === 0 && 还没有图} {keys.length > 0 && ( {keys.map((key, index) => (
{`${SLOT_META[slot].label}${index {index === 0 && ( 封面 )}
{canEdit && ( {index !== 0 && ( )}
); } 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([]); 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(null); const [owners, setOwners] = useState([]); const [shops, setShops] = useState([]); const [giftCatalog, setGiftCatalog] = useState([]); const [giftNames, setGiftNames] = useState([]); // 「常用礼包」:台账里用得最多的几个(新建/补资料的礼包名快捷按钮,见 2026-10-18 需求) const [frequentGifts, setFrequentGifts] = useState([]); 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(); const [filters, setFilters] = useState({ include_void: false, archive_scope: 'active', }); // 默认按固定创建序号正序:老单在上、新单永远追加在最后。 const [sortKey, setSortKey] = useState('seq'); const [sortOrder, setSortOrder] = useState('ascend'); const [realtimePending, setRealtimePending] = useState(false); const [realtimePaused, setRealtimePaused] = useState(readRealtimePaused); const [selectedIds, setSelectedIds] = useState([]); const selectedHasVoid = selectedIds.length > 0 && rows.some((row) => selectedIds.includes(row.id) && row.status === 'void'); // 点顶部统计卡筛选后滚到列表,避免用户还要自己往下找 const listAnchorRef = useRef(null); const [detail, setDetail] = useState(null); const [timeline, setTimeline] = useState([]); const [timelineLoading, setTimelineLoading] = useState(false); const [detailForm] = Form.useForm(); const [createOpen, setCreateOpen] = useState(false); const [createForm] = Form.useForm>(); const [draftRestoredAt, setDraftRestoredAt] = useState(null); const draftSaveTimer = useRef(null); // 「补资料」:对待补资料的单补 CK / 礼包 / 主页截图 / 备注,草稿按订单存在本机 const [supplementTarget, setSupplementTarget] = useState(null); const [supplementForm] = Form.useForm(); const [supplementDraftAt, setSupplementDraftAt] = useState(null); const supplementDraftTimer = useRef(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(null); const [previewing, setPreviewing] = useState(false); // 这次预览有没有带「拉礼包」(没有就是纯探针 + 用本地礼包缓存),只影响提示文案 const [previewPullsGifts, setPreviewPullsGifts] = useState(false); const ckReady = !!ckPreview?.ok && !!ckPreview?.has_role; // 礼包列表:优先用浏览器缓存那份 —— 有数据就不去虎牙拉,要更新点「强制刷新礼包」。 // 直接拿缓存做初值(而不是等 useEffect):贴第一份 CK 时就已经有数据了,不会白拉一次。 const [giftOptions, setGiftOptions] = useState( () => readGiftCache()?.gifts ?? [], ); const [giftsCachedAt, setGiftsCachedAt] = useState( () => readGiftCache()?.at ?? null, ); const giftsRef = useRef(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('to_recharge'); // 批量改状态(勾选多行一次切) const [batchStatusOpen, setBatchStatusOpen] = useState(false); const [batchStatusTarget, setBatchStatusTarget] = useState('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([]); const [rechargeThreshold, setRechargeThreshold] = useState(readRechargeThreshold); // 归档弹窗:ids=null 表示按当前筛选结果批量归档 const [archiveOpen, setArchiveOpen] = useState(false); const [archiveIds, setArchiveIds] = useState(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([]); const [metricsLoading, setMetricsLoading] = useState(false); // 勾选导出 CK(明文只在这一个弹窗里出现;复制走剪贴板) const [ckExportOpen, setCkExportOpen] = useState(false); const [ckExporting, setCkExporting] = useState(false); const [ckExport, setCkExport] = useState(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(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: ( {failures.map((item) => ( {item.order_no}:{item.message} ))} ), }); } 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: ( {failures.map((item) => ( {item.order_no}:{item.message} ))} ), }); } 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: ( {scoreScope && ( 这一趟只查积分,没有查兑换记录,所以不会改业务状态(判定那栏保持上一次的结论)。 )} {result.results.map((item) => ( {item.order_no}: {/* 只查积分时 verdict 是空的(未判定),没必要每行都挂一个「未判定」标签 */} {scoreScope ? item.message : `${item.verdict_label} · ${item.message}`} {item.auto_status ? `;状态已改为「${item.auto_status_label}」` : ''} ))} ), }); 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: ( {failures.map((item) => ( {item.order_no}:{item.message} ))} ), }); } 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: ( {lines.map((line) => ( {line.text} ))} {result.failures.map((item) => ( {item.order_no}:{item.message} ))} ), }); } 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 = { 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) => { 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) => { 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); 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(); 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 ( {name} ); } return ( {gift.category_name && ( [{gift.category_name}] )} {gift.name} ); }; const renderFrequentGiftChips = ( current: string | undefined, pick: (name: string) => void, ) => { const choices = frequentGiftChoices(); if (choices.length === 0) return null; return ( 常用礼包: {choices.map((item) => ( ))} ); }; // 表头排序:一次正序、再点反序(Table 上限制 sortDirections,不会出现「第三次取消」)。 const sortProps = (key: CollabSortKey) => ({ key, sorter: true, sortOrder: sortKey === key ? sortOrder : null, showSorterTooltip: { title: '点击正序,再点一次反序' }, }); const columns: TableProps['columns'] = [ { title: '序号', width: 76, fixed: 'left', align: 'center', ...sortProps('seq'), render: (_, row) => ( // 序号 = 创建时分配的固定 id:不管切换排序、筛选还是翻页都不会重排。 {row.id} ), }, { title: '订单编号', dataIndex: 'order_no', width: 170, fixed: 'left', ...sortProps('order_no'), render: (value: string, row) => ( {value} {row.status === 'void' && 已作废:{row.void_reason || '-'}} ), }, { title: '店铺', dataIndex: 'shop', width: 120, ...sortProps('shop'), render: (value: string, row) => ( { if (next !== value) void patchOrder(row, { shop: next }); }} /> } > ), }, { title: '负责人', dataIndex: 'owner_id', width: 130, ...sortProps('owner'), render: (value: number | null, row) => ( 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), })} /> void patchOrder(row, { remark: next })} /> ), }, { title: '状态', dataIndex: 'status', width: 130, ...sortProps('status'), render: (value: HuyaCollabStatus, row) => ( {row.status_label || STATUS_META[value]?.label || value} {row.is_archived && ( 已归档 )} ), }, { title: '虎牙号', width: 160, ...sortProps('uid'), render: (_, row) => ( {row.uid || 未贴 CK} {row.hy_username || '-'} ), }, { title: 'CK 状态', dataIndex: 'ck_status', width: 170, ...sortProps('ck_status'), render: (value: string, row) => ( {row.ck_status_label || CK_META[value]?.label || value} {row.ck_check_message && ( // 探针结论可能很长(账号 / 角色 / 平台·区服全在里面):**必须截断**, // 否则长文案会顶穿这一列压到「最后改动」上(2026-10-16 用户反馈)。全文放 Tooltip。 {row.ck_check_message} )} ), }, { title: '积分', width: 130, render: (_, row) => { if (!row.last_metric_at) { return 还没查; } 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 ( {tip}}> {row.last_score ?? '-'} {verdict && {verdict.label}} {formatTime(row.last_metric_at)} ); }, }, { title: '最后改动', width: 160, ...sortProps('updated_at'), render: (_, row) => ( {row.updated_by_name || '-'} {formatTime(row.updated_at)} ), }, { title: '操作', width: 300, fixed: 'right', align: 'center', render: (_, row) => ( {canEdit && row.status === 'incomplete' && !row.is_archived && ( )} {canExportCk && !row.is_archived && ( )} {canEdit && row.is_archived && ( )} {canEdit && !row.is_archived && ( }, { key: 'activity', label: '查积分 / 兑换记录', icon: , disabled: !row.has_cookie }, { key: 'ck', label: '贴 / 换 CK', icon: }, { key: 'rebuild', label: '从 App 原始凭据重建', icon: , disabled: !row.has_raw_cookie }, { key: 'disable', label: row.ck_status === 'disabled' ? '恢复这份 CK' : '停用这份 CK', icon: }, { type: 'divider' }, ...(row.status === 'void' ? (isAdmin ? [{ key: 'force', label: '管理员强制恢复…', icon: , danger: true }] : []) : [ { key: 'set', label: '改状态…', icon: }, { key: 'rollback', label: '回退状态…', icon: }, { key: 'void', label: '作废…', icon: , danger: true }, ]), ...(row.status === 'done' ? [ { type: 'divider' as const }, { key: 'archive', label: '归档…', icon: }, ] : []), ...(canManage ? [{ key: 'delete', label: '删除这一行', icon: , 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), }); } }, }} > {realtimePending && 有更新待同步} {canEdit && !isArchiveView && ( )} {canArchiveFiltered && ( )} {canEdit && isArchiveView && ( )} {canEdit && !isArchiveView && ( )} {canEdit && !isArchiveView && ( // 勾选多行一次改状态(往前可跳步;回退在弹窗里勾「回退」) ({ key: option.value, label: option.label, })), onClick: ({ key }) => { setBatchStatusTarget(key as HuyaCollabStatus); setBatchStatusReason(''); setBatchStatusRollback(false); setBatchStatusForce(false); setBatchStatusOpen(true); }, }} > )} {canEdit && !isArchiveView && ( )} {canEdit && !isArchiveView && ( // 查积分 / 查兑换记录**拆成两个按钮**(2026-10-17):查积分只写积分快照, // 既不判定也不自动改状态;只有查兑换记录才产出判定并可能自动改状态。 // 合成一个按钮时「只想要积分」也会顺带把状态改了,两边互相干扰。 <> )} {canExportCk && !isArchiveView && ( // 导出 CK 到剪贴板(勾选几行就导几行;明文只在这个弹窗里出现) )} {canEdit && !isArchiveView && ( )} {canEdit && !isArchiveView && ( )} {canManage && !isArchiveView && ( )} {canManage && !isArchiveView && ( )} {canEdit && !isArchiveView && ( )} {isArchiveView && ( )} 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); }, }} /> setDetail(null)} title={detail ? 订单 {detail.order_no}{detail.is_archived && 已归档} : ''} destroyOnHidden > {detail && ( {detail.is_archived && ( } disabled={busy} onClick={() => confirmUnarchive([detail.id])}> 取消归档 ) : undefined} /> )} {detail.status === 'incomplete' && ( { 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' && ( )} {detail.ck_status_label} {detail.has_cookie ? 已存 CK : 无 CK} {detail.has_raw_cookie && 含 App 原始凭据} ) }, { 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: {detail.status_label} }, ...(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 && ( 探针结论:{detail.ck_check_message} )} {detail.buyer_cluster && detail.buyer_cluster.others.length > 0 && ( // 同一个买家(绑定账号 + 绑定角色相同)名下的其它单:软识别,点一下直接切过去 <> 同一个买家的其它单({detail.buyer_cluster.key} · 共 {detail.buyer_cluster.total} 单) {detail.buyer_cluster.others.map((item) => ( {item.status_label || item.status} {item.gift_name || '未填礼包'} · {item.shop || '未填店铺'} {item.uid ? ` · 虎牙号 ${item.uid}` : ''} ))} )} {canEdit && !detail.is_archived && ( <> 可编辑字段
void patchOrder(detail, values)} > { detailForm.setFieldValue('required_points', giftRequiredPoints(value)); }} />
)} 主页截图(第一张是封面) 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 && ( // 老数据只读展示:绑定图已停用,但已经传过的图不该从界面上凭空消失 <> 历史绑定图(只读,已停用 —— 绑定关系改由「验证 CK」的探针读取) {detail.bind_images.map((key, index) => ( {`历史绑定图${index ))} )} {canEdit && !detail.is_archived && ( {detail.status === 'void' ? ( isAdmin && ( ) ) : ( <> {detail.next_status && ( )} )} )} 操作时间线 {timeline.length === 0 && !timelineLoading && } ({ color: item.success === false ? 'red' : 'blue', children: ( {ACTION_LABELS[item.action] || item.action} · {item.username || '-'} {formatTime(item.created_at)} {item.detail && ( {item.detail} )} ), }))} /> 账号全生命周期(积分 / 兑换记录) {canEdit && !detail.is_archived && ( )} 每次查询都会追加一条快照(只追加、不修改);「已上脚本」的单会按结论自动改成「抢购成功」或「抢购失败」。 {!detail.has_cookie && ( 这单还没贴 CK,查不了积分 / 兑换记录。 )} {metrics.length === 0 && !metricsLoading && ( )} { // 一次查询只查一半时,另一半**压根没查** —— 别把「没查」显示成「积分 -」「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: ( {item.verdict_label} {hasScore ? ( <> 积分 {item.score ?? '-'} 获得 {item.gained_score ?? '-'} / 消耗 {item.used_score ?? '-'} ) : ( 本次没查积分 )} {hasRecords ? ( 兑换记录 {item.records_total} 条(命中 {item.records_matched}) ) : ( 本次没查兑换记录 )} {item.message && ( {item.message} )} {item.matched_preview.length > 0 && ( {item.matched_preview.map((matched, index) => ( {matched.prize_name || '(无礼包名)'} {' '} {matched.status_label} {matched.order_id ? ` · 兑换单 ${matched.order_id}` : ''} {matched.exchange_time ? ` · ${formatTime(new Date(matched.exchange_time * 1000).toISOString())}` : ''} ))} )} {formatTime(item.created_at)} · {item.created_by_name || '系统'} {' · '}{item.source === 'batch' ? '批量查询' : '手动查询'} {' · '}{scope === 'both' ? '积分 + 兑换记录' : scope === 'score' ? '只查积分' : '只查兑换记录'} ), }; })} /> )}
void submitCreate()} confirmLoading={busy} okText="新建" // 一键建单:不再要求先勾「先建单」或等 CK 验证通过;资料没填全自动存成「待补资料」 // 内容比一屏高:给个滚动区,别让底部按钮被顶出可视范围 styles={{ body: { maxHeight: '78vh', overflowY: 'auto', paddingRight: 12 } }} destroyOnHidden >
, allValues: Record) => { if (typeof changed.ck_text === 'string') scheduleCkPreview(changed.ck_text); persistCreateDraft(allValues); }} > {draftRestoredAt && ( 清空草稿 )} /> )} 只填订单号 + 店铺也能直接点右下角「新建」:资料没填全会自动按「待补资料」创建, CK / 礼包 / 主页截图之后在详情里补即可。填写内容会自动暂存在本机,关闭 / 刷新后再打开会恢复。 {user?.username || '-'}(默认) {/* 店铺独占一行:按钮要够宽才不会被挤到第二行(「新店」跟在同一行后面) */} {/* 高度随内容走:短 CK 就 3 行,别固定 5 行把弹窗撑高 */} {(previewing || ckPreview) && (
{previewing ? ( ) : ckPreview?.ok && ckPreview.has_role ? (
{`游戏账号:${ckPreview.bind_game_account || '-'}` + `(${ckPreview.bind_game_channel || '-'})` + `;虎牙昵称:${ckPreview.nickname || '-'}` + `;虎牙号:${ckPreview.uid}` + (giftSourceText ? `;${giftSourceText}` : '')}
{ckPreview.uid_used_by.length > 0 && ( // 放开 uid 唯一性后同一份 CK 可以服务多单,这里只提示已经挂在哪些单上
{`这份 CK 的虎牙号已经挂在:${ckPreview.uid_used_by .map((item) => `${item.order_no}(${item.status_label || item.status})`) .join('、')} —— 同一个号服务多单是允许的,照常提交`}
)} } /> ) : ( )}
)} {renderFrequentGiftChips(createGiftValue, (name) => { createForm.setFieldsValue({ gift_name: name, required_points: giftRequiredPoints(name), }); setGiftHint(''); })} 0 ? `实时礼包缓存于 ${giftCacheLabel}(${giftOptions.length} 个)` : '', giftCatalog.length > 0 ? `标准字典 ${giftCatalog.filter((gift) => gift.is_active).length} 个可用` : '', ].filter(Boolean).join(';') || undefined} validateStatus={giftHint ? 'warning' : undefined} > { supplementForm.setFieldValue('required_points', giftRequiredPoints(value)); }} /> )}
setImportOpen(false)} onOk={() => void submitImport()} confirmLoading={importing} okText="导入" destroyOnHidden > 列顺序:店铺 / 订单编号 / 负责人 / 绑定斗鱼号 / 礼包 / 备注(首行表头可留)。 已存在的订单号按「更新」处理;单行出错不影响其他行。 setImportText(event.target.value)} placeholder={'佳佳\t2627501604891357\t张三\t用户123\t不朽伊人大礼包\t熟人'} /> {importPreview.rows.length > 0 && ( <> 预览(前 {importPreview.rows.length} 行,共 {importPreview.total} 行) {importPreview.header.length > 1 ? '' : ':未识别到表头,按固定列序解析'} 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 || -, }))} /> )} {importResult && ( 结果:新建 {importResult.created} / 更新 {importResult.updated} / 跳过 {importResult.skipped} {importResult.errors.map((error) => ( 第 {error.line} 行({error.order_no || '无订单号'}):{error.message} ))} )} setCkTarget(null)} footer={[ , , , ]} destroyOnHidden > 贴一份新的外部 CK:支持「纯 CK」或「账号----密码----CK」。保存后状态回到「未验证」, 点「验证 CK」用只读探针确认(探针通不通由服务端说了算)。 setCkTarget((current) => (current ? { ...current, text: event.target.value } : current))} placeholder="udb_uid=...;udb_passport=...;udb_cred=..." /> setRechargeOpen(false)} onOk={() => void submitRecharge()} confirmLoading={busy} okText="核账并落状态" destroyOnHidden > 允许结余阈值 setRechargeThreshold(Number(value) || 0)} addonAfter="积分" style={{ width: 160 }} /> 结余 = 账号积分合计 - 礼包积分合计。结余小于 0 或大于阈值 → 整组「积分异常」; 其余 → 整组「已充值」。阈值默认 100。 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 > {statusAction?.mode === 'force' && ( )} {statusAction && statusAction.mode !== 'void' && ( setBatchStatusTarget(value)} options={STATUS_ORDER_OPTIONS} /> setBatchStatusReason(event.target.value)} placeholder={batchStatusTarget === 'void' ? '作废原因(必填)' : batchStatusForce ? '强制恢复原因(必填)' : '改状态说明(可选)'} /> setBatchStatusRollback(event.target.checked)}> 回退(目标不是「往前」时必须勾选,比如 已上脚本 → 待充值) {isAdmin && selectedHasVoid && ( setBatchStatusForce(event.target.checked)}> 强制恢复所选批次中的「已作废」订单(仅超级管理员,必须填写原因) )} 逐行独立提交:某一行前置校验没过(例如资料不全进不了「待充值」)只跳过那一行, 结果会在弹窗里逐条列出来。一次最多 50 行。 关闭, , ]} destroyOnHidden > {ckExport && ckExport.skipped.length > 0 && ( 跳过 {ckExport.skipped.length} 条: {ckExport.skipped.map((item) => `${item.order_no}(${item.reason})`).join(';')} )} {ckExportRisky.length > 0 && ( 其中 {ckExportRisky.length} 条的 CK 已失效 / 已停用: {ckExportRisky.map((item) => `${item.order_no}(${item.ck_status_label})`).join(';')} )} setCkExportFormat(event.target.value)}> 每行一份纯 CK(直接粘) 订单号 + Tab + CK(多选时好对号) 共 {ckExport?.exported ?? 0} 份 · {ckExportText.length} 字符; 关掉这个弹窗就清掉,不会留在页面上。 setPointsConfigOpen(false)} onOk={() => void savePointsConfig()} confirmLoading={pointsConfigSaving} okText="保存" width={680} destroyOnHidden >
setShopModalOpen(false)} onChanged={() => void load()} /> setGiftModalOpen(false)} onChanged={() => void load()} /> ); }