feat(douyu-pool): 支持和平精英号池兑换与商品自动刷新,优化号池操作面板

- 扩展号池支持 peace (和平精英/小跑道) 活动 scope,映射 xpd_balance 与 exchange_xpd_goods 任务
- 新增 POST /douyu/pool/refresh-goods 接口与前端一键刷新,支持复用库内有效 CK 自动更新商品快照
- 前端 DouyuPoolPage 升级视觉设计与栅格布局,商品选择器支持按活动类型分类联动
- 修复 DouyuTask.result 类型守卫、account_events actor_id 类型防御与 runner 过滤规则边界值处理
- 验证方式:全量 pytest (797 通过)、pyright (0 错误)、tsc -b 及 ruff format/check 全部通过
This commit is contained in:
yml2213
2026-10-08 13:15:21 +08:00
parent 88422f2f05
commit bb20533c90
8 changed files with 669 additions and 211 deletions
+4 -1
View File
@@ -1642,7 +1642,10 @@ def account_events(
.all()
}
for item in items:
item["actor_name"] = actor_names.get(item.get("actor_id"), "")
actor_id = item.get("actor_id")
item["actor_name"] = (
actor_names.get(actor_id, "") if isinstance(actor_id, int) else ""
)
return {
"items": items,
+149 -11
View File
@@ -21,6 +21,8 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from core.douyu import DouyuActivityClient, DouyuActivityError
from ..database import get_db
from ..deps import require_permission
from ..models import (
@@ -28,6 +30,7 @@ from ..models import (
AccountLease,
DouyuEsportsGoodsSnapshot,
DouyuGoodsSnapshot,
DouyuXpdGoodsSnapshot,
User,
)
from ..permissions import user_has_permission
@@ -53,9 +56,12 @@ POOL_BATCH_CONCURRENCY = 1
def _goods_snapshot(db: Session, scope: str, commodity_id: str):
model = (
DouyuGoodsSnapshot if scope == pool.SCOPE_ELITE else DouyuEsportsGoodsSnapshot
)
if scope == pool.SCOPE_ELITE:
model = DouyuGoodsSnapshot
elif scope == pool.SCOPE_ESPORTS:
model = DouyuEsportsGoodsSnapshot
else:
model = DouyuXpdGoodsSnapshot
return (
db.query(model)
.filter(model.commodity_id == commodity_id)
@@ -73,14 +79,124 @@ def _require_scope(scope: str) -> str:
return scope
def _try_auto_refresh_goods(db: Session, scope: str) -> bool:
"""尝试从库内找一个有效账号刷新该作用域的商品快照。"""
acc = (
db.query(Account)
.filter(
Account.status == "active",
Account.ck_check_status == "valid",
Account.cookie.isnot(None),
Account.cookie != "",
)
.first()
)
if not acc or not acc.cookie:
return False
try:
client = DouyuActivityClient(cookie=str(acc.cookie))
cfg = ensure_douyu_config(db)
now = _utcnow()
if scope == pool.SCOPE_ELITE:
res = client.list_goods(manual_id=str(cfg.manual_id), rid=str(cfg.rid))
for raw in res.get("goods", []):
cid = str(raw.get("commodityId") or raw.get("commodity_id") or "")
if not cid:
continue
row = (
db.query(DouyuGoodsSnapshot)
.filter(DouyuGoodsSnapshot.commodity_id == cid)
.first()
)
if row is None:
row = DouyuGoodsSnapshot(commodity_id=cid)
db.add(row)
row.name = str(raw.get("commodityName") or raw.get("name") or "")
row.score = int(raw.get("score") or 0)
row.status = str(raw.get("status") or "")
row.raw = raw
row.updated_at = now
db.commit()
return True
elif scope == pool.SCOPE_ESPORTS:
res = client.list_esports_goods(
manual_id=str(cfg.esports_manual_id), rid=str(cfg.room_id)
)
for raw in res.get("goods", []):
cid = str(raw.get("commodityId") or raw.get("commodity_id") or "")
if not cid:
continue
row = (
db.query(DouyuEsportsGoodsSnapshot)
.filter(DouyuEsportsGoodsSnapshot.commodity_id == cid)
.first()
)
if row is None:
row = DouyuEsportsGoodsSnapshot(commodity_id=cid)
db.add(row)
row.name = str(raw.get("commodityName") or raw.get("name") or "")
row.score = int(raw.get("score") or 0)
row.status = str(raw.get("status") or "")
row.raw = raw
row.updated_at = now
db.commit()
return True
elif scope == "peace":
ctx = client.xpd_embed_query(
act_alias=str(cfg.xpd_act_alias), rid=str(cfg.xpd_rid)
)
embed_q = ctx.get("query") or {}
role_res = client.xpd_get_role(
embed_query=embed_q,
act_id=str(cfg.xpd_act_id),
rid=str(cfg.xpd_rid),
)
role = role_res.get("info") or {}
res = client.xpd_list_goods(
embed_query=embed_q,
act_id=str(cfg.xpd_act_id),
openid=str(role.get("game_open_id") or ""),
roleid=str(role.get("role_id") or ""),
areaid=str(acc.xpd_area_id or 1),
)
for raw in res.get("goods", []):
cid = str(raw.get("commodity_id") or raw.get("commodityId") or "")
if not cid:
continue
row = (
db.query(DouyuXpdGoodsSnapshot)
.filter(DouyuXpdGoodsSnapshot.commodity_id == cid)
.first()
)
if row is None:
row = DouyuXpdGoodsSnapshot(commodity_id=cid)
db.add(row)
row.name = str(raw.get("name") or raw.get("commodityName") or "")
row.price = int(raw.get("price") or 0)
row.org_price = int(raw.get("org_price") or 0)
row.category = str(raw.get("category") or "")
row.goods_left = raw.get("goods_left")
row.raw = raw
row.updated_at = now
db.commit()
return True
except (DouyuActivityError, OSError, ValueError, KeyError, TypeError):
db.rollback()
return False
return False
def _goods_required(db: Session, scope: str, commodity_id: str) -> int:
goods = _goods_snapshot(db, scope, commodity_id)
if goods is None:
_try_auto_refresh_goods(db, scope)
goods = _goods_snapshot(db, scope, commodity_id)
if goods is None:
raise HTTPException(
status_code=404,
detail="找不到该商品,请先在对应工作台刷新商品列表",
)
score = int(goods.score or 0)
score = int(getattr(goods, "score", None) or getattr(goods, "price", None) or 0)
if score <= 0:
raise HTTPException(status_code=400, detail="该商品没有有效积分价格,无法分配")
return score
@@ -94,9 +210,30 @@ def _is_pool_admin(current: User) -> bool:
return user_has_permission(current, "account:pool_admin")
@router.post("/refresh-goods")
def pool_refresh_goods(
scope: str = Query("elite", pattern="^(elite|esports|peace)$"),
db: Session = Depends(get_db),
current: User = Depends(require_permission("account:pool_use")),
) -> dict[str, Any]:
"""使用库内有效 CK 自动刷新商品快照。"""
ok = _try_auto_refresh_goods(db, scope)
model = (
DouyuGoodsSnapshot
if scope == pool.SCOPE_ELITE
else (
DouyuEsportsGoodsSnapshot
if scope == pool.SCOPE_ESPORTS
else DouyuXpdGoodsSnapshot
)
)
count = db.query(model).count()
return {"success": ok, "scope": scope, "count": count}
@router.get("/supply")
def pool_supply(
scope: str = Query("elite", pattern="^(elite|esports)$"),
scope: str = Query("elite", pattern="^(elite|esports|peace)$"),
commodity_id: str = Query(..., min_length=1, max_length=64),
db: Session = Depends(get_db),
current: User = Depends(require_permission("account:pool_use")),
@@ -221,9 +358,12 @@ async def pool_exchange(
)
account_ids = [lease.account_id for lease in leases]
task_type = (
"exchange_goods" if scope == pool.SCOPE_ELITE else "exchange_esports_goods"
)
if scope == pool.SCOPE_ELITE:
task_type = "exchange_goods"
elif scope == pool.SCOPE_ESPORTS:
task_type = "exchange_esports_goods"
else:
task_type = "exchange_xpd_goods"
by_id = {c.account_id: c for c in candidates}
# 补给链:A 梯队只有「兑换」一步;B 梯队是「查余额 → 充值 → 查积分 → 赠令 → 兑换」。
@@ -384,9 +524,7 @@ def list_leases(
id=row.id,
account_id=row.account_id,
username=(
accounts.get(row.account_id).username
if accounts.get(row.account_id)
else ""
accounts[row.account_id].username if row.account_id in accounts else ""
),
holder_user_id=row.holder_user_id,
holder_name=holders.get(row.holder_user_id, ""),
+2 -1
View File
@@ -32,7 +32,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from ..models import Account, AccountEvent, AccountLease, DouyuTask, User
from .account_binding import SCOPE_ELITE, SCOPE_ESPORTS
from .account_binding import SCOPE_ELITE, SCOPE_ESPORTS, SCOPE_PEACE
#: 租约默认有效期(分钟)。到点由 `account_lease_reaper` 兜底回收。
DEFAULT_LEASE_TTL_MINUTES = 60
@@ -53,6 +53,7 @@ _POOL_OWNER_ROLES = ("super_admin", "operation")
_SCOPE_COLUMNS: dict[str, tuple[str, str | None]] = {
SCOPE_ELITE: ("points", "elite_opened"),
SCOPE_ESPORTS: ("esports_points", "esports_opened"),
SCOPE_PEACE: ("xpd_balance", None),
}
SUPPORTED_POOL_SCOPES = tuple(_SCOPE_COLUMNS)
+4 -1
View File
@@ -291,8 +291,11 @@ class DouyuBatchRunnerCore:
current = getattr(account, column, None)
if current is None:
return ""
raw_val = rule.get("value")
if raw_val is None:
return ""
try:
target = int(rule.get("value"))
target = int(raw_val)
except (TypeError, ValueError):
return ""
if compare(int(current), target):
+25 -3
View File
@@ -15,7 +15,7 @@ import type {
*/
export const douyuPoolApi = {
/** 不锁号地预览「能分出几个、要花多少钱」 */
supply: (scope: 'elite' | 'esports', commodityId: string) =>
supply: (scope: 'elite' | 'esports' | 'peace', commodityId: string) =>
api.get<DouyuPoolSupply, DouyuPoolSupply>('/douyu/pool/supply', {
params: { scope, commodity_id: commodityId },
}),
@@ -37,7 +37,29 @@ export const douyuPoolApi = {
api.post<{ success: boolean; released: number }, { success: boolean; released: number }>(
`/douyu/pool/leases/release-batch/${batchId}`,
),
/** 复用旧接口拿商品列表(皮肤下拉的数据源) */
goods: () => api.get<{ commodity_id: string; name: string; score: number | null }[], { commodity_id: string; name: string; score: number | null }[]>('/douyu/goods'),
/** 使用有效 CK 自动刷新远端商品快照 */
refreshGoods: (scope: 'elite' | 'esports' | 'peace' = 'elite') =>
api.post<{ success: boolean; scope: string; count: number }, { success: boolean; scope: string; count: number }>(
'/douyu/pool/refresh-goods',
null,
{ params: { scope } },
),
/** 复用接口拿商品列表(皮肤下拉的数据源,按活动 scope 分流) */
goods: async (scope: 'elite' | 'esports' | 'peace' = 'elite') => {
if (scope === 'peace') {
const list = await api.get<
{ commodity_id: string; name: string; price: number | null }[],
{ commodity_id: string; name: string; price: number | null }[]
>('/douyu/xpd-goods');
return (list || []).map((item) => ({
commodity_id: item.commodity_id,
name: item.name,
score: item.price,
}));
}
return api.get<{ commodity_id: string; name: string; score: number | null }[], { commodity_id: string; name: string; score: number | null }[]>(
scope === 'esports' ? '/douyu/esports-goods' : '/douyu/goods',
);
},
stopBatch: (batchId: string) => api.post<MessageResponse, MessageResponse>(`/douyu/stop/${batchId}`),
};
+1 -1
View File
@@ -1215,7 +1215,7 @@ export interface DouyuPoolAccount {
}
export interface DouyuPoolExchangeRequest {
scope: 'elite' | 'esports';
scope: 'elite' | 'esports' | 'peace';
commodity_id: string;
quantity: number;
allow_topup: boolean;
+462 -192
View File
@@ -1,14 +1,14 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert, Badge, Button, Card, Col, Descriptions, InputNumber, Modal,
QRCode, Row, Select, Space, Statistic, Table, Tabs, Tag, Tooltip,
QRCode, Radio, Row, Select, Space, Statistic, Table, Tabs, Tag, Tooltip,
Typography, theme,
} from 'antd';
import {
CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined,
CopyOutlined, CreditCardOutlined, EyeOutlined, FireOutlined,
HistoryOutlined, ReloadOutlined, StopOutlined, SyncOutlined,
ThunderboltOutlined,
HistoryOutlined, ReloadOutlined, ShopOutlined, ShoppingCartOutlined, StopOutlined, SyncOutlined,
ThunderboltOutlined, TrophyOutlined,
} from '@ant-design/icons';
import { message } from '../utils/antdMessage';
import { getErrorMessage } from '../utils/error';
@@ -30,23 +30,63 @@ import {
const { Text, Paragraph } = Typography;
const DOUYU_POOL_RECENT_GOODS_KEY = 'douyu_pool_recent_goods_ids';
export type PoolScope = 'elite' | 'esports' | 'peace';
function loadRecentGoodsIds(): string[] {
interface ScopeConfig {
key: PoolScope;
title: string;
badge: string;
pointsLabel: string;
icon: React.ReactNode;
desc: string;
}
const SCOPE_CONFIGS: Record<PoolScope, ScopeConfig> = {
elite: {
key: 'elite',
title: '精英宝典',
badge: '💎',
pointsLabel: '精英积分',
icon: <ShoppingCartOutlined style={{ fontSize: 16 }} />,
desc: '和平精英精英宝典活动 · 消耗精英积分',
},
esports: {
key: 'esports',
title: 'PEL 电竞手册',
badge: '🏆',
pointsLabel: '电竞积分',
icon: <TrophyOutlined style={{ fontSize: 16 }} />,
desc: 'PEL 职业联赛电竞手册 · 消耗电竞积分',
},
peace: {
key: 'peace',
title: '和平小店',
badge: '🛒',
pointsLabel: '点券/积分',
icon: <ShopOutlined style={{ fontSize: 16 }} />,
desc: '斗鱼和平小店特惠商城 · 消耗小店点券/鱼翅',
},
};
function getRecentGoodsKey(scope: PoolScope): string {
return `douyu_pool_recent_goods_${scope}`;
}
function loadRecentGoodsIds(scope: PoolScope): string[] {
try {
const raw = localStorage.getItem(DOUYU_POOL_RECENT_GOODS_KEY);
const raw = localStorage.getItem(getRecentGoodsKey(scope));
return raw ? (JSON.parse(raw) as string[]) : [];
} catch {
return [];
}
}
function saveRecentGoodsId(id: string) {
function saveRecentGoodsId(scope: PoolScope, id: string) {
if (!id) return;
try {
const existing = loadRecentGoodsIds().filter((x) => x !== id);
const existing = loadRecentGoodsIds(scope).filter((x) => x !== id);
const updated = [id, ...existing].slice(0, 8);
localStorage.setItem(DOUYU_POOL_RECENT_GOODS_KEY, JSON.stringify(updated));
localStorage.setItem(getRecentGoodsKey(scope), JSON.stringify(updated));
} catch {
// 忽略存储异常
}
@@ -56,30 +96,38 @@ function saveRecentGoodsId(id: string) {
const TASK_TYPE_NAMES: Record<string, string> = {
query_elite_status: '检查精英宝典状态',
query_gold_balance: '查询鱼翅余额',
query_points: '查询精英积分',
query_points: '查询积分余额',
create_gold_qr: '鱼翅充值',
donate_elite_gift: '赠送精英令',
create_elite_qr: '精英令充值',
create_esports_qr: '电竞币充值',
donate_elite_gift: '赠送道具',
exchange_goods: '兑换精英宝典皮肤',
exchange_esports_goods: '兑换电竞手册皮肤',
exchange_xpd_goods: '兑换和平小店商品',
};
/**
* 账号分配台(自动化工作台)。
*
* 客服操作闭环:选皮肤 → 填数量 → 提交分配 →
* 操作闭环:选活动 → 选皮肤 → 填数量 → 提交分配 →
* 若需充值自动弹出微信支付码 → 扫码到账后自动赠送并兑换 → 产出兑换凭证。
*/
export default function DouyuPoolPage() {
const { token } = theme.useToken();
// 当前选中的活动 scope
const [scope, setScope] = useState<PoolScope>('elite');
const [goods, setGoods] = useState<{ commodity_id: string; name: string; score: number | null }[]>([]);
const [commodityId, setCommodityId] = useState<string>('');
const [quantity, setQuantity] = useState(1);
const [allowTopup, setAllowTopup] = useState(true);
const [recentGoodsIds, setRecentGoodsIds] = useState<string[]>(() => loadRecentGoodsIds());
const [recentGoodsIds, setRecentGoodsIds] = useState<string[]>(() => loadRecentGoodsIds('elite'));
const [supply, setSupply] = useState<DouyuPoolSupply | null>(null);
const [result, setResult] = useState<DouyuPoolExchangeResult | null>(null);
const [leases, setLeases] = useState<DouyuPoolLease[]>([]);
const [loading, setLoading] = useState(false);
const [refreshingGoods, setRefreshingGoods] = useState(false);
const [previewing, setPreviewing] = useState(false);
// 活跃批次与任务流水监控
@@ -105,16 +153,44 @@ export default function DouyuPoolPage() {
return () => window.clearInterval(timer);
}, []);
const loadGoods = useCallback(async () => {
// 测算库存的具体实现(指定 scope 与 commodityId,彻底杜绝切换活动时的异步时序错乱)
const loadSupplyFor = useCallback(async (targetScope: PoolScope, targetCommodityId: string) => {
if (!targetCommodityId) {
setSupply(null);
return;
}
setPreviewing(true);
try {
const rows = await douyuPoolApi.goods();
const res = await douyuPoolApi.supply(targetScope, targetCommodityId);
setSupply(res);
} catch {
setSupply(null);
} finally {
setPreviewing(false);
}
}, []);
// 加载对应活动 scope 的商品列表并自动测算
const loadGoods = useCallback(async (targetScope: PoolScope) => {
try {
const rows = await douyuPoolApi.goods(targetScope);
const usable = rows.filter((item) => (item.score ?? 0) > 0);
setGoods(usable);
if (usable.length > 0 && !commodityId) setCommodityId(usable[0].commodity_id);
if (usable.length > 0) {
setCommodityId((prevId) => {
const exists = usable.some((item) => item.commodity_id === prevId);
const nextId = exists ? prevId : usable[0].commodity_id;
void loadSupplyFor(targetScope, nextId);
return nextId;
});
} else {
setCommodityId('');
setSupply(null);
}
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
}, [commodityId]);
}, [loadSupplyFor]);
const loadLeases = useCallback(async () => {
try {
@@ -125,34 +201,49 @@ export default function DouyuPoolPage() {
}
}, []);
useEffect(() => { void loadGoods(); }, [loadGoods]);
useEffect(() => { void loadLeases(); }, [loadLeases]);
// 初始化加载
useEffect(() => {
void loadGoods('elite');
void loadLeases();
}, [loadGoods, loadLeases]);
const loadSupply = useCallback(async () => {
if (!commodityId) return;
setPreviewing(true);
// 切换活动 scope
const handleScopeChange = useCallback((newScope: PoolScope) => {
setScope(newScope);
setSupply(null);
setCommodityId('');
setRecentGoodsIds(loadRecentGoodsIds(newScope));
void loadGoods(newScope);
}, [loadGoods]);
// 一键刷新远端最新商品快照并刷新租约
const handleRefreshAll = async () => {
setRefreshingGoods(true);
try {
setSupply(await douyuPoolApi.supply('elite', commodityId));
const res = await douyuPoolApi.refreshGoods(scope);
if (res.success) {
message.success(`已自动使用有效账号刷新 ${SCOPE_CONFIGS[scope].title} 商品列表(共 ${res.count} 个)`);
} else {
message.info(`已同步最新商品快照(共 ${res.count} 个)`);
}
await Promise.all([loadGoods(scope), loadLeases()]);
} catch (e: unknown) {
setSupply(null);
message.error(getErrorMessage(e));
} finally {
setPreviewing(false);
setRefreshingGoods(false);
}
}, [commodityId]);
useEffect(() => { void loadSupply(); }, [loadSupply]);
};
// 轮询活跃批次任务
const pollBatchTasks = useCallback(async (batchId: string) => {
try {
const res = await douyuApi.listTasks('elite', batchId);
const res = await douyuApi.listTasks(scope, batchId);
setBatchTasks(res);
return res;
} catch {
return [];
}
}, []);
}, [scope]);
useEffect(() => {
if (!activeBatchId) {
@@ -182,7 +273,7 @@ export default function DouyuPoolPage() {
};
}, [activeBatchId, pollBatchTasks]);
// 提取待支付任务列表(create_gold_qr 且有 pay_url)
// 提取待支付任务列表
const pendingPayTasks = useMemo(() => {
return batchTasks.filter(
(task) =>
@@ -233,11 +324,12 @@ export default function DouyuPoolPage() {
const handleSelectGood = useCallback((id: string) => {
setCommodityId(id);
saveRecentGoodsId(id);
setRecentGoodsIds(loadRecentGoodsIds());
}, []);
saveRecentGoodsId(scope, id);
setRecentGoodsIds(loadRecentGoodsIds(scope));
void loadSupplyFor(scope, id);
}, [scope, loadSupplyFor]);
// 快捷常用商品列表(最近使用历史 + 热门高价值套装)
// 快捷常用商品列表(最近使用历史 + 热门高价值皮肤)
const quickGoods = useMemo(() => {
if (!goods.length) return [];
const map = new Map(goods.map((g) => [g.commodity_id, g]));
@@ -253,10 +345,10 @@ export default function DouyuPoolPage() {
}
}
// 2. 热门高价值套装(如兔兔白日梦、狂战萌兔)
// 2. 热门高价值皮肤(如兔兔白日梦、狂战萌兔等)
for (const g of goods) {
if (
(g.name.includes('兔兔白日梦') || g.name.includes('狂战萌兔')) &&
(g.name.includes('兔兔白日梦') || g.name.includes('狂战萌兔') || g.name.includes('套装') || (g.score ?? 0) >= 1000) &&
!seen.has(g.commodity_id)
) {
list.push(g);
@@ -266,25 +358,31 @@ export default function DouyuPoolPage() {
// 3. 兜底补足
for (const g of goods) {
if (list.length >= 6) break;
if (list.length >= 8) break;
if (!seen.has(g.commodity_id)) {
list.push(g);
seen.add(g.commodity_id);
}
}
return list.slice(0, 6);
return list.slice(0, 8);
}, [goods, recentGoodsIds]);
const submit = async () => {
if (!commodityId) { message.warning('请先选择皮肤'); return; }
saveRecentGoodsId(commodityId);
setRecentGoodsIds(loadRecentGoodsIds());
if (!commodityId) {
message.warning('请先选择皮肤');
return;
}
saveRecentGoodsId(scope, commodityId);
setRecentGoodsIds(loadRecentGoodsIds(scope));
setLoading(true);
try {
const data = await douyuPoolApi.exchange({
scope: 'elite', commodity_id: commodityId, quantity,
allow_topup: allowTopup, allow_fallback: true,
scope,
commodity_id: commodityId,
quantity,
allow_topup: allowTopup,
allow_fallback: true,
});
setResult(data);
if (data.batch_id) {
@@ -295,7 +393,7 @@ export default function DouyuPoolPage() {
} else {
message.success(`已分配 ${data.allocated} 个账号,正在执行补给与兑换(批次 ${data.batch_id})`);
}
await Promise.all([loadSupply(), loadLeases()]);
await Promise.all([loadSupplyFor(scope, commodityId), loadLeases()]);
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
@@ -322,7 +420,7 @@ export default function DouyuPoolPage() {
try {
const res = await douyuPoolApi.releaseBatch(activeBatchId);
message.success(`已释放该批次 ${res.released} 个账号租约`);
await Promise.all([loadSupply(), loadLeases()]);
await Promise.all([loadSupplyFor(scope, commodityId), loadLeases()]);
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
@@ -332,7 +430,7 @@ export default function DouyuPoolPage() {
try {
await douyuPoolApi.releaseLease(leaseId);
message.success('已释放租约');
await Promise.all([loadSupply(), loadLeases()]);
await Promise.all([loadSupplyFor(scope, commodityId), loadLeases()]);
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
@@ -341,9 +439,9 @@ export default function DouyuPoolPage() {
const goodsOptions = useMemo(
() => goods.map((item) => ({
value: item.commodity_id,
label: `${item.name}(${item.score ?? 0} 分)`,
label: `${item.name}(${item.score ?? 0} ${SCOPE_CONFIGS[scope].pointsLabel})`,
})),
[goods],
[goods, scope],
);
// 账号维度聚合任务执行进度
@@ -351,8 +449,8 @@ export default function DouyuPoolPage() {
if (!result?.accounts) return [];
return result.accounts.map((acc) => {
const accTasks = batchTasks.filter((t) => t.account_id === acc.account_id);
const payTask = accTasks.find((t) => ['create_gold_qr', 'create_elite_qr'].includes(t.task_type));
const exchangeTask = accTasks.find((t) => ['exchange_goods', 'exchange_esports_goods'].includes(t.task_type));
const payTask = accTasks.find((t) => ['create_gold_qr', 'create_elite_qr', 'create_esports_qr'].includes(t.task_type));
const exchangeTask = accTasks.find((t) => ['exchange_goods', 'exchange_esports_goods', 'exchange_xpd_goods'].includes(t.task_type));
const donateTask = accTasks.find((t) => t.task_type === 'donate_elite_gift');
const runningTask = accTasks.find((t) => ['running', 'pending'].includes(t.status));
const failedTask = accTasks.find((t) => ['failed', 'error', 'manual_required'].includes(t.status));
@@ -370,7 +468,7 @@ export default function DouyuPoolPage() {
currentStepText = '🔄 正在兑换皮肤...';
stepTone = 'processing';
} else if (donateTask?.status === 'running') {
currentStepText = '🎁 正在赠送精英令...';
currentStepText = '🎁 正在赠送道具...';
stepTone = 'processing';
} else if (payTask?.status === 'running') {
currentStepText = hasPaymentQrcode(payTask) ? '💳 等待扫码支付' : '💳 生成支付码中...';
@@ -475,8 +573,16 @@ export default function DouyuPoolPage() {
];
const leaseColumns: ColumnsType<DouyuPoolLease> = [
{
title: '活动类型', dataIndex: 'scope', width: 130,
render: (val: string) => {
if (val === 'esports') return <Tag color="gold">🏆 电竞手册</Tag>;
if (val === 'peace') return <Tag color="purple">🛒 和平小店</Tag>;
return <Tag color="blue">💎 精英宝典</Tag>;
},
},
{ title: '账号', dataIndex: 'username', ellipsis: true },
{ title: '商品ID', dataIndex: 'commodity_id', width: 220 },
{ title: '商品ID', dataIndex: 'commodity_id', width: 200 },
{
title: '批次号', dataIndex: 'batch_id', width: 140,
render: (val: string) => (
@@ -523,162 +629,322 @@ export default function DouyuPoolPage() {
? (activePayTask.account_nickname || activePayTask.account_username || activePayTask.account_uid || `#${activePayTask.account_id}`)
: '';
const activeScopeInfo = SCOPE_CONFIGS[scope];
return (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
{/* 顶部发起卡片 */}
{/* 顶部主卡片:活动选择 + 皮肤配置 + 测算概览 */}
<Card
title="发起兑换(系统自动分配账号)"
style={{
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.04)',
borderRadius: 10,
}}
title={(
<Space size={10} align="center">
<ThunderboltOutlined style={{ color: token.colorPrimary, fontSize: 18 }} />
<span style={{ fontWeight: 600, fontSize: 16 }}>智能账号分配台</span>
<Tag color="cyan" style={{ borderRadius: 10, fontSize: 11, padding: '0 8px' }}>自动补给 · 自动回收</Tag>
</Space>
)}
extra={(
<Button icon={<ReloadOutlined />} onClick={() => { void loadGoods(); void loadSupply(); void loadLeases(); }}>
刷新
<Button
icon={<ReloadOutlined />}
loading={refreshingGoods}
onClick={() => void handleRefreshAll()}
>
刷新商品与数据
</Button>
)}
>
{/* 常用 / 历史快捷标签 */}
{quickGoods.length > 0 && (
<div style={{ marginBottom: 16 }}>
<Space wrap size={[8, 8]} align="center">
<Text type="secondary" style={{ fontSize: 13, marginRight: 2 }}>
<HistoryOutlined style={{ marginRight: 4 }} />
常用 / 历史快捷:
<Space direction="vertical" size={20} style={{ width: '100%' }}>
{/* 第一步:活动选择(顶级切换) */}
<div
style={{
background: token.colorFillAlter,
padding: '12px 16px',
borderRadius: 8,
border: `1px solid ${token.colorBorderSecondary}`,
}}
>
<Row align="middle" justify="space-between" gutter={[16, 12]}>
<Col xs={24} sm={5}>
<Text strong style={{ fontSize: 14 }}>
<span style={{ color: token.colorPrimary, marginRight: 6 }}>①</span>
选择活动类型:
</Text>
</Col>
<Col xs={24} sm={19}>
<Radio.Group
value={scope}
onChange={(e) => handleScopeChange(e.target.value as PoolScope)}
buttonStyle="solid"
size="middle"
>
<Radio.Button value="elite" style={{ padding: '0 16px', height: 38, lineHeight: '36px' }}>
<Space size={6}>
<span>💎</span>
<span style={{ fontWeight: 500 }}>精英宝典</span>
<Tag color="blue" style={{ fontSize: 11, marginRight: 0, borderRadius: 4 }}>精英积分</Tag>
</Space>
</Radio.Button>
<Radio.Button value="esports" style={{ padding: '0 16px', height: 38, lineHeight: '36px' }}>
<Space size={6}>
<span>🏆</span>
<span style={{ fontWeight: 500 }}>PEL 电竞手册</span>
<Tag color="gold" style={{ fontSize: 11, marginRight: 0, borderRadius: 4 }}>电竞积分</Tag>
</Space>
</Radio.Button>
<Radio.Button value="peace" style={{ padding: '0 16px', height: 38, lineHeight: '36px' }}>
<Space size={6}>
<span>🛒</span>
<span style={{ fontWeight: 500 }}>和平小店</span>
<Tag color="purple" style={{ fontSize: 11, marginRight: 0, borderRadius: 4 }}>点券/余额</Tag>
</Space>
</Radio.Button>
</Radio.Group>
<Text type="secondary" style={{ fontSize: 12, marginLeft: 16, display: 'inline-block' }}>
{activeScopeInfo.desc}
</Text>
</Col>
</Row>
</div>
{/* 第二步:皮肤选择与常用标签 */}
<div>
<div style={{ marginBottom: 8 }}>
<Text strong style={{ fontSize: 14 }}>
<span style={{ color: token.colorPrimary, marginRight: 6 }}>②</span>
选择要兑换的皮肤 & 分配参数:
</Text>
{quickGoods.map((item) => {
const isSelected = commodityId === item.commodity_id;
const isHot = item.name.includes('兔兔白日梦') || item.name.includes('狂战萌兔');
return (
<Tag.CheckableTag
key={item.commodity_id}
checked={isSelected}
onChange={() => handleSelectGood(item.commodity_id)}
</div>
{/* 常用 / 历史快捷标签 */}
{quickGoods.length > 0 && (
<div style={{ marginBottom: 12, paddingLeft: 2 }}>
<Space wrap size={[8, 8]} align="center">
<Text type="secondary" style={{ fontSize: 12, marginRight: 2 }}>
<HistoryOutlined style={{ marginRight: 4 }} />
{activeScopeInfo.title} 常用 / 热门皮肤:
</Text>
{quickGoods.map((item) => {
const isSelected = commodityId === item.commodity_id;
const isHot = item.name.includes('兔兔白日梦') || item.name.includes('狂战萌兔') || (item.score ?? 0) >= 1000;
return (
<Tag.CheckableTag
key={item.commodity_id}
checked={isSelected}
onChange={() => handleSelectGood(item.commodity_id)}
style={{
padding: '3px 12px',
fontSize: 12,
borderRadius: 14,
border: isSelected ? undefined : `1px solid ${token.colorBorder}`,
cursor: 'pointer',
transition: 'all 0.2s',
}}
>
{isHot && <FireOutlined style={{ color: '#ff4d4f', marginRight: 4 }} />}
{item.name}
<Text
type={isSelected ? undefined : 'secondary'}
style={{ fontSize: 11, marginLeft: 4, color: isSelected ? '#fff' : undefined }}
>
({item.score ?? 0}分)
</Text>
</Tag.CheckableTag>
);
})}
</Space>
</div>
)}
{/* 皮肤选择下拉 + 数量 + 补足策略 + 提交按钮 */}
<Row gutter={[16, 16]} align="bottom">
<Col xs={24} md={10}>
<Text type="secondary" style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
选择皮肤商品 (支持拼音/汉字搜索)
</Text>
<Select
showSearch
optionFilterProp="label"
size="large"
style={{ width: '100%' }}
placeholder={`选择 ${activeScopeInfo.title} 皮肤`}
value={commodityId || undefined}
onChange={handleSelectGood}
options={goodsOptions}
/>
</Col>
<Col xs={12} md={4}>
<Text type="secondary" style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
需要账号数
</Text>
<InputNumber
min={1}
max={50}
size="large"
style={{ width: '100%' }}
value={quantity}
onChange={(value) => setQuantity(Number(value) || 1)}
addonAfter="个"
/>
</Col>
<Col xs={12} md={5}>
<Text type="secondary" style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
积分不足时策略
</Text>
<Select
size="large"
style={{ width: '100%' }}
value={allowTopup ? 'topup' : 'skip'}
onChange={(value) => setAllowTopup(value === 'topup')}
options={[
{ value: 'topup', label: '允许自动充值补足差额' },
{ value: 'skip', label: '仅用已有积分 (不充值)' },
]}
/>
</Col>
<Col xs={24} md={5}>
<Button
type="primary"
block
size="large"
icon={<ThunderboltOutlined />}
loading={loading}
onClick={() => void submit()}
style={{
height: 40,
fontWeight: 600,
fontSize: 15,
boxShadow: '0 2px 8px rgba(24, 144, 255, 0.35)',
}}
>
提交并自动分配
</Button>
</Col>
</Row>
</div>
{/* 第三步:实时测算与库存看板 */}
{supply && (
<div
style={{
background: token.colorFillAlter,
padding: '16px 20px',
borderRadius: 8,
border: `1px solid ${token.colorBorderSecondary}`,
}}
>
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text strong style={{ fontSize: 13 }}>
📊 当前皮肤库存测算(商品:{supply.commodity?.name || commodityId} · 所需 {supply.commodity?.score} {activeScopeInfo.pointsLabel})
</Text>
{previewing && <Tag icon={<SyncOutlined spin />} color="processing">测算刷新中</Tag>}
</div>
<Row gutter={[16, 16]}>
<Col xs={12} sm={6}>
<Card
size="small"
style={{
padding: '3px 10px',
fontSize: 13,
borderRadius: 16,
border: isSelected ? undefined : `1px solid ${token.colorBorder}`,
cursor: 'pointer',
transition: 'all 0.2s',
background: '#f6ffed',
borderColor: '#b7eb8f',
borderRadius: 6,
}}
>
{isHot && <FireOutlined style={{ color: '#ff4d4f', marginRight: 4 }} />}
{item.name}
<Text
type={isSelected ? undefined : 'secondary'}
style={{ fontSize: 12, marginLeft: 4, color: isSelected ? '#fff' : undefined }}
>
({item.score ?? 0}分)
</Text>
</Tag.CheckableTag>
);
})}
</Space>
</div>
)}
<Statistic
title={<span style={{ color: '#389e0d', fontSize: 13, fontWeight: 500 }}>🟢 免充值直接可用</span>}
value={supply.zero_topup}
suffix={<span style={{ fontSize: 13, color: '#389e0d' }}>个账号</span>}
valueStyle={{ color: '#237804', fontWeight: 600, fontSize: 24 }}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card
size="small"
style={{
background: '#fffbe6',
borderColor: '#ffe58f',
borderRadius: 6,
}}
>
<Statistic
title={<span style={{ color: '#d46b08', fontSize: 13, fontWeight: 500 }}>🟡 需充值补足可用</span>}
value={supply.after_topup}
suffix={<span style={{ fontSize: 13, color: '#d46b08' }}>个账号</span>}
valueStyle={{ color: '#d46b08', fontWeight: 600, fontSize: 24 }}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card
size="small"
style={{
background: '#e6f7ff',
borderColor: '#91d5ff',
borderRadius: 6,
}}
>
<Statistic
title={(
<span style={{ color: '#096dd9', fontSize: 13, fontWeight: 500 }}>
池全量补足预估
<Tooltip title="将池内所有待充账号全部补足所需总花费(非本次单次分配费用)">
<Text type="secondary" style={{ fontSize: 12, marginLeft: 4 }}>ℹ️</Text>
</Tooltip>
</span>
)}
value={supply.cost.est_yuan}
suffix={<span style={{ fontSize: 13, color: '#096dd9' }}>元</span>}
valueStyle={{ color: '#096dd9', fontWeight: 600, fontSize: 24 }}
loading={previewing}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card
size="small"
style={{
background: '#fafafa',
borderColor: token.colorBorderSecondary,
borderRadius: 6,
}}
>
<Statistic
title={<span style={{ fontSize: 13, fontWeight: 500 }}>我的在租账号</span>}
value={leases.length}
suffix={<span style={{ fontSize: 13 }}>个</span>}
valueStyle={{ fontWeight: 600, fontSize: 24 }}
/>
</Card>
</Col>
</Row>
</div>
)}
<Row gutter={[16, 16]} align="bottom">
<Col xs={24} md={10}>
<Text type="secondary" style={{ display: 'block', marginBottom: 4 }}>选择要兑换的皮肤</Text>
<Select
showSearch
optionFilterProp="label"
style={{ width: '100%' }}
placeholder="选择皮肤"
value={commodityId || undefined}
onChange={handleSelectGood}
options={goodsOptions}
{supply?.manual_payment_required && (
<Alert
type="info"
showIcon
message="充值渠道为「微信扫码支付」"
description="提交分配后,若包含需充值账号,系统将在此处自动弹出微信支付码。扫码支付成功后,全流程自动推进并产出兑换凭证。"
style={{ borderRadius: 6 }}
/>
</Col>
<Col xs={12} md={4}>
<Text type="secondary" style={{ display: 'block', marginBottom: 4 }}>需要几个账号</Text>
<InputNumber
min={1}
max={50}
style={{ width: '100%' }}
value={quantity}
onChange={(value) => setQuantity(Number(value) || 1)}
/>
</Col>
<Col xs={12} md={5}>
<Text type="secondary" style={{ display: 'block', marginBottom: 4 }}>积分不足时策略</Text>
<Select
style={{ width: '100%' }}
value={allowTopup ? 'topup' : 'skip'}
onChange={(value) => setAllowTopup(value === 'topup')}
options={[
{ value: 'topup', label: '自动充值鱼翅补足' },
{ value: 'skip', label: '仅用已有积分 (不充值)' },
]}
/>
</Col>
<Col xs={24} md={5}>
<Button
type="primary"
block
icon={<ThunderboltOutlined />}
loading={loading}
onClick={() => void submit()}
size="middle"
>
提交并自动分配
</Button>
</Col>
</Row>
{supply && (
<Row gutter={16} style={{ marginTop: 20 }}>
<Col xs={12} md={6}>
<Statistic
title="免充值可用账号"
value={supply.zero_topup}
suffix="个"
valueStyle={{ color: token.colorSuccess }}
/>
</Col>
<Col xs={12} md={6}>
<Statistic
title="需充值可用账号"
value={supply.after_topup}
suffix="个"
valueStyle={{ color: token.colorWarning }}
/>
</Col>
<Col xs={12} md={6}>
<Statistic
title={(
<span>
池内全量补足预算
<Tooltip title="将池内所有待充账号全部补足所需总花费(非单次分配费用)">
<Text type="secondary" style={{ fontSize: 12, marginLeft: 4 }}>ℹ️</Text>
</Tooltip>
</span>
)}
value={supply.cost.est_yuan}
suffix="元"
loading={previewing}
/>
</Col>
<Col xs={12} md={6}>
<Statistic title="我的在租账号" value={leases.length} suffix="个" />
</Col>
</Row>
)}
{supply?.manual_payment_required && (
<Alert
style={{ marginTop: 16 }}
type="info"
showIcon
message="当前充值渠道为「微信扫码支付」"
description="提交后若分配到需充值账号,系统将在此处自动弹出支付二维码。扫码支付后系统将自动完成赠令与皮肤兑换全流程。"
/>
)}
)}
</Space>
</Card>
{/* 活跃批次执行与进度面板 */}
{activeBatchId && (
<Card
style={{
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.04)',
borderRadius: 10,
}}
title={(
<Space size={12}>
<span>本次分配与任务流水(批次 {activeBatchId})</span>
<span style={{ fontWeight: 600 }}>本次分配与任务流水(批次 {activeBatchId})</span>
{batchLoading ? (
<Tag icon={<SyncOutlined spin />} color="processing">实时同步中</Tag>
) : (
@@ -743,6 +1009,10 @@ export default function DouyuPoolPage() {
{/* 我的租约列表 */}
<Card
style={{
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.04)',
borderRadius: 10,
}}
title="我的租约(用完会自动收回,也可随时手动释放)"
extra={(
<Button icon={<ReloadOutlined />} size="small" onClick={() => void loadLeases()}>
@@ -765,8 +1035,8 @@ export default function DouyuPoolPage() {
<Modal
title={(
<Space>
<CreditCardOutlined />
<span>微信扫码充值</span>
<CreditCardOutlined style={{ color: token.colorPrimary }} />
<span style={{ fontWeight: 600 }}>微信扫码充值</span>
</Space>
)}
open={payModalVisible}
@@ -832,7 +1102,7 @@ export default function DouyuPoolPage() {
type="success"
showIcon
message="微信支付已到账"
description="系统正在自动进行赠送精英令与兑换,请稍候..."
description="系统正在自动进行赠送与兑换,请稍候..."
/>
<Button type="primary" onClick={() => setPayModalVisible(false)}>完成并关闭</Button>
</Space>