From 90a00659187368dfb5d09038dd4cfcb316a7f74e Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 5 Sep 2026 19:21:59 +0800 Subject: [PATCH] =?UTF-8?q?=E8=99=8E=E7=89=99=E8=8F=9C=E5=8D=95=E6=8C=89?= =?UTF-8?q?=E8=A7=92=E8=89=B2=E6=98=BE=E7=A4=BA=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E6=A1=86=E6=9A=97=E8=89=B2=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 客服在虎牙导航只显示浏览器兑换,管理员/运营保留完整入口;退出登录 与显示凭据确认框改用 App.useApp() 的 modal 实例以适配暗色主题; 版本号升至 0.3.0。 --- docs/HUYA_BROWSER_EXCHANGE.md | 13 +- pyproject.toml | 2 +- uv.lock | 2 +- web/frontend/src/api/types.ts | 16 ++ web/frontend/src/layouts/MainLayout.tsx | 46 +++-- web/frontend/src/pages/AccountsPage.tsx | 6 +- .../src/pages/HuyaBrowserExchangePage.tsx | 183 +++++++++++++++++- 7 files changed, 237 insertions(+), 31 deletions(-) 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,请联系管理员在账号管理中完成登录'}> , + , + ]} + > + {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 || '-' }, + ]} + /> + ) : ( + + ) + )} + +