feat: 业务错误Modal引导体系 + 左侧栏UI增强 (0.0.25)

业务错误 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 样式 (暗/亮双套)
- 表头取消加粗 + 模型/任务列表间距
This commit is contained in:
2026-06-22 17:01:09 +08:00
parent 8b697e775d
commit dd8f84ae27
9 changed files with 269 additions and 65 deletions

View File

@@ -1,6 +1,29 @@
# 更新日志
> 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。
---
## 0.0.252026-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.242026-06-22

View File

@@ -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 (
<>
<Layout>

View File

@@ -141,7 +141,7 @@ export function ModelInputForm() {
const [currentValues, setCurrentValues] = useState<Record<string, unknown>>({});
// 提交突变 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]);
// ======== useModelUIparam_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<string, unknown>).displayed === true;
if (!displayed) {
message.error(errorMessageRef.current || '任务提交失败,请重试');
}
}
}, [selectedModel, form, fields, taskTag, submitTask]);

View File

@@ -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<string, unknown>;
const metaConfig = (model.meta_config || {}) as Record<string, unknown>;
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 && <span style={{ marginRight: 4 }}>{emoji}</span>}
{model.name}
</Text>
{model.status !== 'active' && (
@@ -188,11 +194,12 @@ export function ModelSelector({ models, loading, error, groupedModels, refetch }
return (
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{/* 标题栏 */}
{/* 标题栏 — 左侧色条 + 背景,与任务记录标题栏统一 */}
<div
style={{
padding: '8px 12px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
padding: '8px 12px 8px 9px',
borderLeft: `3px solid ${token.colorPrimary}`,
background: token.colorFillSecondary,
display: 'flex',
alignItems: 'center',
gap: 6,

View File

@@ -12,7 +12,7 @@
// ============================================================
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { Table, Tag, Typography, theme as antTheme, Popover, Checkbox, Button, Space } from 'antd';
import { Table, Tag, Typography, theme as antTheme, Popover, Checkbox, Button, Space, Pagination } from 'antd';
import type { ColumnsType, TableProps } from 'antd/es/table';
import type { FilterValue, SorterResult } from 'antd/es/table/interface';
import { HistoryOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons';
@@ -187,6 +187,18 @@ export function TaskHistory({ allModels }: TaskHistoryProps) {
.task-table .ant-table-row:hover > 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<TaskInfoItemResponseBody>['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<TaskInfoItemResponseBody>;
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<HTMLDivElement>(null);
const hScrollRef = useRef<HTMLDivElement>(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 (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
{/* 标题栏 */}
{/* 标题栏 — 左侧色条 + 背景,视觉权重高于表格列头 */}
<div
style={{
padding: '6px 12px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
padding: '8px 12px 8px 9px',
borderLeft: `3px solid ${token.colorPrimary}`,
background: token.colorFillSecondary,
display: 'flex',
alignItems: 'center',
gap: 6,
flexShrink: 0,
}}
>
<HistoryOutlined style={{ fontSize: 14 }} />
<HistoryOutlined style={{ fontSize: 14, color: token.colorPrimary }} />
<Text strong style={{ fontSize: 13 }}></Text>
{total > 0 && (
<Text type="secondary" style={{ fontSize: 11 }}> {total} </Text>
@@ -729,8 +766,22 @@ export function TaskHistory({ allModels }: TaskHistoryProps) {
</Popover>
</div>
{/* 表格 — 横向 + 纵向滚动,筛选/排序通过列头操作 */}
<div ref={tableWrapperRef} style={{ flex: 1, overflow: 'hidden', minHeight: Math.min(window.outerHeight, window.outerHeight, 630) }}>
{/* 固定水平滚动条 — 在表格上方,始终可见,与表格 body 双向同步 */}
<div
ref={hScrollRef}
className="task-hscroll"
style={{
flexShrink: 0,
overflowX: 'auto',
overflowY: 'hidden',
height: 8,
}}
>
<div style={{ width: scrollX, height: 1 }} />
</div>
{/* 表格 — 纵向滚动由 scroll.y 控制,横向滚动由上方固定滚动条控制 */}
<div ref={tableWrapperRef} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
<style>{tableCss}</style>
<Table<TaskInfoItemResponseBody>
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: '确定' }}
/>
</div>
{/* 独立分页器 — 固定在底部,不随表格数据量移动 */}
<div style={{ flexShrink: 0, display: 'flex', justifyContent: 'center', padding: '4px 0 0', borderTop: `1px solid ${token.colorBorderSecondary}` }}>
<Pagination
current={tableParams.pagination.current}
pageSize={tableParams.pagination.pageSize}
total={total}
size="small"
showSizeChanger
pageSizeOptions={[10, 20, 50]}
onChange={(page, pageSize) => {
if (page !== taskPage) setTaskPage(page);
if (pageSize !== taskPageSize) setTaskPageSize(pageSize);
setTableParams((prev) => ({
...prev,
pagination: { current: page, pageSize },
}));
}}
/>
</div>
</div>
);
}

View File

@@ -134,6 +134,7 @@ export function LeftPanel() {
display: 'flex',
flexDirection: 'column',
flexShrink: 0,
paddingBottom: 4,
}}
>
<ModelSelector
@@ -181,7 +182,7 @@ export function LeftPanel() {
</div>
{/* 下方:任务记录(剩余高度) */}
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', minHeight: 0, paddingTop: 4 }}>
<TaskHistory allModels={models} />
</div>
</div>

View File

@@ -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);

View File

@@ -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,

View File

@@ -53,6 +53,14 @@ const listeners = new Map<string, Set<Listener>>();
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',