虎牙菜单按角色显示并修复确认框暗色模式

客服在虎牙导航只显示浏览器兑换,管理员/运营保留完整入口;退出登录
与显示凭据确认框改用 App.useApp() 的 modal 实例以适配暗色主题;
版本号升至 0.3.0。
This commit is contained in:
yml2213
2026-09-05 19:21:59 +08:00
parent 178eba1eea
commit 90a0065918
7 changed files with 237 additions and 31 deletions
+11 -2
View File
@@ -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. 在兑换列表点击「浏览器兑换」,弹出「浏览器兑换」窗口。
+1 -1
View File
@@ -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"
Generated
+1 -1
View File
@@ -320,7 +320,7 @@ wheels = [
[[package]]
name = "live-hub-py"
version = "0.2.1"
version = "0.3.0"
source = { editable = "." }
dependencies = [
{ name = "alembic" },
+16
View File
@@ -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;
+26 -20
View File
@@ -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: <ShopOutlined /> });
}
// 虎牙
if (canAny(['huya:account', 'huya:view_all', 'huya:view_assigned'])) {
huyaItems.push({ key: '/huya/accounts', label: '账号管理', icon: <GiftOutlined /> });
huyaItems.push({ key: '/huya/device-bindings', label: '设备绑定', icon: <ApiOutlined /> });
}
if (canAny(['huya:account', 'huya:import'])) {
huyaItems.push({ key: '/huya/register', label: '自动注册', icon: <MobileOutlined /> });
}
if (canAny(['huya:account', 'huya:assign'])) {
huyaItems.push({ key: '/huya/assignments', label: '分配管理', icon: <SwapOutlined /> });
}
if (canAny(['huya:account', 'huya:cookie:view', 'huya:cookie:export'])) {
huyaItems.push({ key: '/huya/cookies', label: 'Cookie 管理', icon: <KeyOutlined /> });
}
// 虎牙:客服只保留浏览器兑换;管理员/运营显示完整入口
if (can('huya:task')) {
huyaItems.push({ key: '/huya/elite', label: '精英宝典', icon: <BookOutlined /> });
huyaItems.push({ key: '/huya/browser-exchange', label: '浏览器兑换', icon: <GlobalOutlined /> });
huyaItems.push({ key: '/huya/tasks', label: '旧版任务台', icon: <ShoppingCartOutlined /> });
if (user?.role === 'support') {
huyaItems.push({ key: '/huya/browser-exchange', label: '浏览器兑换', icon: <GlobalOutlined /> });
} else {
if (canAny(['huya:account', 'huya:view_all', 'huya:view_assigned'])) {
huyaItems.push({ key: '/huya/accounts', label: '账号管理', icon: <GiftOutlined /> });
huyaItems.push({ key: '/huya/device-bindings', label: '设备绑定', icon: <ApiOutlined /> });
}
if (canAny(['huya:account', 'huya:import'])) {
huyaItems.push({ key: '/huya/register', label: '自动注册', icon: <MobileOutlined /> });
}
if (canAny(['huya:account', 'huya:assign'])) {
huyaItems.push({ key: '/huya/assignments', label: '分配管理', icon: <SwapOutlined /> });
}
if (canAny(['huya:account', 'huya:cookie:view', 'huya:cookie:export'])) {
huyaItems.push({ key: '/huya/cookies', label: 'Cookie 管理', icon: <KeyOutlined /> });
}
huyaItems.push({ key: '/huya/elite', label: '精英宝典', icon: <BookOutlined /> });
huyaItems.push({ key: '/huya/browser-exchange', label: '浏览器兑换', icon: <GlobalOutlined /> });
huyaItems.push({ key: '/huya/tasks', label: '旧版任务台', icon: <ShoppingCartOutlined /> });
}
}
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: '退出',
+4 -2
View File
@@ -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<AccountItem[]>([]);
const [users, setUsers] = useState<UserInfo[]>([]);
const [tags, setTags] = useState<string[]>([]);
@@ -238,7 +240,7 @@ export default function AccountsPage() {
setSensitiveVisible(false);
return;
}
Modal.confirm({
modal.confirm({
title: '显示账号凭据?',
content: '密码、邮箱和邮箱密码将临时显示在当前页面。关闭或刷新页面后会恢复脱敏。',
okText: '显示',
@@ -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<HuyaAccountItem[]>([]);
const [accounts, setAccounts] = useState<HuyaAccountItem[]>([]);
@@ -34,6 +46,15 @@ export default function HuyaBrowserExchangePage() {
const [exchangeKit, setExchangeKit] = useState<HuyaBrowserExchangeKit | null>(null);
const [exchangeTitle, setExchangeTitle] = useState('');
const [exchangeId, setExchangeId] = useState<number | null>(null);
const [recordsState, setRecordsState] = useState<{
account: HuyaAccountItem;
phase: 'loading' | 'done' | 'error';
records: HuyaExchangeRecord[];
errorMsg: string;
} | null>(null);
const [recordsMap, setRecordsMap] = useState<Record<number, RecordsCell>>({});
// 查询序号:重新发起查询或关闭弹窗时自增,使进行中的轮询失效
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<RecordsCell>) => {
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<HuyaAccountItem>['columns'] = [
@@ -191,6 +273,34 @@ export default function HuyaBrowserExchangePage() {
? <Tag color="success">有 Cookie</Tag>
: <Tag color="warning">无 Cookie</Tag>),
},
{
title: '兑换记录',
render: (_value, record) => {
const cell = recordsMap[record.id];
if (!cell) return <Text type="secondary">未查询</Text>;
if (cell.phase === 'loading') return <Spin size="small" />;
if (cell.phase === 'error') {
return (
<Tooltip title={cell.errorMsg}>
<Text type="danger">查询失败</Text>
</Tooltip>
);
}
if (!cell.records.length) return <Text type="secondary">暂无记录</Text>;
const names = cell.records.map((item) => item.prize_name || '未命名奖品');
const summary = names.length > 1 ? `${names[0]} 等 ${names.length} 条` : names[0];
return (
<Tooltip title={names.join('、')}>
<Text
style={{ cursor: 'pointer' }}
onClick={() => openCachedRecords(record, cell)}
>
{summary}
</Text>
</Tooltip>
);
},
},
{
title: '操作',
width: 200,
@@ -198,7 +308,7 @@ export default function HuyaBrowserExchangePage() {
<Space size={6}>
<Tooltip title={record.has_cookie
? '自动写入登录态并打开兑换页面(用你自己的 Chrome/Edge 完成)'
: '该账号没有 Cookie,请先在账号管理中完成登录'}>
: '该账号没有 Cookie,请联系管理员在账号管理中完成登录'}>
<Button
size="small"
type="primary"
@@ -211,6 +321,14 @@ export default function HuyaBrowserExchangePage() {
浏览器兑换
</Button>
</Tooltip>
<Tooltip title="查询该账号的兑换记录">
<Button
size="small"
icon={<HistoryOutlined />}
loading={recordsState?.phase === 'loading' && recordsState.account.id === record.id}
onClick={() => void queryExchangeRecords(record)}
/>
</Tooltip>
<Popconfirm title="确认从兑换列表移除该账号?" onConfirm={() => void removeAccount(record)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
@@ -321,6 +439,61 @@ export default function HuyaBrowserExchangePage() {
/>
</Modal>
<Modal
title={`兑换记录 · ${recordsState?.account.nickname || recordsState?.account.username || `账号#${recordsState?.account.id}` || ''}`}
open={recordsState !== null}
onCancel={closeRecordsModal}
width={760}
footer={[
<Button
key="retry"
loading={recordsState?.phase === 'loading'}
onClick={() => recordsState && void queryExchangeRecords(recordsState.account)}
>
重新查询
</Button>,
<Button key="close" type="primary" onClick={closeRecordsModal}>关闭</Button>,
]}
>
{recordsState?.phase === 'loading' && (
<div style={{ padding: '48px 0', textAlign: 'center' }}>
<Spin size="large" />
<Paragraph type="secondary" style={{ marginTop: 12 }}>正在通过精英宝典任务查询兑换记录…</Paragraph>
</div>
)}
{recordsState?.phase === 'error' && (
<Alert type="error" showIcon message="兑换记录查询失败" description={recordsState.errorMsg} />
)}
{recordsState?.phase === 'done' && (
recordsState.records.length ? (
<Table
rowKey="record_id"
size="small"
dataSource={recordsState.records}
pagination={false}
scroll={{ y: 420 }}
columns={[
{ title: '#', dataIndex: 'index', width: 48 },
{ title: '奖品', dataIndex: 'prize_name', render: (value: string) => value || '-' },
{ title: '积分', dataIndex: 'score', width: 90, render: (value: number) => (value != null ? `${value}积分` : '-') },
{
title: '状态',
dataIndex: 'status_label',
width: 90,
render: (value: string) => (
<Tag color={value === '已发放' ? 'success' : 'warning'}>{value}</Tag>
),
},
{ title: '兑换时间', dataIndex: 'exchange_time_text', width: 170, render: (value: string) => value || '-' },
{ title: '订单号', dataIndex: 'order_id', render: (value: string) => value || '-' },
]}
/>
) : (
<Empty description="暂无兑换记录" />
)
)}
</Modal>
<Modal
title={`浏览器兑换 · ${exchangeTitle}`}
open={exchangeKit !== null}