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:
@@ -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`),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user