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