diff --git a/docs/HUYA_BROWSER_EXCHANGE.md b/docs/HUYA_BROWSER_EXCHANGE.md
index ac5a4b3..7d29cc7 100644
--- a/docs/HUYA_BROWSER_EXCHANGE.md
+++ b/docs/HUYA_BROWSER_EXCHANGE.md
@@ -4,11 +4,11 @@
## 入口与账号范围
-独立页面:虎牙 → 浏览器兑换(位于精英宝典下方;账号管理页不再提供该功能)。
+独立页面:虎牙 → 浏览器兑换。导航按角色区分:客服只看到浏览器兑换一个入口;管理员/运营仍显示完整的虎牙菜单(账号管理、精英宝典等)。
- 页面维护一份「兑换账号列表」,通过「导入账号」从自己可见的账号池勾选加入;客服只能看到分配给自己的账号。
- 列表按用户隔离保存(工作台 scope `browser_exchange`),与精英宝典的账号集合互不影响。
-- 账号需先在账号管理完成 App 协议登录(有 Cookie)才能执行兑换。
+- 账号需已有 Cookie 才能执行兑换;账号的 App 协议登录由管理员在账号管理页(直链 `/huya/accounts`)完成,客服遇到无 Cookie 账号时联系管理员处理。
## 一键自动注入(推荐):安装内置扩展
@@ -26,6 +26,15 @@
扩展只在注入时覆盖与登录态同名的旧 Cookie,其余虎牙 Cookie 不动;扩展图标点击可作为兜底(读取当前后台页签留存的材料);未检测到材料时图标会显示「无任务」角标。
+## 查询兑换记录
+
+兑换列表每行的「兑换记录」按钮会为该账号创建 `query_exchange_records` 精英宝典任务(活动 SID 取精英宝典配置),前端轮询任务完成后:
+
+- 表格新增的「兑换记录」列直接展示奖品名(多条时显示"xxx 等 N 条",悬停可看全部名称),未查询显示"未查询",查询中显示加载图标,失败显示"查询失败"及原因。
+- 点击单元格中的奖品名可打开详情弹窗,查看每条记录的奖品、消耗积分、发放状态、兑换时间与订单号。
+
+任务失败(如 SID 未配置、Cookie 失效)时弹窗内直接显示原因,可点击「重新查询」。
+
## 手动方式(未装扩展时的兜底)
1. 在兑换列表点击「浏览器兑换」,弹出「浏览器兑换」窗口。
diff --git a/pyproject.toml b/pyproject.toml
index 2b90523..183a894 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "live-hub-py"
-version = "0.2.1"
+version = "0.3.0"
description = "直播账号运营 Web 后台"
readme = "README.md"
requires-python = ">=3.12,<3.13"
diff --git a/uv.lock b/uv.lock
index 70eea80..0b0729c 100644
--- a/uv.lock
+++ b/uv.lock
@@ -320,7 +320,7 @@ wheels = [
[[package]]
name = "live-hub-py"
-version = "0.2.1"
+version = "0.3.0"
source = { editable = "." }
dependencies = [
{ name = "alembic" },
diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts
index 8c814f7..a544640 100644
--- a/web/frontend/src/api/types.ts
+++ b/web/frontend/src/api/types.ts
@@ -706,6 +706,22 @@ export interface HuyaTaskBatchResult {
success: boolean;
}
+/** 兑换记录条目 (query_exchange_records 任务结果 records) */
+export interface HuyaExchangeRecord {
+ record_id: number;
+ prize_name: string;
+ score: number | null;
+ score_text?: string;
+ exchange_time: number | null;
+ exchange_time_text?: string;
+ prize_status: number;
+ status_label: string;
+ order_id?: string;
+ icon?: string;
+ index?: number;
+ [key: string]: unknown;
+}
+
export interface HuyaTaskItem {
id: number;
batch_id: string;
diff --git a/web/frontend/src/layouts/MainLayout.tsx b/web/frontend/src/layouts/MainLayout.tsx
index f204d25..e410f2d 100644
--- a/web/frontend/src/layouts/MainLayout.tsx
+++ b/web/frontend/src/layouts/MainLayout.tsx
@@ -1,11 +1,11 @@
import { useEffect, useState } from 'react';
-import { Layout, Menu, Avatar, Space, Typography, Button, Modal, Dropdown } from 'antd';
+import { Layout, Menu, Avatar, Space, Typography, Button, App, Dropdown } from 'antd';
import type { MenuProps } from 'antd';
import {
DashboardOutlined, UserOutlined, LogoutOutlined,
CloudServerOutlined, TeamOutlined, ApiOutlined, KeyOutlined,
MenuFoldOutlined, MenuUnfoldOutlined, SwapOutlined,
- SunOutlined, MoonOutlined, DesktopOutlined, GiftOutlined, ShoppingCartOutlined, ApartmentOutlined, MobileOutlined,
+ SunOutlined, MoonOutlined, DesktopOutlined, GiftOutlined, ApartmentOutlined, MobileOutlined, ShoppingCartOutlined,
BookOutlined, TrophyOutlined, ShopOutlined, GlobalOutlined,
SafetyCertificateOutlined, FileSearchOutlined,
} from '@ant-design/icons';
@@ -40,6 +40,8 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
const { mode, isDark, setMode } = useTheme();
const { can, canAny } = usePermissions(user);
const appInfo = useAppInfo();
+ // App.useApp() 的 modal 实例会继承 ConfigProvider 主题(静态 Modal.confirm 不会,暗色模式下是白底)
+ const { modal } = App.useApp();
useEffect(() => {
if (!user) navigate('/login');
@@ -80,24 +82,28 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
douyuItems.push({ key: '/douyu/peace', label: '和平小店', icon: });
}
- // 虎牙
- if (canAny(['huya:account', 'huya:view_all', 'huya:view_assigned'])) {
- huyaItems.push({ key: '/huya/accounts', label: '账号管理', icon: });
- huyaItems.push({ key: '/huya/device-bindings', label: '设备绑定', icon: });
- }
- if (canAny(['huya:account', 'huya:import'])) {
- huyaItems.push({ key: '/huya/register', label: '自动注册', icon: });
- }
- if (canAny(['huya:account', 'huya:assign'])) {
- huyaItems.push({ key: '/huya/assignments', label: '分配管理', icon: });
- }
- if (canAny(['huya:account', 'huya:cookie:view', 'huya:cookie:export'])) {
- huyaItems.push({ key: '/huya/cookies', label: 'Cookie 管理', icon: });
- }
+ // 虎牙:客服只保留浏览器兑换;管理员/运营显示完整入口
if (can('huya:task')) {
- huyaItems.push({ key: '/huya/elite', label: '精英宝典', icon: });
- huyaItems.push({ key: '/huya/browser-exchange', label: '浏览器兑换', icon: });
- huyaItems.push({ key: '/huya/tasks', label: '旧版任务台', icon: });
+ if (user?.role === 'support') {
+ huyaItems.push({ key: '/huya/browser-exchange', label: '浏览器兑换', icon: });
+ } else {
+ if (canAny(['huya:account', 'huya:view_all', 'huya:view_assigned'])) {
+ huyaItems.push({ key: '/huya/accounts', label: '账号管理', icon: });
+ huyaItems.push({ key: '/huya/device-bindings', label: '设备绑定', icon: });
+ }
+ if (canAny(['huya:account', 'huya:import'])) {
+ huyaItems.push({ key: '/huya/register', label: '自动注册', icon: });
+ }
+ if (canAny(['huya:account', 'huya:assign'])) {
+ huyaItems.push({ key: '/huya/assignments', label: '分配管理', icon: });
+ }
+ if (canAny(['huya:account', 'huya:cookie:view', 'huya:cookie:export'])) {
+ huyaItems.push({ key: '/huya/cookies', label: 'Cookie 管理', icon: });
+ }
+ huyaItems.push({ key: '/huya/elite', label: '精英宝典', icon: });
+ huyaItems.push({ key: '/huya/browser-exchange', label: '浏览器兑换', icon: });
+ huyaItems.push({ key: '/huya/tasks', label: '旧版任务台', icon: });
+ }
}
if (canAny(['yyb:session', 'yyb:history'])) {
@@ -126,7 +132,7 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
}
const handleLogout = () => {
- Modal.confirm({
+ modal.confirm({
title: '确认退出',
content: '确定要退出登录吗?',
okText: '退出',
diff --git a/web/frontend/src/pages/AccountsPage.tsx b/web/frontend/src/pages/AccountsPage.tsx
index 37a84fa..a52a87c 100644
--- a/web/frontend/src/pages/AccountsPage.tsx
+++ b/web/frontend/src/pages/AccountsPage.tsx
@@ -1,7 +1,7 @@
import { useEffect, useState, useMemo, useCallback } from 'react';
import {
Alert, Table, Button, Modal, Input, Select, Popconfirm, Typography, Tag, Space,
- Row, Col, Card, Statistic,
+ Row, Col, Card, Statistic, App,
} from 'antd';
import { message } from '../utils/antdMessage';
import type { TableProps } from 'antd';
@@ -19,6 +19,8 @@ const TAG_COLORS = [
];
export default function AccountsPage() {
+ // App.useApp() 的 modal 实例继承主题,静态 Modal.confirm 在暗色模式下是白底
+ const { modal } = App.useApp();
const [accounts, setAccounts] = useState([]);
const [users, setUsers] = useState([]);
const [tags, setTags] = useState([]);
@@ -238,7 +240,7 @@ export default function AccountsPage() {
setSensitiveVisible(false);
return;
}
- Modal.confirm({
+ modal.confirm({
title: '显示账号凭据?',
content: '密码、邮箱和邮箱密码将临时显示在当前页面。关闭或刷新页面后会恢复脱敏。',
okText: '显示',
diff --git a/web/frontend/src/pages/HuyaBrowserExchangePage.tsx b/web/frontend/src/pages/HuyaBrowserExchangePage.tsx
index ed40bb9..e64d0ff 100644
--- a/web/frontend/src/pages/HuyaBrowserExchangePage.tsx
+++ b/web/frontend/src/pages/HuyaBrowserExchangePage.tsx
@@ -1,12 +1,17 @@
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import {
- Alert, Button, Card, Input, Modal, Popconfirm, Select, Space, Table, Tag, Tooltip, Typography,
+ Alert, Button, Card, Empty, Input, Modal, Popconfirm, Select, Space, Spin, Table, Tag, Tooltip, Typography,
} from 'antd';
import type { TableProps } from 'antd';
import {
- CopyOutlined, DeleteOutlined, GlobalOutlined, ImportOutlined, ReloadOutlined, SearchOutlined,
+ CopyOutlined, DeleteOutlined, GlobalOutlined, HistoryOutlined, ImportOutlined, ReloadOutlined, SearchOutlined,
} from '@ant-design/icons';
-import { huyaApi, type HuyaAccountItem, type HuyaBrowserExchangeKit } from '../api/modules';
+import {
+ huyaApi,
+ type HuyaAccountItem,
+ type HuyaBrowserExchangeKit,
+ type HuyaExchangeRecord,
+} from '../api/modules';
import { getErrorMessage } from '../utils/error';
import { message } from '../utils/antdMessage';
import { copyTextToClipboard } from '../utils/clipboard';
@@ -16,6 +21,13 @@ const { Text, Paragraph } = Typography;
const SCOPE = 'browser_exchange' as const;
const IMPORT_PAGE_SIZE = 20;
+/** 表格"兑换记录"列的单元格状态(按账号缓存最近一次查询结果) */
+interface RecordsCell {
+ phase: 'loading' | 'done' | 'error';
+ records: HuyaExchangeRecord[];
+ errorMsg?: string;
+}
+
export default function HuyaBrowserExchangePage() {
const [pool, setPool] = useState([]);
const [accounts, setAccounts] = useState([]);
@@ -34,6 +46,15 @@ export default function HuyaBrowserExchangePage() {
const [exchangeKit, setExchangeKit] = useState(null);
const [exchangeTitle, setExchangeTitle] = useState('');
const [exchangeId, setExchangeId] = useState(null);
+ const [recordsState, setRecordsState] = useState<{
+ account: HuyaAccountItem;
+ phase: 'loading' | 'done' | 'error';
+ records: HuyaExchangeRecord[];
+ errorMsg: string;
+ } | null>(null);
+ const [recordsMap, setRecordsMap] = useState>({});
+ // 查询序号:重新发起查询或关闭弹窗时自增,使进行中的轮询失效
+ const recordsSeqRef = useRef(0);
const load = useCallback(async () => {
setLoading(true);
@@ -170,6 +191,67 @@ export default function HuyaBrowserExchangePage() {
}
};
+ const queryExchangeRecords = async (record: HuyaAccountItem) => {
+ const seq = ++recordsSeqRef.current;
+ const alive = () => seq === recordsSeqRef.current;
+ const patch = (update: Partial) => {
+ setRecordsMap((prev) => {
+ const current = prev[record.id];
+ const merged: RecordsCell = {
+ phase: update.phase ?? current?.phase ?? 'loading',
+ records: update.records ?? current?.records ?? [],
+ errorMsg: update.errorMsg ?? current?.errorMsg,
+ };
+ return { ...prev, [record.id]: merged };
+ });
+ setRecordsState((prev) => (prev && prev.account.id === record.id ? { ...prev, ...update } : prev));
+ };
+ setRecordsMap((prev) => ({ ...prev, [record.id]: { phase: 'loading', records: [] } }));
+ setRecordsState({ account: record, phase: 'loading', records: [], errorMsg: '' });
+ try {
+ const batch = await huyaApi.createTasks({
+ account_ids: [record.id],
+ task_type: 'query_exchange_records',
+ handbook_scope: 'elite',
+ });
+ const startedAt = Date.now();
+ for (;;) {
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ if (!alive()) return;
+ if (Date.now() - startedAt > 60_000) {
+ patch({ phase: 'error', errorMsg: '查询超时,请稍后重试' });
+ return;
+ }
+ const tasks = await huyaApi.listTasks(batch.batch_id, 'elite');
+ const task = tasks.find((item) => item.account_id === record.id);
+ if (!task) continue;
+ if (task.status === 'success') {
+ const result = (task.result || {}) as { records?: HuyaExchangeRecord[] };
+ patch({ phase: 'done', records: result.records || [] });
+ return;
+ }
+ if (['failed', 'error', 'stopped', 'timeout'].includes(task.status)) {
+ patch({ phase: 'error', errorMsg: task.message || '查询失败' });
+ return;
+ }
+ }
+ } catch (error) {
+ if (alive()) patch({ phase: 'error', errorMsg: getErrorMessage(error) });
+ }
+ };
+
+ const closeRecordsModal = () => {
+ recordsSeqRef.current += 1;
+ setRecordsState(null);
+ };
+
+ /** 点击表格单元格时用缓存结果打开详情弹窗(不重新查询) */
+ const openCachedRecords = (record: HuyaAccountItem, cell: RecordsCell) => {
+ if (cell.phase !== 'done') return;
+ recordsSeqRef.current += 1;
+ setRecordsState({ account: record, phase: 'done', records: cell.records, errorMsg: '' });
+ };
+
const exchangeHelperReady = huyaExchangeHelperReady();
const columns: TableProps['columns'] = [
@@ -191,6 +273,34 @@ export default function HuyaBrowserExchangePage() {
? 有 Cookie
: 无 Cookie),
},
+ {
+ title: '兑换记录',
+ render: (_value, record) => {
+ const cell = recordsMap[record.id];
+ if (!cell) return 未查询;
+ if (cell.phase === 'loading') return ;
+ if (cell.phase === 'error') {
+ return (
+
+ 查询失败
+
+ );
+ }
+ if (!cell.records.length) return 暂无记录;
+ const names = cell.records.map((item) => item.prize_name || '未命名奖品');
+ const summary = names.length > 1 ? `${names[0]} 等 ${names.length} 条` : names[0];
+ return (
+
+ openCachedRecords(record, cell)}
+ >
+ {summary}
+
+
+ );
+ },
+ },
{
title: '操作',
width: 200,
@@ -198,7 +308,7 @@ export default function HuyaBrowserExchangePage() {
+ : '该账号没有 Cookie,请联系管理员在账号管理中完成登录'}>
+
+ }
+ loading={recordsState?.phase === 'loading' && recordsState.account.id === record.id}
+ onClick={() => void queryExchangeRecords(record)}
+ />
+
void removeAccount(record)}>
} />
@@ -321,6 +439,61 @@ export default function HuyaBrowserExchangePage() {
/>
+ recordsState && void queryExchangeRecords(recordsState.account)}
+ >
+ 重新查询
+ ,
+ ,
+ ]}
+ >
+ {recordsState?.phase === 'loading' && (
+
+ )}
+ {recordsState?.phase === 'error' && (
+
+ )}
+ {recordsState?.phase === 'done' && (
+ recordsState.records.length ? (
+ value || '-' },
+ { title: '积分', dataIndex: 'score', width: 90, render: (value: number) => (value != null ? `${value}积分` : '-') },
+ {
+ title: '状态',
+ dataIndex: 'status_label',
+ width: 90,
+ render: (value: string) => (
+ {value}
+ ),
+ },
+ { title: '兑换时间', dataIndex: 'exchange_time_text', width: 170, render: (value: string) => value || '-' },
+ { title: '订单号', dataIndex: 'order_id', render: (value: string) => value || '-' },
+ ]}
+ />
+ ) : (
+
+ )
+ )}
+
+