在既有「管理员分配 → 客服导入工作台 → 手动选号」之外**新增**一条 「客服选皮肤 + 数量 → 系统分配账号 + 按需补积分 → 用完自动收回」的路径。 旧路径只做并集与可选参数扩展,行为不变(有用例守着)。 - 归属与使用权分离:新增 account_leases(生成列 + 唯一索引,并发抢号由数据库裁决), assigned_to 收窄为管理员/运营的维护责任;客服可见范围扩为「名下 ∪ 租用中」 - account_pool 服务:候选筛选、两梯队排序(先花快过期的 / 先选充值少的)、 供给与成本预估、租约领取/释放/回收 - 补给链:查余额 → 充鱼翅(单次 ≤800 元,超限拆单)→ 赠精英令 → 兑换; 运行期两道闸门:_skip_when(余额已够就别再花钱)、_depends_on(前置未成功就跳过) - 转人工:鱼翅支付等 10 分钟未到账转 manual_required,租约先不还; account_lease_reaper 兜 TTL 到期 / 漏释放 / 孤儿批次 - 运营流水:业务事件补 actor_id = task.created_by;账号详情页展示操作人、渠道 与租赁记录 - 新工作台:/douyu/pool + 菜单「账号分配台」(新建,不改造旧工作台) - 修复 D1:官方补录的兑换事件漏写 subject_key(10,612 条主键为空), 会让 365 天同皮肤规则漏判 87% 的历史;补代码 + 回填脚本 - 迁移 20261007_0052;权限 account:pool_use / account:pool_admin 测试:766 passed, 6 skipped(改动前基线 727);ruff 与 tsc -b 干净。 设计稿与实施记录:docs/设计-账号自动分配与回收.md
376 lines
15 KiB
TypeScript
376 lines
15 KiB
TypeScript
import type { ReactNode } from 'react';
|
||
import {
|
||
CheckCircleOutlined, CreditCardOutlined, FieldTimeOutlined, GiftOutlined,
|
||
QrcodeOutlined, ReloadOutlined, SearchOutlined, ShoppingOutlined,
|
||
} from '@ant-design/icons';
|
||
import type { DouyuGoodsItem, DouyuTaskItem } from '../../api/modules';
|
||
|
||
export type HandbookKind = 'elite' | 'esports' | 'peace';
|
||
|
||
export const TASK_STATUS_COLORS: Record<string, string> = {
|
||
planned: 'default',
|
||
pending: 'default',
|
||
running: 'processing',
|
||
success: 'success',
|
||
failed: 'error',
|
||
error: 'error',
|
||
stopped: 'warning',
|
||
// 账号池补给链:条件已满足 / 前置未成功 → 跳过(不是失败,别标红)
|
||
skipped: 'default',
|
||
// 鱼翅等待超时转人工:需要有人去处理,用醒目但不等于失败的颜色
|
||
manual_required: 'warning',
|
||
};
|
||
|
||
export const TASK_STATUS_LABELS: Record<string, string> = {
|
||
planned: '已计划',
|
||
pending: '等待中',
|
||
running: '执行中',
|
||
success: '成功',
|
||
failed: '失败',
|
||
error: '异常',
|
||
stopped: '已停止',
|
||
skipped: '已跳过',
|
||
manual_required: '转人工',
|
||
};
|
||
|
||
export const ELITE_QUICK_ACTIONS = [
|
||
{ key: 'get_bind_qr', icon: <QrcodeOutlined /> },
|
||
{ key: 'confirm_bind', icon: <CheckCircleOutlined /> },
|
||
{ key: 'query_elite_status', icon: <SearchOutlined /> },
|
||
{ key: 'create_elite_qr', icon: <CreditCardOutlined /> },
|
||
{ key: 'query_points', icon: <SearchOutlined /> },
|
||
{ key: 'query_game_name', icon: <SearchOutlined /> },
|
||
{ key: 'query_change_bind_time', icon: <FieldTimeOutlined /> },
|
||
{ key: 'query_limited_goods', icon: <SearchOutlined /> },
|
||
{ key: 'query_gold_balance', icon: <SearchOutlined /> },
|
||
{ key: 'lock_goods', icon: <ShoppingOutlined /> },
|
||
{ key: 'exchange_goods', icon: <ShoppingOutlined /> },
|
||
{ key: 'create_gold_qr', icon: <CreditCardOutlined /> },
|
||
{ key: 'donate_elite_gift', icon: <GiftOutlined /> },
|
||
];
|
||
|
||
export const ESPORTS_QUICK_ACTIONS = [
|
||
{ key: 'prepare_esports_bind', icon: <QrcodeOutlined /> },
|
||
{ key: 'query_esports_game_name', icon: <SearchOutlined /> },
|
||
{ key: 'create_esports_qr', icon: <CreditCardOutlined /> },
|
||
{ key: 'query_esports_points', icon: <SearchOutlined /> },
|
||
{ key: 'query_change_bind_time', icon: <FieldTimeOutlined /> },
|
||
{ key: 'query_limited_goods', icon: <SearchOutlined /> },
|
||
{ key: 'query_gold_balance', icon: <SearchOutlined /> },
|
||
{ key: 'refresh_esports_goods', icon: <ReloadOutlined /> },
|
||
{ key: 'exchange_esports_goods', icon: <ShoppingOutlined /> },
|
||
{ key: 'create_gold_qr', icon: <CreditCardOutlined /> },
|
||
{ key: 'donate_esports_chicken_gift', icon: <GiftOutlined /> },
|
||
{ key: 'donate_esports_firework_gift', icon: <GiftOutlined /> },
|
||
];
|
||
|
||
export const PEACE_QUICK_ACTIONS = [
|
||
{ key: 'get_xpd_bind_qr', icon: <QrcodeOutlined /> },
|
||
{ key: 'query_xpd_bind_info', icon: <SearchOutlined /> },
|
||
{ key: 'confirm_xpd_bind', icon: <CheckCircleOutlined /> },
|
||
{ key: 'query_xpd_role', icon: <SearchOutlined /> },
|
||
{ key: 'query_xpd_balance', icon: <SearchOutlined /> },
|
||
{ key: 'query_xpd_fragments', icon: <SearchOutlined /> },
|
||
{ key: 'query_xpd_purchase_records', icon: <FieldTimeOutlined /> },
|
||
{ key: 'refresh_xpd_goods', icon: <ReloadOutlined /> },
|
||
{ key: 'exchange_xpd_goods', icon: <ShoppingOutlined /> },
|
||
];
|
||
|
||
export const ELITE_TASK_TYPES = new Set([
|
||
'get_bind_qr',
|
||
'confirm_bind',
|
||
'create_elite_qr',
|
||
'query_elite_status',
|
||
'create_gold_qr',
|
||
'donate_elite_gift',
|
||
'query_points',
|
||
'lock_goods',
|
||
'pay_locked_order',
|
||
'exchange_goods',
|
||
'query_game_name',
|
||
'query_change_bind_time',
|
||
'query_limited_goods',
|
||
'query_gold_balance',
|
||
'refresh_goods',
|
||
'query_exchange_records',
|
||
'prefetch_csrf_token',
|
||
'query_account_overview',
|
||
]);
|
||
export const ESPORTS_TASK_TYPES = new Set([
|
||
'prepare_esports_bind',
|
||
'get_esports_bind_qr',
|
||
'query_esports_game_name',
|
||
'confirm_esports_bind',
|
||
'create_esports_qr',
|
||
'query_esports_points',
|
||
'query_gold_balance',
|
||
'query_change_bind_time',
|
||
'query_limited_goods',
|
||
'refresh_esports_goods',
|
||
'exchange_esports_goods',
|
||
'create_gold_qr',
|
||
'donate_esports_chicken_gift',
|
||
'donate_esports_firework_gift',
|
||
'query_account_overview',
|
||
]);
|
||
export const PEACE_TASK_TYPES = new Set([
|
||
'get_xpd_bind_qr',
|
||
'query_xpd_bind_info',
|
||
'confirm_xpd_bind',
|
||
'query_xpd_role',
|
||
'refresh_xpd_goods',
|
||
'query_xpd_balance',
|
||
'query_xpd_fragments',
|
||
'query_xpd_purchase_records',
|
||
'exchange_xpd_goods',
|
||
'query_account_overview',
|
||
]);
|
||
export const DOUYU_GOLD_AMOUNT_STORAGE_KEY = 'douyu_task_gold_amount';
|
||
export const DOUYU_GIFT_COUNT_STORAGE_KEY = 'douyu_task_gift_count';
|
||
export const DOUYU_LAYOUT_MODE_STORAGE_KEY = 'douyu_task_layout_mode';
|
||
// v2 避开旧版页面切换时写入其他工作台的共享账号列表。
|
||
export const DOUYU_WORKBENCH_IDS_STORAGE_KEY = (kind: HandbookKind) => `douyu_task_workbench_ids_v2_${kind}`;
|
||
export const CONFIRM_FAIL_TIP_SECONDS = 6;
|
||
|
||
export function resultText(result: Record<string, unknown> | null | undefined, key: string): string {
|
||
const value = result?.[key];
|
||
return typeof value === 'string' ? value : '';
|
||
}
|
||
|
||
export function taskPayloadText(task: DouyuTaskItem, key: string): string {
|
||
const payload = task.result?.payload;
|
||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return '';
|
||
return resultText(payload as Record<string, unknown>, key);
|
||
}
|
||
|
||
export function resultFlag(result: Record<string, unknown> | null | undefined, key: string): boolean {
|
||
const value = result?.[key];
|
||
if (value === true) return true;
|
||
if (value === false || value == null) return false;
|
||
const text = String(value).trim().toLowerCase();
|
||
return text === '1' || text === 'true' || text === 'yes';
|
||
}
|
||
|
||
// 提取任务结果中的二维码 URL:绑定/电竞绑定用 url,支付类用 pay_url
|
||
export function taskQrUrl(task: DouyuTaskItem | null | undefined): string {
|
||
if (!task) return '';
|
||
if (task.task_type === 'get_bind_qr') return resultText(task.result, 'url');
|
||
if (task.task_type === 'get_xpd_bind_qr') return resultText(task.result, 'url');
|
||
if (['create_elite_qr', 'create_esports_qr', 'create_gold_qr'].includes(task.task_type)) {
|
||
return resultText(task.result, 'pay_url');
|
||
}
|
||
if (['prepare_esports_bind', 'get_esports_bind_qr'].includes(task.task_type)) {
|
||
return resultText(task.result, 'url');
|
||
}
|
||
return '';
|
||
}
|
||
|
||
// 精英宝典开通状态展示:积分区分不了"开通后积分花完"和"从未开通",
|
||
// 以后端落库的 manualType(elite_opened)为准。
|
||
export function eliteStatusText(
|
||
account: { elite_opened?: boolean | null; elite_expire_time?: number | null },
|
||
): string {
|
||
if (account.elite_opened == null) return '';
|
||
if (!account.elite_opened) return '未开通';
|
||
const expire = account.elite_expire_time;
|
||
if (!expire || expire <= 0) return '已开通';
|
||
const at = new Date(expire * 1000);
|
||
if (Number.isNaN(at.getTime())) return '已开通';
|
||
const pad = (n: number) => String(n).padStart(2, '0');
|
||
return `已开通 ${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} 到期`;
|
||
}
|
||
|
||
export function bindReadyForConfirm(task: DouyuTaskItem | null | undefined): boolean {
|
||
if (!task) return false;
|
||
if (task.task_type === 'get_xpd_bind_qr') {
|
||
if (resultFlag(task.result, 'xpd_bound') || resultFlag(task.result, 'bind_confirmed')) return false;
|
||
// 轮询识别到角色后停在待确认(xpd_pending_confirm),有角色名即可确认绑定
|
||
return Boolean(resultText(task.result, 'role_name'));
|
||
}
|
||
if (task.task_type !== 'get_bind_qr') return false;
|
||
if (task.result?.bind_ready_for_confirm === true) return true;
|
||
const roleName = resultText(task.result, 'role_name');
|
||
if (!roleName) return false;
|
||
// 已确认绑定的角色不能再次直接确认
|
||
if (resultFlag(task.result, 'bind_confirmed') || resultFlag(task.result, 'is_bound_act')) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
export function hasBindQrcode(task: DouyuTaskItem | null | undefined): boolean {
|
||
if (!task) return false;
|
||
if (!['get_bind_qr', 'get_xpd_bind_qr'].includes(task.task_type)) return false;
|
||
// 有二维码,或已有待确认角色(无需再扫码)都应可展示绑定面板
|
||
return Boolean(resultText(task.result, 'url'))
|
||
|| bindReadyForConfirm(task)
|
||
|| resultFlag(task.result, 'esports_bound');
|
||
}
|
||
|
||
export function hasPaymentQrcode(task: DouyuTaskItem | null | undefined): boolean {
|
||
return Boolean(
|
||
task
|
||
&& ['create_elite_qr', 'create_esports_qr', 'create_gold_qr'].includes(task.task_type)
|
||
&& resultText(task.result, 'pay_url'),
|
||
);
|
||
}
|
||
|
||
export function paymentArrived(task: DouyuTaskItem | null | undefined): boolean {
|
||
if (!task || !['create_elite_qr', 'create_esports_qr', 'create_gold_qr'].includes(task.task_type)) return false;
|
||
if (task.status !== 'success') return false;
|
||
return task.result?.gold_recharged === true
|
||
|| task.result?.elite_opened === true
|
||
|| task.result?.esports_opened === true;
|
||
}
|
||
|
||
export function bindPhaseText(phase: string, polling: boolean): string {
|
||
if (phase === 'role_ready') return '已识别角色,待确认';
|
||
if (phase === 'change_waiting') return '换绑冷却中';
|
||
if (phase === 'role_timeout') return '未检测到角色';
|
||
if (phase === 'stopped') return '任务已停止';
|
||
if (phase === 'confirmed') return '已绑定';
|
||
if (phase === 'confirm_failed') return '确认未生效';
|
||
if (polling || phase === 'waiting_scan') return '等待扫码绑定';
|
||
return phase || '等待绑定';
|
||
}
|
||
|
||
export function goodsLabel(item: DouyuGoodsItem): string {
|
||
const stock = item.raw?.count;
|
||
const stockText =
|
||
stock != null && Number.isFinite(Number(stock)) ? ` / 库存${stock}` : '';
|
||
return `${item.name || item.commodity_id}${item.score ? ` / ${item.score}积分` : ''}${stockText}`;
|
||
}
|
||
|
||
// 道聚城 iPrice 为 10 倍点券单位(如 6800=680 点券),仅展示层换算,兑换请求仍传原值。
|
||
export const XPD_POINT_PRICE_SCALE = 10;
|
||
|
||
export function xpdPointPrice(item: DouyuGoodsItem): number {
|
||
const xpd = item as DouyuGoodsItem & { price?: number | null };
|
||
const raw = item.raw?.raw as Record<string, unknown> | undefined;
|
||
const rawPrice = Number(xpd.price ?? raw?.iPrice);
|
||
if (!Number.isFinite(rawPrice) || rawPrice <= 0) return 0;
|
||
return Math.round(rawPrice / XPD_POINT_PRICE_SCALE);
|
||
}
|
||
|
||
export function xpdGoodsLabel(item: DouyuGoodsItem): string {
|
||
const xpd = item as DouyuGoodsItem & { price?: number | null; goods_left?: number | null };
|
||
const raw = item.raw?.raw as Record<string, unknown> | undefined;
|
||
const pointPrice = xpdPointPrice(item);
|
||
const fragmentPrice = Number(raw?.iJb2Price);
|
||
const prices = [
|
||
Number.isFinite(pointPrice) && pointPrice > 0 ? `${pointPrice}点券` : '',
|
||
Number.isFinite(fragmentPrice) && fragmentPrice > 0 ? `${fragmentPrice}碎片` : '',
|
||
].filter(Boolean);
|
||
const stock = xpd.goods_left;
|
||
const stockText = stock != null && Number.isFinite(Number(stock))
|
||
? (Number(stock) < 0 ? '库存未公开' : `库存${stock}`)
|
||
: '库存未知';
|
||
return `${item.name || item.commodity_id}${prices.length ? ` / ${prices.join(' 或 ')}` : ' / 暂不可兑换'} / ${stockText}`;
|
||
}
|
||
|
||
export function xpdGoodsPaymentOptions(item: DouyuGoodsItem | undefined): Array<{ value: '1' | '5'; label: string }> {
|
||
if (!item) return [];
|
||
const raw = item.raw?.raw as Record<string, unknown> | undefined;
|
||
const pointPrice = xpdPointPrice(item);
|
||
const fragmentPrice = Number(raw?.iJb2Price);
|
||
return [
|
||
Number.isFinite(pointPrice) && pointPrice > 0
|
||
? { value: '1' as const, label: `使用点券兑换(${pointPrice}点券)` }
|
||
: null,
|
||
Number.isFinite(fragmentPrice) && fragmentPrice > 0
|
||
? { value: '5' as const, label: `使用扭蛋碎片兑换(${fragmentPrice}碎片)` }
|
||
: null,
|
||
].filter((option): option is { value: '1' | '5'; label: string } => option !== null);
|
||
}
|
||
|
||
export function formatWaitSeconds(seconds: number | null | undefined): string {
|
||
if (seconds == null || Number.isNaN(Number(seconds))) return '';
|
||
const total = Math.max(0, Math.floor(Number(seconds)));
|
||
if (total <= 0) return '可换绑';
|
||
const days = Math.floor(total / 86400);
|
||
const hours = Math.floor((total % 86400) / 3600);
|
||
const minutes = Math.floor((total % 3600) / 60);
|
||
const secs = total % 60;
|
||
if (days > 0) return `${days}天${hours}小时${minutes}分`;
|
||
if (hours > 0) return `${hours}小时${minutes}分${secs}秒`;
|
||
return `${minutes}分${secs}秒`;
|
||
}
|
||
|
||
export function esportsRebindStatus(canChangeTime: number | null | undefined): {
|
||
available: boolean;
|
||
text: string;
|
||
} | null {
|
||
if (canChangeTime == null || Number.isNaN(Number(canChangeTime))) return null;
|
||
const timestamp = Math.floor(Number(canChangeTime));
|
||
if (timestamp <= 0 || timestamp <= Math.floor(Date.now() / 1000)) {
|
||
return { available: true, text: '可换绑' };
|
||
}
|
||
return {
|
||
available: false,
|
||
text: new Date(timestamp * 1000).toLocaleString('zh-CN', { hour12: false }),
|
||
};
|
||
}
|
||
|
||
export function resultNumber(result: Record<string, unknown> | null | undefined, key: string): number | null {
|
||
const value = result?.[key];
|
||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||
if (typeof value === 'string' && value.trim()) {
|
||
const n = Number(value);
|
||
return Number.isFinite(n) ? n : null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export function limitedGoodsName(item: unknown): string {
|
||
if (!item || typeof item !== 'object') return '';
|
||
const record = item as Record<string, unknown>;
|
||
const value = record.commodityName ?? record.name;
|
||
return typeof value === 'string' ? value : '';
|
||
}
|
||
|
||
export function limitedGoodsTaskText(task: DouyuTaskItem | null | undefined): string {
|
||
if (!task) return '';
|
||
if (task.message?.trim()) return task.message.trim();
|
||
const count = resultNumber(task.result, 'limited_count');
|
||
if (count == null) return '';
|
||
if (count <= 0) return '无限制商品';
|
||
const goods = task.result?.limited_goods;
|
||
const names = Array.isArray(goods)
|
||
? goods.map(limitedGoodsName).filter(Boolean).slice(0, 5)
|
||
: [];
|
||
return names.length ? `限兑 ${count} 个: ${names.join(', ')}` : `限兑 ${count} 个`;
|
||
}
|
||
|
||
export function savedPositiveInteger(key: string, fallback = 1): number {
|
||
const value = Number(localStorage.getItem(key));
|
||
return Number.isInteger(value) && value >= 1 ? value : fallback;
|
||
}
|
||
|
||
|
||
export const HANDBOOK_META: Record<HandbookKind, {
|
||
title: string;
|
||
description: string;
|
||
taskTypes: Set<string>;
|
||
quickActions: Array<{ key: string; icon: ReactNode }>;
|
||
}> = {
|
||
elite: {
|
||
title: '精英宝典工作台',
|
||
description: '精英宝典绑定、开通、积分与兑换任务',
|
||
taskTypes: ELITE_TASK_TYPES,
|
||
quickActions: ELITE_QUICK_ACTIONS,
|
||
},
|
||
esports: {
|
||
title: '电竞手册工作台',
|
||
description: '电竞手册角色绑定与开通任务',
|
||
taskTypes: ESPORTS_TASK_TYPES,
|
||
quickActions: ESPORTS_QUICK_ACTIONS,
|
||
},
|
||
peace: {
|
||
title: '和平小店工作台',
|
||
description: '和平小店绑定角色、商品与点券余额查询',
|
||
taskTypes: PEACE_TASK_TYPES,
|
||
quickActions: PEACE_QUICK_ACTIONS,
|
||
},
|
||
};
|
||
|