Compare commits
2 Commits
a2741edf01
...
eb12248a62
| Author | SHA1 | Date | |
|---|---|---|---|
| eb12248a62 | |||
| 59059caae7 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -36,3 +36,5 @@ dist-ssr
|
||||
*.py
|
||||
*.pyi
|
||||
WORKLOG.md
|
||||
*openapi*.json
|
||||
response_*.json
|
||||
|
||||
22
CHANGELOG.md
22
CHANGELOG.md
@@ -1,6 +1,28 @@
|
||||
# 更新日志
|
||||
|
||||
> 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。
|
||||
---
|
||||
|
||||
## 0.0.15(2026-06-09)
|
||||
|
||||
**主题切换性能优化 — 桌面端流畅度提升**
|
||||
|
||||
- View Transitions API 驱动主题切换:GPU 合成器单次 cross-fade 替代 80+ 个独立 CSS 过渡,消除 Electron 卡顿
|
||||
- 主题更新时序统一:`applyHtmlTheme` 从 updater → `useLayoutEffect`,CSS 变量与 React 提交同帧
|
||||
- 过渡参数对齐:全部 `0.2s ease` → `0.15s linear`,桌面应用更利落
|
||||
- CSS 过渡精准覆盖:antd 容器组件(Layout/Card/Modal/Table/Input/Menu 等 ~80 个类)替代 `html *` 全局选择器
|
||||
- 设置页 Esc 键关闭 + macOS 关闭按钮适配 + 关闭后焦点防跳转
|
||||
|
||||
---
|
||||
|
||||
## 0.0.14 -- 🚀更新公告
|
||||
|
||||
**模型选择器焕新**
|
||||
|
||||
- 模型列表改为标签页分组展示,新增「全部模型」Tab
|
||||
- 列表项新增悬停高亮效果,维护中模型自动置灰并提示
|
||||
- 修复选择模型后参数表单重复 key 报错
|
||||
- 类型体系重构,支持后端分类 slug 自动映射
|
||||
|
||||
---
|
||||
|
||||
|
||||
23
CLAUDE.md
23
CLAUDE.md
@@ -2,3 +2,26 @@
|
||||
|
||||
- 所有交互、解释、代码注释与生成内容均使用简体中文,专有技术名词可保留英文,但需附带中文说明
|
||||
- 进行任务时都需要考虑安全、性能、主题切换、业务自定义错误、事件总线驱动方式以及日志记录
|
||||
- 有需要请自己调用MCP服务
|
||||
|
||||
# 主题系统架构
|
||||
|
||||
## 双轨颜色体系
|
||||
|
||||
1. **自定义 CSS 变量**(`globals.css`):`data-theme="light|dark"` → `var(--color-bg-base)` 等,用于 body 和自定义组件
|
||||
2. **antd 令牌**(`antd-theme.ts`):`ConfigProvider theme={algorithm}` → `useToken()` → inline style,用于 antd 组件
|
||||
|
||||
## 主题切换时序(关键)
|
||||
|
||||
```
|
||||
ThemeProvider.useLayoutEffect([isDark])
|
||||
→ applyHtmlTheme(isDark) ← data-theme 属性变更
|
||||
(与 ConfigProvider 的 useInsertionEffect 同帧执行)
|
||||
→ 浏览器绘制 ← 所有颜色变更在同一帧生效
|
||||
```
|
||||
|
||||
## 过渡策略
|
||||
|
||||
- **Electron/Chromium**:View Transitions API(`document.startViewTransition` + `flushSync`),GPU 合成器单次 cross-fade
|
||||
- **其他浏览器**:CSS `transition: color/bg/border/shadow 0.15s linear`,精准覆盖 ~80 个 antd 容器类(不覆盖交互组件自有 transition)
|
||||
- **不启用 antd cssVar**:经实测 cssVar 模式下 CSS-in-JS 仍走标签替换路径,过渡无效
|
||||
206
electron/main.ts
206
electron/main.ts
@@ -1,18 +1,18 @@
|
||||
import { app, BrowserWindow, ipcMain, dialog } from 'electron';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {app, BrowserWindow, ipcMain, dialog, session} from 'electron';
|
||||
import {createRequire} from 'node:module';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
import { getPlatform, isMacOS } from './main/utils/platform';
|
||||
import { getWindowIconPath } from './main/utils/logo';
|
||||
import { buildWindowTitle, parseEdition } from '../shared/constants/app';
|
||||
import { loadEnvFile } from './main/load-env';
|
||||
import { initUpdater, registerUpdateIpcHandlers } from './main/updater';
|
||||
import { setupAppMenu } from './main/menu';
|
||||
import { initLogger, flushLogger, logger } from './main/logger';
|
||||
import { registerLogIpcHandlers } from './main/log-ipc';
|
||||
import { registerSafeStorageIpcHandlers } from './main/safe-storage-ipc';
|
||||
import { BIDIRECTIONAL } from '../shared/constants/ipc-channels';
|
||||
import {getPlatform, isMacOS} from './main/utils/platform';
|
||||
import {getWindowIconPath} from './main/utils/logo';
|
||||
import {buildWindowTitle, parseEdition} from '../shared/constants/app';
|
||||
import {loadEnvFile} from './main/load-env';
|
||||
import {initUpdater, registerUpdateIpcHandlers} from './main/updater';
|
||||
import {setupAppMenu} from './main/menu';
|
||||
import {initLogger, flushLogger, logger} from './main/logger';
|
||||
import {registerLogIpcHandlers} from './main/log-ipc';
|
||||
import {registerSafeStorageIpcHandlers} from './main/safe-storage-ipc';
|
||||
import {BIDIRECTIONAL} from '../shared/constants/ipc-channels';
|
||||
|
||||
createRequire(import.meta.url);
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -28,8 +28,8 @@ export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron');
|
||||
export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist');
|
||||
|
||||
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
|
||||
? path.join(process.env.APP_ROOT, 'public')
|
||||
: RENDERER_DIST;
|
||||
? path.join(process.env.APP_ROOT, 'public')
|
||||
: RENDERER_DIST;
|
||||
|
||||
// ---------- 平台 & 版本信息 ----------
|
||||
const currentPlatform = getPlatform();
|
||||
@@ -41,20 +41,20 @@ const WINDOW_TITLE = buildWindowTitle(currentPlatform, currentEdition);
|
||||
// ============================================================
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
try {
|
||||
logger.error('app', 'Uncaught exception (main process)', error);
|
||||
} catch {
|
||||
/* logger 自身异常 — 最后防线 */
|
||||
}
|
||||
try {
|
||||
logger.error('app', 'Uncaught exception (main process)', error);
|
||||
} catch {
|
||||
/* logger 自身异常 — 最后防线 */
|
||||
}
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
try {
|
||||
const error = reason instanceof Error ? reason : new Error(String(reason));
|
||||
logger.error('app', 'Unhandled rejection (main process)', error);
|
||||
} catch {
|
||||
/* logger 自身异常 — 最后防线 */
|
||||
}
|
||||
try {
|
||||
const error = reason instanceof Error ? reason : new Error(String(reason));
|
||||
logger.error('app', 'Unhandled rejection (main process)', error);
|
||||
} catch {
|
||||
/* logger 自身异常 — 最后防线 */
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
@@ -68,60 +68,60 @@ let mainWindow: BrowserWindow | null = null;
|
||||
// ============================================================
|
||||
|
||||
function createMainWindow(): BrowserWindow {
|
||||
const iconPath = getWindowIconPath(app.getAppPath());
|
||||
const iconPath = getWindowIconPath(app.getAppPath());
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
title: WINDOW_TITLE,
|
||||
icon: iconPath,
|
||||
width: 1280,
|
||||
height: 800,
|
||||
minWidth: 960,
|
||||
minHeight: 600,
|
||||
show: false,
|
||||
...(isMacOS() ? { titleBarStyle: 'hiddenInset' as const } : {}),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.mjs'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
mainWindow = new BrowserWindow({
|
||||
title: WINDOW_TITLE,
|
||||
icon: iconPath,
|
||||
width: 1280,
|
||||
height: 800,
|
||||
minWidth: 960,
|
||||
minHeight: 600,
|
||||
show: false,
|
||||
...(isMacOS() ? {titleBarStyle: 'hiddenInset' as const} : {}),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.mjs'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
mainWindow.setTitle(WINDOW_TITLE);
|
||||
mainWindow.setTitle(WINDOW_TITLE);
|
||||
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
mainWindow.webContents.openDevTools();
|
||||
}
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
mainWindow.webContents.openDevTools();
|
||||
}
|
||||
|
||||
// macOS 全屏事件
|
||||
if (isMacOS()) {
|
||||
mainWindow.on('enter-full-screen', () =>
|
||||
mainWindow?.webContents.send('window-fullscreen-changed', true),
|
||||
);
|
||||
mainWindow.on('leave-full-screen', () =>
|
||||
mainWindow?.webContents.send('window-fullscreen-changed', false),
|
||||
);
|
||||
}
|
||||
// macOS 全屏事件
|
||||
if (isMacOS()) {
|
||||
mainWindow.on('enter-full-screen', () =>
|
||||
mainWindow?.webContents.send('window-fullscreen-changed', true),
|
||||
);
|
||||
mainWindow.on('leave-full-screen', () =>
|
||||
mainWindow?.webContents.send('window-fullscreen-changed', false),
|
||||
);
|
||||
}
|
||||
|
||||
mainWindow.webContents.on('did-finish-load', () => {
|
||||
mainWindow?.webContents.send('main-process-message', new Date().toLocaleString());
|
||||
mainWindow?.webContents.send('platform-info', {
|
||||
platform: currentPlatform,
|
||||
edition: currentEdition,
|
||||
});
|
||||
});
|
||||
mainWindow.webContents.on('did-finish-load', () => {
|
||||
mainWindow?.webContents.send('main-process-message', new Date().toLocaleString());
|
||||
mainWindow?.webContents.send('platform-info', {
|
||||
platform: currentPlatform,
|
||||
edition: currentEdition,
|
||||
});
|
||||
});
|
||||
|
||||
// ready-to-show 后显示,避免白屏闪烁
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
mainWindow?.show();
|
||||
});
|
||||
// ready-to-show 后显示,避免白屏闪烁
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
mainWindow?.show();
|
||||
});
|
||||
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
mainWindow.loadURL(VITE_DEV_SERVER_URL);
|
||||
} else {
|
||||
mainWindow.loadFile(path.join(RENDERER_DIST, 'index.html'));
|
||||
}
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
mainWindow.loadURL(VITE_DEV_SERVER_URL);
|
||||
} else {
|
||||
mainWindow.loadFile(path.join(RENDERER_DIST, 'index.html'));
|
||||
}
|
||||
|
||||
return mainWindow;
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -129,41 +129,51 @@ function createMainWindow(): BrowserWindow {
|
||||
// ============================================================
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (!isMacOS()) {
|
||||
app.quit();
|
||||
}
|
||||
if (!isMacOS()) {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createMainWindow();
|
||||
}
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createMainWindow();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
flushLogger();
|
||||
flushLogger();
|
||||
});
|
||||
|
||||
app.whenReady().then(() => {
|
||||
initLogger();
|
||||
registerLogIpcHandlers();
|
||||
registerSafeStorageIpcHandlers();
|
||||
setupAppMenu();
|
||||
app.setName(WINDOW_TITLE);
|
||||
registerUpdateIpcHandlers();
|
||||
initLogger();
|
||||
registerLogIpcHandlers();
|
||||
registerSafeStorageIpcHandlers();
|
||||
setupAppMenu();
|
||||
app.setName(WINDOW_TITLE);
|
||||
registerUpdateIpcHandlers();
|
||||
const fakeUserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36';
|
||||
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
|
||||
const url = details.url;
|
||||
if (url.includes("rh-images-1252422369.cos.ap-beijing.myqcloud.com")) {
|
||||
details.requestHeaders['User-Agent'] = fakeUserAgent;
|
||||
}
|
||||
callback({
|
||||
requestHeaders: details.requestHeaders
|
||||
});
|
||||
});
|
||||
|
||||
// 文件对话框 IPC 处理(供渲染进程选择文件夹)
|
||||
ipcMain.handle(BIDIRECTIONAL.FILE_DIALOG, async (_event, options: Electron.OpenDialogOptions) => {
|
||||
if (!mainWindow) return { canceled: true, filePaths: [] };
|
||||
return dialog.showOpenDialog(mainWindow, {
|
||||
title: options?.title || '选择文件夹',
|
||||
defaultPath: options?.defaultPath || app.getPath('home'),
|
||||
properties: options?.properties || ['openDirectory'],
|
||||
});
|
||||
});
|
||||
// 文件对话框 IPC 处理(供渲染进程选择文件夹)
|
||||
ipcMain.handle(BIDIRECTIONAL.FILE_DIALOG, async (_event, options: Electron.OpenDialogOptions) => {
|
||||
if (!mainWindow) return {canceled: true, filePaths: []};
|
||||
return dialog.showOpenDialog(mainWindow, {
|
||||
title: options?.title || '选择文件夹',
|
||||
defaultPath: options?.defaultPath || app.getPath('home'),
|
||||
properties: options?.properties || ['openDirectory'],
|
||||
});
|
||||
});
|
||||
|
||||
// 始终打开主窗口
|
||||
// 登录/注册由渲染进程内的 Modal 弹层处理,不再新开窗口
|
||||
const win = createMainWindow();
|
||||
initUpdater(win);
|
||||
// 始终打开主窗口
|
||||
// 登录/注册由渲染进程内的 Modal 弹层处理,不再新开窗口
|
||||
const win = createMainWindow();
|
||||
initUpdater(win);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ele-heixiu",
|
||||
"private": true,
|
||||
"version": "0.0.13",
|
||||
"version": "0.0.14",
|
||||
"description": "船长·HeiXiu — 桌面效率工作台",
|
||||
"author": "HeiXiu 杨烨",
|
||||
"type": "module",
|
||||
|
||||
@@ -13,7 +13,7 @@ export function Layout({ children }: { children: ReactNode }) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-screen flex flex-col transition-colors duration-300"
|
||||
className="h-screen flex flex-col transition-colors duration-150"
|
||||
style={{ background: token.colorBgLayout }}
|
||||
>
|
||||
<BannerCarousel />
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
// ============================================================
|
||||
// ThemeProvider — 主题 Provider 组件
|
||||
//
|
||||
// 桌面端(Electron)使用 View Transitions API 驱动主题切换:
|
||||
// GPU 合成器截取旧/新两帧做一次 cross-fade,替代 80+ 个
|
||||
// 独立 CSS 过渡,消除 Electron Chromium 的合成器卡顿。
|
||||
// 浏览器不支持时回退到 CSS transition 方案。
|
||||
// ============================================================
|
||||
|
||||
import { useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||
import { useState, useEffect, useLayoutEffect, useCallback, type ReactNode } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
|
||||
@@ -20,33 +26,66 @@ interface ThemeProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** 检测 View Transitions API 是否可用(Chromium 111+,Electron 28+) */
|
||||
function supportsViewTransition(): boolean {
|
||||
return typeof document !== 'undefined' && 'startViewTransition' in document;
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: ThemeProviderProps) {
|
||||
const [isDark, setIsDark] = useState<boolean>(readStoredTheme);
|
||||
|
||||
// 挂载时同步
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
applyHtmlTheme(isDark);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [isDark]);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setIsDark((prev) => {
|
||||
const next = !prev;
|
||||
writeStoredTheme(next);
|
||||
applyHtmlTheme(next);
|
||||
return next;
|
||||
});
|
||||
if (supportsViewTransition()) {
|
||||
// View Transitions API:GPU 合成器 cross-fade,1 个动画替代 N 个 CSS 过渡
|
||||
document.startViewTransition(() => {
|
||||
flushSync(() => {
|
||||
setIsDark((prev) => {
|
||||
const next = !prev;
|
||||
writeStoredTheme(next);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// 回退:普通 React 状态更新 + CSS transition
|
||||
setIsDark((prev) => {
|
||||
const next = !prev;
|
||||
writeStoredTheme(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setLight = useCallback(() => {
|
||||
setIsDark(false);
|
||||
writeStoredTheme(false);
|
||||
applyHtmlTheme(false);
|
||||
if (supportsViewTransition()) {
|
||||
document.startViewTransition(() => {
|
||||
flushSync(() => {
|
||||
setIsDark(false);
|
||||
writeStoredTheme(false);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
setIsDark(false);
|
||||
writeStoredTheme(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setDark = useCallback(() => {
|
||||
setIsDark(true);
|
||||
writeStoredTheme(true);
|
||||
applyHtmlTheme(true);
|
||||
if (supportsViewTransition()) {
|
||||
document.startViewTransition(() => {
|
||||
flushSync(() => {
|
||||
setIsDark(true);
|
||||
writeStoredTheme(true);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
setIsDark(true);
|
||||
writeStoredTheme(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 监听系统主题变化(仅用户从未手动切换时跟随)
|
||||
@@ -56,7 +95,6 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === null) {
|
||||
setIsDark(e.matches);
|
||||
applyHtmlTheme(e.matches);
|
||||
}
|
||||
};
|
||||
mq.addEventListener('change', handleChange);
|
||||
|
||||
@@ -24,9 +24,9 @@ import {
|
||||
type CreatTaskRequestBody,
|
||||
type ModelsListResponseBody,
|
||||
type Banner,
|
||||
type ModelCategory,
|
||||
type ModelCategoryStringEnum,
|
||||
} from '@/services/modules';
|
||||
import {useAsyncData, useAsyncMutation} from './use-async';
|
||||
import {useAsyncData, UseAsyncDataReturn, useAsyncMutation} from './use-async';
|
||||
|
||||
// ============================================================
|
||||
// Banner
|
||||
@@ -47,53 +47,38 @@ export function useBannerList() {
|
||||
// 模型
|
||||
// ============================================================
|
||||
|
||||
/** 模型列表数据 Hook(按类别分别请求后合并) */
|
||||
/** 模型列表数据 Hook(单次请求全量模型 + 客户端按 category_name 分组)
|
||||
|
||||
* 因后端 category 查询参数实际不可用(传参返回空列表),
|
||||
* 改为不带分类参数一次拉取全部,再由客户端根据响应中的
|
||||
* category_name 字段手动分组。 */
|
||||
export function useModelList() {
|
||||
const {isLoggedIn} = useAppContext();
|
||||
|
||||
// 使用单个 fetcher 返回合并后的结果
|
||||
const result = useAsyncData<ModelsListResponseBody[]>(
|
||||
async () => {
|
||||
const categories: ModelCategory[] = ["image_to_image", "text_to_image", "image_to_video", "text_to_audio"];
|
||||
const results = await Promise.allSettled(
|
||||
categories.map((category) =>
|
||||
ModelAPI.fetchModels({
|
||||
provider_name: '',
|
||||
category,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const allModels: ModelsListResponseBody[] = [];
|
||||
results.forEach((r) => {
|
||||
if (r.status === 'fulfilled') {
|
||||
allModels.push(...r.value);
|
||||
}
|
||||
});
|
||||
// 按优先级降序排列
|
||||
allModels.sort((a, b) => b.priority - a.priority);
|
||||
return allModels;
|
||||
},
|
||||
const result: UseAsyncDataReturn<ModelsListResponseBody[]> = useAsyncData<ModelsListResponseBody[]>(
|
||||
() => ModelAPI.fetchModels({page: 1, page_size: 200}),
|
||||
[],
|
||||
{enabled: isLoggedIn, label: 'model-list'},
|
||||
);
|
||||
|
||||
// 按类别分组
|
||||
const groupedModels = useMemo(() => {
|
||||
// 按 category_name 分组(后端返回的 category_name 为 slug 格式如 img2img)
|
||||
const groupedModels: Map<string, ModelsListResponseBody[]> = useMemo(() => {
|
||||
const groups = new Map<string, ModelsListResponseBody[]>();
|
||||
result.data?.forEach((model) => {
|
||||
const cat = model.category_name || 'other';
|
||||
result.data?.forEach((model: ModelsListResponseBody) => {
|
||||
const cat: ModelCategoryStringEnum = model.model_type;
|
||||
if (!groups.has(cat)) groups.set(cat, []);
|
||||
groups.get(cat)!.push(model);
|
||||
});
|
||||
// 每组内按优先级降序排列
|
||||
// groups.forEach((models) => {
|
||||
// models.sort((a, b) => b.priority - a.priority);
|
||||
// });
|
||||
return groups;
|
||||
}, [result.data]);
|
||||
|
||||
return {
|
||||
...result,
|
||||
/** 按类别分组后的模型 */
|
||||
/** 按 model_type 分组后的模型(key 为后端 slug) */
|
||||
groupedModels,
|
||||
};
|
||||
}
|
||||
@@ -163,4 +148,4 @@ export function useSubmitTask(options?: {
|
||||
// ---------- 重新导出类型和工具 ----------
|
||||
|
||||
export {MODEL_CATEGORY_LABELS};
|
||||
export type {ModelCategory, ModelsListResponseBody, TaskInfoItemResponseBody, Banner};
|
||||
export type {ModelCategoryStringEnum, ModelsListResponseBody, TaskInfoItemResponseBody, Banner};
|
||||
|
||||
@@ -211,7 +211,7 @@ export function HomeContent() {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'background 0.2s ease',
|
||||
transition: 'background 0.15s linear',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -283,7 +283,7 @@ export function HomeContent() {
|
||||
: hoveredDivider === 'left'
|
||||
? token.colorPrimary
|
||||
: token.colorBorder,
|
||||
transition: 'background 0.2s ease',
|
||||
transition: 'background 0.15s linear',
|
||||
}} />
|
||||
</div>
|
||||
</>
|
||||
@@ -311,7 +311,7 @@ export function HomeContent() {
|
||||
: hoveredDivider === 'right'
|
||||
? token.colorPrimary
|
||||
: token.colorBorder,
|
||||
transition: 'background 0.2s ease',
|
||||
transition: 'background 0.15s linear',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -150,7 +150,7 @@ export function LeftPanel() {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'background 0.2s ease',
|
||||
transition: 'background 0.15s linear',
|
||||
zIndex: 10,
|
||||
}}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
@@ -166,7 +166,7 @@ export function LeftPanel() {
|
||||
: isHovered
|
||||
? token.colorPrimary
|
||||
: token.colorBorder,
|
||||
transition: 'background 0.2s ease',
|
||||
transition: 'background 0.15s linear',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import { useHomeContext } from '@/contexts/home-context';
|
||||
import { useAppContext } from '@/contexts/app-context';
|
||||
import { useSubmitTask } from '@/hooks/use-api';
|
||||
import { emit, EVENTS } from '@/utils/event-bus';
|
||||
import type { TaskInfoItemResponseBody } from '@/services/modules';
|
||||
import type { ModelsListResponseBody, TaskInfoItemResponseBody } from '@/services/modules';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
const { TextArea } = Input;
|
||||
@@ -39,7 +39,10 @@ const { Dragger } = Upload;
|
||||
|
||||
/** 从 ui_config 中解析的参数描述 */
|
||||
interface ParamDescriptor {
|
||||
/** React key(item.id,保证唯一) */
|
||||
key: string;
|
||||
/** API 字段名(item.map_to) */
|
||||
name: string;
|
||||
label: string;
|
||||
/** 控件类型 */
|
||||
controlType: 'text' | 'number' | 'textarea' | 'select' | 'slider' | 'switch' | 'image-upload';
|
||||
@@ -90,21 +93,26 @@ function inferControlType(config: Record<string, unknown>): ParamDescriptor['con
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 param_schema + ui_config 构建参数描述符列表 */
|
||||
/**
|
||||
* 从 param_schema 构建参数描述符列表
|
||||
*
|
||||
* 后端 param_schema 格式为 ParamSchemaItem[](对象数组),
|
||||
* 每项的 ui 字段内嵌了控件类型、标签、校验规则等 UI 配置。
|
||||
*/
|
||||
function buildParamDescriptors(
|
||||
paramSchema: string[],
|
||||
uiConfig: Record<string, unknown>,
|
||||
paramSchema: ModelsListResponseBody['param_schema'],
|
||||
): ParamDescriptor[] {
|
||||
return paramSchema.map((key) => {
|
||||
const config = (uiConfig[key] || {}) as Record<string, unknown>;
|
||||
return paramSchema.map((item) => {
|
||||
const config = (item.ui || {}) as Record<string, unknown>;
|
||||
const controlType = inferControlType(config);
|
||||
return {
|
||||
key,
|
||||
label: (config.label as string) || key,
|
||||
key: item.id,
|
||||
name: item.map_to,
|
||||
label: (config.label as string) || item.map_to,
|
||||
controlType,
|
||||
defaultValue: config.default ?? config.defaultValue,
|
||||
required: (config.required as boolean) || false,
|
||||
placeholder: (config.placeholder as string) || `请输入${config.label || key}`,
|
||||
placeholder: (config.placeholder as string) || `请输入${config.label || item.map_to}`,
|
||||
min: config.min as number | undefined,
|
||||
max: config.max as number | undefined,
|
||||
step: config.step as number | undefined,
|
||||
@@ -145,10 +153,7 @@ export function ModelInputForm() {
|
||||
// 参数描述符
|
||||
const paramDescriptors = useMemo(() => {
|
||||
if (!selectedModel) return [];
|
||||
return buildParamDescriptors(
|
||||
selectedModel.param_schema || [],
|
||||
selectedModel.ui_config || {},
|
||||
);
|
||||
return buildParamDescriptors(selectedModel.param_schema || []);
|
||||
}, [selectedModel]);
|
||||
|
||||
// 提交
|
||||
@@ -419,7 +424,7 @@ export function ModelInputForm() {
|
||||
{paramDescriptors.map((desc) => (
|
||||
<Form.Item
|
||||
key={desc.key}
|
||||
name={desc.key}
|
||||
name={desc.name}
|
||||
label={desc.label}
|
||||
rules={desc.required ? [{ required: true, message: `请输入${desc.label}` }] : undefined}
|
||||
initialValue={desc.defaultValue}
|
||||
|
||||
@@ -1,122 +1,235 @@
|
||||
// ============================================================
|
||||
// ModelSelector — 模型选择器(垂直单选列表,按类别分组)
|
||||
// ModelSelector — 模型选择器(Tabs 标签页分组 + 平铺列表)
|
||||
// 使用 useModelList() Hook 获取数据,组件仅负责渲染
|
||||
// ============================================================
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Menu, Spin, Empty, Tag, Typography, theme as antTheme } from 'antd';
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { Tabs, Spin, Empty, Tag, Typography, Button, Result, message, theme as antTheme } from 'antd';
|
||||
import { AppstoreOutlined } from '@ant-design/icons';
|
||||
|
||||
import { useModelList, MODEL_CATEGORY_LABELS } from '@/hooks/use-api';
|
||||
import { useModelList } from '@/hooks/use-api';
|
||||
import { useHomeContext } from '@/contexts/home-context';
|
||||
import type { ModelCategory } from '@/services/modules';
|
||||
import { resolveCategoryLabel, MODEL_CATEGORIES } from '@/services/modules';
|
||||
import type { ModelsListResponseBody, ModelCategoryStringEnum } from '@/services/modules';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// ---------- 组件 ----------
|
||||
/** "全部模型" 的虚拟 tab key */
|
||||
const ALL_TAB_KEY = '__all__';
|
||||
|
||||
// ---------- 单模型列表项(平铺) ----------
|
||||
|
||||
/** 单个模型行:hover 高亮 + 选中态 + 状态标签 + 禁用态处理 */
|
||||
interface ModelItemProps {
|
||||
model: ModelsListResponseBody;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
function ModelItem({ model, isSelected, onSelect }: ModelItemProps) {
|
||||
const { token } = antTheme.useToken();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
// 维护中的模型不可选
|
||||
const isDisabled = model.status === 'maintenance';
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (isDisabled) {
|
||||
message.warning(`${model.name} 正在维护中,暂不可用`);
|
||||
return;
|
||||
}
|
||||
onSelect();
|
||||
}, [isDisabled, model.name, onSelect]);
|
||||
|
||||
// 背景色 / 边框色 优先级:选中 > hover > 默认
|
||||
let bgColor = 'transparent';
|
||||
let borderColor = 'transparent';
|
||||
if (isSelected) {
|
||||
bgColor = token.colorPrimaryBg;
|
||||
borderColor = token.colorPrimaryBorder;
|
||||
} else if (isHovered && !isDisabled) {
|
||||
bgColor = token.colorFillSecondary;
|
||||
borderColor = token.colorBorderSecondary;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||
borderRadius: 6,
|
||||
background: bgColor,
|
||||
border: `1px solid ${borderColor}`,
|
||||
opacity: isDisabled ? 0.5 : 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 8,
|
||||
transition: 'background 0.15s linear, border-color 0.15s linear, opacity 0.15s linear',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: 13,
|
||||
color: isSelected ? token.colorPrimaryText : token.colorText,
|
||||
}}
|
||||
title={model.name}
|
||||
>
|
||||
{model.name}
|
||||
</Text>
|
||||
{model.status !== 'active' && (
|
||||
<Tag
|
||||
color={model.status === 'inactive' ? 'default' : 'orange'}
|
||||
style={{ fontSize: 10, lineHeight: '16px', padding: '0 4px' }}
|
||||
>
|
||||
{model.status === 'maintenance' ? '维护' : '停用'}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 主组件 ----------
|
||||
|
||||
export function ModelSelector() {
|
||||
const { token } = antTheme.useToken();
|
||||
const { selectedModel, setSelectedModel } = useHomeContext();
|
||||
const { data: models, loading, groupedModels } = useModelList();
|
||||
const { token } = antTheme.useToken();
|
||||
const { selectedModel, setSelectedModel } = useHomeContext();
|
||||
const { data: models, loading, error, groupedModels, refetch } = useModelList();
|
||||
const [activeTab, setActiveTab] = useState(ALL_TAB_KEY);
|
||||
|
||||
// 构建 Menu items:分组标题 + 模型项
|
||||
const menuItems = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const items: any[] = [];
|
||||
// 当前 Tab 对应的模型列表
|
||||
const filteredModels:ModelsListResponseBody[] = useMemo(() => {
|
||||
if (activeTab === ALL_TAB_KEY) return models ?? [];
|
||||
return groupedModels.get(activeTab) ?? [];
|
||||
}, [activeTab, models, groupedModels]);
|
||||
|
||||
groupedModels.forEach((groupModels, category) => {
|
||||
const categoryLabel = MODEL_CATEGORY_LABELS[category as ModelCategory] || category;
|
||||
items.push({
|
||||
key: `group-${category}`,
|
||||
label: (
|
||||
<Text strong style={{ fontSize: 12, color: token.colorTextSecondary }}>
|
||||
{categoryLabel} ({groupModels.length})
|
||||
</Text>
|
||||
),
|
||||
type: 'group' as const,
|
||||
children: groupModels.map((model) => ({
|
||||
key: model.id,
|
||||
label: (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<Text
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: 13,
|
||||
}}
|
||||
title={model.name}
|
||||
>
|
||||
{model.name}
|
||||
</Text>
|
||||
{model.status !== 'active' && (
|
||||
<Tag
|
||||
color={model.status === 'inactive' ? 'default' : 'orange'}
|
||||
style={{ fontSize: 10, lineHeight: '16px', padding: '0 4px' }}
|
||||
>
|
||||
{model.status === 'maintenance' ? '维护' : '停用'}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
})),
|
||||
});
|
||||
});
|
||||
// 构建 Tab 项(全部 + 各分类,顺序由 MODEL_CATEGORIES 控制)
|
||||
const tabItems: { key: string; label: React.ReactNode }[] = useMemo(() => {
|
||||
const items: { key: string; label: React.ReactNode }[] = [
|
||||
{
|
||||
key: ALL_TAB_KEY,
|
||||
label: `全部 (${models?.length ?? 0})`,
|
||||
},
|
||||
];
|
||||
|
||||
return items;
|
||||
}, [groupedModels, token.colorTextSecondary]);
|
||||
// 按 MODEL_CATEGORIES 定义的顺序遍历,确保 Tab 顺序可控
|
||||
const seenCategories = new Set<string>();
|
||||
MODEL_CATEGORIES.forEach((cat: ModelCategoryStringEnum) => {
|
||||
const groupModels = groupedModels.get(cat);
|
||||
if (groupModels && groupModels.length > 0) {
|
||||
seenCategories.add(cat);
|
||||
items.push({
|
||||
key: cat,
|
||||
label: `${resolveCategoryLabel(cat)} (${groupModels.length})`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 选中菜单项
|
||||
const handleSelect = ({ key }: { key: string }) => {
|
||||
const model = models?.find((m) => m.id === key);
|
||||
if (model) {
|
||||
setSelectedModel(model);
|
||||
}
|
||||
};
|
||||
// 兜底:API 返回了 MODEL_CATEGORIES 中未注册的新分类,追加到末尾
|
||||
groupedModels.forEach((groupModels, category) => {
|
||||
if (!seenCategories.has(category) && groupModels.length > 0) {
|
||||
items.push({
|
||||
key: category,
|
||||
label: `${resolveCategoryLabel(category)} (${groupModels.length})`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 加载态
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return items;
|
||||
}, [models, groupedModels]);
|
||||
|
||||
// 空态
|
||||
if (!models || models.length === 0) {
|
||||
return <Empty description="暂无可用模型" image={Empty.PRESENTED_IMAGE_SIMPLE} />;
|
||||
}
|
||||
// ---------- 加载态 ----------
|
||||
|
||||
// 选中 key
|
||||
const selectedKeys = selectedModel ? [selectedModel.id] : [];
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<AppstoreOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />
|
||||
<Text strong style={{ fontSize: 13 }}>选择模型</Text>
|
||||
</div>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={selectedKeys}
|
||||
onSelect={handleSelect}
|
||||
items={menuItems}
|
||||
style={{
|
||||
borderInlineEnd: 'none',
|
||||
background: 'transparent',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
// ---------- 错误态 ----------
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Result
|
||||
status="error"
|
||||
title="模型列表加载失败"
|
||||
subTitle={error.message || '请检查网络连接后重试'}
|
||||
extra={
|
||||
<Button type="primary" size="small" onClick={refetch}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 空态 ----------
|
||||
|
||||
if (!models || models.length === 0) {
|
||||
return <Empty description="暂无可用模型" image={Empty.PRESENTED_IMAGE_SIMPLE} />;
|
||||
}
|
||||
|
||||
// ---------- 正常渲染 ----------
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
{/* 标题栏 */}
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<AppstoreOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />
|
||||
<Text strong style={{ fontSize: 13 }}>
|
||||
选择模型
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* 分类 Tabs */}
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={tabItems}
|
||||
size="small"
|
||||
type="card"
|
||||
tabBarStyle={{ margin: '0 0 4px 0', padding: '0 8px' }}
|
||||
/>
|
||||
|
||||
{/* 模型列表 */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '0 8px 8px' }}>
|
||||
{filteredModels.length === 0 ? (
|
||||
<Empty
|
||||
description="该分类暂无模型"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
style={{ marginTop: 24 }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{filteredModels.map((model) => (
|
||||
<ModelItem
|
||||
key={model.id}
|
||||
model={model}
|
||||
isSelected={selectedModel?.id === model.id}
|
||||
onSelect={() => setSelectedModel(model)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -204,7 +204,7 @@ export function TaskHistory() {
|
||||
|
||||
// 同步 context 分页 ↔ tableParams
|
||||
useEffect(() => {
|
||||
setTableParams((prev) => ({
|
||||
setTableParams((prev:TableParams) => ({
|
||||
...prev,
|
||||
pagination: { current: taskPage, pageSize: taskPageSize },
|
||||
}));
|
||||
@@ -212,9 +212,9 @@ export function TaskHistory() {
|
||||
|
||||
// -------- 模型列表(用于模型列筛选选项 + 模型名→ID 映射)--------
|
||||
|
||||
// TODO: 与 ModelSelector 中的 useModelList 重复请求,后续统一缓存层
|
||||
|
||||
const { data: allModels } = useModelList();
|
||||
const modelFilters = useMemo(() => {
|
||||
const modelFilters:{text:string, value:string}[] = useMemo(() => {
|
||||
if (!allModels) return [];
|
||||
return allModels.map((m) => ({ text: m.name, value: m.id }));
|
||||
}, [allModels]);
|
||||
|
||||
@@ -5,14 +5,17 @@
|
||||
// - 通过 open/onClose props 控制显隐,不使用路由(避免主页面被卸载)
|
||||
// - Modal 居中弹出 + 遮罩层,首页内容始终保持挂载不动
|
||||
// - destroyOnHidden → 关闭即销毁 DOM,不堆内存
|
||||
// - 仅可通过标题栏 X 按钮关闭(mask 不可关闭、ESC 禁用)
|
||||
// - Esc 键可关闭(跨平台统一)
|
||||
// - macOS:关闭按钮移至标题左侧(遵循 macOS HIG)
|
||||
//
|
||||
// 对应 QT 原版:设置窗口为独立窗口叠加在主窗口之上,主窗口不销毁
|
||||
// ============================================================
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { Modal } from 'antd';
|
||||
import { SettingOutlined } from '@ant-design/icons';
|
||||
import { SettingOutlined, CloseOutlined } from '@ant-design/icons';
|
||||
import { useAppContext } from '@/contexts/app-context';
|
||||
import { isMacOS } from '@/utils/platform';
|
||||
import { ThemeSetting } from './blocks/ThemeSetting';
|
||||
import { StoragePathSetting } from './blocks/StoragePathSetting';
|
||||
import { SystemInfoSetting } from './blocks/SystemInfoSetting';
|
||||
@@ -26,18 +29,31 @@ interface SettingsPageProps {
|
||||
|
||||
export function SettingsPage({ open, onClose }: SettingsPageProps) {
|
||||
const { edition } = useAppContext();
|
||||
const isMac = isMacOS();
|
||||
|
||||
// 关闭后移除焦点,避免跑到导航栏设置按钮上
|
||||
const handleAfterClose = useCallback(() => {
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
width={480}
|
||||
onCancel={onClose}
|
||||
afterClose={handleAfterClose}
|
||||
destroyOnHidden={true}
|
||||
mask={{ closable: false }}
|
||||
keyboard={false}
|
||||
closable={!isMac}
|
||||
footer={null}
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
{isMac && (
|
||||
<CloseOutlined
|
||||
onClick={onClose}
|
||||
className="cursor-pointer text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
/>
|
||||
)}
|
||||
<SettingOutlined />
|
||||
设置
|
||||
</span>
|
||||
|
||||
@@ -31,12 +31,17 @@ export {
|
||||
|
||||
export {
|
||||
ModelAPI,
|
||||
MODEL_CATEGORIES,
|
||||
MODEL_CATEGORY_LABELS,
|
||||
CATEGORY_SLUG_MAP,
|
||||
resolveCategoryLabel,
|
||||
toCategorySlug,
|
||||
type ModelsListReqeustParams,
|
||||
type ModelsListResponseBody,
|
||||
type ModelInfoRequestParams,
|
||||
type ModelInfoResponseBody,
|
||||
type ModelCategory,
|
||||
type ModelCategoryStringEnum,
|
||||
type ParamSchemaItem,
|
||||
} from './models';
|
||||
|
||||
export {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {get} from '../request';
|
||||
// 请注意后端没有关于字段category的查询
|
||||
// 注意:后端 /api/v1/models 的 category 查询参数实际不可用(传参返回空列表),
|
||||
// 因此客户端采用"全量拉取 + 按 category_name 字段分组"的策略。
|
||||
// 后端分类 slug 格式为 img2img / txt2img 等,前端通过 CATEGORY_SLUG_MAP 映射。
|
||||
// ---------- 路由常量 ----------
|
||||
|
||||
const ModelsUrlObj = {
|
||||
@@ -10,23 +12,93 @@ const ModelsUrlObj = {
|
||||
|
||||
// ---------- 类型 ----------
|
||||
|
||||
/** 模型类别 */
|
||||
export type ModelCategory = 'image_to_image' | 'text_to_image' | 'image_to_video' | 'text_to_audio';
|
||||
/** 模型类别常量数组(单一数据源,类型由此推导) */
|
||||
export const MODEL_CATEGORIES = [
|
||||
'image_to_image',
|
||||
'text_to_image',
|
||||
'image_to_video',
|
||||
'text_to_audio',
|
||||
] as const;
|
||||
|
||||
export type ModelCategoryStringEnum = typeof MODEL_CATEGORIES[number];
|
||||
|
||||
/**
|
||||
* 模型参数 Schema 项
|
||||
*
|
||||
* 后端 /api/v1/models 返回的 param_schema 为对象数组,
|
||||
* 每项描述一个模型入参的元信息(标识、字段映射、规格约束、UI 配置)。
|
||||
*/
|
||||
export interface ParamSchemaItem {
|
||||
id: string;
|
||||
/** 映射到提交参数中的字段名 */
|
||||
map_to: string;
|
||||
/** 规格约束(类型、范围、默认值等) */
|
||||
spec: Record<string, unknown>;
|
||||
/** UI 控件配置 */
|
||||
ui: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 模型类别中文映射 */
|
||||
export const MODEL_CATEGORY_LABELS: Record<ModelCategory, string> = {
|
||||
export const MODEL_CATEGORY_LABELS: Record<ModelCategoryStringEnum, string> = {
|
||||
image_to_image: '图生图',
|
||||
text_to_image: '文生图',
|
||||
image_to_video: '图生视频',
|
||||
text_to_audio: '文生音频',
|
||||
};
|
||||
|
||||
/**
|
||||
* 前端 ModelCategory → 后端分类 slug 映射
|
||||
*
|
||||
* 后端分类 API 使用简写 slug(如 img2img),而非前端的长标识符。
|
||||
* 查询模型列表时需将前端类别转为后端 slug 作为 category 参数。
|
||||
*/
|
||||
export const CATEGORY_SLUG_MAP: Record<ModelCategoryStringEnum, string> = {
|
||||
image_to_image: 'img2img',
|
||||
text_to_image: 'txt2img',
|
||||
image_to_video: 'img2video',
|
||||
text_to_audio: 'txt2audio',
|
||||
};
|
||||
|
||||
/** 后端 slug → 前端 ModelCategory 反向映射(由 CATEGORY_SLUG_MAP 自动推导) */
|
||||
const SLUG_TO_CATEGORY: Record<string, ModelCategoryStringEnum> = Object.fromEntries(
|
||||
Object.entries(CATEGORY_SLUG_MAP).map(([cat, slug]) => [slug, cat as ModelCategoryStringEnum]),
|
||||
) as Record<string, ModelCategoryStringEnum>;
|
||||
|
||||
/**
|
||||
* 根据任意 category_name 值解析出最佳中文标签
|
||||
*
|
||||
* 解析优先级:
|
||||
* 1. 匹配 MODEL_CATEGORY_LABELS(前端 ModelCategory 字面量)
|
||||
* 2. 匹配 SLUG_TO_CATEGORY 反向映射(后端 slug → 中英文标签)
|
||||
* 3. 兜底返回原始字符串
|
||||
*/
|
||||
export function resolveCategoryLabel(categoryName: string): string {
|
||||
// 优先按 ModelCategory 字面量查找
|
||||
if (categoryName in MODEL_CATEGORY_LABELS) {
|
||||
return MODEL_CATEGORY_LABELS[categoryName as ModelCategoryStringEnum];
|
||||
}
|
||||
// 按后端 slug 反向查找
|
||||
const cat = SLUG_TO_CATEGORY[categoryName];
|
||||
if (cat) {
|
||||
return MODEL_CATEGORY_LABELS[cat];
|
||||
}
|
||||
// 兜底返回原始值
|
||||
return categoryName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将前端 ModelCategory 转为后端 API 用的分类 slug
|
||||
*/
|
||||
export function toCategorySlug(category: ModelCategoryStringEnum): string {
|
||||
return CATEGORY_SLUG_MAP[category];
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询可用模型的请求参数
|
||||
*/
|
||||
export interface ModelsListReqeustParams {
|
||||
provider_name?: string;
|
||||
category?: ModelCategory;
|
||||
category?: ModelCategoryStringEnum;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
@@ -38,14 +110,14 @@ export interface ModelsListResponseBody {
|
||||
id: string;
|
||||
name: string;
|
||||
provider_name: string;
|
||||
model_type: string;
|
||||
category_name: string;
|
||||
model_type: ModelCategoryStringEnum;
|
||||
category_name: ModelCategoryStringEnum | null;
|
||||
status: string;
|
||||
priority: number;
|
||||
response_mode: string;
|
||||
pricing_config: Record<string, unknown>;
|
||||
param_schema: string[];
|
||||
quota_config: Record<string, unknown>;
|
||||
param_schema: ParamSchemaItem[];
|
||||
quota_config: Record<string, unknown> | null;
|
||||
ui_config: Record<string, unknown>;
|
||||
meta_config: Record<string, unknown>;
|
||||
constraints_config: Record<string, unknown>;
|
||||
|
||||
@@ -168,8 +168,125 @@ body {
|
||||
background-color: var(--color-bg-base);
|
||||
min-height: 100vh;
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
color 0.3s ease;
|
||||
background-color 0.15s linear,
|
||||
color 0.15s linear;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* View Transitions API — GPU 合成器驱动的主题切换动画
|
||||
* 替代 80+ 个独立 CSS 过渡,单次 cross-fade 消除 Electron 卡顿。
|
||||
* 仅在 Chromium 111+(Electron 28+)生效,其他浏览器回退 CSS 过渡。
|
||||
* ============================================================ */
|
||||
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation-duration: 0.15s;
|
||||
animation-timing-function: linear;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 主题切换过渡 — 精准覆盖大面积视觉容器,避免 * 全局选择器
|
||||
* 在 Electron 中为数千个 DOM 节点同时启动过渡造成卡顿。
|
||||
*
|
||||
* 策略:
|
||||
* 1. 容器/布局组件(Card / Modal / Layout / Drawer 等)
|
||||
* 2. 表格 & 列表(Table / List / Timeline)
|
||||
* 3. 表单控件(Input / Select / Picker)
|
||||
* 4. 导航 & 标签(Menu / Tabs / Tag / Breadcrumb)
|
||||
* 5. 反馈 & 展示(Alert / Empty / Result / Statistic / Skeleton)
|
||||
*
|
||||
* 不覆盖交互组件自有 transition(Button / Switch / Slider 等
|
||||
* 均使用 transition: all,特异性更高不受影响)。
|
||||
* ============================================================ */
|
||||
|
||||
/* —— 布局 & 容器 —— */
|
||||
body,
|
||||
.ant-layout,
|
||||
.ant-layout-header,
|
||||
.ant-layout-sider,
|
||||
.ant-layout-content,
|
||||
.ant-card,
|
||||
.ant-card-head,
|
||||
.ant-card-body,
|
||||
.ant-card-actions,
|
||||
.ant-modal-content,
|
||||
.ant-modal-header,
|
||||
.ant-modal-body,
|
||||
.ant-modal-footer,
|
||||
.ant-drawer-content,
|
||||
.ant-drawer-header,
|
||||
.ant-drawer-body,
|
||||
.ant-drawer-footer,
|
||||
|
||||
/* —— 表格 & 列表 —— */
|
||||
.ant-table,
|
||||
.ant-table-thead > tr > th,
|
||||
.ant-table-tbody > tr > td,
|
||||
.ant-table-tbody > tr:hover > td,
|
||||
.ant-list,
|
||||
.ant-list-item,
|
||||
.ant-timeline-item,
|
||||
.ant-transfer-list,
|
||||
.ant-tree-node-content-wrapper,
|
||||
|
||||
/* —— 表单控件 —— */
|
||||
.ant-input,
|
||||
.ant-input-affix-wrapper,
|
||||
.ant-select-selector,
|
||||
.ant-select-dropdown,
|
||||
.ant-picker,
|
||||
.ant-picker-input,
|
||||
.ant-picker-dropdown,
|
||||
.ant-picker-panel-container,
|
||||
.ant-input-number,
|
||||
.ant-input-number-input,
|
||||
.ant-radio-button-wrapper,
|
||||
.ant-radio-group,
|
||||
.ant-checkbox-wrapper,
|
||||
.ant-segmented,
|
||||
.ant-segmented-item,
|
||||
.ant-upload-drag,
|
||||
.ant-upload-list-item,
|
||||
|
||||
/* —— 导航 & 标签 —— */
|
||||
.ant-menu,
|
||||
.ant-menu-item,
|
||||
.ant-menu-submenu-title,
|
||||
.ant-tabs-nav,
|
||||
.ant-tabs-nav-list,
|
||||
.ant-tabs-tab,
|
||||
.ant-tabs-content,
|
||||
.ant-tag,
|
||||
.ant-breadcrumb,
|
||||
.ant-pagination-item,
|
||||
.ant-dropdown-menu,
|
||||
|
||||
/* —— 反馈 & 展示 —— */
|
||||
.ant-alert,
|
||||
.ant-empty,
|
||||
.ant-result,
|
||||
.ant-statistic,
|
||||
.ant-statistic-content,
|
||||
.ant-badge,
|
||||
.ant-avatar,
|
||||
.ant-divider,
|
||||
.ant-skeleton,
|
||||
.ant-skeleton-input,
|
||||
.ant-skeleton-button,
|
||||
.ant-notification-notice,
|
||||
.ant-message-notice-content,
|
||||
.ant-popover-inner,
|
||||
.ant-tooltip-inner,
|
||||
.ant-collapse,
|
||||
.ant-collapse-item,
|
||||
.ant-collapse-header,
|
||||
.ant-collapse-content-box,
|
||||
.ant-descriptions-item-container {
|
||||
transition:
|
||||
color 0.15s linear,
|
||||
background-color 0.15s linear,
|
||||
border-color 0.15s linear,
|
||||
box-shadow 0.15s linear;
|
||||
}
|
||||
|
||||
#root {
|
||||
|
||||
Reference in New Issue
Block a user