feat(douyu): 新增和平小店查询模块(绑定角色/商品列表/点券余额)

- activity_client: getIframeUrl 签发 code/sig,道聚城 getrole.out/recommend/balance 接口
  (2026-08 起需 isCode=1 + authType=delegate + sAnchorId 才能通过 Livelink 校验)
- 迁移 0016: accounts xpd_* 字段、douyu_config 小店配置(actAlias/actId/rid)、
  douyu_xpd_goods_snapshot 商品快照表
- runner/service/router/schema 注册 query_xpd_role/refresh_xpd_goods/query_xpd_balance
  三个任务,任务结果剥离 role.raw 防 openid 泄露
- 前端: 和平小店路由/菜单/DouyuTasksPage 三态/商品下拉/配置弹窗
This commit is contained in:
yml2213
2026-08-06 17:23:59 +08:00
parent f8de495029
commit fdc74eba8c
12 changed files with 702 additions and 25 deletions
+1
View File
@@ -75,6 +75,7 @@ function AppContent() {
<Route path="douyu/tasks" element={<Navigate to="/douyu/elite" replace />} />
<Route path="douyu/elite" element={lazyRoute(<DouyuTasksPage handbook="elite" />)} />
<Route path="douyu/esports" element={lazyRoute(<DouyuTasksPage handbook="esports" />)} />
<Route path="douyu/peace" element={lazyRoute(<DouyuTasksPage handbook="peace" />)} />
<Route path="huya/accounts" element={lazyRoute(<HuyaAccountsPage />)} />
<Route path="huya/register" element={lazyRoute(<HuyaRegisterPage />)} />
<Route path="huya/assignments" element={lazyRoute(<HuyaAssignmentsPage />)} />
+2
View File
@@ -6,6 +6,7 @@ import type {
DouyuTaskBatchRequest,
DouyuTaskBatchResult,
DouyuTaskItem,
DouyuXpdGoodsItem,
MessageResponse,
PageParams,
PaginatedResponse,
@@ -21,6 +22,7 @@ export const douyuApi = {
updateConfig: (data: Partial<DouyuConfig>) => api.put<DouyuConfig, DouyuConfig>('/douyu/config', data),
listGoods: () => api.get<DouyuGoodsItem[], DouyuGoodsItem[]>('/douyu/goods'),
listEsportsGoods: () => api.get<DouyuGoodsItem[], DouyuGoodsItem[]>('/douyu/esports-goods'),
listPeaceGoods: () => api.get<DouyuXpdGoodsItem[], DouyuXpdGoodsItem[]>('/douyu/xpd-goods'),
createTasks: (data: DouyuTaskBatchRequest) =>
api.post<DouyuTaskBatchResult, DouyuTaskBatchResult>('/douyu/tasks/batch', data),
listTasks: (batchId?: string) =>
+17
View File
@@ -227,6 +227,13 @@ export interface DouyuTaskAccountItem {
esports_bind_status: string;
esports_change_role_wait_time: number | null;
esports_can_change_time: number | null;
xpd_game_name: string;
xpd_openid: string;
xpd_role_id: string;
xpd_plat_id: number | null;
xpd_area_id: number | null;
xpd_balance: number | null;
xpd_bind_status: string;
assigned_to: number | null;
assigned_username: string | null;
}
@@ -246,6 +253,9 @@ export interface DouyuConfig {
esports_chicken_skin_id: string;
esports_firework_gift_id: string;
esports_firework_skin_id: string;
xpd_act_alias: string;
xpd_act_id: string;
xpd_rid: string;
gold_pay_type: number;
gift_id: string;
skin_id: string;
@@ -291,6 +301,13 @@ export interface DouyuGoodsItem {
updated_at: string | null;
}
export interface DouyuXpdGoodsItem extends DouyuGoodsItem {
price: number | null;
org_price: number | null;
category: string;
goods_left: number | null;
}
// ==================== Huya ====================
export interface HuyaAccountItem {
+2 -1
View File
@@ -6,7 +6,7 @@ import {
CloudServerOutlined, TeamOutlined, ApiOutlined, KeyOutlined,
MenuFoldOutlined, MenuUnfoldOutlined, SwapOutlined,
SunOutlined, MoonOutlined, DesktopOutlined, GiftOutlined, ShoppingCartOutlined, ApartmentOutlined, MobileOutlined,
BookOutlined, TrophyOutlined,
BookOutlined, TrophyOutlined, ShopOutlined,
SafetyCertificateOutlined,
} from '@ant-design/icons';
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
@@ -74,6 +74,7 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
if (can('douyu:task')) {
douyuItems.push({ key: '/douyu/elite', label: '精英宝典', icon: <BookOutlined /> });
douyuItems.push({ key: '/douyu/esports', label: '电竞手册', icon: <TrophyOutlined /> });
douyuItems.push({ key: '/douyu/peace', label: '和平小店', icon: <ShopOutlined /> });
}
// 虎牙
+92 -20
View File
@@ -32,7 +32,7 @@ import { message } from '../utils/antdMessage';
const { Text } = Typography;
type HandbookKind = 'elite' | 'esports';
type HandbookKind = 'elite' | 'esports' | 'peace';
const TASK_STATUS_COLORS: Record<string, string> = {
planned: 'default',
@@ -83,6 +83,12 @@ const ESPORTS_QUICK_ACTIONS = [
{ key: 'donate_esports_firework_gift', icon: <GiftOutlined /> },
];
const PEACE_QUICK_ACTIONS = [
{ key: 'query_xpd_role', icon: <SearchOutlined /> },
{ key: 'refresh_xpd_goods', icon: <ReloadOutlined /> },
{ key: 'query_xpd_balance', icon: <SearchOutlined /> },
];
const ELITE_TASK_TYPES = new Set([
'get_bind_qr',
'confirm_bind',
@@ -115,6 +121,11 @@ const ESPORTS_TASK_TYPES = new Set([
'donate_esports_chicken_gift',
'donate_esports_firework_gift',
]);
const PEACE_TASK_TYPES = new Set([
'query_xpd_role',
'refresh_xpd_goods',
'query_xpd_balance',
]);
const DOUYU_GOLD_AMOUNT_STORAGE_KEY = 'douyu_task_gold_amount';
const DOUYU_GIFT_COUNT_STORAGE_KEY = 'douyu_task_gift_count';
const DOUYU_LAYOUT_MODE_STORAGE_KEY = 'douyu_task_layout_mode';
@@ -201,6 +212,13 @@ function goodsLabel(item: DouyuGoodsItem): string {
return `${item.name || item.commodity_id}${item.score ? ` / ${item.score}积分` : ''}${stockText}`;
}
function xpdGoodsLabel(item: DouyuGoodsItem): string {
const xpd = item as DouyuGoodsItem & { price?: number | null; goods_left?: number | null };
const price = xpd.price != null ? ` / ${xpd.price}点券` : '';
const left = xpd.goods_left != null ? ` / 剩${xpd.goods_left}` : '';
return `${item.name || item.commodity_id}${price}${left}`;
}
function formatWaitSeconds(seconds: number | null | undefined): string {
if (seconds == null || Number.isNaN(Number(seconds))) return '';
const total = Math.max(0, Math.floor(Number(seconds)));
@@ -265,13 +283,22 @@ function savedPositiveInteger(key: string, fallback = 1): number {
}
export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) {
const isPeaceHandbook = handbook === 'peace';
const isEsportsHandbook = handbook === 'esports';
const handbookTitle = isEsportsHandbook ? '电竞手册工作台' : '精英宝典工作台';
const handbookDescription = isEsportsHandbook
? '电竞手册角色绑定与开通任务'
: '精英宝典绑定、开通、积分与兑换任务';
const activeTaskTypes = isEsportsHandbook ? ESPORTS_TASK_TYPES : ELITE_TASK_TYPES;
const quickActions = isEsportsHandbook ? ESPORTS_QUICK_ACTIONS : ELITE_QUICK_ACTIONS;
const handbookTitle = isPeaceHandbook
? '和平小店工作台'
: (isEsportsHandbook ? '电竞手册工作台' : '精英宝典工作台');
const handbookDescription = isPeaceHandbook
? '和平小店绑定角色、商品与点券余额查询'
: (isEsportsHandbook
? '电竞手册角色绑定与开通任务'
: '精英宝典绑定、开通、积分与兑换任务');
const activeTaskTypes = isPeaceHandbook
? PEACE_TASK_TYPES
: (isEsportsHandbook ? ESPORTS_TASK_TYPES : ELITE_TASK_TYPES);
const quickActions = isPeaceHandbook
? PEACE_QUICK_ACTIONS
: (isEsportsHandbook ? ESPORTS_QUICK_ACTIONS : ELITE_QUICK_ACTIONS);
const { can } = usePermissions();
const [accounts, setAccounts] = useState<DouyuTaskAccountItem[]>([]);
// 工作台已导入账号 IDlocalStorage 持久化,默认空白,用户手动导入/移除)
@@ -462,7 +489,9 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
try {
const [accResult, goodsResult, taskResult, cfgResult, typeResult] = await Promise.allSettled([
workbenchIds.length > 0 ? douyuApi.listAccounts({ ids: workbenchIds.join(',') }) : Promise.resolve([]),
isEsportsHandbook ? douyuApi.listEsportsGoods() : douyuApi.listGoods(),
isPeaceHandbook
? douyuApi.listPeaceGoods()
: (isEsportsHandbook ? douyuApi.listEsportsGoods() : douyuApi.listGoods()),
douyuApi.listTasks(),
canConfig ? douyuApi.getConfig() : Promise.resolve(null),
douyuApi.taskTypes(),
@@ -508,7 +537,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
} finally {
setLoading(false);
}
}, [canConfig, isEsportsHandbook, workbenchIds]);
}, [canConfig, isEsportsHandbook, isPeaceHandbook, workbenchIds]);
const loadTasks = useCallback(async () => {
if (tasksLoadingRef.current) return;
@@ -1104,6 +1133,11 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
{
title: '游戏名', dataIndex: 'game_name', width: 170,
render: (_, record) => {
if (isPeaceHandbook) {
return record.xpd_game_name
? <Text ellipsis title={record.xpd_game_name}>{record.xpd_game_name}</Text>
: <Text type="secondary"></Text>;
}
const queryTask = isEsportsHandbook
? latestEsportsStateTaskByAccount.get(record.id) || null
: latestQueryGameTaskByAccount.get(record.id) || null;
@@ -1175,8 +1209,8 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
},
},
{
title: isEsportsHandbook ? '电竞积分' : '积分',
dataIndex: isEsportsHandbook ? 'esports_points' : 'points',
title: isPeaceHandbook ? '点券' : (isEsportsHandbook ? '电竞积分' : '积分'),
dataIndex: isPeaceHandbook ? 'xpd_balance' : (isEsportsHandbook ? 'esports_points' : 'points'),
width: 80,
render: (v) => v ?? <Text type="secondary">-</Text>,
},
@@ -1201,6 +1235,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
{
title: '换绑时间', dataIndex: 'change_role_wait_time', width: 130,
render: (_, record) => {
if (isPeaceHandbook) return <Text type="secondary">-</Text>;
const fromTask = latestChangeWaitByAccount.get(record.id);
const canChangeTime = fromTask?.canChangeTime ?? (
isEsportsHandbook ? record.esports_can_change_time : null
@@ -1239,6 +1274,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
return <Text type="secondary">{text || '-'}</Text>;
},
sorter: (a, b) => {
if (isPeaceHandbook) return 0;
const aw = isEsportsHandbook
? latestChangeWaitByAccount.get(a.id)?.canChangeTime ?? a.esports_can_change_time ?? -1
: latestChangeWaitByAccount.get(a.id)?.wait
@@ -1398,7 +1434,39 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
);
const operationBody = (
<>
{isEsportsHandbook ? (
{isPeaceHandbook ? (
<div style={compactOperationGridStyle}>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<SearchOutlined />
<span></span>
</div>
<Space direction="vertical" style={{ width: '100%' }} size={6}>
{renderActionButton('query_xpd_role', 'primary')}
{renderActionButton('query_xpd_balance', 'primary')}
{renderActionButton('refresh_xpd_goods')}
</Space>
</div>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<ShoppingOutlined />
<span></span>
</div>
<Space direction="vertical" style={{ width: '100%' }} size={6}>
<Select
size="small"
value={selectedGoodsId || undefined}
onChange={setSelectedGoodsId}
options={goods.map((item) => ({ value: item.commodity_id, label: xpdGoodsLabel(item) }))}
placeholder="选择小店商品"
showSearch
optionFilterProp="label"
style={{ width: '100%' }}
/>
</Space>
</div>
</div>
) : isEsportsHandbook ? (
<div style={compactOperationGridStyle}>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
@@ -1690,7 +1758,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
</Card>
<Card
size="small"
title={isEsportsHandbook ? '电竞手册操作' : '精英宝典操作'}
title={isPeaceHandbook ? '和平小店操作' : (isEsportsHandbook ? '电竞手册操作' : '精英宝典操作')}
extra={operationExtra}
style={{ width: 340, flexShrink: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
styles={{ body: { padding: 8, flex: 1, minHeight: 0, overflowY: 'auto' } }}
@@ -1702,7 +1770,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
<>
<Card
size="small"
title={isEsportsHandbook ? '电竞手册操作' : '精英宝典操作'}
title={isPeaceHandbook ? '和平小店操作' : (isEsportsHandbook ? '电竞手册操作' : '精英宝典操作')}
extra={operationExtra}
style={{ flexShrink: 0, marginBottom: 12 }}
styles={{ body: { padding: 8 } }}
@@ -1788,12 +1856,12 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
{ title: '标签', dataIndex: 'tag', width: 110, render: (v: string) => (v ? <Tag color="blue">{v}</Tag> : '-') },
{
title: '游戏名',
dataIndex: isEsportsHandbook ? 'esports_game_name' : 'game_name',
dataIndex: isPeaceHandbook ? 'xpd_game_name' : (isEsportsHandbook ? 'esports_game_name' : 'game_name'),
render: (v) => v || '-',
},
{
title: isEsportsHandbook ? '电竞积分' : '积分',
dataIndex: isEsportsHandbook ? 'esports_points' : 'points',
title: isPeaceHandbook ? '点券' : (isEsportsHandbook ? '电竞积分' : '积分'),
dataIndex: isPeaceHandbook ? 'xpd_balance' : (isEsportsHandbook ? 'esports_points' : 'points'),
render: (v) => v ?? '-',
},
]}
@@ -1804,7 +1872,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
{/* Config modal */}
<Modal
title={isEsportsHandbook ? '电竞手册配置' : '精英宝典配置'}
title={isPeaceHandbook ? '和平小店配置' : (isEsportsHandbook ? '电竞手册配置' : '精英宝典配置')}
open={configOpen}
onCancel={() => setConfigOpen(false)}
onOk={() => configFormValues && saveConfig(configFormValues)}
@@ -1813,7 +1881,11 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
>
{configFormValues && (
<Space direction="vertical" style={{ width: '100%' }} size={8}>
{(isEsportsHandbook ? [
{(isPeaceHandbook ? [
{ label: '小店活动代号', key: 'xpd_act_alias' },
{ label: '道聚城活动 ID', key: 'xpd_act_id' },
{ label: '房间 ID', key: 'xpd_rid' },
] : isEsportsHandbook ? [
{ label: '电竞手册 manualID', key: 'esports_manual_id' },
{ label: '电竞手册活动', key: 'esports_act_alias' },
{ label: '房间 ID', key: 'room_id' },
@@ -1840,7 +1912,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
/>
</Space>
))}
{(isEsportsHandbook ? [
{(isPeaceHandbook ? [] : isEsportsHandbook ? [
{ label: '电竞手册金额(分)', key: 'esports_amount' },
] : [
{ label: '宝典金额(分)', key: 'elite_amount' },