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:
@@ -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