协作台账:新表 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)。
1151 lines
47 KiB
TypeScript
1151 lines
47 KiB
TypeScript
/**
|
||
* 虎牙外部单协作台账(`/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>
|
||
);
|
||
}
|