feat: 斗鱼外部单协作台账与店铺实时同步
This commit is contained in:
@@ -21,6 +21,7 @@ const UsersPage = lazy(() => import('./pages/UsersPage'));
|
||||
const CookiePage = lazy(() => import('./pages/CookiePage'));
|
||||
const CookieOperationsPage = lazy(() => import('./pages/CookieOperationsPage'));
|
||||
const DouyuTasksPage = lazy(() => import('./pages/DouyuTasksPage'));
|
||||
const DouyuCollabPage = lazy(() => import('./pages/DouyuCollabPage'));
|
||||
const HuyaAccountsPage = lazy(() => import('./pages/HuyaAccountsPage'));
|
||||
const HuyaAssignmentsPage = lazy(() => import('./pages/HuyaAssignmentsPage'));
|
||||
const HuyaCookiePage = lazy(() => import('./pages/HuyaCookiePage'));
|
||||
@@ -94,6 +95,7 @@ function AppContent() {
|
||||
<Route path="douyu/elite" element={lazyRoute(<DouyuTasksPage key="elite" handbook="elite" />)} />
|
||||
<Route path="douyu/esports" element={lazyRoute(<DouyuTasksPage key="esports" handbook="esports" />)} />
|
||||
<Route path="douyu/peace" element={lazyRoute(<DouyuTasksPage key="peace" handbook="peace" />)} />
|
||||
<Route path="douyu/collab" element={lazyRoute(<DouyuCollabPage />)} />
|
||||
<Route path="huya/accounts" element={lazyRoute(<HuyaAccountsPage />)} />
|
||||
<Route path="huya/device-bindings" element={lazyRoute(<HuyaDeviceBindingsPage />)} />
|
||||
<Route path="huya/register" element={lazyRoute(<HuyaRegisterPage />)} />
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import api from './client';
|
||||
|
||||
export type DouyuCollabStatus =
|
||||
| 'incomplete'
|
||||
| 'to_recharge'
|
||||
| 'recharged'
|
||||
| 'scripted'
|
||||
| 'done'
|
||||
| 'points_error'
|
||||
| 'failed'
|
||||
| 'void';
|
||||
|
||||
export interface DouyuCollabGoods {
|
||||
commodity_id: string;
|
||||
name: string;
|
||||
score: number | null;
|
||||
status: string;
|
||||
open_status: number | null;
|
||||
start_time: number | null;
|
||||
end_time: number | null;
|
||||
mark: string;
|
||||
count: number | null;
|
||||
elite_limited: number | null;
|
||||
is_limited: boolean;
|
||||
raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DouyuCollabOrder {
|
||||
id: number;
|
||||
order_no: string;
|
||||
shop: string;
|
||||
owner_id: number | null;
|
||||
owner_name: string;
|
||||
account_id: number | null;
|
||||
account_username: string;
|
||||
account_uid: string;
|
||||
account_nickname: string;
|
||||
account_exists: boolean;
|
||||
has_cookie: boolean;
|
||||
account_ck_status: string;
|
||||
account_status: string;
|
||||
bind_game_account: string;
|
||||
bind_game_role: string;
|
||||
bind_game_channel: string;
|
||||
bind_snapshot_at: string | null;
|
||||
home_images: string[];
|
||||
home_image_urls: string[];
|
||||
home_image_thumb_urls: string[];
|
||||
gift_name: string;
|
||||
commodity_id: string;
|
||||
required_points: number | null;
|
||||
remark: string;
|
||||
status: DouyuCollabStatus;
|
||||
status_label: string;
|
||||
next_status: DouyuCollabStatus | null;
|
||||
missing_fields: string[];
|
||||
fish_fin_balance: number | null;
|
||||
points_balance: number | null;
|
||||
points_required_total: number | null;
|
||||
points_surplus: number | null;
|
||||
points_threshold: number | null;
|
||||
points_checked_at: string | null;
|
||||
points_check_reason: string;
|
||||
points_check_message: string;
|
||||
points_check_detail: Record<string, unknown> | null;
|
||||
recharged_at: string | null;
|
||||
scripted_at: string | null;
|
||||
done_at: string | null;
|
||||
void_reason: string;
|
||||
is_archived: boolean;
|
||||
archived_at: string | null;
|
||||
archived_by: number | null;
|
||||
archived_by_name: string;
|
||||
archive_reason: string;
|
||||
created_by: number | null;
|
||||
created_by_name: string;
|
||||
updated_by: number | null;
|
||||
updated_by_name: string;
|
||||
version: number;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
account_cluster: {
|
||||
total: number;
|
||||
orders: Array<{
|
||||
id: number;
|
||||
order_no: string;
|
||||
status: DouyuCollabStatus;
|
||||
status_label: string;
|
||||
gift_name: string;
|
||||
}>;
|
||||
} | null;
|
||||
last_metric_at: string | null;
|
||||
last_metric_message: string;
|
||||
last_verdict: string;
|
||||
last_verdict_label: string;
|
||||
last_records_total: number;
|
||||
last_records_matched: number;
|
||||
}
|
||||
|
||||
export interface DouyuCollabOrderList {
|
||||
items: DouyuCollabOrder[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export interface DouyuCollabSummary {
|
||||
total: number;
|
||||
by_status: Record<string, number>;
|
||||
account_missing: number;
|
||||
ck_invalid: number;
|
||||
archived_total: number;
|
||||
}
|
||||
|
||||
export interface DouyuCollabOwner {
|
||||
id: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface DouyuCollabShop {
|
||||
id: number;
|
||||
name: string;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface DouyuCollabAccountLookup {
|
||||
found: boolean;
|
||||
ok: boolean;
|
||||
message: string;
|
||||
errors: string[];
|
||||
account_id: number | null;
|
||||
account_username: string;
|
||||
account_uid: string;
|
||||
account_nickname: string;
|
||||
ck_status: string;
|
||||
ck_status_label: string;
|
||||
manual_type: number | null;
|
||||
manual_score: number | null;
|
||||
expire_time: number | null;
|
||||
manual_opened: boolean;
|
||||
bind_game_account: string;
|
||||
bind_game_role: string;
|
||||
bind_game_channel: string;
|
||||
fish_fin_balance: number | null;
|
||||
goods: DouyuCollabGoods[];
|
||||
goods_count: number;
|
||||
orders_using_account: Array<{
|
||||
id: number;
|
||||
order_no: string;
|
||||
status: DouyuCollabStatus;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface DouyuCollabOrderCreate {
|
||||
order_no: string;
|
||||
shop?: string;
|
||||
owner_id?: number | null;
|
||||
account_username?: string;
|
||||
gift_name?: string;
|
||||
commodity_id?: string;
|
||||
required_points?: number | null;
|
||||
home_images?: string[];
|
||||
remark?: string;
|
||||
draft?: boolean;
|
||||
require_verified?: boolean;
|
||||
}
|
||||
|
||||
export interface DouyuCollabOrderUpdate {
|
||||
order_no?: string;
|
||||
shop?: string;
|
||||
owner_id?: number | null;
|
||||
account_username?: string;
|
||||
gift_name?: string;
|
||||
commodity_id?: string;
|
||||
required_points?: number | null;
|
||||
home_images?: string[];
|
||||
remark?: string;
|
||||
expected_version?: number;
|
||||
}
|
||||
|
||||
export interface DouyuCollabMetric {
|
||||
id: number;
|
||||
order_id: number;
|
||||
order_no: string;
|
||||
account_username: string;
|
||||
fish_fin_balance: number | null;
|
||||
points_balance: number | null;
|
||||
points_required_total: number | null;
|
||||
points_surplus: number | null;
|
||||
records_total: number;
|
||||
records_matched: number;
|
||||
verdict: string;
|
||||
verdict_label: string;
|
||||
message: string;
|
||||
source: string;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface DouyuCollabTimeline {
|
||||
id: number;
|
||||
action: string;
|
||||
username: string;
|
||||
detail: string;
|
||||
success: boolean | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface DouyuCollabQueryResult {
|
||||
order: DouyuCollabOrder;
|
||||
metric: DouyuCollabMetric;
|
||||
verdict: string;
|
||||
message: string;
|
||||
points_reason: string;
|
||||
fish_fin_balance: number | null;
|
||||
points_balance: number | null;
|
||||
required_total: number | null;
|
||||
surplus: number | null;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export const douyuCollabApi = {
|
||||
listOrders: (params: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
include_void?: boolean;
|
||||
archive_scope?: 'active' | 'archived' | 'all';
|
||||
status?: string;
|
||||
shop?: string;
|
||||
owner_id?: number;
|
||||
gift_name?: string;
|
||||
keyword?: string;
|
||||
ck_status?: string;
|
||||
} = {}) =>
|
||||
api.get<DouyuCollabOrderList, DouyuCollabOrderList>('/douyu/external-orders', { params }),
|
||||
summary: (params: { include_void?: boolean; archive_scope?: 'active' | 'archived' | 'all' } = {}) =>
|
||||
api.get<DouyuCollabSummary, DouyuCollabSummary>('/douyu/external-orders/summary', { params }),
|
||||
owners: () => api.get<DouyuCollabOwner[], DouyuCollabOwner[]>('/douyu/external-orders/owners'),
|
||||
shops: () => api.get<string[], string[]>('/douyu/external-orders/shops'),
|
||||
shopRecords: () => api.get<DouyuCollabShop[], DouyuCollabShop[]>('/douyu/collab-shops'),
|
||||
createShop: (data: { name: string; remark?: string }) =>
|
||||
api.post<DouyuCollabShop, DouyuCollabShop>('/douyu/collab-shops', data),
|
||||
updateShop: (
|
||||
id: number,
|
||||
data: { name?: string; sort_order?: number; is_active?: boolean; remark?: string },
|
||||
) => api.put<DouyuCollabShop, DouyuCollabShop>(`/douyu/collab-shops/${id}`, data),
|
||||
deleteShop: (id: number) =>
|
||||
api.delete<{ success: boolean; name: string; id: number }, { success: boolean; name: string; id: number }>(
|
||||
`/douyu/collab-shops/${id}`,
|
||||
),
|
||||
gifts: () => api.get<string[], string[]>('/douyu/external-orders/gifts'),
|
||||
goods: () => api.get<DouyuCollabGoods[], DouyuCollabGoods[]>('/douyu/external-orders/goods'),
|
||||
refreshGoods: (accountId?: number) =>
|
||||
api.post<{ count: number; goods: DouyuCollabGoods[] }, { count: number; goods: DouyuCollabGoods[] }>(
|
||||
'/douyu/external-orders/goods/refresh',
|
||||
undefined,
|
||||
{ params: accountId ? { account_id: accountId } : undefined },
|
||||
),
|
||||
lookupAccount: (username: string, withGoods = true) =>
|
||||
api.post<DouyuCollabAccountLookup, DouyuCollabAccountLookup>(
|
||||
'/douyu/external-orders/accounts/lookup',
|
||||
{ username, with_goods: withGoods },
|
||||
),
|
||||
createOrder: (data: DouyuCollabOrderCreate) =>
|
||||
api.post<DouyuCollabOrder, DouyuCollabOrder>('/douyu/external-orders', data),
|
||||
getOrder: (id: number) =>
|
||||
api.get<DouyuCollabOrder, DouyuCollabOrder>(`/douyu/external-orders/${id}`),
|
||||
updateOrder: (id: number, data: DouyuCollabOrderUpdate) =>
|
||||
api.put<DouyuCollabOrder, DouyuCollabOrder>(`/douyu/external-orders/${id}`, data),
|
||||
setAccount: (id: number, account_username: string, expected_version?: number) =>
|
||||
api.post<DouyuCollabOrder, DouyuCollabOrder>(`/douyu/external-orders/${id}/account`, {
|
||||
account_username,
|
||||
expected_version,
|
||||
}),
|
||||
verifyOrder: (id: number) =>
|
||||
api.post<{ order: DouyuCollabOrder; probe: DouyuCollabAccountLookup }, { order: DouyuCollabOrder; probe: DouyuCollabAccountLookup }>(
|
||||
`/douyu/external-orders/${id}/verify`,
|
||||
),
|
||||
batchVerify: (ids: number[]) =>
|
||||
api.post<
|
||||
{ results: Array<{ id: number; success: boolean; ck_status?: string; role?: string; message?: string }>; success: boolean },
|
||||
{ results: Array<{ id: number; success: boolean; ck_status?: string; role?: string; message?: string }>; success: boolean }
|
||||
>('/douyu/external-orders/verify/batch', ids),
|
||||
queryActivity: (id: number) =>
|
||||
api.post<DouyuCollabQueryResult, DouyuCollabQueryResult>(
|
||||
`/douyu/external-orders/${id}/activity/query`,
|
||||
),
|
||||
setStatus: (id: number, data: {
|
||||
status: DouyuCollabStatus;
|
||||
reason?: string;
|
||||
rollback?: boolean;
|
||||
expected_version?: number;
|
||||
}) => api.post<DouyuCollabOrder, DouyuCollabOrder>(`/douyu/external-orders/${id}/status`, data),
|
||||
batchStatus: (data: {
|
||||
ids: number[];
|
||||
status: DouyuCollabStatus;
|
||||
reason?: string;
|
||||
rollback?: boolean;
|
||||
}) => api.post<{ results: Array<{ id: number; success: boolean; message?: string }>; success: boolean }, { results: Array<{ id: number; success: boolean; message?: string }>; success: boolean }>(
|
||||
'/douyu/external-orders/status/batch',
|
||||
data,
|
||||
),
|
||||
batchActivity: (ids: number[]) =>
|
||||
api.post<{ results: Array<Record<string, unknown>>; success: boolean }, { results: Array<Record<string, unknown>>; success: boolean }>(
|
||||
'/douyu/external-orders/activity/query',
|
||||
ids,
|
||||
),
|
||||
updateImages: (id: number, home_images: string[], expected_version?: number) =>
|
||||
api.post<DouyuCollabOrder, DouyuCollabOrder>(`/douyu/external-orders/${id}/images`, {
|
||||
home_images,
|
||||
expected_version,
|
||||
}),
|
||||
archive: (ids: number[], reason = '') =>
|
||||
api.post<{ archived: number }, { archived: number }>('/douyu/external-orders/archive', { ids, reason }),
|
||||
unarchive: (ids: number[]) =>
|
||||
api.post<{ unarchived: number }, { unarchived: number }>('/douyu/external-orders/unarchive', ids),
|
||||
timeline: (id: number) =>
|
||||
api.get<DouyuCollabTimeline[], DouyuCollabTimeline[]>(`/douyu/external-orders/${id}/timeline`),
|
||||
metrics: (id: number) =>
|
||||
api.get<DouyuCollabMetric[], DouyuCollabMetric[]>(`/douyu/external-orders/${id}/metrics`),
|
||||
deleteOrder: (id: number) =>
|
||||
api.delete<{ success: boolean; order_no: string }, { success: boolean; order_no: string }>(
|
||||
`/douyu/external-orders/${id}`,
|
||||
),
|
||||
};
|
||||
@@ -8,6 +8,7 @@ export { automationApi } from './automation';
|
||||
export { cookieApi } from './cookies';
|
||||
export { dashboardApi } from './dashboard';
|
||||
export { douyuApi } from './douyu';
|
||||
export { douyuCollabApi } from './douyuCollab';
|
||||
export { fileApi } from './files';
|
||||
export { huyaApi } from './huya';
|
||||
export { loginApi } from './login';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -12,9 +12,11 @@ export interface CollabRealtimeEvent {
|
||||
interface UseCollabRealtimeOptions {
|
||||
enabled: boolean;
|
||||
onInvalidate: (event: CollabRealtimeEvent) => void;
|
||||
/** 默认是虎牙协作台账;斗鱼页面传入自己的 WS 路径。 */
|
||||
path?: string;
|
||||
}
|
||||
|
||||
const WS_PATH = '/api/huya/external-orders/ws';
|
||||
const DEFAULT_WS_PATH = '/api/huya/external-orders/ws';
|
||||
const HEARTBEAT_TIMEOUT_MS = 60_000;
|
||||
const MAX_RECONNECT_DELAY_MS = 30_000;
|
||||
|
||||
@@ -32,6 +34,7 @@ function websocketUrl(path: string): string {
|
||||
export function useCollabRealtime({
|
||||
enabled,
|
||||
onInvalidate,
|
||||
path = DEFAULT_WS_PATH,
|
||||
}: UseCollabRealtimeOptions) {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [reconnecting, setReconnecting] = useState(false);
|
||||
@@ -129,7 +132,7 @@ export function useCollabRealtime({
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSocket = new WebSocket(websocketUrl(WS_PATH));
|
||||
const nextSocket = new WebSocket(websocketUrl(path));
|
||||
socket = nextSocket;
|
||||
|
||||
nextSocket.onopen = () => {
|
||||
@@ -179,7 +182,7 @@ export function useCollabRealtime({
|
||||
setConnected(false);
|
||||
setReconnecting(false);
|
||||
};
|
||||
}, [enabled]);
|
||||
}, [enabled, path]);
|
||||
|
||||
return { connected, reconnecting };
|
||||
}
|
||||
|
||||
@@ -85,6 +85,11 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
|
||||
douyuItems.push({ key: '/douyu/peace', label: '和平小店', icon: <ShopOutlined /> });
|
||||
}
|
||||
|
||||
// 外部单协作:权限独立,客服没有 douyu:task 也要能进共享台账。
|
||||
if (can('douyu:collab:view')) {
|
||||
douyuItems.push({ key: '/douyu/collab', label: '外部单协作', icon: <FileExcelOutlined /> });
|
||||
}
|
||||
|
||||
// 虎牙:客服只保留浏览器兑换;管理员/运营显示完整入口
|
||||
if (can('huya:task')) {
|
||||
if (user?.role === 'support') {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user