feat: 斗鱼外部单协作台账与店铺实时同步

This commit is contained in:
yml2213
2026-10-20 20:11:35 +08:00
parent c778ae9a09
commit 71574908ff
18 changed files with 6187 additions and 4 deletions
@@ -0,0 +1,286 @@
import { useEffect, useState } from 'react';
import {
Button,
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 { douyuCollabApi } from '../api/modules';
import type { DouyuCollabShop } from '../api/douyuCollab';
import { message } from '../utils/antdMessage';
import { getErrorMessage } from '../utils/error';
const { Text } = Typography;
/**
* 斗鱼店铺管理:先维护好店铺,新建协作单时直接点选。
*
* - 停用后不再出现在新建下拉里,历史订单不受影响;
* - 重命名会自动同步已有订单上的店铺名,避免筛选出现新旧两个名字;
* - 删除只删字典行,仍有订单使用时后端会拒绝(更安全)。
*/
export default function DouyuCollabShopModal({
open,
shops,
canEdit,
canManage,
onClose,
onChanged,
}: {
open: boolean;
shops: DouyuCollabShop[];
canEdit: boolean;
canManage: boolean;
onClose: () => void;
onChanged: () => void;
}) {
const [newName, setNewName] = useState('');
const [busy, setBusy] = useState(false);
const [renameTarget, setRenameTarget] = useState<DouyuCollabShop | null>(null);
const [renameValue, setRenameValue] = useState('');
useEffect(() => {
if (!open) {
setNewName('');
setRenameTarget(null);
}
}, [open]);
const create = async () => {
const name = newName.trim();
if (!name) {
message.warning('请先输入店铺名');
return;
}
setBusy(true);
try {
await douyuCollabApi.createShop({ name });
message.success(`已添加店铺「${name}」`);
setNewName('');
onChanged();
} catch (error) {
message.error(getErrorMessage(error));
} finally {
setBusy(false);
}
};
const patch = async (
shop: DouyuCollabShop,
payload: { sort_order?: number; is_active?: boolean; remark?: string },
) => {
setBusy(true);
try {
await douyuCollabApi.updateShop(shop.id, payload);
onChanged();
} catch (error) {
message.error(getErrorMessage(error));
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 douyuCollabApi.updateShop(renameTarget.id, { name });
message.success(`已重命名为「${name}」,已有订单里的店铺名也已同步`);
setRenameTarget(null);
onChanged();
} catch (error) {
message.error(getErrorMessage(error));
} finally {
setBusy(false);
}
};
const remove = async (shop: DouyuCollabShop) => {
setBusy(true);
try {
await douyuCollabApi.deleteShop(shop.id);
message.success(`已删除店铺「${shop.name}」`);
onChanged();
} catch (error) {
message.error(getErrorMessage(error));
} finally {
setBusy(false);
}
};
const columns: TableProps<DouyuCollabShop>['columns'] = [
{
title: '店铺名',
dataIndex: 'name',
width: 220,
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);
}}
/>
</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: 120,
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',
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: 80,
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={820}
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<DouyuCollabShop>
rowKey="id"
size="small"
columns={columns}
dataSource={shops}
pagination={false}
scroll={{ y: 340 }}
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()}
/>
<Text type="secondary">保存后会同时更新已有协作单上的店铺名。</Text>
</Space>
</Modal>
</>
);
}
@@ -0,0 +1,119 @@
import { useEffect, useState, type ReactNode } from 'react';
import { Button, Input, Space, Tag, Typography } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import type { DouyuCollabShop } from '../api/douyuCollab';
const { Text } = Typography;
/**
* 斗鱼店铺选择器:一个店铺一个按钮,筛选和新建弹窗共用。
*
* - 当前选中的按钮显示主题色,一眼看得出选的是谁;
* - `allowClear` 为真时多一个「全部 / 不填」按钮;
* - `allowCustom` 允许直接输入新店铺名,后端会在建单时自动登记;
* - 停用店铺可选择是否展示(筛选时展示,避免看不到历史值)。
*/
export default function DouyuCollabShopPicker({
value,
shops,
disabled,
includeInactive = false,
allowCustom = true,
allowClear = true,
clearLabel = '不填',
size = 'small',
onChange,
}: {
value?: string | null;
shops: DouyuCollabShop[];
disabled?: boolean;
includeInactive?: boolean;
allowCustom?: boolean;
allowClear?: boolean;
clearLabel?: string;
size?: 'small' | 'middle' | 'large';
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}>
<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>
);
}