From dd8f84ae278d2855c808c42ffa45894e9978b2c3 Mon Sep 17 00:00:00 2001 From: YoungestSongMo <2130460579@qq.com> Date: Mon, 22 Jun 2026 17:01:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=9A=E5=8A=A1=E9=94=99=E8=AF=AFMod?= =?UTF-8?q?al=E5=BC=95=E5=AF=BC=E4=BD=93=E7=B3=BB=20+=20=E5=B7=A6=E4=BE=A7?= =?UTF-8?q?=E6=A0=8FUI=E5=A2=9E=E5=BC=BA=20(0.0.25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 业务错误 Modal 引导: - HTTP 402+code 1001 → 余额不足Modal → 引导去充值 - HTTP 403+code 4040 → 软件到期Modal → 引导购买VIP - BusinessErrorEvent 事件总线类型,双条件精确匹配避免误拦截 - RequestError.displayed 标记抑制重复 toast 左侧栏 UI: - 模型列表 emoji 渲染 (ui_config.badge / meta_config.icon) - 标题栏色条加深 (borderLeft + colorFillSecondary + 图标染色) - 任务列表分页器固定底部 + 水平滚动条固定 - 统一 ::-webkit-scrollbar 样式 (暗/亮双套) - 表头取消加粗 + 模型/任务列表间距 --- CHANGELOG.md | 23 +++ src/App.tsx | 43 +++++- src/pages/home/features/ModelInputForm.tsx | 17 ++- src/pages/home/features/ModelSelector.tsx | 13 +- src/pages/home/features/TaskHistory.tsx | 164 ++++++++++++++------- src/pages/home/panels/LeftPanel.tsx | 3 +- src/services/request.ts | 54 ++++++- src/services/types.ts | 6 + src/utils/bus.ts | 11 ++ 9 files changed, 269 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b05b1ea..22350db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,29 @@ # 更新日志 > 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。 +--- +## 0.0.25(2026-06-22) + +**业务错误 Modal 引导 + 左侧栏 UI 增强** + +### 业务错误 Modal 引导体系 + +- **HTTP 402 + code 1001 → 余额不足 Modal**:`request.ts` 拦截器双条件精确匹配,`App.tsx` 弹 Modal 引导"去充值" +- **HTTP 403 + code 4040 → 软件到期 Modal**:拦截 403+4040,弹 Modal 引导"去购买 VIP" +- **`BusinessErrorEvent` 事件总线类型**:标准化 `{ code, message }` 负载,新增 `BALANCE_INSUFFICIENT` 和 `SUBSCRIPTION_EXPIRED` 事件 +- **`RequestError.displayed` 标记**:Modal 已展示的错误标记 `displayed=true`,`ModelInputForm.doSubmit` 读取标记跳过 `message.error` 重复 toast +- **防御性兜底**:HTTP 200 + code 1001 也入拦截器(兼容后端行为变体) + +### 左侧栏 UI 增强 + +- **模型列表 emoji 渲染**:从后端 `ui_config.badge` / `meta_config.icon` 提取 emoji 显示在模型名称前 +- **标题栏色条加深**:模型选择器 + 任务记录标题栏加 `borderLeft: 3px` 色条 + `colorFillSecondary` 背景 + 图标染色 +- **任务列表分页器固定**:`Pagination` 独立到表格外部,`pagination={false}` on Table,分页器始终固定在底部 +- **水平滚动条固定**:独立 `div` + 双向 `scroll` 事件同步 + MutationObserver,不随垂直滚动移动 +- **统一滚动条样式**:`::-webkit-scrollbar` 暗色/亮色双套,纵向 6px、横向 6px,风格统一 +- **表头取消加粗**:`.ant-table-thead > tr > th { font-weight: normal }` +- **模型列表与任务列表间距**:`paddingBottom: 4` / `paddingTop: 4` + --- ## 0.0.24(2026-06-22) diff --git a/src/App.tsx b/src/App.tsx index 2de4bfd..ea3f851 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,11 @@ import { useState, useEffect, useCallback } from 'react'; +import { App as AntdApp } from 'antd'; import { useAppContext } from '@/components/providers/AppProvider'; import { LoginPage } from './pages/auth'; import { SettingsPage } from './pages/settings'; -import { on, off, EVENTS } from './utils/bus'; +import { on, off, emit, EVENTS } from './utils/bus'; +import type { BusinessErrorEvent } from './utils/bus'; import { Layout } from '@/components/frame/Layout'; import { HomePage } from '@/pages/home/HomePage'; import { PageDispatcher } from '@/components/frame/PageDispatcher'; @@ -23,6 +25,7 @@ import { PageDispatcher } from '@/components/frame/PageDispatcher'; */ function App() { const { isLoggedIn } = useAppContext(); + const { modal } = AntdApp.useApp(); // 登录 Modal 状态 const [loginOpen, setLoginOpen] = useState(false); @@ -59,6 +62,44 @@ function App() { return () => { off(EVENTS.OPEN_SETTINGS, handler); }; }, []); + // 监听余额不足业务错误 → 弹窗引导充值 + useEffect(() => { + const handler = (payload: BusinessErrorEvent) => { + console.log('[App] 收到 BALANCE_INSUFFICIENT 事件:', payload); + modal.confirm({ + title: '余额不足', + content: payload.message || '当前账户余额不足,请充值后重试', + okText: '去充值', + cancelText: '取消', + onOk: () => { + console.log('[App] 用户点击"去充值",打开充值页面'); + emit(EVENTS.OPEN_PAGE, { target: '/recharge', modalType: 'drawer' }); + }, + }); + }; + on(EVENTS.BALANCE_INSUFFICIENT, handler as (...args: unknown[]) => void); + return () => { off(EVENTS.BALANCE_INSUFFICIENT, handler as (...args: unknown[]) => void); }; + }, [modal]); + + // 监听软件使用权到期(HTTP 403)→ 弹窗引导购买 VIP + useEffect(() => { + const handler = (payload: BusinessErrorEvent) => { + console.log('[App] 收到 SUBSCRIPTION_EXPIRED 事件:', payload); + modal.confirm({ + title: '软件使用权已到期', + content: payload.message || '软件使用权已到期,请购买 VIP 套餐后继续使用', + okText: '去购买 VIP', + cancelText: '取消', + onOk: () => { + console.log('[App] 用户点击"去购买 VIP",打开充值页面'); + emit(EVENTS.OPEN_PAGE, { target: '/recharge', modalType: 'drawer' }); + }, + }); + }; + on(EVENTS.SUBSCRIPTION_EXPIRED, handler as (...args: unknown[]) => void); + return () => { off(EVENTS.SUBSCRIPTION_EXPIRED, handler as (...args: unknown[]) => void); }; + }, [modal]); + return ( <> diff --git a/src/pages/home/features/ModelInputForm.tsx b/src/pages/home/features/ModelInputForm.tsx index 738b6b6..d6295dd 100644 --- a/src/pages/home/features/ModelInputForm.tsx +++ b/src/pages/home/features/ModelInputForm.tsx @@ -141,7 +141,7 @@ export function ModelInputForm() { const [currentValues, setCurrentValues] = useState>({}); // 提交突变 Hook - const { execute: submitTask, loading: submitting, errorMessage } = useSubmitTask({ + const { execute: submitTask, loading: submitting, error: submitError, errorMessage } = useSubmitTask({ onSuccess: useCallback((_data: TaskInfoItemResponseBody) => { void message.success('任务已提交'); emit(EVENTS.TASK_SUBMITTED); @@ -160,6 +160,12 @@ export function ModelInputForm() { errorMessageRef.current = errorMessage; }, [errorMessage]); + /** 最新 Error 对象的 ref(用于检查 error.displayed 标记) */ + const submitErrorRef = useRef(submitError); + useEffect(() => { + submitErrorRef.current = submitError; + }, [submitError]); + // ======== useModelUI:param_schema → UIFieldConfig[] ======== const fields: UIFieldConfig[] = useModelUI( @@ -283,8 +289,13 @@ export function ModelInputForm() { if (result !== undefined) { submitCountRef.current += 1; } else { - // 使用 ref 读取最新错误信息(避免闭包过期) - message.error(errorMessageRef.current || '任务提交失败,请重试'); + // 余额不足 / 软件到期等错误已由 request.ts 拦截器 → 事件总线 → Modal 展示, + // 此处检查 error.displayed 标记,避免 message.error toast 重复提示 + const errObj = submitErrorRef.current; + const displayed = errObj != null && (errObj as unknown as Record).displayed === true; + if (!displayed) { + message.error(errorMessageRef.current || '任务提交失败,请重试'); + } } }, [selectedModel, form, fields, taskTag, submitTask]); diff --git a/src/pages/home/features/ModelSelector.tsx b/src/pages/home/features/ModelSelector.tsx index 2a85c58..c7db8a4 100644 --- a/src/pages/home/features/ModelSelector.tsx +++ b/src/pages/home/features/ModelSelector.tsx @@ -32,6 +32,11 @@ function ModelItem({ model, isSelected, onSelect }: ModelItemProps) { // 维护中的模型不可选 const isDisabled = model.status === 'maintenance'; + // 从后端 ui_config / meta_config 提取 emoji 图标 + const uiConfig = (model.ui_config || {}) as Record; + const metaConfig = (model.meta_config || {}) as Record; + const emoji = (uiConfig.badge as string) || (metaConfig.icon as string) || null; + const handleClick = useCallback(() => { if (isDisabled) { message.warning(`${model.name} 正在维护中,暂不可用`); @@ -81,6 +86,7 @@ function ModelItem({ model, isSelected, onSelect }: ModelItemProps) { }} title={model.name} > + {emoji && {emoji}} {model.name} {model.status !== 'active' && ( @@ -188,11 +194,12 @@ export function ModelSelector({ models, loading, error, groupedModels, refetch } return (
- {/* 标题栏 */} + {/* 标题栏 — 左侧色条 + 背景,与任务记录标题栏统一 */}
td { background: rgba(255,255,255,0.05) !important; } .task-table .task-row-selected:hover > td { background: ${token.blue}28 !important; } .task-table .prompt-search-icon.filtered { color: ${searchIconColor} !important; } + .task-table .ant-table-body { overflow-x: hidden !important; } + .task-table .ant-table-thead > tr > th { font-weight: normal !important; } + /* 纵向滚动条(表格 body) */ + .task-table .ant-table-body::-webkit-scrollbar { width: 6px; } + .task-table .ant-table-body::-webkit-scrollbar-track { background: transparent; } + .task-table .ant-table-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 3px; } + .task-table .ant-table-body::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.35); } + /* 横向滚动条(固定面板) */ + .task-hscroll::-webkit-scrollbar { height: 6px; } + .task-hscroll::-webkit-scrollbar-track { background: transparent; } + .task-hscroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 3px; } + .task-hscroll::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.35); } `; } return ` @@ -197,6 +209,18 @@ export function TaskHistory({ allModels }: TaskHistoryProps) { .task-table .ant-table-row:hover > td { background: #f0f5ff !important; } .task-table .task-row-selected:hover > td { background: #d6ebff !important; } .task-table .prompt-search-icon.filtered { color: ${searchIconColor} !important; } + .task-table .ant-table-body { overflow-x: hidden !important; } + .task-table .ant-table-thead > tr > th { font-weight: normal !important; } + /* 纵向滚动条(表格 body) */ + .task-table .ant-table-body::-webkit-scrollbar { width: 6px; } + .task-table .ant-table-body::-webkit-scrollbar-track { background: transparent; } + .task-table .ant-table-body::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); border-radius: 3px; } + .task-table .ant-table-body::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.25); } + /* 横向滚动条(固定面板) */ + .task-hscroll::-webkit-scrollbar { height: 6px; } + .task-hscroll::-webkit-scrollbar-track { background: transparent; } + .task-hscroll::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); border-radius: 3px; } + .task-hscroll::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.25); } `; }, [isDark, token.blue, token.colorPrimary]); @@ -323,26 +347,19 @@ export function TaskHistory({ allModels }: TaskHistoryProps) { // -------- antd Table onChange -------- const handleTableChange: TableProps['onChange'] = ( - pagination, + _pagination, filters, sorter, ) => { - // 同步分页到 Context - if (pagination.current && pagination.current !== taskPage) setTaskPage(pagination.current); - if (pagination.pageSize && pagination.pageSize !== taskPageSize) setTaskPageSize(pagination.pageSize); - - // 缓存筛选和排序参数 + // 缓存筛选和排序参数(分页由独立 Pagination 组件管理) const s = sorter as SorterResult; - setTableParams({ - pagination: { - current: pagination.current || taskPage, - pageSize: pagination.pageSize || taskPageSize, - }, + setTableParams((prev) => ({ + ...prev, filters: filters as TableFilters, sorter: !Array.isArray(s) && s.field ? { field: s.field, order: s.order as 'ascend' | 'descend' | undefined } : {}, - }); + })); }; // -------- 表格列定义(含列头筛选/排序 + 可见性过滤)-------- @@ -603,20 +620,12 @@ export function TaskHistory({ allModels }: TaskHistoryProps) { // -------- 表格高度 -------- // - // 核心策略:固定预留 pagination 区域高度,确保无论数据加载时机如何, - // 分页器始终有空间渲染,不会溢出可视区。 - // - // scroll.y = wrapper高度 - thead高度 - PAGINATION_RESERVE - 水平滚动条高度 - // - // 为什么固定预留 pagination 而不是动态测量? - // - 无数据/加载中时 pagination DOM 尚不存在 → offsetHeight=0 → scroll.y 偏大 - // - 翻页/切换 pageSize 时 pagination 异步渲染 → 测量时机不可控 - // - 固定预留值(48px 覆盖 small-size pagination + 上下间距)避免上述所有时序问题 - - /** 预留 pagination 高度(small-size: ~32px + 上下 padding/margin 8px × 2) */ - const PAGINATION_RESERVE = 48; + // 核心策略:分页器 + 水平滚动条均已独立到表格 wrapper 之外, + // 因此 scroll.y 使用 wrapper 完整高度(仅减去 thead)。 + const scrollX = isEnterprise ? 1350 : 1250; const tableWrapperRef = useRef(null); + const hScrollRef = useRef(null); const [tableBodyHeight, setTableBodyHeight] = useState(400); useEffect(() => { @@ -625,16 +634,8 @@ export function TaskHistory({ allModels }: TaskHistoryProps) { const calcHeight = () => { const thead = wrapper.querySelector('.ant-table-thead') as HTMLElement | null; - const body = wrapper.querySelector('.ant-table-body') as HTMLElement | null; const theadH = thead?.offsetHeight ?? 0; - - // 水平滚动条占用高度(antd Table scroll.x 触发时出现) - let hScrollH = 0; - if (body && body.scrollWidth > body.clientWidth + 1) { - hScrollH = body.offsetHeight - body.clientHeight; - } - - const available = wrapper.clientHeight - theadH - PAGINATION_RESERVE - hScrollH; + const available = wrapper.clientHeight - theadH; setTableBodyHeight(Math.max(120, available)); }; @@ -654,6 +655,41 @@ export function TaskHistory({ allModels }: TaskHistoryProps) { }; }, [taskPageSize]); + // -------- 固定水平滚动条 ↔ 表格 body 双向同步 -------- + // + // .ant-table-body 原生水平滚动条被 CSS 隐藏(overflow-x: hidden), + // 改为由独立的固定水平滚动条控制;两者通过 scroll 事件双向同步。 + + useEffect(() => { + const wrapper = tableWrapperRef.current; + const hScroll = hScrollRef.current; + if (!wrapper || !hScroll) return; + + const body = wrapper.querySelector('.ant-table-body') as HTMLElement | null; + if (!body) return; + + const syncBodyToHScroll = () => { + hScroll.scrollLeft = body.scrollLeft; + }; + const syncHScrollToBody = () => { + body.scrollLeft = hScroll.scrollLeft; + }; + + body.addEventListener('scroll', syncBodyToHScroll); + hScroll.addEventListener('scroll', syncHScrollToBody); + syncBodyToHScroll(); + + // 数据变化(加载/筛选/翻页)后重同步位置 + const observer = new MutationObserver(syncBodyToHScroll); + observer.observe(body, { childList: true, subtree: true }); + + return () => { + body.removeEventListener('scroll', syncBodyToHScroll); + hScroll.removeEventListener('scroll', syncHScrollToBody); + observer.disconnect(); + }; + }, [polledTasks, tableBodyHeight]); + // -------- 行样式:斑马纹 + 选中行 -------- const rowClassName = (_record: TaskInfoItemResponseBody, index: number) => { @@ -671,18 +707,19 @@ export function TaskHistory({ allModels }: TaskHistoryProps) { return (
- {/* 标题栏 */} + {/* 标题栏 — 左侧色条 + 背景,视觉权重高于表格列头 */}
- + 任务记录 {total > 0 && ( 共 {total} 条 @@ -729,8 +766,22 @@ export function TaskHistory({ allModels }: TaskHistoryProps) {
- {/* 表格 — 横向 + 纵向滚动,筛选/排序通过列头操作 */} -
+ {/* 固定水平滚动条 — 在表格上方,始终可见,与表格 body 双向同步 */} +
+
+
+ + {/* 表格 — 纵向滚动由 scroll.y 控制,横向滚动由上方固定滚动条控制 */} +
className="task-table" @@ -740,26 +791,37 @@ export function TaskHistory({ allModels }: TaskHistoryProps) { size="small" loading={loading} showHeader={true} - scroll={{ x: isEnterprise ? 1350 : 1250, y: tableBodyHeight }} + scroll={{ x: scrollX, y: tableBodyHeight }} rowClassName={rowClassName} onRow={(record) => ({ onClick: () => setSelectedTask(record), style: { cursor: 'pointer' }, })} onChange={handleTableChange} - pagination={{ - current: tableParams.pagination.current, - pageSize: tableParams.pagination.pageSize, - total, - size: 'small', - showSizeChanger: true, - pageSizeOptions: ['10', '20', '50'], - placement: ['bottomCenter'], - style: { marginBottom: 0 }, - }} + pagination={false} locale={{ emptyText: '暂无任务记录', filterReset: '重置', filterConfirm: '确定' }} />
+ + {/* 独立分页器 — 固定在底部,不随表格数据量移动 */} +
+ { + if (page !== taskPage) setTaskPage(page); + if (pageSize !== taskPageSize) setTaskPageSize(pageSize); + setTableParams((prev) => ({ + ...prev, + pagination: { current: page, pageSize }, + })); + }} + /> +
); } diff --git a/src/pages/home/panels/LeftPanel.tsx b/src/pages/home/panels/LeftPanel.tsx index 0e0a5c2..49a2bcf 100644 --- a/src/pages/home/panels/LeftPanel.tsx +++ b/src/pages/home/panels/LeftPanel.tsx @@ -134,6 +134,7 @@ export function LeftPanel() { display: 'flex', flexDirection: 'column', flexShrink: 0, + paddingBottom: 4, }} > {/* 下方:任务记录(剩余高度) */} -
+
diff --git a/src/services/request.ts b/src/services/request.ts index 18cb5ad..a45114a 100644 --- a/src/services/request.ts +++ b/src/services/request.ts @@ -187,6 +187,16 @@ instance.interceptors.response.use( return handle401(response.config); } + // 业务码 1001 → 余额不足(防御性兜底:当前后端实际返回 HTTP 402 + code 1001, + // 已在上方 HTTP error 拦截器处理;此处保留以兼容「HTTP 200 + code 1001」的变体) + if (data.code === 1001) { + console.log('[request] 检测到余额不足 code=1001,发射 BALANCE_INSUFFICIENT 事件'); + emit(EVENTS.BALANCE_INSUFFICIENT, { + code: 1001, + message: data.message || '余额不足,请充值后重试', + }); + } + logger.warn('request', `Business error: ${data.message || '请求失败'}`, undefined, { url: response.config.url, method: response.config.method?.toUpperCase(), @@ -194,12 +204,15 @@ instance.interceptors.response.use( traceId: data.traceId, }); - return Promise.reject( - new RequestError(RequestErrorType.BUSINESS, data.message || '请求失败', { - code: data.code, - traceId: data.traceId, - }), - ); + const businessErr = new RequestError(RequestErrorType.BUSINESS, data.message || '请求失败', { + code: data.code, + traceId: data.traceId, + }); + // 余额不足已通过事件总线 → Modal 展示,标记避免调用方再弹 message.error toast + if (data.code === 1001) { + businessErr.displayed = true; + } + return Promise.reject(businessErr); } @@ -264,12 +277,35 @@ instance.interceptors.response.use( return handle401(error.config); } + // 402 + code 1001 → 余额不足(Payment Required),事件总线驱动弹窗引导充值 + if (status === 402 && data?.code === 1001) { + const balanceMsg = data?.message || '余额不足,请充值后重试'; + console.log('[request] 检测到 HTTP 402 + code 1001 余额不足,发射 BALANCE_INSUFFICIENT 事件'); + emit(EVENTS.BALANCE_INSUFFICIENT, { + code: 1001, + message: balanceMsg, + }); + } + + // 403 + code 4040 → 软件使用权到期,事件总线驱动弹窗引导购买 VIP + if (status === 403 && data?.code === 4040) { + const subMsg = data?.message || '软件使用权已到期,请购买 VIP 套餐后继续使用'; + console.log('[request] 检测到 HTTP 403 + code 4040 软件到期,发射 SUBSCRIPTION_EXPIRED 事件'); + emit(EVENTS.SUBSCRIPTION_EXPIRED, { + code: 4040, + message: subMsg, + }); + } + let errMsg = '请求失败'; switch (status) { case 400: errMsg = '请求参数有误'; break; + case 402: + errMsg = '余额不足'; + break; case 403: errMsg = '没有访问权限'; break; @@ -292,6 +328,12 @@ instance.interceptors.response.use( code: data?.code, }); + // 已通过事件总线 Modal 展示的错误,标记避免调用方再弹 message.error toast + // 双条件精确匹配:402+1001(余额不足)/ 403+4040(软件到期),避免误拦截同状态码的其他业务 + if ((status === 402 && data?.code === 1001) || (status === 403 && data?.code === 4040)) { + httpErr.displayed = true; + } + // HTTP 5xx → error 级别,4xx → warn 级别 if (status >= 500) { logger.error('request', `HTTP ${status}: ${errMsg}`, httpErr, reqCtx); diff --git a/src/services/types.ts b/src/services/types.ts index 7eaad6d..a38e4d6 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -57,6 +57,12 @@ export class RequestError extends Error { code?: number; httpStatus?: number; traceId?: string; + /** + * 标记该错误是否已通过事件总线 → Modal 展示给用户。 + * 调用方(如 useAsyncMutation / 表单提交)读取此标记, + * 若为 true 则跳过 message.error 等重复提示。 + */ + displayed = false; constructor( type: RequestErrorType, diff --git a/src/utils/bus.ts b/src/utils/bus.ts index d759281..de94bd5 100644 --- a/src/utils/bus.ts +++ b/src/utils/bus.ts @@ -53,6 +53,14 @@ const listeners = new Map>(); type EventLogListener = (event: string, ...args: unknown[]) => void; let eventLogListener: EventLogListener | null = null; +/** 业务错误事件的负载类型(事件总线投递用) */ +export interface BusinessErrorEvent { + /** 后端业务状态码 */ + code: number; + /** 后端返回的错误信息(用户可读) */ + message: string; +} + /** 注册事件日志监听器(由下方 logger 模块自动调用,用于审计追踪) */ export function setEventLogListener(fn: EventLogListener): void { eventLogListener = fn; @@ -82,6 +90,9 @@ export const EVENTS = { LOGIN_SUCCESS: 'auth:login-success', LOGOUT: 'auth:logout', TASK_SUBMITTED: 'task:submitted', + BALANCE_INSUFFICIENT: 'task:balance-insufficient', + /** 软件使用权到期(HTTP 403 + 后端 message),引导用户购买 VIP */ + SUBSCRIPTION_EXPIRED: 'auth:subscription-expired', OPEN_SETTINGS: 'settings:open', OPEN_PAGE: 'page:open', CLOSE_PAGE: 'page:close',