feat(pool): 账号自动分配与回收(租约 + 补给链 + 独立工作台)
在既有「管理员分配 → 客服导入工作台 → 手动选号」之外**新增**一条 「客服选皮肤 + 数量 → 系统分配账号 + 按需补积分 → 用完自动收回」的路径。 旧路径只做并集与可选参数扩展,行为不变(有用例守着)。 - 归属与使用权分离:新增 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
This commit is contained in:
@@ -21,6 +21,7 @@ const UsersPage = lazy(() => import('./pages/UsersPage'));
|
||||
const CookiePage = lazy(() => import('./pages/CookiePage'));
|
||||
const CookieOperationsPage = lazy(() => import('./pages/CookieOperationsPage'));
|
||||
const DouyuTasksPage = lazy(() => import('./pages/DouyuTasksPage'));
|
||||
const DouyuPoolPage = lazy(() => import('./pages/DouyuPoolPage'));
|
||||
const HuyaAccountsPage = lazy(() => import('./pages/HuyaAccountsPage'));
|
||||
const HuyaAssignmentsPage = lazy(() => import('./pages/HuyaAssignmentsPage'));
|
||||
const HuyaCookiePage = lazy(() => import('./pages/HuyaCookiePage'));
|
||||
@@ -90,6 +91,7 @@ function AppContent() {
|
||||
<Route path="cookies" element={lazyRoute(<CookiePage />)} />
|
||||
<Route path="cookie-operations" element={lazyRoute(<CookieOperationsPage />)} />
|
||||
<Route path="douyu/tasks" element={<Navigate to="/douyu/elite" replace />} />
|
||||
<Route path="douyu/pool" element={lazyRoute(<DouyuPoolPage />)} />
|
||||
<Route path="douyu/elite" element={lazyRoute(<DouyuTasksPage key="elite" handbook="elite" />)} />
|
||||
<Route path="douyu/esports" element={lazyRoute(<DouyuTasksPage key="esports" handbook="esports" />)} />
|
||||
<Route path="douyu/peace" element={lazyRoute(<DouyuTasksPage key="peace" handbook="peace" />)} />
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import api from './client';
|
||||
import type {
|
||||
DouyuPoolExchangeRequest,
|
||||
DouyuPoolExchangeResult,
|
||||
DouyuPoolLease,
|
||||
DouyuPoolSupply,
|
||||
MessageResponse,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* 账号池(自动分配与回收)。
|
||||
*
|
||||
* 单独一个模块而不是塞进 `douyuApi`:这是**新增的一条路**,
|
||||
* 旧工作台(精英宝典/电竞手册)的接口一个都没动。
|
||||
*/
|
||||
export const douyuPoolApi = {
|
||||
/** 不锁号地预览「能分出几个、要花多少钱」 */
|
||||
supply: (scope: 'elite' | 'esports', commodityId: string) =>
|
||||
api.get<DouyuPoolSupply, DouyuPoolSupply>('/douyu/pool/supply', {
|
||||
params: { scope, commodity_id: commodityId },
|
||||
}),
|
||||
/** 提交需求:分配账号 + 建任务 + 启动批次 */
|
||||
exchange: (data: DouyuPoolExchangeRequest) =>
|
||||
api.post<DouyuPoolExchangeResult, DouyuPoolExchangeResult>('/douyu/pool/exchange', data),
|
||||
leases: (mine = true) =>
|
||||
api.get<DouyuPoolLease[], DouyuPoolLease[]>('/douyu/pool/leases', { params: { mine } }),
|
||||
/** 某个账号的租赁记录(账号详情页用;`includeFinished` 带历史) */
|
||||
leasesOfAccount: (accountId: number, includeFinished = true) =>
|
||||
api.get<DouyuPoolLease[], DouyuPoolLease[]>('/douyu/pool/leases', {
|
||||
params: { account_id: accountId, include_finished: includeFinished },
|
||||
}),
|
||||
releaseLease: (leaseId: number) =>
|
||||
api.post<{ success: boolean; released: boolean }, { success: boolean; released: boolean }>(
|
||||
`/douyu/pool/leases/${leaseId}/release`,
|
||||
),
|
||||
releaseBatch: (batchId: string) =>
|
||||
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'),
|
||||
stopBatch: (batchId: string) => api.post<MessageResponse, MessageResponse>(`/douyu/stop/${batchId}`),
|
||||
};
|
||||
@@ -8,6 +8,7 @@ export { automationApi } from './automation';
|
||||
export { cookieApi } from './cookies';
|
||||
export { dashboardApi } from './dashboard';
|
||||
export { douyuApi } from './douyu';
|
||||
export { douyuPoolApi } from './douyuPool';
|
||||
export { huyaApi } from './huya';
|
||||
export { loginApi } from './login';
|
||||
export { proxyApi } from './proxy';
|
||||
|
||||
@@ -146,6 +146,8 @@ export interface AccountEventItem {
|
||||
source: string;
|
||||
actor_type?: string;
|
||||
actor_id?: number | null;
|
||||
/** 操作人用户名(后端联表补上;池化后「谁做的」是这一屏的关键信息) */
|
||||
actor_name?: string;
|
||||
}
|
||||
|
||||
export interface AccountEventPage {
|
||||
@@ -1163,3 +1165,73 @@ export interface AccountExchangeRecords {
|
||||
total: number;
|
||||
queried_at: string | null;
|
||||
}
|
||||
|
||||
/** 账号池:供给预览(不锁号) */
|
||||
export interface DouyuPoolSupply {
|
||||
scope: string;
|
||||
commodity_id: string;
|
||||
score: number;
|
||||
/** 积分已够、可直接兑的账号数(0 充值) */
|
||||
zero_topup: number;
|
||||
/** 充值积分后可兑的账号数 */
|
||||
after_topup: number;
|
||||
/** 当前被别处租用中的数量 */
|
||||
leased: number;
|
||||
blocked: Record<string, number>;
|
||||
cost: { est_gifts: number; est_yuan: number };
|
||||
cooldown_days: number;
|
||||
commodity: { commodity_id: string; name: string; score: number };
|
||||
cooling: { role_change_in_cooldown: number; changeable_now: number };
|
||||
/** 当前鱼翅充值渠道(只读回显) */
|
||||
recharge_channel: string;
|
||||
/** 是否需要在扫码渠道下人工支付 */
|
||||
manual_payment_required: boolean;
|
||||
}
|
||||
|
||||
export interface DouyuPoolAccount {
|
||||
account_id: number;
|
||||
username: string;
|
||||
/** A = 积分已够;B = 需要充值 */
|
||||
tier: string;
|
||||
points: number;
|
||||
topup_gifts: number;
|
||||
topup_yuan: number;
|
||||
/** 充值订单金额列表(每笔 ≤ 800 元) */
|
||||
recharge_orders: number[];
|
||||
role_change_available_at: string | null;
|
||||
}
|
||||
|
||||
export interface DouyuPoolExchangeRequest {
|
||||
scope: 'elite' | 'esports';
|
||||
commodity_id: string;
|
||||
quantity: number;
|
||||
allow_topup: boolean;
|
||||
allow_fallback: boolean;
|
||||
}
|
||||
|
||||
export interface DouyuPoolExchangeResult {
|
||||
batch_id: string;
|
||||
requested: number;
|
||||
allocated: number;
|
||||
shortfall: number;
|
||||
accounts: DouyuPoolAccount[];
|
||||
reasons: string[];
|
||||
recharge_channel: string;
|
||||
manual_payment_required: boolean;
|
||||
}
|
||||
|
||||
export interface DouyuPoolLease {
|
||||
id: number;
|
||||
account_id: number;
|
||||
username: string;
|
||||
holder_user_id: number;
|
||||
holder_name: string;
|
||||
scope: string;
|
||||
commodity_id: string;
|
||||
batch_id: string;
|
||||
status: string;
|
||||
leased_at: string | null;
|
||||
expires_at: string | null;
|
||||
role_change_available_at: string | null;
|
||||
points: number | null;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
SunOutlined, MoonOutlined, DesktopOutlined, GiftOutlined, ApartmentOutlined, MobileOutlined, ShoppingCartOutlined,
|
||||
BookOutlined, TrophyOutlined, ShopOutlined, GlobalOutlined,
|
||||
SafetyCertificateOutlined, FileSearchOutlined, RobotOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
|
||||
import { getUser, clearAuth, type AuthUser } from '../store/auth';
|
||||
@@ -84,6 +85,10 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
|
||||
douyuItems.push({ key: '/douyu/esports', label: '电竞手册', icon: <TrophyOutlined /> });
|
||||
douyuItems.push({ key: '/douyu/peace', label: '和平小店', icon: <ShopOutlined /> });
|
||||
}
|
||||
// 账号分配台:新增的独立工作台(旧的三个工作台原样保留)
|
||||
if (can('account:pool_use')) {
|
||||
douyuItems.push({ key: '/douyu/pool', label: '账号分配台', icon: <ThunderboltOutlined /> });
|
||||
}
|
||||
|
||||
// 虎牙:客服只保留浏览器兑换;管理员/运营显示完整入口
|
||||
if (can('huya:task')) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { TableProps } from 'antd';
|
||||
import {
|
||||
ArrowLeftOutlined, EditOutlined, ReloadOutlined, ThunderboltOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { accountApi, douyuApi, userApi, type AccountEventItem, type AccountOverview, type UserInfo } from '../api/modules';
|
||||
import { accountApi, douyuApi, douyuPoolApi, userApi, type AccountEventItem, type AccountOverview, type DouyuPoolLease, type UserInfo } from '../api/modules';
|
||||
import AccountOverviewPanel from './douyu-tasks/AccountOverviewPanel';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
import { message } from '../utils/antdMessage';
|
||||
@@ -100,6 +100,7 @@ export default function AccountDetailPage() {
|
||||
const [eventType, setEventType] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [eventsLoading, setEventsLoading] = useState(false);
|
||||
const [leases, setLeases] = useState<DouyuPoolLease[]>([]);
|
||||
const [tagDraft, setTagDraft] = useState('');
|
||||
// 一键查询:向斗鱼实时查一次该账号的全部关键信息(异步任务 + 轮询结果)
|
||||
const [ovLoading, setOvLoading] = useState(false);
|
||||
@@ -209,6 +210,22 @@ export default function AccountDetailPage() {
|
||||
void loadEvents();
|
||||
}, [loadEvents]);
|
||||
|
||||
// 租赁记录:池化后「这个账号被谁租过、用完还回去没有」。
|
||||
// 没有 `account:pool_use` 权限的账号会 403 —— 静默留空,不影响页面其它部分。
|
||||
useEffect(() => {
|
||||
if (!accountId) return;
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const rows = await douyuPoolApi.leasesOfAccount(accountId);
|
||||
if (alive) setLeases(rows);
|
||||
} catch {
|
||||
if (alive) setLeases([]);
|
||||
}
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, [accountId]);
|
||||
|
||||
const columns: TableProps<AccountEventItem>['columns'] = [
|
||||
{
|
||||
title: '时间',
|
||||
@@ -238,6 +255,28 @@ export default function AccountDetailPage() {
|
||||
render: (val: number | null) =>
|
||||
val === null || val === undefined ? <Text type="secondary">-</Text> : val,
|
||||
},
|
||||
{
|
||||
// 池化后客服不再长期持有账号,「这件事是谁做的」必须一眼看得到。
|
||||
// 历史事件(占 71%)没有操作人 —— 如实显示「-」,不要假装是系统做的。
|
||||
title: '操作人 / 渠道',
|
||||
width: 170,
|
||||
render: (_val, record) => {
|
||||
const channel = (record.detail?.channel as string | undefined) || '';
|
||||
const channelLabel = channel === 'supplier_api'
|
||||
? 'API 直充'
|
||||
: channel === 'wechat_qr'
|
||||
? '微信扫码'
|
||||
: channel;
|
||||
return (
|
||||
<Space size={4} wrap>
|
||||
{record.actor_name
|
||||
? <Tag color="blue">{record.actor_name}</Tag>
|
||||
: <Text type="secondary">-</Text>}
|
||||
{channelLabel && <Tag>{channelLabel}</Tag>}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (!accountId) {
|
||||
@@ -678,6 +717,41 @@ export default function AccountDetailPage() {
|
||||
</Spin>
|
||||
|
||||
|
||||
<Card
|
||||
title="租赁记录"
|
||||
extra={<Text type="secondary" style={{ fontSize: 12 }}>客服按需租用,用完自动收回</Text>}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
dataSource={leases}
|
||||
pagination={false}
|
||||
locale={{ emptyText: <Empty description="暂无租赁记录" /> }}
|
||||
columns={[
|
||||
{
|
||||
title: '客服', dataIndex: 'holder_name', width: 120,
|
||||
render: (value: string, row: DouyuPoolLease) => value || `#${row.holder_user_id}`,
|
||||
},
|
||||
{ title: '商品', dataIndex: 'commodity_id', width: 200 },
|
||||
{ title: '批次', dataIndex: 'batch_id', width: 130 },
|
||||
{
|
||||
title: '状态', dataIndex: 'status', width: 100,
|
||||
render: (value: string) => (
|
||||
<Tag color={value === 'active' ? 'green' : 'default'}>{value}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '租用时间', dataIndex: 'leased_at', width: 170,
|
||||
render: (value: string | null) => (value ? formatTime(value) : '-'),
|
||||
},
|
||||
{
|
||||
title: '释放时间', dataIndex: 'expires_at', width: 170,
|
||||
render: (value: string | null) => (value ? formatTime(value) : '-'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="账号时间线"
|
||||
extra={
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert, Button, Card, Col, Descriptions, InputNumber, Row, Select, Space,
|
||||
Statistic, Table, Tag, Typography, theme,
|
||||
} from 'antd';
|
||||
import { ReloadOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
||||
import { message } from '../utils/antdMessage';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
import { douyuPoolApi } from '../api/modules';
|
||||
import type {
|
||||
DouyuPoolAccount, DouyuPoolExchangeResult, DouyuPoolLease, DouyuPoolSupply,
|
||||
} from '../api/modules';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
|
||||
/**
|
||||
* 账号分配台(**新建的工作台**,不是改造精英宝典页)。
|
||||
*
|
||||
* 客服在这里只做三件事:选皮肤 → 填数量 → 提交,剩下的(领号、补积分、兑换、
|
||||
* 用完自动收回)由系统做。旧工作台的手动选号路径完整保留,两者互不影响。
|
||||
*/
|
||||
export default function DouyuPoolPage() {
|
||||
const { token } = theme.useToken();
|
||||
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 [supply, setSupply] = useState<DouyuPoolSupply | null>(null);
|
||||
const [result, setResult] = useState<DouyuPoolExchangeResult | null>(null);
|
||||
const [leases, setLeases] = useState<DouyuPoolLease[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
|
||||
const loadGoods = useCallback(async () => {
|
||||
try {
|
||||
const rows = await douyuPoolApi.goods();
|
||||
const usable = rows.filter((item) => (item.score ?? 0) > 0);
|
||||
setGoods(usable);
|
||||
if (usable.length > 0 && !commodityId) setCommodityId(usable[0].commodity_id);
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
}
|
||||
}, [commodityId]);
|
||||
|
||||
const loadLeases = useCallback(async () => {
|
||||
try {
|
||||
setLeases(await douyuPoolApi.leases(true));
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void loadGoods(); }, [loadGoods]);
|
||||
useEffect(() => { void loadLeases(); }, [loadLeases]);
|
||||
|
||||
const loadSupply = useCallback(async () => {
|
||||
if (!commodityId) return;
|
||||
setPreviewing(true);
|
||||
try {
|
||||
setSupply(await douyuPoolApi.supply('elite', commodityId));
|
||||
} catch (e: unknown) {
|
||||
setSupply(null);
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
setPreviewing(false);
|
||||
}
|
||||
}, [commodityId]);
|
||||
|
||||
useEffect(() => { void loadSupply(); }, [loadSupply]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!commodityId) { message.warning('请先选择皮肤'); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await douyuPoolApi.exchange({
|
||||
scope: 'elite', commodity_id: commodityId, quantity,
|
||||
allow_topup: allowTopup, allow_fallback: true,
|
||||
});
|
||||
setResult(data);
|
||||
if (data.shortfall > 0) {
|
||||
message.warning(`实配 ${data.allocated} / 需求 ${data.requested},缺 ${data.shortfall}`);
|
||||
} else {
|
||||
message.success(`已分配 ${data.allocated} 个账号,批次 ${data.batch_id}`);
|
||||
}
|
||||
await Promise.all([loadSupply(), loadLeases()]);
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const releaseLease = async (leaseId: number) => {
|
||||
try {
|
||||
await douyuPoolApi.releaseLease(leaseId);
|
||||
message.success('已释放');
|
||||
await Promise.all([loadSupply(), loadLeases()]);
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
const goodsOptions = useMemo(
|
||||
() => goods.map((item) => ({
|
||||
value: item.commodity_id,
|
||||
label: `${item.name}(${item.score ?? 0} 分)`,
|
||||
})),
|
||||
[goods],
|
||||
);
|
||||
|
||||
const accountColumns: ColumnsType<DouyuPoolAccount> = [
|
||||
{ title: '账号ID', dataIndex: 'account_id', width: 90 },
|
||||
{ title: '账号', dataIndex: 'username', ellipsis: true },
|
||||
{
|
||||
title: '梯队', dataIndex: 'tier', width: 90,
|
||||
render: (tier: string) => (
|
||||
<Tag color={tier === 'A' ? 'green' : 'orange'}>
|
||||
{tier === 'A' ? '免充值' : '需充值'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '积分', dataIndex: 'points', width: 90 },
|
||||
{
|
||||
title: '需充值', width: 160,
|
||||
render: (_, row) => (row.topup_yuan > 0
|
||||
? <Text type="warning">{row.topup_yuan} 元({row.recharge_orders.join(' + ')})</Text>
|
||||
: <Text type="secondary">无需</Text>),
|
||||
},
|
||||
{
|
||||
title: '可换绑时间', dataIndex: 'role_change_available_at', width: 180,
|
||||
render: (value: string | null) => (value ? value.replace('T', ' ').slice(0, 19) : '—'),
|
||||
},
|
||||
];
|
||||
|
||||
const leaseColumns: ColumnsType<DouyuPoolLease> = [
|
||||
{ title: '账号', dataIndex: 'username', ellipsis: true },
|
||||
{ title: '商品', dataIndex: 'commodity_id', width: 200 },
|
||||
{ title: '批次', dataIndex: 'batch_id', width: 130 },
|
||||
{
|
||||
title: '到期', dataIndex: 'expires_at', width: 170,
|
||||
render: (value: string | null) => (value ? value.replace('T', ' ').slice(0, 19) : '—'),
|
||||
},
|
||||
{
|
||||
title: '操作', width: 100,
|
||||
render: (_, row) => (
|
||||
<Button size="small" danger onClick={() => void releaseLease(row.id)}>释放</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Card
|
||||
title="发起兑换(系统自动分配账号)"
|
||||
extra={(
|
||||
<Button icon={<ReloadOutlined />} onClick={() => { void loadGoods(); void loadSupply(); void loadLeases(); }}>
|
||||
刷新
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
<Row gutter={[16, 16]} align="bottom">
|
||||
<Col xs={24} md={10}>
|
||||
<Text type="secondary">皮肤</Text>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: '100%' }}
|
||||
placeholder="选择要兑换的皮肤"
|
||||
value={commodityId || undefined}
|
||||
onChange={setCommodityId}
|
||||
options={goodsOptions}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} md={4}>
|
||||
<Text type="secondary">需要几个账号</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">积分不足时</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()}
|
||||
>
|
||||
提交并自动分配
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{supply && (
|
||||
<Row gutter={16} style={{ marginTop: 16 }}>
|
||||
<Col xs={12} md={6}>
|
||||
<Statistic title="可直接兑(免充值)" value={supply.zero_topup} suffix="个" />
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Statistic title="充值后可兑" value={supply.after_topup} suffix="个" />
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Statistic
|
||||
title="全部兑完预计花费"
|
||||
value={supply.cost.est_yuan}
|
||||
suffix="元"
|
||||
loading={previewing}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Statistic title="当前租用中" value={supply.leased} suffix="个" />
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{supply?.manual_payment_required && (
|
||||
<Alert
|
||||
style={{ marginTop: 12 }}
|
||||
type="warning"
|
||||
showIcon
|
||||
message={`当前鱼翅渠道为「${supply.recharge_channel}」,需要人工扫码支付`}
|
||||
description="系统会生成支付码并等待 10 分钟;超时后转入人工处理,需要你到「我的租约」里继续或放弃。"
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{result && (
|
||||
<Card title={`本次分配结果(批次 ${result.batch_id || '—'})`}>
|
||||
<Descriptions size="small" column={4} style={{ marginBottom: 12 }}>
|
||||
<Descriptions.Item label="需求">{result.requested}</Descriptions.Item>
|
||||
<Descriptions.Item label="实配">{result.allocated}</Descriptions.Item>
|
||||
<Descriptions.Item label="缺口">
|
||||
<Text type={result.shortfall > 0 ? 'danger' : undefined}>{result.shortfall}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="充值渠道">{result.recharge_channel || '—'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{result.reasons.length > 0 && (
|
||||
<Paragraph type="warning" style={{ marginBottom: 12 }}>
|
||||
{result.reasons.map((reason) => <div key={reason}>· {reason}</div>)}
|
||||
</Paragraph>
|
||||
)}
|
||||
<Table
|
||||
rowKey="account_id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
columns={accountColumns}
|
||||
dataSource={result.accounts}
|
||||
locale={{ emptyText: '本次没有分配账号' }}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title="我的租约(用完自动收回,也可手动释放)">
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={leaseColumns}
|
||||
dataSource={leases}
|
||||
pagination={{ pageSize: 10, showSizeChanger: false }}
|
||||
locale={{ emptyText: '当前没有租用中的账号' }}
|
||||
style={{ background: token.colorBgContainer }}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,10 @@ export const TASK_STATUS_COLORS: Record<string, string> = {
|
||||
failed: 'error',
|
||||
error: 'error',
|
||||
stopped: 'warning',
|
||||
// 账号池补给链:条件已满足 / 前置未成功 → 跳过(不是失败,别标红)
|
||||
skipped: 'default',
|
||||
// 鱼翅等待超时转人工:需要有人去处理,用醒目但不等于失败的颜色
|
||||
manual_required: 'warning',
|
||||
};
|
||||
|
||||
export const TASK_STATUS_LABELS: Record<string, string> = {
|
||||
@@ -25,6 +29,8 @@ export const TASK_STATUS_LABELS: Record<string, string> = {
|
||||
failed: '失败',
|
||||
error: '异常',
|
||||
stopped: '已停止',
|
||||
skipped: '已跳过',
|
||||
manual_required: '转人工',
|
||||
};
|
||||
|
||||
export const ELITE_QUICK_ACTIONS = [
|
||||
|
||||
Reference in New Issue
Block a user