feat(huya): 协作台账店铺字典·新建强校验·同买家多单 + uid 放开(迁移 0057/0058)

- 店铺字典(迁移 0057):大按钮点选 + 「新店」自定义自动登记;重命名可级联、
  停用/备注;列表行内改、详情、筛选条共用同一个 picker
- 新建强校验:订单编号/店铺/CK/礼包名/主页截图必填;贴 CK 即探针预览,
  读不到绑定角色禁止提交;礼包用工作台配置的活动 SID 现拉(浏览器缓存 + 强制刷新)
- 图片:绑定图停用、「绑定斗鱼号」下线(绑定关系改由探针读 bind_game_*),
  主页截图改为新建必填(弹窗内先传 OSS 再随表单落库)
- 同买家多单软识别:按「绑定账号 + 绑定角色」整批一次查询,列表「同买家」列 +
  详情分组,点单号直接切单
- uid 放开唯一性(迁移 0058):同一份 CK 可服务多单,原 409 冲突改为提示 + 审计
- 列表体验:序号列、搜索扩到 11 个字段、CK 状态列截断、新建弹窗放大并去滚动条
- 验证:pytest 932 passed / 6 skipped;接口冒烟 56 项全绿;tsc+eslint+build 通过
This commit is contained in:
yml2213
2026-10-16 21:29:08 +08:00
parent df8c63b829
commit aac264b639
17 changed files with 3237 additions and 155 deletions
+22
View File
@@ -32,6 +32,7 @@ import type {
HuyaTaskItem,
HuyaBrowserExchangeKit,
HuyaCollabAuditItem,
HuyaCollabCkPreview,
HuyaCollabCkRequest,
HuyaCollabCkStatusRequest,
HuyaCollabImagesRequest,
@@ -42,6 +43,9 @@ import type {
HuyaCollabOrderQuery,
HuyaCollabOrderUpdate,
HuyaCollabOwner,
HuyaCollabShop,
HuyaCollabShopCreate,
HuyaCollabShopUpdate,
HuyaCollabStatusRequest,
HuyaCollabSummary,
HuyaCollabVerifyResult,
@@ -199,6 +203,18 @@ export const huyaApi = {
api.get<HuyaCollabOwner[], HuyaCollabOwner[]>('/huya/external-orders/owners'),
/** 已用过的店铺(筛选下拉) */
collabOrderShops: () => api.get<string[], string[]>('/huya/external-orders/shops'),
// ---- 店铺字典(新建协作单时点选;见业务规则 docs/设计-虎牙外部CK协作台账.md)----
/** 全部店铺(含停用的:管理弹窗要看到;新建下拉由页面按 is_active 过滤) */
listCollabShops: () => api.get<HuyaCollabShop[], HuyaCollabShop[]>('/huya/collab-shops'),
createCollabShop: (data: HuyaCollabShopCreate) =>
api.post<HuyaCollabShop, HuyaCollabShop>('/huya/collab-shops', data),
updateCollabShop: (id: number, data: HuyaCollabShopUpdate) =>
api.put<HuyaCollabShop, HuyaCollabShop>(`/huya/collab-shops/${id}`, data),
deleteCollabShop: (id: number) =>
api.delete<{ success: boolean; name: string }, { success: boolean; name: string }>(
`/huya/collab-shops/${id}`,
),
getCollabOrder: (id: number) =>
api.get<HuyaCollabOrder, HuyaCollabOrder>(`/huya/external-orders/${id}`),
createCollabOrder: (data: HuyaCollabOrderCreate) =>
@@ -218,6 +234,12 @@ export const huyaApi = {
/** 整槽替换图片(提交 /api/files/upload 返回的 object_key 数组) */
updateCollabOrderImages: (id: number, data: HuyaCollabImagesRequest) =>
api.post<HuyaCollabOrder, HuyaCollabOrder>(`/huya/external-orders/${id}/images`, data),
/** 粘贴 CK 后的即时预览(只读):探针 + 拉活动礼包,一行都不写库 */
previewCollabCk: (ck_text: string, with_gifts = true) =>
api.post<HuyaCollabCkPreview, HuyaCollabCkPreview>('/huya/external-orders/ck/preview', {
ck_text,
with_gifts,
}),
/** 验证 CK(只读探针):写 ck_status,并刷新绑定账号/角色快照 */
verifyCollabOrder: (id: number) =>
api.post<HuyaCollabOrder, HuyaCollabOrder>(`/huya/external-orders/${id}/verify`),
+90
View File
@@ -1259,6 +1259,33 @@ export interface HuyaCollabOrder {
version: number;
created_at: string | null;
updated_at: string | null;
/**
* 同一买家的其它单(软识别:绑定账号 + 绑定角色相同,只读派生)。
* 只有真的 ≥2 单才有值,否则是 null。
*/
buyer_cluster: HuyaCollabBuyerCluster | null;
}
/** 「同一买家的其它单」里的一条 */
export interface HuyaCollabBuyerOrder {
id: number;
order_no: string;
shop: string;
gift_name: string;
uid: string;
status: HuyaCollabStatus;
status_label: string;
created_at: string | null;
}
/** 同一买家的多单(软识别,不是人工"软绑定"关系) */
export interface HuyaCollabBuyerCluster {
/** 买家指纹的可读文本:绑定账号 · 绑定角色 */
key: string;
/** 识别口径(后端说明字段,界面上标注用) */
method: string;
total: number;
others: HuyaCollabBuyerOrder[];
}
export interface HuyaCollabOrderList {
@@ -1306,7 +1333,11 @@ export interface HuyaCollabOrderCreate {
ck_text?: string;
bind_douyu_account?: string;
gift_name?: string;
/** 主页截图(object_key 数组,第一张作封面);严格模式下必填至少 1 张 */
home_images?: string[];
remark?: string;
/** 严格模式(默认 true):店铺/CK/礼包名/主页截图必填,且 CK 必须读到绑定角色 */
require_verified?: boolean;
}
export interface HuyaCollabOrderUpdate {
@@ -1386,3 +1417,62 @@ export interface FileUploadResult {
content_type: string;
size: number;
}
/** 活动礼包(用一份有效 CK 现拉) */
export interface HuyaCollabGift {
product_id: string;
name: string;
price: number | null;
remain_text: string;
stock: number | null;
score: number | null;
category_name: string;
icon: string;
}
/** 粘贴 CK 后的即时预览:ok 且 has_role 才允许提交新建 */
export interface HuyaCollabCkPreview {
ok: boolean;
message: string;
uid: string;
yyuid: string;
hy_username: string;
ck_status: HuyaCollabCkStatus;
bind_game_account: string;
bind_game_role: string;
bind_game_channel: string;
has_role: boolean;
gifts: HuyaCollabGift[];
gifts_message: string;
/** 这个虎牙号已经挂在哪些单上(允许同一份 CK 服务多单,只是提示) */
uid_used_by: HuyaCollabBuyerOrder[];
}
/** 协作台账的店铺字典项 */
export interface HuyaCollabShop {
id: number;
name: string;
sort_order: number;
/** false = 停用(新建时不再出现在下拉里;历史订单照旧) */
is_active: boolean;
remark: string;
created_by_name: string;
updated_by_name: string;
created_at: string | null;
updated_at: string | null;
}
export interface HuyaCollabShopCreate {
name: string;
sort_order?: number;
remark?: string;
}
export interface HuyaCollabShopUpdate {
name?: string;
sort_order?: number;
is_active?: boolean;
remark?: string;
/** 改名时是否级联更新已有订单里的店铺名(默认 true) */
rename_orders?: boolean;
}
@@ -0,0 +1,143 @@
/**
* 新建协作单时的「主页截图」上传(先传 OSS 拿 object_key,随表单一起提交)。
*
* - 第一张就是封面(列表和详情都按第一张当封面);
* - 新建时还没落库,所以预览用的地址来自上传接口返回的 thumbnail_url / url;
* - 放进 `Form.Item name="home_images"`,值就是 object_key 数组。
*/
import { useEffect, useRef, useState } from 'react';
import { Button, Image, Popconfirm, Space, Tag, Tooltip, Typography, Upload } from 'antd';
import { CloudUploadOutlined, DeleteOutlined, SwapOutlined } from '@ant-design/icons';
import { fileApi } from '../api/files';
import type { FileUploadResult } from '../api/types';
import { message } from '../utils/antdMessage';
import { getErrorMessage } from '../utils/error';
const { Text } = Typography;
type Preview = Pick<FileUploadResult, 'url' | 'thumbnail_url'>;
export default function HuyaCollabHomeImageUploader({
value = [],
max = 20,
disabled,
onChange,
}: {
value?: string[];
max?: number;
disabled?: boolean;
onChange?: (value: string[]) => void;
}) {
const [busy, setBusy] = useState(false);
const [previews, setPreviews] = useState<Record<string, Preview>>({});
// 连传多张时每次都用"上一次的结果 + 新 key",不吃闭包里的旧 value(否则会互相覆盖)
const keysRef = useRef<string[]>(value ?? []);
useEffect(() => {
keysRef.current = value ?? [];
}, [value]);
const keys = value ?? [];
const full = keys.length >= max;
const uploadOne = async (file: File) => {
setBusy(true);
try {
const uploaded = await fileApi.upload(file, 'collab');
setPreviews((prev) => ({
...prev,
[uploaded.object_key]: { url: uploaded.url, thumbnail_url: uploaded.thumbnail_url },
}));
const next = [...keysRef.current, uploaded.object_key];
keysRef.current = next;
onChange?.(next);
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setBusy(false);
}
};
const remove = (key: string) => {
const next = keysRef.current.filter((item) => item !== key);
keysRef.current = next;
onChange?.(next);
};
const setCover = (key: string) => {
const next = [key, ...keysRef.current.filter((item) => item !== key)];
keysRef.current = next;
onChange?.(next);
};
return (
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{keys.length === 0 ? (
<Text type="secondary">还没有图(必填:至少 1 张,第一张作封面)</Text>
) : (
<Image.PreviewGroup
items={keys.map((key) => previews[key]?.url || `/api/files/object?key=${encodeURIComponent(key)}`)}
>
<Space wrap size={12}>
{keys.map((key, index) => (
<Space key={key} direction="vertical" size={2} align="center">
<div style={{ position: 'relative' }}>
<Image
src={
previews[key]?.thumbnail_url
|| previews[key]?.url
|| `/api/files/object?key=${encodeURIComponent(key)}&variant=thumb`
}
width={88}
height={88}
style={{ objectFit: 'cover', borderRadius: 4 }}
alt={`主页截图${index + 1}`}
/>
{index === 0 && (
<Tag color="blue" style={{ position: 'absolute', left: 0, top: 0, margin: 0 }}>
封面
</Tag>
)}
</div>
{!disabled && (
<Space size={2}>
{index !== 0 && (
<Tooltip title="设为封面">
<Button size="small" type="text" icon={<SwapOutlined />} onClick={() => setCover(key)} />
</Tooltip>
)}
<Popconfirm
title="去掉这张图?"
description="对象不会立刻删除,交给孤儿清理脚本"
onConfirm={() => remove(key)}
>
<Tooltip title="移除">
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Tooltip>
</Popconfirm>
</Space>
)}
</Space>
))}
</Space>
</Image.PreviewGroup>
)}
{!disabled && (
<Upload
accept="image/*"
multiple
showUploadList={false}
disabled={busy || full}
beforeUpload={(file) => {
void uploadOne(file as File);
return false;
}}
>
<Button icon={<CloudUploadOutlined />} loading={busy} disabled={full}>
{full ? `最多 ${max} 张` : '上传主页截图'}
</Button>
</Upload>
)}
</Space>
);
}
@@ -0,0 +1,276 @@
import { useEffect, useState } from 'react';
import {
Button, Checkbox, Empty, Input, InputNumber, Modal, Popconfirm, Space, Switch,
Table, Tag, Tooltip, Typography,
} from 'antd';
import type { TableProps } from 'antd';
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons';
import { huyaApi, type HuyaCollabShop } from '../api/modules';
import { message } from '../utils/antdMessage';
import { getErrorMessage } from '../utils/error';
const { Text } = Typography;
/**
* 店铺管理:维护「新建协作单」下拉里的店铺。
*
* - 加 / 排序 / 备注 / 停用(停用=不再出现在新建下拉,历史订单照旧);
* - **重命名**会级联更新已有订单里的店铺名(弹窗里可取消勾选),
* 否则筛选里会同时出现新旧两个名字;
* - 删除只删字典行、不动订单(需要 `huya:collab:manage`)。
*/
export default function HuyaCollabShopModal({
open,
shops,
canEdit,
canManage,
onClose,
onChanged,
}: {
open: boolean;
shops: HuyaCollabShop[];
canEdit: boolean;
canManage: boolean;
onClose: () => void;
onChanged: () => void;
}) {
const [newName, setNewName] = useState('');
const [busy, setBusy] = useState(false);
const [renameTarget, setRenameTarget] = useState<HuyaCollabShop | null>(null);
const [renameValue, setRenameValue] = useState('');
const [renameOrders, setRenameOrders] = useState(true);
useEffect(() => {
if (!open) {
setNewName('');
setRenameTarget(null);
}
}, [open]);
const create = async () => {
const name = newName.trim();
if (!name) {
message.warning('请先输入店铺名');
return;
}
setBusy(true);
try {
await huyaApi.createCollabShop({ name });
message.success(`已添加店铺「${name}」`);
setNewName('');
onChanged();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setBusy(false);
}
};
const patch = async (shop: HuyaCollabShop, payload: { sort_order?: number; is_active?: boolean; remark?: string }) => {
setBusy(true);
try {
await huyaApi.updateCollabShop(shop.id, payload);
onChanged();
} catch (e: unknown) {
message.error(getErrorMessage(e));
onChanged();
} finally {
setBusy(false);
}
};
const submitRename = async () => {
if (!renameTarget) return;
const name = renameValue.trim();
if (!name || name === renameTarget.name) {
setRenameTarget(null);
return;
}
setBusy(true);
try {
await huyaApi.updateCollabShop(renameTarget.id, { name, rename_orders: renameOrders });
message.success(renameOrders ? `已重命名为「${name}」,已有订单里的旧名字也同步了` : `已重命名为「${name}」`);
setRenameTarget(null);
onChanged();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setBusy(false);
}
};
const remove = async (shop: HuyaCollabShop) => {
setBusy(true);
try {
await huyaApi.deleteCollabShop(shop.id);
message.success(`已从字典删除「${shop.name}」`);
onChanged();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setBusy(false);
}
};
const columns: TableProps<HuyaCollabShop>['columns'] = [
{
title: '店铺名', dataIndex: 'name', width: 180,
render: (value: string, row) => (
<Space size={4}>
<Text strong>{value}</Text>
{!row.is_active && <Tag>已停用</Tag>}
{canEdit && (
<Tooltip title="重命名">
<Button
size="small"
type="text"
icon={<EditOutlined />}
disabled={busy}
onClick={() => {
setRenameTarget(row);
setRenameValue(row.name);
setRenameOrders(true);
}}
/>
</Tooltip>
)}
</Space>
),
},
{
title: '排序', dataIndex: 'sort_order', width: 100,
render: (value: number, row) => (
<InputNumber
size="small"
value={value}
disabled={!canEdit || busy}
onChange={(next) => {
if (next === null || next === value) return;
void patch(row, { sort_order: Number(next) });
}}
/>
),
},
{
title: '新建时可选', dataIndex: 'is_active', width: 110, align: 'center',
render: (value: boolean, row) => (
<Switch
size="small"
checked={value}
disabled={!canEdit || busy}
checkedChildren="启用"
unCheckedChildren="停用"
onChange={(checked) => void patch(row, { is_active: checked })}
/>
),
},
{
title: '备注', dataIndex: 'remark', width: 200,
render: (value: string, row) => (
<Input
size="small"
defaultValue={value}
disabled={!canEdit || busy}
placeholder="备注(失焦保存)"
onBlur={(event) => {
const next = event.target.value.trim();
if (next !== value) void patch(row, { remark: next });
}}
/>
),
},
{
title: '最后改动', width: 150,
render: (_, row) => (
<Text type="secondary" style={{ fontSize: 12 }}>
{row.updated_by_name || '-'}
{row.updated_at ? ` · ${new Date(row.updated_at).toLocaleString()}` : ''}
</Text>
),
},
{
title: '操作', width: 90, align: 'center',
render: (_, row) =>
canManage ? (
<Popconfirm
title={`从字典删除「${row.name}」?`}
description="只删字典行,已有订单里的店铺名不变"
onConfirm={() => void remove(row)}
disabled={busy}
>
<Button size="small" danger type="text" icon={<DeleteOutlined />} disabled={busy} />
</Popconfirm>
) : (
<Text type="secondary">-</Text>
),
},
];
return (
<>
<Modal
title="店铺管理"
open={open}
onCancel={onClose}
footer={<Button onClick={onClose}>关闭</Button>}
width={860}
destroyOnHidden
>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Text type="secondary">
这里的店铺就是新建协作单时下拉里能点选的那些。停用只是不再出现在新建下拉里,
历史订单不受影响;重命名可以选择是否同步已有订单。
</Text>
{canEdit && (
<Space.Compact style={{ width: '100%' }}>
<Input
placeholder="输入店铺名,回车添加"
value={newName}
maxLength={64}
onChange={(event) => setNewName(event.target.value)}
onPressEnter={() => void create()}
/>
<Button type="primary" icon={<PlusOutlined />} loading={busy} onClick={() => void create()}>
添加
</Button>
</Space.Compact>
)}
<Table<HuyaCollabShop>
rowKey="id"
size="small"
columns={columns}
dataSource={shops}
pagination={false}
scroll={{ y: 320 }}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有店铺,先加一个" />,
}}
/>
</Space>
</Modal>
<Modal
title={renameTarget ? `重命名「${renameTarget.name}」` : '重命名店铺'}
open={!!renameTarget}
onCancel={() => setRenameTarget(null)}
onOk={() => void submitRename()}
confirmLoading={busy}
okText="保存"
destroyOnHidden
>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Input
value={renameValue}
maxLength={64}
autoFocus
onChange={(event) => setRenameValue(event.target.value)}
onPressEnter={() => void submitRename()}
/>
<Checkbox checked={renameOrders} onChange={(event) => setRenameOrders(event.target.checked)}>
同时更新已有订单里的店铺名(不勾选的话,筛选里会同时出现新旧两个名字)
</Checkbox>
</Space>
</Modal>
</>
);
}
@@ -0,0 +1,126 @@
import { useEffect, useState, type ReactNode } from 'react';
import { Button, Input, Space, Tag, Typography } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import type { HuyaCollabShop } from '../api/modules';
const { Text } = Typography;
/**
* 店铺选择器:**一个店铺一个独立按钮**(中间留白),不做下拉。
*
* - 选中那个按钮变主题蓝(primary),没选的是白底描边,一眼看得出选的是谁;
* - `allowClear` 为真时多一个「不填 / 全部」按钮(筛选里传"全部"),
* 店铺必填的地方传 `false` 把空值这条路堵掉;
* - `allowCustom`:点「+ 新店」直接输新名字,选中即用(后端会自动登记进字典);
* - `includeInactive`:停用的店铺也列出来(筛选/详情里要能看到历史值);
* - 当前值不在字典里(老数据/刚自定义的名字)也会显示成一个按钮,不会被吞掉。
*/
export default function HuyaCollabShopPicker({
value,
shops,
disabled,
includeInactive = false,
allowCustom = true,
allowClear = true,
clearLabel = '不填',
size = 'small',
onChange,
}: {
value?: string | null;
shops: HuyaCollabShop[];
disabled?: boolean;
includeInactive?: boolean;
allowCustom?: boolean;
/** 是否给一个「清空」按钮:店铺必填的地方传 false,别让人点出个空值 */
allowClear?: boolean;
clearLabel?: string;
/** 按钮尺寸:列表单元格 / 筛选条用小号,新建弹窗用大号(更好点) */
size?: 'small' | 'middle' | 'large';
/** 放进 Form.Item 时由 antd 注入;单独用时自己传 */
onChange?: (value: string) => void;
}) {
const [customOpen, setCustomOpen] = useState(false);
const [customName, setCustomName] = useState('');
useEffect(() => {
if (!customOpen) setCustomName('');
}, [customOpen]);
const current = value ?? '';
const visible = shops.filter((shop) => includeInactive || shop.is_active);
const options: { value: string; label: ReactNode }[] = visible.map((shop) => ({
value: shop.name,
label: (
<Space size={4}>
{shop.name}
{!shop.is_active && <Tag style={{ margin: 0 }}>停用</Tag>}
</Space>
),
}));
if (current && !visible.some((shop) => shop.name === current)) {
// 老数据 / 刚自定义的:照样给个可点的按钮,不然界面上看不出选中了啥
options.unshift({ value: current, label: current });
}
const submitCustom = () => {
const name = customName.trim();
if (!name) {
setCustomOpen(false);
return;
}
onChange?.(name);
setCustomOpen(false);
};
return (
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Space wrap size={size === 'large' ? 12 : 8}>
{/* 一个店铺一个独立按钮,中间留白(原来是连成一排的分段控件,太小不好点) */}
{options.map((option) => (
<Button
key={option.value}
size={size}
type={option.value === current ? 'primary' : 'default'}
disabled={disabled}
onClick={() => onChange?.(option.value)}
>
{option.label}
</Button>
))}
{allowClear && (
<Button
size={size}
type={current === '' ? 'primary' : 'default'}
disabled={disabled}
onClick={() => onChange?.('')}
>
<Text type={current === '' ? undefined : 'secondary'}>{clearLabel}</Text>
</Button>
)}
{allowCustom && !disabled && !customOpen && (
<Button size={size} type="link" icon={<PlusOutlined />} onClick={() => setCustomOpen(true)}>
新店
</Button>
)}
</Space>
{customOpen && (
<Input
size={size}
autoFocus
maxLength={64}
placeholder="输入新店铺名,回车确认"
value={customName}
onChange={(event) => setCustomName(event.target.value)}
onPressEnter={submitCustom}
onBlur={submitCustom}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setCustomName('');
setCustomOpen(false);
}
}}
/>
)}
</Space>
);
}
+9
View File
@@ -20,3 +20,12 @@ html[data-theme='dark'] body {
.huya-task-account-table .ant-table-placeholder:hover > .ant-table-cell {
background: transparent;
}
/* 新建协作单弹窗:表单项间距压紧一点,配合紧凑布局尽量不出现滚动条 */
.huya-collab-create .ant-form-item {
margin-bottom: 14px;
}
.huya-collab-create .ant-form-item-label {
padding-bottom: 2px;
}
+548 -81
View File
@@ -6,30 +6,36 @@
* 2. **绑定账号 / 绑定角色**来自「验证 CK」的只读探针快照(人工不可编辑,每次验证覆盖);
* 3. **内联编辑必须带 `expected_version`**:别人先改过会 409,绝不静默覆盖(Excel 最痛的点)。
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, 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,
Alert, App, AutoComplete, Button, Card, Col, Descriptions, Divider, Drawer, Dropdown, Empty, Form,
Image, Input, Modal, Popconfirm, Popover, 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,
LinkOutlined, PlusOutlined, ReloadOutlined, SafetyCertificateOutlined, ShopOutlined,
StopOutlined, SwapOutlined, UndoOutlined,
} from '@ant-design/icons';
import {
fileApi,
huyaApi,
type HuyaCollabAuditItem,
type HuyaCollabCkPreview,
type HuyaCollabGift,
type HuyaCollabImageSlot,
type HuyaCollabOrder,
type HuyaCollabOrderQuery,
type HuyaCollabOrderUpdate,
type HuyaCollabOwner,
type HuyaCollabShop,
type HuyaCollabStatus,
type HuyaCollabSummary,
} from '../api/modules';
import HuyaCollabShopModal from '../components/HuyaCollabShopModal';
import HuyaCollabShopPicker from '../components/HuyaCollabShopPicker';
import HuyaCollabHomeImageUploader from '../components/HuyaCollabHomeImageUploader';
import { message } from '../utils/antdMessage';
import { getErrorMessage } from '../utils/error';
import { usePermissions } from '../hooks/usePermissions';
@@ -64,13 +70,42 @@ const ACTION_LABELS: Record<string, string> = {
};
const SLOT_META: Record<HuyaCollabImageSlot, { label: string; keysKey: 'bind_images' | 'home_images' }> = {
bind_images: { label: '绑定图', keysKey: 'bind_images' },
home_images: { label: '主页账号图', keysKey: 'home_images' },
// 绑定图不再人工上传:绑定关系由探针自动读(2026-10-16 用户拍板),只留老数据只读展示
bind_images: { label: '历史绑定图', keysKey: 'bind_images' },
home_images: { label: '主页截图', keysKey: 'home_images' },
};
const IMPORT_PREVIEW_ROWS = 20;
// 「记住最近选的礼包」的本地存储键(同一个礼包连开几单时不用反复选)
const LAST_GIFT_KEY = 'huya.collab.lastGift';
// 「礼包列表」的本地缓存键:一个活动期间就那一批礼包,没必要每贴一份 CK 都去虎牙拉一次;
// 要更新就点「强制刷新礼包」(活动换 SID 后必点一次)。
const GIFTS_CACHE_KEY = 'huya.collab.gifts';
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 {
// 忽略:缓存只是体验优化
}
}
function formatTime(value: string | null | undefined): string {
if (!value) return '-';
const time = new Date(value);
@@ -98,7 +133,8 @@ function EditableText({
return (
<div
onClick={() => { if (!disabled) setEditing(true); }}
style={{ cursor: disabled ? 'default' : 'text', minHeight: 22 }}
// wordBreak:礼品名 / 角色名里偶发的一长串不带空格的字符也不会顶穿这一列
style={{ cursor: disabled ? 'default' : 'text', minHeight: 22, wordBreak: 'break-word' }}
title={disabled ? undefined : '点击编辑'}
>
{value || <Text type="secondary">{placeholder || '—'}</Text>}
@@ -208,7 +244,7 @@ function ImageSlotEditor({
export default function HuyaCollabPage() {
const { modal } = App.useApp();
const { can } = usePermissions();
const { can, user } = usePermissions();
const canEdit = can('huya:collab:edit');
const canManage = can('huya:collab:manage');
@@ -220,7 +256,8 @@ export default function HuyaCollabPage() {
const [busy, setBusy] = useState(false);
const [summary, setSummary] = useState<HuyaCollabSummary | null>(null);
const [owners, setOwners] = useState<HuyaCollabOwner[]>([]);
const [shops, setShops] = useState<string[]>([]);
const [shops, setShops] = useState<HuyaCollabShop[]>([]);
const [shopModalOpen, setShopModalOpen] = useState(false);
const [filters, setFilters] = useState<HuyaCollabOrderQuery>({ include_void: false });
const [selectedIds, setSelectedIds] = useState<number[]>([]);
@@ -231,6 +268,22 @@ export default function HuyaCollabPage() {
const [createOpen, setCreateOpen] = useState(false);
const [createForm] = Form.useForm<Record<string, unknown>>();
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);
@@ -253,7 +306,7 @@ export default function HuyaCollabPage() {
huyaApi.listCollabOrders({ ...filters, page, page_size: pageSize }),
huyaApi.collabOrdersSummary(),
huyaApi.collabOrderOwners(),
huyaApi.collabOrderShops(),
huyaApi.listCollabShops(),
]);
setRows(list.items);
setTotal(list.total);
@@ -276,6 +329,88 @@ export default function HuyaCollabPage() {
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: '', 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.bind_game_role}`);
// 「记住的礼包」如果不在当前列表里就清掉重选;列表为空时不动它(允许手填)
const options = preview.gifts.length > 0 ? preview.gifts : giftsRef.current;
const picked = String(createForm.getFieldValue('gift_name') ?? '').trim();
if (picked && options.length > 0 && !options.some((gift) => gift.name === 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],
);
/**
* 输入 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],
);
const patchOrder = async (row: HuyaCollabOrder, payload: HuyaCollabOrderUpdate) => {
try {
const updated = await huyaApi.updateCollabOrder(row.id, { ...payload, expected_version: row.version });
@@ -293,7 +428,6 @@ export default function HuyaCollabPage() {
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,
});
@@ -308,6 +442,15 @@ export default function HuyaCollabPage() {
}
};
/** 打开「同一买家的另一单」:先按 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 {
@@ -511,19 +654,27 @@ export default function HuyaCollabPage() {
};
const submitCreate = async () => {
if (!ckReady) {
message.warning('这份 CK 没通过验证(或读不到绑定角色),不能提交');
return;
}
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 ?? ''),
// 「绑定斗鱼号」不再录入:绑了谁由探针读(bind_game_*),人工那一列已下线
gift_name: String(values.gift_name ?? ''),
home_images: (values.home_images as string[] | undefined) ?? [],
remark: String(values.remark ?? ''),
require_verified: true, // 服务端再校验一次:CK 必须读到绑定角色才让建
});
if (created.gift_name) window.localStorage.setItem(LAST_GIFT_KEY, created.gift_name);
message.success(`已新建 ${created.order_no}`);
setCreateOpen(false);
setCkPreview(null);
setGiftHint('');
createForm.resetFields();
void load();
} catch (e: unknown) {
@@ -561,10 +712,25 @@ export default function HuyaCollabPage() {
}, [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 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 columns: TableProps<HuyaCollabOrder>['columns'] = [
{
title: '序号', width: 70, fixed: 'left', align: 'center',
render: (_, __, index) => (
// 跨页连续:第 2 页第 1 行 = pageSize + 1
<Text type="secondary">{(page - 1) * pageSize + index + 1}</Text>
),
},
{
title: '订单编号', dataIndex: 'order_no', width: 170, fixed: 'left',
render: (value: string, row) => (
@@ -575,10 +741,26 @@ export default function HuyaCollabPage() {
),
},
{
title: '店铺', dataIndex: 'shop', width: 110,
title: '店铺', dataIndex: 'shop', width: 120,
render: (value: string, row) => (
<EditableText value={value} disabled={!canEdit} placeholder="+ 店铺"
onCommit={(next) => void patchOrder(row, { shop: next })} />
<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}>
{value || '+ 选店铺'}
</Button>
</Popover>
),
},
{
@@ -612,31 +794,75 @@ export default function HuyaCollabPage() {
),
},
{
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
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 key={slot} size={4}>
<Space 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}封面`} />
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>
),
},
@@ -677,13 +903,27 @@ export default function HuyaCollabPage() {
),
},
{
title: 'CK 状态', dataIndex: 'ck_status', width: 150,
title: 'CK 状态', dataIndex: 'ck_status', width: 170,
render: (value: string, row) => (
<Space direction="vertical" size={2}>
<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={{ fontSize: 12 }} ellipsis>{row.ck_check_message}</Text>
<Text
type="secondary"
style={{
display: 'block',
fontSize: 12,
maxWidth: 150,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{row.ck_check_message}
</Text>
</Tooltip>
)}
</Space>
@@ -788,10 +1028,17 @@ export default function HuyaCollabPage() {
<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 })); }} />
<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}
@@ -804,7 +1051,8 @@ export default function HuyaCollabPage() {
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 }}
<Input.Search allowClear style={{ width: 340 }}
placeholder="搜订单号 / 店铺 / 绑定账号 / 角色 / 虎牙号 / 礼包 / 备注"
onSearch={(value) => { setPage(1); setFilters((prev) => ({ ...prev, keyword: value })); }} />
<Space size={4}>
<Text type="secondary">显示已作废</Text>
@@ -826,7 +1074,26 @@ export default function HuyaCollabPage() {
</Button>
)}
{canEdit && (
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>新建</Button>
<Button icon={<ShopOutlined />} onClick={() => setShopModalOpen(true)}>店铺管理</Button>
)}
{canEdit && (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setCkPreview(null);
setPreviewing(false);
setGiftHint('');
previewSeq.current += 1; // 丢掉在途的预览结论
createForm.resetFields();
// 记住最近选的礼包:同一个礼包连开几单时不用反复选
const lastGift = window.localStorage.getItem(LAST_GIFT_KEY);
if (lastGift) createForm.setFieldsValue({ gift_name: lastGift });
setCreateOpen(true);
}}
>
新建
</Button>
)}
</Space>
</Space>
@@ -838,7 +1105,7 @@ export default function HuyaCollabPage() {
loading={loading}
columns={columns}
dataSource={rows}
scroll={{ x: 1680, y: 'calc(100vh - 460px)' }}
scroll={{ x: 1800, y: 'calc(100vh - 460px)' }}
rowSelection={canEdit ? {
selectedRowKeys: selectedIds,
onChange: (keys) => setSelectedIds(keys.map(Number)),
@@ -887,12 +1154,45 @@ export default function HuyaCollabPage() {
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 && (
<>
<Divider style={{ margin: '4px 0' }}>可编辑字段</Divider>
@@ -902,7 +1202,6 @@ export default function HuyaCollabPage() {
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)}
@@ -914,19 +1213,15 @@ export default function HuyaCollabPage() {
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="shop" label="店铺"><Input maxLength={64} /></Form.Item>
<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="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>
@@ -949,19 +1244,40 @@ export default function HuyaCollabPage() {
</>
)}
<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)}
/>
))}
<Divider style={{ margin: '4px 0' }}>主页截图(第一张是封面)</Divider>
<ImageSlotEditor
order={detail}
slot="home_images"
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)}
/>
{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 && (
<Space>
@@ -1012,39 +1328,181 @@ export default function HuyaCollabPage() {
<Modal
title="新建协作单"
open={createOpen}
width={920}
onCancel={() => setCreateOpen(false)}
onOk={() => void submitCreate()}
confirmLoading={busy}
okText="新建"
okButtonProps={{ disabled: !ckReady || previewing }}
// 内容比一屏高:给个滚动区,别让底部按钮被顶出可视范围
styles={{ body: { maxHeight: '78vh', overflowY: 'auto', paddingRight: 12 } }}
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>
<Form
className="huya-collab-create"
form={createForm}
layout="vertical"
size="middle"
onValuesChange={(changed: Record<string, unknown>) => {
if (typeof changed.ck_text === 'string') scheduleCkPreview(changed.ck_text);
}}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="owner_id" label="负责人">
<Select allowClear showSearch optionFilterProp="label" options={ownerOptions} placeholder="未指派" />
<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="ck_text" label="外部 CK(可选,支持「纯CK」或「账号----密码----CK」)">
<Input.TextArea rows={3} placeholder="贴进来就会解析出虎牙 uid" />
{/* 店铺独占一行:按钮要够宽才不会被挤到第二行(「新店」跟在同一行后面) */}
<Form.Item
name="shop"
label="店铺"
tooltip="必填:点一下选;点「新店」可以直接输个新名字(会自动进店铺字典)"
rules={[{ required: true, message: '请点一下选店铺' }]}
>
<HuyaCollabShopPicker shops={shops} allowClear={false} size="large" />
</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>
<Form.Item
name="ck_text"
label="外部 CK"
tooltip="必填:支持「纯CK」或「账号----密码----CK」。贴进来即自动探针验证 + 读绑定角色;礼包列表本地有缓存就不再重复拉,要更新点旁边的「强制刷新礼包」"
rules={[{ required: true, message: '请贴一份外部 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.bind_game_role}`}
description={
<>
<div>
{`订单编号里的账号:${ckPreview.bind_game_account || '-'}`
+ `(${ckPreview.bind_game_channel || '-'})`
+ `;虎牙号 ${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="error"
showIcon
message="这份 CK 不能用,禁止提交"
description={`${ckPreview?.message || ''}${ckPreview?.ok && !ckPreview.has_role ? '(探针通了但读不到绑定角色)' : ''}`}
/>
)}
</div>
)}
<Row gutter={16}>
<Col span={14}>
<Form.Item
name="gift_name"
label="礼包名(用户购买的物品)"
tooltip="必填:先贴有效 CK,再从礼包列表里点选(列表有本地缓存,不用每次重拉;拉不到时可以直接手输)"
rules={[{ required: true, message: '请选一个礼包' }]}
extra={giftHint || (giftsCachedAt && giftOptions.length > 0
? `礼包列表缓存于 ${giftCacheLabel}(${giftOptions.length} 个)`
: undefined)}
validateStatus={giftHint ? 'warning' : undefined}
>
<AutoComplete
allowClear
placeholder={ckReady ? '从礼包里点选(可直接输)' : '先贴一份有效 CK'}
options={giftOptions.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(' '),
}))}
filterOption={(input, option) =>
String(option?.value ?? '').toLowerCase().includes(input.toLowerCase())}
onChange={(value: string) => {
// 记住最近选的礼包:连开几单同一个礼包就不用反复点
setGiftHint('');
if (value) window.localStorage.setItem(LAST_GIFT_KEY, value);
}}
/>
</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="上传主页截图;绑定图不用再传了 —— 绑了谁由「验证 CK」的探针自动读"
rules={[{
validator: (_rule, value: string[] | undefined) => (
value && value.length > 0
? Promise.resolve()
: Promise.reject(new Error('请至少上传 1 张主页截图'))
),
}]}
>
<HuyaCollabHomeImageUploader max={MAX_IMAGES_PER_SLOT} />
</Form.Item>
</Col>
<Col span={10}>
<Form.Item name="remark" label="备注(可选)">
<Input.TextArea rows={2} maxLength={512} />
</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}
width={900}
onCancel={() => setImportOpen(false)}
onOk={() => void submitImport()}
confirmLoading={importing}
@@ -1145,6 +1603,15 @@ export default function HuyaCollabPage() {
)}
</Space>
</Modal>
<HuyaCollabShopModal
open={shopModalOpen}
shops={shops}
canEdit={canEdit}
canManage={canManage}
onClose={() => setShopModalOpen(false)}
onChanged={() => void load()}
/>
</Space>
);
}