diff --git a/tests/test_douyu_pool_api.py b/tests/test_douyu_pool_api.py index 3f72798..8aede9f 100644 --- a/tests/test_douyu_pool_api.py +++ b/tests/test_douyu_pool_api.py @@ -101,13 +101,33 @@ class TestSupply(_ApiBase): def test_unsupported_scope_is_400(self): with pytest.raises(HTTPException) as exc: pool_router.pool_supply( - scope="peace", + scope="unknown_scope", commodity_id=COMMODITY, db=self.session, current=self.support, ) assert exc.value.status_code == 400 + def test_peace_scope_supply(self): + from web.backend.models import DouyuXpdGoodsSnapshot + + xpd_item = DouyuXpdGoodsSnapshot( + commodity_id="xpd_bag_1", + name="和平礼包", + price=8800, + ) + self.session.add(xpd_item) + self.session.commit() + + supply = pool_router.pool_supply( + scope="peace", + commodity_id="xpd_bag_1", + db=self.session, + current=self.support, + ) + assert supply["commodity"]["name"] == "和平礼包" + assert supply["commodity"]["score"] == 8800 + class TestExchange(_ApiBase): def _patch_runner(self, monkeypatch, captured: list): @@ -151,6 +171,7 @@ class TestExchange(_ApiBase): assert task.task_type == "exchange_goods" assert task.handbook_scope == "elite" assert task.batch_id == result.batch_id + assert task.result is not None assert task.result["payload"]["commodity_id"] == COMMODITY assert task.result["payload"]["num"] == 1 diff --git a/web/backend/routers/accounts.py b/web/backend/routers/accounts.py index fa9204b..47242bf 100644 --- a/web/backend/routers/accounts.py +++ b/web/backend/routers/accounts.py @@ -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, diff --git a/web/backend/routers/douyu_pool.py b/web/backend/routers/douyu_pool.py index 455fa06..1f4495c 100644 --- a/web/backend/routers/douyu_pool.py +++ b/web/backend/routers/douyu_pool.py @@ -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, ""), diff --git a/web/backend/services/account_pool.py b/web/backend/services/account_pool.py index 5088541..f9023f2 100644 --- a/web/backend/services/account_pool.py +++ b/web/backend/services/account_pool.py @@ -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) diff --git a/web/backend/services/douyu_runner_core.py b/web/backend/services/douyu_runner_core.py index 50b2739..e35c5cb 100644 --- a/web/backend/services/douyu_runner_core.py +++ b/web/backend/services/douyu_runner_core.py @@ -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): diff --git a/web/frontend/src/api/douyuPool.ts b/web/frontend/src/api/douyuPool.ts index ea58ed7..c5f795e 100644 --- a/web/frontend/src/api/douyuPool.ts +++ b/web/frontend/src/api/douyuPool.ts @@ -15,7 +15,7 @@ import type { */ export const douyuPoolApi = { /** 不锁号地预览「能分出几个、要花多少钱」 */ - supply: (scope: 'elite' | 'esports', commodityId: string) => + supply: (scope: 'elite' | 'esports' | 'peace', commodityId: string) => api.get('/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(`/douyu/stop/${batchId}`), }; diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 8d27d17..d7fe571 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -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; diff --git a/web/frontend/src/pages/DouyuPoolPage.tsx b/web/frontend/src/pages/DouyuPoolPage.tsx index 23db2be..a029587 100644 --- a/web/frontend/src/pages/DouyuPoolPage.tsx +++ b/web/frontend/src/pages/DouyuPoolPage.tsx @@ -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 = { + elite: { + key: 'elite', + title: '精英宝典', + badge: '💎', + pointsLabel: '精英积分', + icon: , + desc: '和平精英精英宝典活动 · 消耗精英积分', + }, + esports: { + key: 'esports', + title: 'PEL 电竞手册', + badge: '🏆', + pointsLabel: '电竞积分', + icon: , + desc: 'PEL 职业联赛电竞手册 · 消耗电竞积分', + }, + peace: { + key: 'peace', + title: '和平小店', + badge: '🛒', + pointsLabel: '点券/积分', + icon: , + 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 = { 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('elite'); + const [goods, setGoods] = useState<{ commodity_id: string; name: string; score: number | null }[]>([]); const [commodityId, setCommodityId] = useState(''); const [quantity, setQuantity] = useState(1); const [allowTopup, setAllowTopup] = useState(true); - const [recentGoodsIds, setRecentGoodsIds] = useState(() => loadRecentGoodsIds()); + const [recentGoodsIds, setRecentGoodsIds] = useState(() => loadRecentGoodsIds('elite')); const [supply, setSupply] = useState(null); const [result, setResult] = useState(null); const [leases, setLeases] = useState([]); 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 = [ + { + title: '活动类型', dataIndex: 'scope', width: 130, + render: (val: string) => { + if (val === 'esports') return 🏆 电竞手册; + if (val === 'peace') return 🛒 和平小店; + return 💎 精英宝典; + }, + }, { 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 ( - {/* 顶部发起卡片 */} + {/* 顶部主卡片:活动选择 + 皮肤配置 + 测算概览 */} + + 智能账号分配台 + 自动补给 · 自动回收 + + )} extra={( - )} > - {/* 常用 / 历史快捷标签 */} - {quickGoods.length > 0 && ( -
- - - - 常用 / 历史快捷: + + {/* 第一步:活动选择(顶级切换) */} +
+ + + + ① + 选择活动类型: + + + + handleScopeChange(e.target.value as PoolScope)} + buttonStyle="solid" + size="middle" + > + + + 💎 + 精英宝典 + 精英积分 + + + + + 🏆 + PEL 电竞手册 + 电竞积分 + + + + + 🛒 + 和平小店 + 点券/余额 + + + + + {activeScopeInfo.desc} + + + +
+ + {/* 第二步:皮肤选择与常用标签 */} +
+
+ + ② + 选择要兑换的皮肤 & 分配参数: - {quickGoods.map((item) => { - const isSelected = commodityId === item.commodity_id; - const isHot = item.name.includes('兔兔白日梦') || item.name.includes('狂战萌兔'); - return ( - handleSelectGood(item.commodity_id)} +
+ + {/* 常用 / 历史快捷标签 */} + {quickGoods.length > 0 && ( +
+ + + + {activeScopeInfo.title} 常用 / 热门皮肤: + + {quickGoods.map((item) => { + const isSelected = commodityId === item.commodity_id; + const isHot = item.name.includes('兔兔白日梦') || item.name.includes('狂战萌兔') || (item.score ?? 0) >= 1000; + return ( + 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 && } + {item.name} + + ({item.score ?? 0}分) + + + ); + })} + +
+ )} + + {/* 皮肤选择下拉 + 数量 + 补足策略 + 提交按钮 */} + + + + 选择皮肤商品 (支持拼音/汉字搜索) + + setAllowTopup(value === 'topup')} + options={[ + { value: 'topup', label: '允许自动充值补足差额' }, + { value: 'skip', label: '仅用已有积分 (不充值)' }, + ]} + /> + + + + + +
+ + {/* 第三步:实时测算与库存看板 */} + {supply && ( +
+
+ + 📊 当前皮肤库存测算(商品:{supply.commodity?.name || commodityId} · 所需 {supply.commodity?.score} {activeScopeInfo.pointsLabel}) + + {previewing && } color="processing">测算刷新中} +
+ + + + - {isHot && } - {item.name} - - ({item.score ?? 0}分) - - - ); - })} - -
- )} + 🟢 免充值直接可用} + value={supply.zero_topup} + suffix={个账号} + valueStyle={{ color: '#237804', fontWeight: 600, fontSize: 24 }} + /> + + + + + 🟡 需充值补足可用} + value={supply.after_topup} + suffix={个账号} + valueStyle={{ color: '#d46b08', fontWeight: 600, fontSize: 24 }} + /> + + + + + + 池全量补足预估 + + ℹ️ + + + )} + value={supply.cost.est_yuan} + suffix={元} + valueStyle={{ color: '#096dd9', fontWeight: 600, fontSize: 24 }} + loading={previewing} + /> + + + + + 我的在租账号} + value={leases.length} + suffix={个} + valueStyle={{ fontWeight: 600, fontSize: 24 }} + /> + + + +
+ )} - - - 选择要兑换的皮肤 - setAllowTopup(value === 'topup')} - options={[ - { value: 'topup', label: '自动充值鱼翅补足' }, - { value: 'skip', label: '仅用已有积分 (不充值)' }, - ]} - /> - - - - - - - {supply && ( - - - - - - - - - - 池内全量补足预算 - - ℹ️ - - - )} - value={supply.cost.est_yuan} - suffix="元" - loading={previewing} - /> - - - - - - )} - - {supply?.manual_payment_required && ( - - )} + )} + {/* 活跃批次执行与进度面板 */} {activeBatchId && ( - 本次分配与任务流水(批次 {activeBatchId}) + 本次分配与任务流水(批次 {activeBatchId}) {batchLoading ? ( } color="processing">实时同步中 ) : ( @@ -743,6 +1009,10 @@ export default function DouyuPoolPage() { {/* 我的租约列表 */} } size="small" onClick={() => void loadLeases()}> @@ -765,8 +1035,8 @@ export default function DouyuPoolPage() { - - 微信扫码充值 + + 微信扫码充值 )} open={payModalVisible} @@ -832,7 +1102,7 @@ export default function DouyuPoolPage() { type="success" showIcon message="微信支付已到账" - description="系统正在自动进行赠送精英令与兑换,请稍候..." + description="系统正在自动进行赠送与兑换,请稍候..." />