Files
live-hub-py/web/frontend/src/pages/HuyaCollabPage.tsx
T
yml2213 df8c63b829 feat(huya): 外部 CK 协作台账(S1–S6)+ 图片上传能力
协作台账:新表 huya_external_orders 替代 Excel 多人协同表(设计见
docs/设计-虎牙外部CK协作台账.md),一条 CK 一行,所有人共享、改动留痕。

- 前端 pages/HuyaCollabPage.tsx + 菜单「虎牙 → 外部单协作」/huya/collab:
  内联编辑(带乐观锁 version)、图片缩略图、详情抽屉(字段表单 + 两个图片槽位 +
  操作时间线)、粘贴导入弹窗(带 TSV 预览)、批量/单条验证 CK、页头统计条、筛选
- 两套状态机:业务 pending→bound→recharged→scripted→done(旁路 void)、
  凭据 unverified/valid/invalid/disabled;唯一写入口在 services/huya_collab_service.py,
  字段级审计 + 整数 version 乐观锁(不用 updated_at:DATETIME 只到秒)
- CK 只读探针 check_user_bind_game_account(真机判据:响应非 None 且 status == 200),
  成功即覆盖写绑定快照 bind_game_account/bind_game_role/bind_game_channel(迁移 0056),
  失败只改凭据状态、不动业务状态;人工只能停用/恢复(POST /{id}/ck/status)
- 贴/换/重建 CK、TSV 批量导入(SAVEPOINT 单行隔离,部分失败不整体失败)、
  权限 huya:collab:view/edit/manage(删除只给运营)
- 孤儿图清理脚本 scripts/cleanup_orphan_file_assets.py(默认 --dry-run)

图片上传(协作台账截图用):OSS/本地双后端 + 缩略图变体 + /api/files 代理回读,
权限 file:upload / file:view。

验证:pytest 880 passed / 6 skipped;ruff format 全绿、ruff check 仅剩既有告警;
前端 tsc -b / eslint / vite build 通过;接口端到端冒烟 29 项全绿(TestClient,探针用假客户端);
探针真机冒烟 3 例(有效 CK=True、别人 uid=False、裸 cookie=False)。
2026-10-16 19:38:59 +08:00

1151 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 虎牙外部单协作台账(`/huya/collab`)— 替代 Excel 多人协同表。
*
* 设计:`docs/设计-虎牙外部CK协作台账.md`。三条要点:
* 1. **一行 = 一单 = 一个外部虎牙 CK**,所有人共享同一张表,改动靠审计留痕;
* 2. **绑定账号 / 绑定角色**来自「验证 CK」的只读探针快照(人工不可编辑,每次验证覆盖);
* 3. **内联编辑必须带 `expected_version`**:别人先改过会 409,绝不静默覆盖(Excel 最痛的点)。
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
App, Button, Card, Col, Descriptions, Divider, Drawer, Dropdown, Empty, Form, Image,
Input, Modal, Popconfirm, Progress, Row, Select, Space, Statistic, Switch, Table, Tag,
Timeline, Tooltip, Typography, Upload,
} from 'antd';
import type { TableProps } from 'antd';
import {
CheckCircleOutlined, CloudUploadOutlined, DeleteOutlined, EditOutlined, FileExcelOutlined,
LinkOutlined, PlusOutlined, ReloadOutlined, SafetyCertificateOutlined, StopOutlined,
SwapOutlined, UndoOutlined,
} from '@ant-design/icons';
import {
fileApi,
huyaApi,
type HuyaCollabAuditItem,
type HuyaCollabImageSlot,
type HuyaCollabOrder,
type HuyaCollabOrderQuery,
type HuyaCollabOrderUpdate,
type HuyaCollabOwner,
type HuyaCollabStatus,
type HuyaCollabSummary,
} from '../api/modules';
import { message } from '../utils/antdMessage';
import { getErrorMessage } from '../utils/error';
import { usePermissions } from '../hooks/usePermissions';
const { Text, Paragraph } = Typography;
const STATUS_META: Record<HuyaCollabStatus, { label: string; color: string }> = {
pending: { label: '待处理', color: 'default' },
bound: { label: '已绑定', color: 'blue' },
recharged: { label: '已充值', color: 'geekblue' },
scripted: { label: '已上脚本', color: 'purple' },
done: { label: '已完成', color: 'success' },
void: { label: '已作废', color: 'error' },
};
const CK_META: Record<string, { label: string; color: string }> = {
unverified: { label: '未验证', color: 'default' },
valid: { label: '有效', color: 'success' },
invalid: { label: '已失效', color: 'error' },
disabled: { label: '已停用', color: 'warning' },
};
const ACTION_LABELS: Record<string, string> = {
'huya:collab:create': '新建',
'huya:collab:update': '编辑',
'huya:collab:status': '状态变更',
'huya:collab:ck': 'CK 变更',
'huya:collab:ck_status': 'CK 验证',
'huya:collab:probe_snapshot': '绑定快照刷新',
'huya:collab:image': '图片变更',
'huya:collab:delete': '删除',
};
const SLOT_META: Record<HuyaCollabImageSlot, { label: string; keysKey: 'bind_images' | 'home_images' }> = {
bind_images: { label: '绑定图', keysKey: 'bind_images' },
home_images: { label: '主页账号图', keysKey: 'home_images' },
};
const IMPORT_PREVIEW_ROWS = 20;
const MAX_IMAGES_PER_SLOT = 20;
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); }}
style={{ cursor: disabled ? 'default' : 'text', minHeight: 22 }}
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 } = usePermissions();
const canEdit = can('huya:collab:edit');
const canManage = can('huya:collab:manage');
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<string[]>([]);
const [filters, setFilters] = useState<HuyaCollabOrderQuery>({ include_void: false });
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [detail, setDetail] = useState<HuyaCollabOrder | null>(null);
const [timeline, setTimeline] = useState<HuyaCollabAuditItem[]>([]);
const [timelineLoading, setTimelineLoading] = useState(false);
const [detailForm] = Form.useForm<HuyaCollabOrderUpdate>();
const [createOpen, setCreateOpen] = useState(false);
const [createForm] = Form.useForm<Record<string, unknown>>();
const [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);
const [statusAction, setStatusAction] = useState<{
order: HuyaCollabOrder; mode: 'void' | 'rollback'; target?: HuyaCollabStatus;
} | null>(null);
const [statusReason, setStatusReason] = useState('');
const [rollbackTarget, setRollbackTarget] = useState<HuyaCollabStatus>('pending');
const load = useCallback(async () => {
setLoading(true);
try {
const [list, nextSummary, nextOwners, nextShops] = await Promise.all([
huyaApi.listCollabOrders({ ...filters, page, page_size: pageSize }),
huyaApi.collabOrdersSummary(),
huyaApi.collabOrderOwners(),
huyaApi.collabOrderShops(),
]);
setRows(list.items);
setTotal(list.total);
setSummary(nextSummary);
setOwners(nextOwners);
setShops(nextShops);
// 选中是「当前页」的概念:刷新后剔除已经不在这页上的行,避免点到别人的脏 id。
setSelectedIds((ids) => ids.filter((id) => list.items.some((row) => row.id === id)));
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setLoading(false);
}
}, [filters, page, pageSize]);
useEffect(() => { void load(); }, [load]);
const 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 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,
bind_douyu_account: row.bind_douyu_account,
gift_name: row.gift_name,
remark: row.remark,
});
setTimeline([]);
setTimelineLoading(true);
try {
setTimeline(await huyaApi.collabOrderTimeline(row.id));
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setTimelineLoading(false);
}
};
const verifyOne = async (row: HuyaCollabOrder) => {
setBusy(true);
try {
const updated = await huyaApi.verifyCollabOrder(row.id);
applyUpdated(updated);
if (updated.ck_status === 'valid') {
message.success(
updated.bind_game_role
? `CK 有效:绑定角色 ${updated.bind_game_role}`
: 'CK 有效',
);
} else {
message.warning(updated.ck_check_message || 'CK 验证未通过');
}
void load();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setBusy(false);
}
};
const verifySelected = async () => {
if (selectedIds.length === 0) return;
setBusy(true);
try {
const result = await huyaApi.verifyCollabOrders(selectedIds);
const failures = result.results.filter((item) => !item.ok);
if (failures.length === 0) {
message.success(`验证完成:${result.valid} 条全部有效`);
} else {
modal.info({
title: `验证完成:有效 ${result.valid} / 未通过 ${result.failed}`,
width: 640,
content: (
<Space direction="vertical" style={{ width: '100%' }}>
{failures.map((item) => (
<Text key={item.id} type="danger">
{item.order_no}:{item.message}
</Text>
))}
</Space>
),
});
}
setSelectedIds([]);
void load();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setBusy(false);
}
};
const advance = async (row: HuyaCollabOrder) => {
if (!row.next_status) return;
setBusy(true);
try {
applyUpdated(await huyaApi.updateCollabOrderStatus(row.id, {
status: row.next_status,
expected_version: row.version,
}));
message.success(`已标记为「${STATUS_META[row.next_status].label}」`);
void load();
} catch (e: unknown) {
message.error(getErrorMessage(e));
void load();
} finally {
setBusy(false);
}
};
const submitStatusAction = async () => {
if (!statusAction) return;
const { order, mode, target } = statusAction;
if (mode === 'void' && !statusReason.trim()) {
message.warning('作废必须填原因');
return;
}
setBusy(true);
try {
applyUpdated(await huyaApi.updateCollabOrderStatus(order.id, {
status: mode === 'void' ? 'void' : (target ?? rollbackTarget),
reason: statusReason,
rollback: mode === 'rollback',
expected_version: order.version,
}));
message.success(mode === 'void' ? '已作废' : '已回退');
setStatusAction(null);
setStatusReason('');
void load();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setBusy(false);
}
};
const submitCk = async (action: 'paste' | 'rebuild') => {
if (!ckTarget) return;
if (action === 'paste' && !ckTarget.text.trim()) {
message.warning('请先贴一份 CK');
return;
}
setBusy(true);
try {
const updated = await huyaApi.updateCollabOrderCk(ckTarget.order.id, {
ck_text: ckTarget.text,
action,
expected_version: ckTarget.order.version,
});
applyUpdated(updated);
message.success(action === 'rebuild' ? '已从 App 原始凭据重建' : 'CK 已保存,待验证');
setCkTarget(null);
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setBusy(false);
}
};
const toggleCkDisabled = async (row: HuyaCollabOrder) => {
const next = row.ck_status === 'disabled' ? 'unverified' : 'disabled';
setBusy(true);
try {
const updated = await huyaApi.updateCollabOrderCkStatus(row.id, {
status: next,
message: next === 'disabled' ? '人工停用' : '人工恢复,待验证',
expected_version: row.version,
});
applyUpdated(updated);
message.success(next === 'disabled' ? '已停用这份 CK' : '已恢复(待验证)');
void load();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setBusy(false);
}
};
const removeOrder = async (row: HuyaCollabOrder) => {
setBusy(true);
try {
await huyaApi.deleteCollabOrder(row.id);
message.success(`已删除订单 ${row.order_no}`);
if (detail?.id === row.id) setDetail(null);
void load();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setBusy(false);
}
};
const commitSlot = async (row: HuyaCollabOrder, slot: HuyaCollabImageSlot, keys: string[]) => {
const updated = await huyaApi.updateCollabOrderImages(row.id, {
slot, images: keys, expected_version: row.version,
});
applyUpdated(updated);
};
const uploadImage = async (row: HuyaCollabOrder, slot: HuyaCollabImageSlot, file: File) => {
setBusy(true);
try {
const uploaded = await fileApi.upload(file, 'collab');
const current = slot === 'bind_images' ? row.bind_images : row.home_images;
await commitSlot(row, slot, [...current, uploaded.object_key]);
message.success('图片已上传');
} catch (e: unknown) {
message.error(getErrorMessage(e));
void load();
} finally {
setBusy(false);
}
};
const removeImage = async (row: HuyaCollabOrder, slot: HuyaCollabImageSlot, key: string) => {
setBusy(true);
try {
const current = slot === 'bind_images' ? row.bind_images : row.home_images;
await commitSlot(row, slot, current.filter((item) => item !== key));
message.success('已从这一行移除');
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setBusy(false);
}
};
const setCoverImage = async (row: HuyaCollabOrder, slot: HuyaCollabImageSlot, key: string) => {
setBusy(true);
try {
const current = slot === 'bind_images' ? row.bind_images : row.home_images;
await commitSlot(row, slot, [key, ...current.filter((item) => item !== key)]);
message.success('已设为封面');
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setBusy(false);
}
};
const submitCreate = async () => {
try {
const values = await createForm.validateFields();
const created = await huyaApi.createCollabOrder({
order_no: String(values.order_no ?? '').trim(),
shop: String(values.shop ?? ''),
owner_id: (values.owner_id as number | undefined) ?? null,
ck_text: String(values.ck_text ?? ''),
bind_douyu_account: String(values.bind_douyu_account ?? ''),
gift_name: String(values.gift_name ?? ''),
remark: String(values.remark ?? ''),
});
message.success(`已新建 ${created.order_no}`);
setCreateOpen(false);
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 shopOptions = shops.map((shop) => ({ value: shop, label: shop }));
const finished = summary ? (summary.by_status.done ?? 0) : 0;
const columns: TableProps<HuyaCollabOrder>['columns'] = [
{
title: '订单编号', dataIndex: 'order_no', width: 170, fixed: 'left',
render: (value: string, row) => (
<Space direction="vertical" size={0}>
<Text copyable={{ text: value }} strong>{value}</Text>
{row.status === 'void' && <Tag color="error">已作废:{row.void_reason || '-'}</Tag>}
</Space>
),
},
{
title: '店铺', dataIndex: 'shop', width: 110,
render: (value: string, row) => (
<EditableText value={value} disabled={!canEdit} placeholder="+ 店铺"
onCommit={(next) => void patchOrder(row, { shop: next })} />
),
},
{
title: '负责人', dataIndex: 'owner_id', width: 130,
render: (value: number | null, row) => (
<Select
size="small" variant="borderless" style={{ width: '100%' }}
value={value ?? undefined} options={ownerOptions} disabled={!canEdit}
placeholder="+ 指派" allowClear showSearch optionFilterProp="label"
onChange={(next: number | undefined) => void patchOrder(row, { owner_id: next ?? null })}
/>
),
},
{
title: '绑定账号 / 角色', width: 210,
render: (_, row) => (
<Space direction="vertical" size={0}>
<Text strong>{row.bind_game_account || <Text type="secondary">未读到</Text>}</Text>
{row.bind_game_role
? <Tag color="green" style={{ marginTop: 2 }}>角色 {row.bind_game_role}</Tag>
: <Text type="secondary" style={{ fontSize: 12 }}>未读到绑定角色</Text>}
{row.bind_game_channel && (
<Text type="secondary" style={{ fontSize: 12 }}>{row.bind_game_channel}</Text>
)}
{!row.bind_snapshot_at && (
<Tooltip title="点这一行的「验证 CK」就会读到绑定账号与角色">
<Text type="secondary" style={{ fontSize: 12 }}>还没验证过</Text>
</Tooltip>
)}
</Space>
),
},
{
title: '绑定图 / 主页图', width: 170,
render: (_, row) => (
<Space size={12}>
{(['bind_images', 'home_images'] as HuyaCollabImageSlot[]).map((slot) => {
const keys = slot === 'bind_images' ? row.bind_images : row.home_images;
const urls = slot === 'bind_images' ? row.bind_image_urls : row.home_image_urls;
const thumbs = slot === 'bind_images' ? row.bind_image_thumb_urls : row.home_image_thumb_urls;
if (keys.length === 0) {
return (
<Text key={slot} type="secondary" style={{ fontSize: 12 }}>
{SLOT_META[slot].label} 0
</Text>
);
}
return (
<Space key={slot} size={4}>
<Image.PreviewGroup items={urls}>
<Image src={thumbs[0] || urls[0]} width={32} height={32}
style={{ objectFit: 'cover', borderRadius: 4 }}
alt={`${SLOT_META[slot].label}封面`} />
</Image.PreviewGroup>
<Text type="secondary" style={{ fontSize: 12 }}>{keys.length} 张</Text>
</Space>
);
})}
</Space>
),
},
{
title: '礼包 / 备注', width: 160,
render: (_, row) => (
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<EditableText value={row.gift_name} disabled={!canEdit} placeholder="+ 礼包名"
onCommit={(next) => void patchOrder(row, { gift_name: next })} />
<EditableText value={row.remark} disabled={!canEdit} placeholder="+ 备注"
onCommit={(next) => void patchOrder(row, { remark: next })} />
</Space>
),
},
{
title: '状态', dataIndex: 'status', width: 130,
render: (value: HuyaCollabStatus, row) => (
<Space direction="vertical" size={2}>
<Tag color={STATUS_META[value]?.color}>{row.status_label || STATUS_META[value]?.label || value}</Tag>
{row.next_status && canEdit && (
<Popconfirm title={`标记为「${STATUS_META[row.next_status].label}」?`}
onConfirm={() => void advance(row)} disabled={busy}>
<Button size="small" type="link" style={{ padding: 0 }}>
→ {STATUS_META[row.next_status].label}
</Button>
</Popconfirm>
)}
</Space>
),
},
{
title: '虎牙号', width: 160,
render: (_, row) => (
<Space direction="vertical" size={0}>
<Text>{row.uid || <Text type="secondary">未贴 CK</Text>}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{row.hy_username || '-'}</Text>
</Space>
),
},
{
title: 'CK 状态', dataIndex: 'ck_status', width: 150,
render: (value: string, row) => (
<Space direction="vertical" size={2}>
<Tag color={CK_META[value]?.color}>{row.ck_status_label || CK_META[value]?.label || value}</Tag>
{row.ck_check_message && (
<Tooltip title={`${row.ck_check_message}(${formatTime(row.ck_checked_at)})`}>
<Text type="secondary" style={{ fontSize: 12 }} ellipsis>{row.ck_check_message}</Text>
</Tooltip>
)}
</Space>
),
},
{
title: '最后改动', width: 160,
render: (_, row) => (
<Space direction="vertical" size={0}>
<Text>{row.updated_by_name || '-'}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{formatTime(row.updated_at)}</Text>
</Space>
),
},
{
title: '操作', width: 150, fixed: 'right', align: 'center',
render: (_, row) => (
<Space size={4}>
<Button size="small" icon={<EditOutlined />} onClick={() => void openDetail(row)}>详情</Button>
{canEdit && (
<Dropdown
menu={{
items: [
{ key: 'verify', label: '验证 CK(只读探针)', icon: <SafetyCertificateOutlined /> },
{ key: 'ck', label: '贴 / 换 CK', icon: <LinkOutlined /> },
{ key: 'rebuild', label: '从 App 原始凭据重建', icon: <ReloadOutlined />, disabled: !row.has_raw_cookie },
{ key: 'disable', label: row.ck_status === 'disabled' ? '恢复这份 CK' : '停用这份 CK', icon: <StopOutlined /> },
{ type: 'divider' },
{ key: 'rollback', label: '回退状态…', icon: <UndoOutlined /> },
{ key: 'void', label: '作废…', icon: <StopOutlined />, danger: true },
...(canManage
? [{ key: 'delete', label: '删除这一行', icon: <DeleteOutlined />, danger: true }]
: []),
],
onClick: ({ key }) => {
if (key === 'verify') void verifyOne(row);
if (key === 'ck') setCkTarget({ order: row, text: '' });
if (key === 'rebuild') setCkTarget({ order: row, text: '' });
if (key === 'disable') void toggleCkDisabled(row);
if (key === 'rollback') {
setRollbackTarget('pending');
setStatusReason('');
setStatusAction({ order: row, mode: 'rollback' });
}
if (key === 'void') {
setStatusReason('');
setStatusAction({ order: row, mode: 'void' });
}
if (key === 'delete') {
modal.confirm({
title: `删除订单 ${row.order_no}?`,
content: '只删这一行,对象存储里的图不会立刻删除(交给孤儿清理脚本)。',
okButtonProps: { danger: true },
onOk: () => removeOrder(row),
});
}
},
}}
>
<Button size="small" icon={<SwapOutlined />} disabled={busy} />
</Dropdown>
)}
</Space>
),
},
];
return (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Row gutter={12}>
<Col span={4}><Card size="small"><Statistic title="全部" value={summary?.total ?? 0} /></Card></Col>
<Col span={4}>
<Card size="small">
<Statistic title="待处理" value={summary?.by_status.pending ?? 0} valueStyle={{ color: '#faad14' }} />
</Card>
</Col>
<Col span={4}>
<Card size="small">
<Statistic title="已绑定" value={summary?.by_status.bound ?? 0} valueStyle={{ color: '#1677ff' }} />
</Card>
</Col>
<Col span={4}>
<Card size="small">
<Statistic title="已充值 / 已上脚本"
value={(summary?.by_status.recharged ?? 0) + (summary?.by_status.scripted ?? 0)}
valueStyle={{ color: '#722ed1' }} />
</Card>
</Col>
<Col span={4}>
<Card size="small">
<Statistic title="已完成" value={finished} valueStyle={{ color: '#52c41a' }} />
<Progress percent={summary?.total ? Math.round((finished / summary.total) * 100) : 0} size="small" />
</Card>
</Col>
<Col span={4}>
<Card size="small">
<Statistic title="CK 失效" value={summary?.ck_invalid ?? 0} valueStyle={{ color: '#ff4d4f' }} />
</Card>
</Col>
</Row>
<Card size="small">
<Space wrap style={{ width: '100%', justifyContent: 'space-between' }}>
<Space wrap>
<Select allowClear showSearch style={{ width: 150 }} placeholder="店铺"
options={shopOptions}
value={filters.shop || undefined}
onChange={(value?: string) => { setPage(1); setFilters((prev) => ({ ...prev, shop: value })); }} />
<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 })); }} />
<Input.Search allowClear placeholder="订单号 / 绑定账号 / 角色 / 虎牙号" style={{ width: 260 }}
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>
<Space wrap>
<Button icon={<ReloadOutlined />} onClick={() => void load()} disabled={loading}>刷新</Button>
{canEdit && (
<Button icon={<SafetyCertificateOutlined />} disabled={selectedIds.length === 0 || busy}
onClick={() => void verifySelected()}>
批量验证 CK{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
)}
{canEdit && (
<Button icon={<FileExcelOutlined />} onClick={() => { setImportResult(null); setImportOpen(true); }}>
粘贴导入
</Button>
)}
{canEdit && (
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>新建</Button>
)}
</Space>
</Space>
</Card>
<Table<HuyaCollabOrder>
rowKey="id"
size="small"
loading={loading}
columns={columns}
dataSource={rows}
scroll={{ x: 1680, y: 'calc(100vh - 460px)' }}
rowSelection={canEdit ? {
selectedRowKeys: selectedIds,
onChange: (keys) => setSelectedIds(keys.map(Number)),
} : undefined}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (count) => `共 ${count} 条`,
onChange: (nextPage, nextPageSize) => { setPage(nextPage); setPageSize(nextPageSize); },
}}
/>
<Drawer
width={680}
open={!!detail}
onClose={() => setDetail(null)}
title={detail ? `订单 ${detail.order_no}` : ''}
destroyOnHidden
>
{detail && (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Descriptions size="small" column={2} bordered
items={[
{ key: 'uid', label: '虎牙号', children: detail.uid || '-' },
{ key: 'username', label: '登录名 / 昵称', children: `${detail.hy_username || '-'} / ${detail.nickname || '-'}` },
{ key: 'ck', label: 'CK 状态',
children: (
<Space>
<Tag color={CK_META[detail.ck_status]?.color}>{detail.ck_status_label}</Tag>
{detail.has_cookie ? <Tag color="geekblue">已存 CK</Tag> : <Tag>无 CK</Tag>}
{detail.has_raw_cookie && <Tag color="cyan">含 App 原始凭据</Tag>}
</Space>
) },
{ key: 'checked', label: '最近验证', children: formatTime(detail.ck_checked_at) },
{ key: 'game_account', label: '绑定账号(探针)', children: detail.bind_game_account || '-' },
{ key: 'game_role', label: '绑定角色(探针)', children: detail.bind_game_role || '-' },
{ key: 'game_channel', label: '平台 / 区服', children: detail.bind_game_channel || '-' },
{ key: 'snapshot_at', label: '绑定快照时间', children: formatTime(detail.bind_snapshot_at) },
{ key: 'status', label: '业务状态',
children: <Tag color={STATUS_META[detail.status]?.color}>{detail.status_label}</Tag> },
{ key: 'bound_at', label: '绑定 / 充值时间',
children: `${formatTime(detail.bound_at)} / ${formatTime(detail.recharged_at)}` },
{ key: 'scripted_at', label: '上脚本 / 归档时间',
children: `${formatTime(detail.scripted_at)} / ${formatTime(detail.done_at)}` },
{ key: 'updated', label: '最后改动',
children: `${detail.updated_by_name || '-'} · ${formatTime(detail.updated_at)}` },
]} />
{detail.ck_check_message && (
<Text type="secondary">探针结论:{detail.ck_check_message}</Text>
)}
{canEdit && (
<>
<Divider style={{ margin: '4px 0' }}>可编辑字段</Divider>
<Form
form={detailForm}
layout="vertical"
size="small"
initialValues={{
order_no: detail.order_no, shop: detail.shop, owner_id: detail.owner_id,
bind_douyu_account: detail.bind_douyu_account,
gift_name: detail.gift_name, remark: detail.remark,
}}
onFinish={(values) => void patchOrder(detail, values)}
>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="order_no" label="订单编号" rules={[{ required: true, message: '不能为空' }]}>
<Input maxLength={64} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="shop" label="店铺"><Input maxLength={64} /></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="bind_douyu_account" label="绑定斗鱼号(手工填,Excel 导入列)"
tooltip="这一列是人工自由文本;「绑定账号 / 绑定角色」由「验证 CK」的探针写入">
<Input maxLength={128} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="gift_name" label="礼包名"><Input maxLength={128} /></Form.Item>
</Col>
<Col span={24}>
<Form.Item name="remark" label="备注"><Input.TextArea rows={2} maxLength={512} /></Form.Item>
</Col>
</Row>
<Space>
<Button type="primary" htmlType="submit" disabled={busy}>保存字段</Button>
<Button icon={<SafetyCertificateOutlined />} disabled={busy || !detail.has_cookie}
onClick={() => void verifyOne(detail)}>验证 CK</Button>
<Button icon={<LinkOutlined />} onClick={() => setCkTarget({ order: detail, text: '' })}>贴 / 换 CK</Button>
<Button icon={<ReloadOutlined />} disabled={!detail.has_raw_cookie}
onClick={() => setCkTarget({ order: detail, text: '' })}>重建 CK</Button>
<Button icon={<StopOutlined />} onClick={() => void toggleCkDisabled(detail)}>
{detail.ck_status === 'disabled' ? '恢复 CK' : '停用 CK'}
</Button>
</Space>
</Form>
</>
)}
<Divider style={{ margin: '4px 0' }}>截图(第一张是封面)</Divider>
{(['bind_images', 'home_images'] as HuyaCollabImageSlot[]).map((slot) => (
<ImageSlotEditor
key={slot}
order={detail}
slot={slot}
canEdit={canEdit}
busy={busy}
onUpload={(targetSlot, file) => void uploadImage(detail, targetSlot, file)}
onRemove={(targetSlot, key) => void removeImage(detail, targetSlot, key)}
onSetCover={(targetSlot, key) => void setCoverImage(detail, targetSlot, key)}
/>
))}
{canEdit && (
<Space>
{detail.next_status && (
<Button icon={<CheckCircleOutlined />} disabled={busy}
onClick={() => void advance(detail)}>
标记为「{STATUS_META[detail.next_status].label}」
</Button>
)}
<Button icon={<UndoOutlined />} disabled={busy}
onClick={() => { setRollbackTarget('pending'); setStatusReason(''); setStatusAction({ order: detail, mode: 'rollback' }); }}>
回退状态
</Button>
{detail.status !== 'void' && (
<Button danger icon={<StopOutlined />} disabled={busy}
onClick={() => { setStatusReason(''); setStatusAction({ order: detail, mode: 'void' }); }}>
作废
</Button>
)}
</Space>
)}
<Divider style={{ margin: '4px 0' }}>操作时间线</Divider>
{timeline.length === 0 && !timelineLoading && <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有操作记录" />}
<Timeline
items={timeline.map((item) => ({
color: item.success === false ? 'red' : 'blue',
children: (
<Space direction="vertical" size={0}>
<Text strong>
{ACTION_LABELS[item.action] || item.action}
<Text type="secondary" style={{ fontWeight: 400 }}> · {item.username || '-'}</Text>
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{formatTime(item.created_at)}</Text>
{item.detail && (
<Paragraph style={{ marginBottom: 0 }} ellipsis={{ rows: 2, expandable: true, symbol: '展开' }}>
<Text code style={{ fontSize: 12 }}>{item.detail}</Text>
</Paragraph>
)}
</Space>
),
}))}
/>
</Space>
)}
</Drawer>
<Modal
title="新建协作单"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={() => void submitCreate()}
confirmLoading={busy}
okText="新建"
destroyOnHidden
>
<Form form={createForm} layout="vertical" size="small">
<Form.Item name="order_no" label="订单编号" rules={[{ required: true, message: '订单号不能为空' }]}>
<Input maxLength={64} placeholder="Excel B 列" />
</Form.Item>
<Row gutter={12}>
<Col span={12}><Form.Item name="shop" label="店铺"><Input maxLength={64} /></Form.Item></Col>
<Col span={12}>
<Form.Item name="owner_id" label="负责人">
<Select allowClear showSearch optionFilterProp="label" options={ownerOptions} placeholder="未指派" />
</Form.Item>
</Col>
</Row>
<Form.Item name="ck_text" label="外部 CK(可选,支持「纯CK」或「账号----密码----CK」)">
<Input.TextArea rows={3} placeholder="贴进来就会解析出虎牙 uid" />
</Form.Item>
<Row gutter={12}>
<Col span={12}><Form.Item name="bind_douyu_account" label="绑定斗鱼号(可选)"><Input maxLength={128} /></Form.Item></Col>
<Col span={12}><Form.Item name="gift_name" label="礼包名(可选)"><Input maxLength={128} /></Form.Item></Col>
</Row>
<Form.Item name="remark" label="备注(可选)"><Input.TextArea rows={2} maxLength={512} /></Form.Item>
</Form>
</Modal>
<Modal
title="粘贴导入(Excel 复制出来的 TSV)"
open={importOpen}
width={760}
onCancel={() => setImportOpen(false)}
onOk={() => void submitImport()}
confirmLoading={importing}
okText="导入"
destroyOnHidden
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Text type="secondary">
列顺序:店铺 / 订单编号 / 负责人 / 绑定斗鱼号 / 礼包 / 备注(首行表头可留)。
已存在的订单号按「更新」处理;单行出错不影响其他行。
</Text>
<Input.TextArea rows={8} value={importText} onChange={(event) => setImportText(event.target.value)}
placeholder={'佳佳\t2627501604891357\t张三\t用户123\t不朽伊人大礼包\t熟人'} />
{importPreview.rows.length > 0 && (
<>
<Text type="secondary">
预览(前 {importPreview.rows.length} 行,共 {importPreview.total} 行)
{importPreview.header.length > 1 ? '' : ':未识别到表头,按固定列序解析'}
</Text>
<Table
size="small"
rowKey={(_, index) => String(index)}
pagination={false}
scroll={{ x: 700, y: 200 }}
dataSource={importPreview.rows.map((cells, index) => ({ index, cells }))}
columns={['店铺', '订单编号', '负责人', '绑定斗鱼号', '礼包', '备注'].map((title, columnIndex) => ({
title, dataIndex: ['cells', columnIndex], width: 120, ellipsis: true,
render: (value: string | undefined) => value || <Text type="secondary">-</Text>,
}))}
/>
</>
)}
{importResult && (
<Space direction="vertical" size={2} style={{ width: '100%' }}>
<Text>
结果:新建 {importResult.created} / 更新 {importResult.updated} / 跳过 {importResult.skipped}
</Text>
{importResult.errors.map((error) => (
<Text key={`${error.line}-${error.order_no}`} type="danger">
第 {error.line} 行({error.order_no || '无订单号'}):{error.message}
</Text>
))}
</Space>
)}
</Space>
</Modal>
<Modal
title={ckTarget?.order ? `CK:订单 ${ckTarget.order.order_no}` : 'CK'}
open={!!ckTarget}
onCancel={() => setCkTarget(null)}
footer={[
<Button key="cancel" onClick={() => setCkTarget(null)}>取消</Button>,
<Button key="rebuild" icon={<ReloadOutlined />} disabled={busy || !ckTarget?.order.has_raw_cookie}
onClick={() => void submitCk('rebuild')}>用 App 原始凭据重建</Button>,
<Button key="paste" type="primary" disabled={busy} onClick={() => void submitCk('paste')}>保存并待验证</Button>,
]}
destroyOnHidden
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Text type="secondary">
贴一份新的外部 CK:支持「纯 CK」或「账号----密码----CK」。保存后状态回到「未验证」,
点「验证 CK」用只读探针确认(探针通不通由服务端说了算)。
</Text>
<Input.TextArea rows={6} value={ckTarget?.text ?? ''} autoFocus
onChange={(event) => setCkTarget((current) => (current ? { ...current, text: event.target.value } : current))}
placeholder="udb_uid=...;udb_passport=...;udb_cred=..." />
</Space>
</Modal>
<Modal
title={statusAction?.mode === 'void'
? `作废订单 ${statusAction.order.order_no}`
: `回退订单 ${statusAction?.order.order_no ?? ''}`}
open={!!statusAction}
onCancel={() => setStatusAction(null)}
onOk={() => void submitStatusAction()}
confirmLoading={busy}
okButtonProps={{ danger: statusAction?.mode === 'void' }}
okText={statusAction?.mode === 'void' ? '作废' : '回退'}
destroyOnHidden
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{statusAction?.mode === 'rollback' && (
<Select
style={{ width: '100%' }}
value={rollbackTarget}
onChange={(value: HuyaCollabStatus) => setRollbackTarget(value)}
options={(['pending', 'bound', 'recharged', 'scripted'] as HuyaCollabStatus[]).map((key) => ({
value: key, label: STATUS_META[key].label,
}))}
/>
)}
<Input.TextArea rows={2} value={statusReason} onChange={(event) => setStatusReason(event.target.value)}
placeholder={statusAction?.mode === 'void' ? '作废原因(必填)' : '回退说明(可选)'} />
{statusAction?.mode === 'rollback' && (
<Text type="secondary">回退会清掉后面阶段的时间戳,并在审计里留痕。</Text>
)}
</Space>
</Modal>
</Space>
);
}