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:
23
CHANGELOG.md
23
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)
|
||||
|
||||
|
||||
43
src/App.tsx
43
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 (
|
||||
<>
|
||||
<Layout>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
// ======== 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<string, unknown>).displayed === true;
|
||||
if (!displayed) {
|
||||
message.error(errorMessageRef.current || '任务提交失败,请重试');
|
||||
}
|
||||
}
|
||||
}, [selectedModel, form, fields, taskTag, submitTask]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user