3817 lines
162 KiB
TypeScript
3817 lines
162 KiB
TypeScript
/**
|
||
* 虎牙外部单协作台账(`/huya/collab`)— 替代 Excel 多人协同表。
|
||
*
|
||
* 设计:`docs/设计-虎牙外部CK协作台账.md`。三条要点:
|
||
* 1. **一行 = 一单 = 一个外部虎牙 CK**,所有人共享同一张表,改动靠审计留痕;
|
||
* 2. **绑定账号 / 绑定角色**来自「验证 CK」的只读探针快照(人工不可编辑,每次验证覆盖);
|
||
* 3. **内联编辑必须带 `expected_version`**:别人先改过会 409,绝不静默覆盖(Excel 最痛的点)。
|
||
*/
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { 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>
|
||
);
|
||
}
|