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:
@@ -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,
|
||||
|
||||
@@ -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, ""),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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}`),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user