虎牙菜单按角色显示并修复确认框暗色模式
客服在虎牙导航只显示浏览器兑换,管理员/运营保留完整入口;退出登录 与显示凭据确认框改用 App.useApp() 的 modal 实例以适配暗色主题; 版本号升至 0.3.0。
This commit is contained in:
@@ -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
@@ -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"
|
||||
|
||||
@@ -320,7 +320,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "live-hub-py"
|
||||
version = "0.2.1"
|
||||
version = "0.3.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: '退出',
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user