From 1da95eaef35514ddb7ff77b9717174f0e0d4ba4f Mon Sep 17 00:00:00 2001 From: YoungestSongMo <2130460579@qq.com> Date: Fri, 5 Jun 2026 16:24:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=EF=BC=88=E4=B8=89=E6=A0=8F=E5=B8=83=E5=B1=80?= =?UTF-8?q?=20+=20=E8=BD=AE=E6=92=AD=E5=9B=BE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 首页整体架构 - 新增 src/pages/home/ — 完整首页模块(10 个组件) - HomePage / HomeContent:三栏弹性布局,拖动分隔条调整列宽 - BannerCarousel:顶部轮播图(antd Carousel,2280×90 长条图片适配) - LeftPanel:模型选择器(Menu 分组) + 任务记录(Table 分页),纵向拖拽调整比例 - CenterPanel / ModelInputForm:动态表单,根据模型 param_schema/ui_config 渲染控件 - RightPanel / OutputPreview:任务输出预览(Image/Video) - 新增 Layout 组件:h-screen flex 布局,首页 Banner + NavBar + Outlet ## 数据层(数据与视图分离) - 新增 src/services/modules/ — API 模块(banner/models/task/auth) - TaskAPI 静态方法,TaskInfoItemResponseBody 判别联合类型 - ModelAPI / BannerAPI,const 断言路由常量 - 新增 src/hooks/use-async.ts — 通用异步 Hook - useAsyncData:竞态处理、条件请求(enabled)、自动错误日志 - useAsyncMutation:数据变更,onSuccess/onError - getErrorMessage():RequestErrorType → 可读错误信息 - 新增 src/hooks/use-api.ts — 业务 Hook - useBannerList / useModelList / useTaskList / useTaskDetail / useSubmitTask ## 主题与交互 - 重写暗色令牌:科技感蓝紫(#080C24 底 / #0F1340 面板 / #2A3278 边框) - 三栏分隔线:token.colorBorderSecondary 常驻 + 手柄竖线 hover 高亮 - 左面板纵向拖拽:模型区/任务区可调比例,ResizeObserver 自适应 - 暗色/亮色同步:tokens.ts + antd-theme.ts + globals.css 三处一致 ## 基础设施 - EventBus 新增 TASK_SUBMITTED 事件(替代 Context 数字递增 hack) - HomeContext 状态管理(选中模型、任务分页、折叠状态) - 所有组件边框使用 token.colorBorderSecondary(主题自适应) - Table 滚动高度 ResizeObserver 动态计算(修复分页器遮挡) --- .codegraph/daemon.pid | 4 +- .env.development | 3 +- package-lock.json | 14 +- package.json | 3 +- src/components/Layout.tsx | 11 +- src/components/navbar/NavBar.tsx | 2 +- src/contexts/home-context.ts | 43 +++ src/hooks/use-api.ts | 164 +++++++++++ src/hooks/use-async.ts | 254 ++++++++++++++++ src/main.tsx | 62 ++-- src/pages/home/BannerCarousel.tsx | 74 +++++ src/pages/home/CenterPanel.tsx | 23 ++ src/pages/home/HomeContent.tsx | 261 +++++++++++++++++ src/pages/home/HomePage.tsx | 273 +----------------- src/pages/home/LeftPanel.tsx | 177 ++++++++++++ src/pages/home/ModelInputForm.tsx | 463 ++++++++++++++++++++++++++++++ src/pages/home/ModelSelector.tsx | 122 ++++++++ src/pages/home/OutputPreview.tsx | 165 +++++++++++ src/pages/home/RightPanel.tsx | 23 ++ src/pages/home/TaskHistory.tsx | 250 ++++++++++++++++ src/services/modules/auth.ts | 32 +-- src/services/modules/banner.ts | 43 +++ src/services/modules/index.ts | 64 ++++- src/services/modules/models.ts | 87 ++++++ src/services/modules/task.ts | 232 +++++++++++++++ src/theme/antd-theme.ts | 14 +- src/theme/globals.css | 20 +- src/theme/tokens.ts | 60 ++-- src/utils/event-bus.ts | 2 + tsconfig.node.tsbuildinfo | 1 - vite.config.ts | 163 ++++++----- 31 files changed, 2639 insertions(+), 470 deletions(-) create mode 100644 src/contexts/home-context.ts create mode 100644 src/hooks/use-api.ts create mode 100644 src/hooks/use-async.ts create mode 100644 src/pages/home/BannerCarousel.tsx create mode 100644 src/pages/home/CenterPanel.tsx create mode 100644 src/pages/home/HomeContent.tsx create mode 100644 src/pages/home/LeftPanel.tsx create mode 100644 src/pages/home/ModelInputForm.tsx create mode 100644 src/pages/home/ModelSelector.tsx create mode 100644 src/pages/home/OutputPreview.tsx create mode 100644 src/pages/home/RightPanel.tsx create mode 100644 src/pages/home/TaskHistory.tsx create mode 100644 src/services/modules/banner.ts create mode 100644 src/services/modules/models.ts create mode 100644 src/services/modules/task.ts delete mode 100644 tsconfig.node.tsbuildinfo diff --git a/.codegraph/daemon.pid b/.codegraph/daemon.pid index affbb7a..e909482 100644 --- a/.codegraph/daemon.pid +++ b/.codegraph/daemon.pid @@ -1,6 +1,6 @@ { - "pid": 91784, + "pid": 145380, "version": "0.9.9", "socketPath": "\\\\.\\pipe\\codegraph-aef859f0205bacea", - "startedAt": 1780539931451 + "startedAt": 1780622650728 } diff --git a/.env.development b/.env.development index 4cb6d19..11b8f6c 100644 --- a/.env.development +++ b/.env.development @@ -3,7 +3,8 @@ # ============================================================ # 渲染进程 API 地址(Vite 编译时注入,不可在主进程中使用) -VITE_API_BASE_URL=http://8.160.179.64:8000 +# VITE_API_BASE_URL=http://8.160.179.64:8000 +VITE_API_BASE_URL=https://www.heixiu.net # 主进程 — 更新检查 API(指向本地测试服务器) UPDATE_API_URL=http://127.0.0.1:8000 diff --git a/package-lock.json b/package-lock.json index 3805c20..000d706 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,14 @@ { "name": "ele-heixiu", - "version": "0.0.1", + "version": "0.0.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ele-heixiu", - "version": "0.0.1", + "version": "0.0.12", "dependencies": { + "@react-buddy/ide-toolbox": "^2.5.0", "antd": "^6.4.3", "axios": "^1.16.1", "electron-updater": "^6.8.3", @@ -1949,6 +1950,15 @@ "react-dom": ">=18.0.0" } }, + "node_modules/@react-buddy/ide-toolbox": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@react-buddy/ide-toolbox/-/ide-toolbox-2.5.0.tgz", + "integrity": "sha512-ev2BZUeCymJfKuV3M+rQzdVZYlAacid8GexD3TtP0S+UBCBbH6ZCdkbx2eJoh/X+CEWFctg+2ppxIoaK52A7Rw==", + "license": "Apache-2.0", + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", diff --git a/package.json b/package.json index 40168c8..216dfd6 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "electron-updater": "^6.8.3", "react": "^19.2.7", "react-dom": "^19.2.7", - "react-router-dom": "^7.16.0" + "react-router-dom": "^7.16.0", + "@react-buddy/ide-toolbox": "^2.5.0" }, "build": { "appId": "com.heixiu.electron", diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index e3417f1..02ace83 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,19 +1,24 @@ // ============================================================ -// Layout — 页面布局壳(NavBar + 页面内容 Outlet) +// Layout — 页面布局壳(Banner轮播 + NavBar + 页面内容 Outlet) +// BannerCarousel 仅在首页显示(路由为 "/" 或 "") // ============================================================ -import { Outlet } from 'react-router-dom'; +import { useLocation, Outlet } from 'react-router-dom'; import { theme as antTheme } from 'antd'; import { NavBar } from './navbar'; +import { BannerCarousel } from '@/pages/home/BannerCarousel'; export function Layout() { const { token } = antTheme.useToken(); + const location = useLocation(); + const isHomePage = location.pathname === '/' || location.pathname === ''; return (
+ {isHomePage && }
diff --git a/src/components/navbar/NavBar.tsx b/src/components/navbar/NavBar.tsx index ce570cb..49172df 100644 --- a/src/components/navbar/NavBar.tsx +++ b/src/components/navbar/NavBar.tsx @@ -78,7 +78,7 @@ export function NavBar() { case 'button': if (item.key === 'logout') { - logout(); + logout(); } break; diff --git a/src/contexts/home-context.ts b/src/contexts/home-context.ts new file mode 100644 index 0000000..02b466d --- /dev/null +++ b/src/contexts/home-context.ts @@ -0,0 +1,43 @@ +// ============================================================ +// HomeContext — 首页状态管理 +// 管理:选中模型、任务分页、选中任务记录、刷新信号 +// ============================================================ + +import { createContext, useContext } from 'react'; +import type { ModelsListResponseBody } from '@/services/modules/models'; +import type { TaskInfoItemResponseBody } from '@/services/modules/task'; + +// ---------- Context 类型 ---------- + +export interface HomeContextValue { + /** 当前选中的模型 */ + selectedModel: ModelsListResponseBody | null; + setSelectedModel: (model: ModelsListResponseBody | null) => void; + + /** 任务记录分页 — 当前页码 */ + taskPage: number; + /** 任务记录分页 — 每页条数 */ + taskPageSize: number; + setTaskPage: (page: number) => void; + setTaskPageSize: (size: number) => void; + + /** 当前选中的任务记录(用于右侧预览) */ + selectedTask: TaskInfoItemResponseBody | null; + setSelectedTask: (task: TaskInfoItemResponseBody | null) => void; + + /** 侧边栏折叠状态 */ + isLeftCollapsed: boolean; + toggleLeftCollapsed: () => void; +} + +export const HomeContext = createContext(null); + +// ---------- Consumer Hook ---------- + +export function useHomeContext(): HomeContextValue { + const ctx = useContext(HomeContext); + if (!ctx) { + throw new Error('useHomeContext() 必须在 内部调用'); + } + return ctx; +} diff --git a/src/hooks/use-api.ts b/src/hooks/use-api.ts new file mode 100644 index 0000000..5db318c --- /dev/null +++ b/src/hooks/use-api.ts @@ -0,0 +1,164 @@ +// ============================================================ +// use-api.ts — 业务 API Hook 集合 +// +// 基于 useAsyncData / useAsyncMutation 封装各模块 API, +// 组件只需调用 Hook 获取 { data, loading, error } 三元组, +// 不再需要在组件内手写 useEffect + cancelled flag + useState。 +// +// 每个 Hook 自动处理: +// - 登录状态门控(enabled: isLoggedIn) +// - 竞态条件 +// - 错误日志 +// - 用户可读错误信息(errorMessage) +// ============================================================ + +import { useMemo } from 'react'; +import { useAppContext } from '@/contexts/app-context'; +import { + TaskAPI, + ModelAPI, + fetchBanners, + MODEL_CATEGORY_LABELS, + type TaskListRequestParams, + type TaskInfoItemResponseBody, + type CreatTaskRequestBody, + type ModelsListResponseBody, + type Banner, + type ModelCategory, +} from '@/services/modules'; +import { useAsyncData, useAsyncMutation } from './use-async'; + +// ============================================================ +// Banner +// ============================================================ + +/** 首页轮播图数据 Hook */ +export function useBannerList() { + const { isLoggedIn } = useAppContext(); + + return useAsyncData( + fetchBanners, + [], + { enabled: isLoggedIn, label: 'banner-list' }, + ); +} + +// ============================================================ +// 模型 +// ============================================================ + +/** 模型列表数据 Hook(按类别分别请求后合并) */ +export function useModelList() { + const { isLoggedIn } = useAppContext(); + + // 使用单个 fetcher 返回合并后的结果 + const result = useAsyncData( + async () => { + const categories: ModelCategory[] = ['txt2img', 'img2img', 'img2video', 'txt2audio']; + 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; + }, + [], + { enabled: isLoggedIn, label: 'model-list' }, + ); + + // 按类别分组 + const groupedModels = useMemo(() => { + const groups = new Map(); + result.data?.forEach((model) => { + const cat = model.category_name || 'other'; + if (!groups.has(cat)) groups.set(cat, []); + groups.get(cat)!.push(model); + }); + return groups; + }, [result.data]); + + return { + ...result, + /** 按类别分组后的模型 */ + groupedModels, + }; +} + +// ============================================================ +// 任务列表 +// ============================================================ + +export interface UseTaskListParams { + page: number; + pageSize: number; + /** 外部传入的刷新信号(任意变化触发重新请求) */ + refreshSignal?: number; +} + +/** 任务列表 Hook(分页) */ +export function useTaskList(params: UseTaskListParams) { + const { isLoggedIn } = useAppContext(); + const { page, pageSize, refreshSignal = 0 } = params; + + return useAsyncData( + () => { + const req: TaskListRequestParams = { page, page_size: pageSize }; + return TaskAPI.getTaskList(req); + }, + [page, pageSize, refreshSignal], + { enabled: isLoggedIn, label: 'task-list' }, + ); +} + +// ============================================================ +// 任务详情 +// ============================================================ + +/** 任务详情 Hook(taskId 为 null 时不请求) */ +export function useTaskDetail(taskId: string | null | undefined) { + const { isLoggedIn } = useAppContext(); + + return useAsyncData( + () => TaskAPI.getTaskInfo(taskId!), + [taskId], + { enabled: isLoggedIn && !!taskId, label: 'task-detail' }, + ); +} + +// ============================================================ +// 提交任务 +// ============================================================ + +/** 提交任务 Mutation Hook */ +export function useSubmitTask(options?: { + onSuccess?: (data: TaskInfoItemResponseBody) => void; + onError?: (err: Error) => void; +}) { + return useAsyncMutation( + (body) => TaskAPI.submitTask(body), + { + label: 'submit-task', + onSuccess: options?.onSuccess, + onError: options?.onError, + }, + ); +} + +// ---------- 重新导出类型和工具 ---------- + +export { MODEL_CATEGORY_LABELS }; +export type { ModelCategory, ModelsListResponseBody, TaskInfoItemResponseBody, Banner }; diff --git a/src/hooks/use-async.ts b/src/hooks/use-async.ts new file mode 100644 index 0000000..bcc10c6 --- /dev/null +++ b/src/hooks/use-async.ts @@ -0,0 +1,254 @@ +// ============================================================ +// useAsyncData / useAsyncMutation — 通用异步数据 Hook +// +// 职责: +// - 自动竞态处理(仅应用最后一次请求的结果) +// - 自动错误日志记录 +// - RequestErrorType → 用户可读错误信息映射 +// - 为 use-api.ts 提供基础设施 +// +// 用法: +// const { data, loading, error, refetch } = useAsyncData( +// () => TaskAPI.getTaskList(params), +// [page, pageSize], +// { enabled: isLoggedIn }, +// ); +// +// const { execute, loading, error } = useAsyncMutation( +// (body) => TaskAPI.submitTask(body), +// { onSuccess: () => refetchTasks() }, +// ); +// ============================================================ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { RequestError, RequestErrorType } from '@/services/types'; +import { logger } from '@/utils/logger'; + +// ---------- 错误信息映射 ---------- + +/** RequestErrorType → 用户可读的默认错误信息 */ +export function getErrorMessage(err: unknown): string { + if (err instanceof RequestError) { + switch (err.type) { + case RequestErrorType.NETWORK: + return '网络连接失败,请检查网络'; + case RequestErrorType.TIMEOUT: + return '请求超时,请稍后重试'; + case RequestErrorType.HTTP: + return err.message || '服务器响应异常'; + case RequestErrorType.BUSINESS: + return err.message || '业务处理失败'; + case RequestErrorType.CANCELLED: + return '请求已取消'; + case RequestErrorType.AUTH_EXPIRED: + return '登录已过期,请重新登录'; + default: + return err.message || '未知错误'; + } + } + if (err instanceof Error) { + return err.message || '发生未知错误'; + } + return '发生未知错误'; +} + +// ---------- useAsyncData — 数据获取 ---------- + +export interface UseAsyncDataOptions { + /** 是否启用请求(false 时不触发 fetch,常用于登录后才请求) */ + enabled?: boolean; + /** 请求标识(用于日志,如 'task-list'、'models') */ + label?: string; +} + +export interface UseAsyncDataReturn { + /** 响应数据 */ + data: T | null; + /** 是否正在加载(含首次请求和后续刷新) */ + loading: boolean; + /** 错误对象(RequestError 或普通 Error) */ + error: Error | null; + /** 用户可读的错误信息 */ + errorMessage: string | null; + /** 手动触发重新请求 */ + refetch: () => void; +} + +/** + * 通用数据获取 Hook。 + * + * - 当 `deps` 变化或 `refetch()` 调用时自动重新请求 + * - 自动处理竞态条件(仅最后一次请求的结果生效) + * - `enabled: false` 时不发起请求(适用于需登录后请求的场景) + */ +export function useAsyncData( + fetcher: () => Promise, + deps: unknown[], + options: UseAsyncDataOptions = {}, +): UseAsyncDataReturn { + const { enabled = true, label } = options; + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // 竞态计数器 + const raceRef = useRef(0); + // 手动刷新触发器 + const [refreshTick, setRefreshTick] = useState(0); + // 是否已挂载(避免 setState on unmounted) + const mountedRef = useRef(true); + + const refetch = useCallback(() => { + setRefreshTick((t) => t + 1); + }, []); + + useEffect(() => { + mountedRef.current = true; + return () => { mountedRef.current = false; }; + }, []); + + useEffect(() => { + if (!enabled) { + setData(null); + setError(null); + return; + } + + const raceId = ++raceRef.current; + let cancelled = false; + + const run = async () => { + setLoading(true); + setError(null); + try { + const result = await fetcher(); + if (!cancelled && raceId === raceRef.current && mountedRef.current) { + setData(result); + } + } catch (err) { + if (!cancelled && raceId === raceRef.current && mountedRef.current) { + const normalized = err instanceof Error ? err : new Error(String(err)); + setError(normalized); + // 仅在非取消、非认证过期时记录日志(取消是预期行为,认证过期由拦截器统一处理) + if (err instanceof RequestError) { + if (err.type !== RequestErrorType.CANCELLED && err.type !== RequestErrorType.AUTH_EXPIRED) { + logger.warn('request', `${label || 'fetch'} 请求失败`, normalized); + } + } else { + logger.warn('request', `${label || 'fetch'} 请求失败`, normalized); + } + } + } finally { + if (!cancelled && raceId === raceRef.current && mountedRef.current) { + setLoading(false); + } + } + }; + + void run(); + + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled, refreshTick, ...deps]); + + return { + data, + loading, + error, + errorMessage: error ? getErrorMessage(error) : null, + refetch, + }; +} + +// ---------- useAsyncMutation — 数据变更 ---------- + +export interface UseAsyncMutationOptions { + /** 请求标识(用于日志) */ + label?: string; + /** 成功回调 */ + onSuccess?: (data: T, ...args: Args) => void; + /** 失败回调 */ + onError?: (err: Error, ...args: Args) => void; +} + +export interface UseAsyncMutationReturn { + /** 执行变更 */ + execute: (...args: Args) => Promise; + /** 是否正在执行 */ + loading: boolean; + /** 最近一次错误 */ + error: Error | null; + /** 用户可读的错误信息 */ + errorMessage: string | null; + /** 清除错误状态 */ + reset: () => void; +} + +/** + * 通用数据变更 Hook(POST / PUT / DELETE 等)。 + * + * - 不自动执行,需手动调用 `execute()` + * - 返回 Promise,调用方可 await 获取结果 + * - 自动错误日志记录 + */ +export function useAsyncMutation( + mutator: (...args: Args) => Promise, + options: UseAsyncMutationOptions = {}, +): UseAsyncMutationReturn { + const { label, onSuccess, onError } = options; + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { mountedRef.current = false; }; + }, []); + + const execute = useCallback( + async (...args: Args): Promise => { + setLoading(true); + setError(null); + try { + const result = await mutator(...args); + if (mountedRef.current) { + setLoading(false); + } + onSuccess?.(result, ...args); + return result; + } catch (err) { + const normalized = err instanceof Error ? err : new Error(String(err)); + if (mountedRef.current) { + setError(normalized); + setLoading(false); + } + if (err instanceof RequestError) { + if (err.type !== RequestErrorType.CANCELLED && err.type !== RequestErrorType.AUTH_EXPIRED) { + logger.warn('request', `${label || 'mutation'} 执行失败`, normalized); + } + } else { + logger.warn('request', `${label || 'mutation'} 执行失败`, normalized); + } + onError?.(normalized, ...args); + return undefined; + } + }, + [mutator, label, onSuccess, onError], + ); + + const reset = useCallback(() => { + setError(null); + }, []); + + return { + execute, + loading, + error, + errorMessage: error ? getErrorMessage(error) : null, + reset, + }; +} diff --git a/src/main.tsx b/src/main.tsx index 81622ef..0a04afe 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,53 +1,53 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; -import { App as AntdApp } from 'antd'; +import {App as AntdApp} from 'antd'; import App from './App'; -import { ThemeProvider } from './components/ThemeProvider'; -import { AppProvider } from './components/AppProvider'; -import { SettingsProvider } from './components/SettingsProvider'; +import {ThemeProvider} from './components/ThemeProvider'; +import {AppProvider} from './components/AppProvider'; +import {SettingsProvider} from './components/SettingsProvider'; // 全局样式(包含 Tailwind 指令 + 主题扩展 + 重置) import './theme/globals.css'; ReactDOM.createRoot(document.getElementById('root')!).render( - - {/* AppProvider — 全局应用状态(平台/版本/登录态) */} - - {/* ThemeProvider 内部管理 ConfigProvider(亮色/暗色切换) */} - - {/* SettingsProvider — 集中管理设置项(存储路径等) */} - - {/* AntdApp 提供 message / notification / modal 的静态方法上下文 */} - - - - - - - , + + {/* AppProvider — 全局应用状态(平台/版本/登录态) */} + + {/* ThemeProvider 内部管理 ConfigProvider(亮色/暗色切换) */} + + {/* SettingsProvider — 集中管理设置项(存储路径等) */} + + {/* AntdApp 提供 message / notification / modal 的静态方法上下文 */} + + + + + + + , ); // 仅在 Electron 环境中监听主进程消息(浏览器预览时跳过) import { safeIpcOn } from './utils/ipc'; safeIpcOn('main-process-message', (_event: unknown, message: unknown) => { - console.log('[Main Process Message]:', message); + console.log('[Main Process Message]:', message); }); // 渲染进程全局未捕获错误(兜底记录到日志文件) -import { logger } from './utils/logger'; +import {logger} from './utils/logger'; window.addEventListener('error', (event) => { - logger.error('ui', 'Uncaught error (renderer)', event.error, { - filename: event.filename, - lineno: event.lineno, - colno: event.colno, - }); + logger.error('ui', 'Uncaught error (renderer)', event.error, { + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + }); }); window.addEventListener('unhandledrejection', (event) => { - const error = - event.reason instanceof Error - ? event.reason - : new Error(String(event.reason)); - logger.error('ui', 'Unhandled promise rejection (renderer)', error); + const error = + event.reason instanceof Error + ? event.reason + : new Error(String(event.reason)); + logger.error('ui', 'Unhandled promise rejection (renderer)', error); }); diff --git a/src/pages/home/BannerCarousel.tsx b/src/pages/home/BannerCarousel.tsx new file mode 100644 index 0000000..af2d2b8 --- /dev/null +++ b/src/pages/home/BannerCarousel.tsx @@ -0,0 +1,74 @@ +// ============================================================ +// BannerCarousel — 首页顶部轮播图 +// 使用 useBannerList() Hook 获取数据,组件仅负责渲染 +// ============================================================ + +import { useRef, useMemo } from 'react'; +import { Carousel, Skeleton } from 'antd'; +import type { CarouselRef } from 'antd/es/carousel'; + +import { useBannerList } from '@/hooks/use-api'; +import type { Banner } from '@/services/modules'; + +// ---------- 组件 Props ---------- + +interface BannerCarouselProps { + className?: string; +} + +// ---------- 组件 ---------- + +export function BannerCarousel({ className }: BannerCarouselProps) { + const carouselRef = useRef(null); + const { data: rawBanners, loading } = useBannerList(); + + // 过滤 + 排序(纯计算,无副作用) + const banners = useMemo(() => { + if (!rawBanners) return []; + return rawBanners + .filter((b) => b.is_active) + .filter((b) => b.category === 'banner') + .sort((a, b) => a.sort_order - b.sort_order); + }, [rawBanners]); + + // 无数据时不渲染 + if (!loading && banners.length === 0) { + return null; + } + + // 加载态 — 适配长条比例(2280×90) + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ 1} + > + {banners.map((banner) => ( +
+ {banner.title} +
+ ))} +
+
+ ); +} diff --git a/src/pages/home/CenterPanel.tsx b/src/pages/home/CenterPanel.tsx new file mode 100644 index 0000000..dff7c1b --- /dev/null +++ b/src/pages/home/CenterPanel.tsx @@ -0,0 +1,23 @@ +// ============================================================ +// CenterPanel — 中列容器(模型输入表单) +// ============================================================ + +import { theme as antTheme } from 'antd'; +import { ModelInputForm } from './ModelInputForm'; + +export function CenterPanel() { + const { token } = antTheme.useToken(); + + return ( +
+ +
+ ); +} diff --git a/src/pages/home/HomeContent.tsx b/src/pages/home/HomeContent.tsx new file mode 100644 index 0000000..16ab033 --- /dev/null +++ b/src/pages/home/HomeContent.tsx @@ -0,0 +1,261 @@ +// ============================================================ +// HomeContent — 首页三列正文布局 + HomeProvider +// 可拖拽调整三栏宽度,所有数据请求在登录后发送 +// ============================================================ + +import { useState, useCallback, useRef, useEffect } from 'react'; +import { theme as antTheme, Empty } from 'antd'; + +import { HomeContext } from '@/contexts/home-context'; +import { useAppContext } from '@/contexts/app-context'; +import type { ModelsListResponseBody, TaskInfoItemResponseBody } from '@/services/modules'; +import { LeftPanel } from './LeftPanel'; +import { CenterPanel } from './CenterPanel'; +import { RightPanel } from './RightPanel'; + +import { Typography } from 'antd'; + +const { Text } = Typography; + +// ---------- 常量 ---------- + +/** 左列最小/默认宽度 */ +const LEFT_MIN = 400; +const LEFT_DEFAULT = 750; +/** 右列最小/默认宽度 */ +const RIGHT_MIN = 300; +const RIGHT_DEFAULT = 480; +/** 拖拽分隔条宽度 */ +const DIVIDER_WIDTH = 6; + +// ---------- 组件 ---------- + +export function HomeContent() { + const { token } = antTheme.useToken(); + const { isLoggedIn } = useAppContext(); + + // ======== HomeContext 状态 ======== + + const [selectedModel, setSelectedModel] = useState(null); + const [selectedTask, setSelectedTask] = useState(null); + const [taskPage, setTaskPage] = useState(1); + const [taskPageSize, setTaskPageSize] = useState(10); + const [isLeftCollapsed, setIsLeftCollapsed] = useState(false); + + const toggleLeftCollapsed = useCallback(() => { + setIsLeftCollapsed((v) => !v); + }, []); + + const handleSetSelectedModel = useCallback( + (model: ModelsListResponseBody | null) => { + setSelectedModel(model); + setSelectedTask(null); + }, + [], + ); + + const handleSetSelectedTask = useCallback( + (task: TaskInfoItemResponseBody | null) => { + setSelectedTask(task); + }, + [], + ); + + const contextValue = { + selectedModel, + setSelectedModel: handleSetSelectedModel, + taskPage, + taskPageSize, + setTaskPage, + setTaskPageSize, + selectedTask, + setSelectedTask: handleSetSelectedTask, + isLeftCollapsed, + toggleLeftCollapsed, + }; + + // ======== 拖拽调整列宽 ======== + + const [leftWidth, setLeftWidth] = useState(LEFT_DEFAULT); + const [rightWidth, setRightWidth] = useState(RIGHT_DEFAULT); + const containerRef = useRef(null); + + // 拖拽状态 + const [dragging, setDragging] = useState<'left' | 'right' | null>(null); + // 分隔条 hover 状态 + const [hoveredDivider, setHoveredDivider] = useState<'left' | 'right' | null>(null); + const dragState = useRef<{ + startX: number; + startWidth: number; + }>({ startX: 0, startWidth: 0 }); + + const handleDividerMouseDown = (side: 'left' | 'right') => (e: React.MouseEvent) => { + e.preventDefault(); + // eslint-disable-next-line react-hooks/refs + dragState.current = { startX: e.clientX, startWidth: side === 'left' ? leftWidth : rightWidth }; + setDragging(side); + }; + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!dragging) return; + + const ds = dragState.current; + const delta = e.clientX - ds.startX; + + if (dragging === 'left') { + const newWidth = Math.max(LEFT_MIN, ds.startWidth + delta); + setLeftWidth(newWidth); + } else { + const newWidth = Math.max(RIGHT_MIN, ds.startWidth - delta); + setRightWidth(newWidth); + } + }; + + const handleMouseUp = () => { + setDragging(null); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [dragging]); + + // ======== 分隔条样式工厂 ======== + + const dividerStyle = (side: 'left' | 'right'): React.CSSProperties => { + const isActive = dragging === side; + const isHovered = hoveredDivider === side; + return { + width: DIVIDER_WIDTH, + flexShrink: 0, + cursor: 'col-resize', + background: isActive + ? token.colorPrimary + : isHovered + ? `${token.colorPrimary}60` + : token.colorBorderSecondary, + position: 'relative' as const, + zIndex: 10, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + transition: 'background 0.2s ease', + }; + }; + + // ======== 未登录提示 ======== + + if (!isLoggedIn) { + return ( + +
+ + 请先登录后再使用首页功能 + + } + image={Empty.PRESENTED_IMAGE_SIMPLE} + /> +
+
+ ); + } + + // ======== 三列布局(可拖拽) ======== + + return ( + +
+ {/* 左列 */} + {!isLeftCollapsed && ( + <> +
+ +
+ + {/* 左分隔条 */} +
setHoveredDivider('left')} + onMouseLeave={() => setHoveredDivider(null)} + > + {/* 拖拽手柄竖线 */} +
+
+ + )} + + {/* 中列 */} +
+ +
+ + {/* 右分隔条 */} +
setHoveredDivider('right')} + onMouseLeave={() => setHoveredDivider(null)} + > + {/* 拖拽手柄竖线 */} +
+
+ + {/* 右列 */} +
+ +
+
+ + ); +} diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx index c27df5e..93fa3c8 100644 --- a/src/pages/home/HomePage.tsx +++ b/src/pages/home/HomePage.tsx @@ -1,277 +1,14 @@ // ============================================================ -// HomePage — 首页仪表盘(从 App.tsx 提取) +// HomePage — 首页(业务入口) +// 组合:BannerCarousel(在 Layout 中)+ HomeContent(三列正文) // ============================================================ -import { useState } from 'react'; -import { - Button, - Card, - Space, - Tag, - Typography, - Switch as AntSwitch, - Slider, - Progress, - theme as antTheme, -} from 'antd'; -import { - ApiOutlined, - ThunderboltOutlined, - BulbOutlined, - BulbFilled, - DownloadOutlined, - SyncOutlined, - CheckCircleOutlined, - ExclamationCircleOutlined, - RocketOutlined, -} from '@ant-design/icons'; - -import { getPlatformName, isDev } from '@/utils/platform'; -import { gradientPrimary } from '@/theme/tokens'; -import { useTheme } from '@/hooks/use-theme'; -import { useUpdater } from '@/hooks/use-updater'; -import { useAppContext } from '@/contexts/app-context'; - -const { Title, Text } = Typography; +import { HomeContent } from './HomeContent'; export function HomePage() { - const { isDark, toggleTheme } = useTheme(); - const { platform, edition, isLoggedIn, logout } = useAppContext(); - const { token } = antTheme.useToken(); - const [progress, setProgress] = useState(65); - const { - status: updateStatus, - progress: updateProgress, - info: updateInfo, - error: updateError, - checkForUpdates, - installUpdate, - } = useUpdater(); - return ( -
-
- {/* ---- 标题区 + 主题开关 ---- */} -
-
- - HeiXiu · 桌面工作台 - - } - unCheckedChildren={} - title={isDark ? '切换亮色模式' : '切换暗色模式'} - /> -
- - Electron + React 19 + TypeScript + Ant Design + Tailwind CSS - -
- - - {isDark ? '🌙 暗色' : '☀️ 亮色'} - - - {edition === 'enterprise' ? '企业版' : '个人版'} - - {isLoggedIn ? ( - <> - 已登录 - - - ) : ( - 未登录 - )} - -
- - {/* ---- 导航栏状态提示 ---- */} - - - 💡 当前使用 H5 自定义导航栏 替代系统菜单。 - {platform === 'darwin' - ? ' macOS 保留了最小 Edit 菜单以支持 Cmd+C/V 等快捷键。' - : ' Windows/Linux 已完全移除系统菜单栏。'} - - - - {/* ---- 主题色预览 ---- */} - - - {/* 渐变展示 */} -
- 品牌渐变 -
-
- 135° 渐变 -
-
- 90° 渐变 -
-
-
- - {/* 组件示例 */} -
- Ant Design 组件 - - - - - - - -
- - {/* 滑块 + 进度条 */} -
- 交互组件 -
- - 进度: - - - -
-
-
-
- - {/* ---- 平台信息 ---- */} - - -
- - 运行平台: - {getPlatformName()} -
-
- - 运行环境: - - {isDev() ? '开发模式' : '生产模式'} - -
-
- - 当前导航: - - H5 自定义导航栏({edition === 'enterprise' ? '企业版' : '个人版'} - 布局) - -
-
-
- - {/* ---- 版本更新 ---- */} - - - {/* 状态提示 */} - {updateStatus === 'checking' && ( -
- - 正在检查更新... -
- )} - {updateStatus === 'no-update' && ( -
- - 已是最新版本 -
- )} - {updateStatus === 'error' && ( -
- - {updateError?.message || '检查更新失败'} -
- )} - - {/* 发现新版本 */} - {(updateStatus === 'available' || updateStatus === 'downloading') && ( - <> -
- - 发现新版本:{updateInfo?.version} - {updateInfo?.forceUpdate && 强制更新} -
- {updateInfo?.releaseNotes && ( - - {updateInfo.releaseNotes} - - )} - - )} - - {/* 下载进度 */} - {updateStatus === 'downloading' && ( - - )} - - {/* 下载完成 */} - {updateStatus === 'downloaded' && ( - <> -
- - 更新已下载完毕,点击按钮安装并重启 -
- - - )} - - {/* 操作按钮 */} -
- - {isDev() && ( - - (生产环境启动 5 秒后自动检查) - - )} -
-
-
-
+
+
); } diff --git a/src/pages/home/LeftPanel.tsx b/src/pages/home/LeftPanel.tsx new file mode 100644 index 0000000..0e0a88e --- /dev/null +++ b/src/pages/home/LeftPanel.tsx @@ -0,0 +1,177 @@ +// ============================================================ +// LeftPanel — 左列容器(模型选择 + 可拖拽分割线 + 任务记录) +// 纵向拖拽分隔线支持鼠标调整上下区域高度比例 +// ============================================================ + +import { useState, useRef, useEffect, useCallback } from 'react'; +import { theme as antTheme } from 'antd'; +import { ModelSelector } from './ModelSelector'; +import { TaskHistory } from './TaskHistory'; + +// ---------- 常量 ---------- + +/** 模型选择区最小高度 */ +const TOP_MIN = 120; +/** 任务列表区最小高度 */ +const BOTTOM_MIN = 200; +/** 默认模型选择区百分比 */ +const TOP_DEFAULT_PCT = 0.40; +/** 拖拽分隔条高度 */ +const DIVIDER_HEIGHT = 6; + +// ---------- 组件 ---------- + +export function LeftPanel() { + const { token } = antTheme.useToken(); + + // 面板容器引用 + const containerRef = useRef(null); + + // 模型选择区高度(像素) + const [topHeight, setTopHeight] = useState(null); + // 拖拽状态 + const [dragging, setDragging] = useState(false); + // hover 状态 + const [hovered, setHovered] = useState(false); + + const dragState = useRef<{ + startY: number; + startHeight: number; + }>({ startY: 0, startHeight: 0 }); + + // 初始化:按百分比计算顶部高度 + const initHeight = useCallback(() => { + const el = containerRef.current; + if (!el) return; + const total = el.clientHeight; + setTopHeight(Math.max(TOP_MIN, Math.round(total * TOP_DEFAULT_PCT))); + }, []); + + useEffect(() => { + initHeight(); + }, [initHeight]); + + // 响应容器 resize(窗口大小变化时重新按比例计算) + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const observer = new ResizeObserver(() => { + if (!dragging) { + const total = el.clientHeight; + setTopHeight((prev) => { + if (prev === null) return Math.max(TOP_MIN, Math.round(total * TOP_DEFAULT_PCT)); + // 保持原有比例 + const ratio = prev / total; + return Math.max(TOP_MIN, Math.round(total * Math.min(ratio, 0.8))); + }); + } + }); + observer.observe(el); + return () => observer.disconnect(); + }, [dragging]); + + // 拖拽事件 + const handleDividerMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + dragState.current = { startY: e.clientY, startHeight: topHeight ?? 200 }; + setDragging(true); + }; + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!dragging) return; + + const el = containerRef.current; + if (!el) return; + + const total = el.clientHeight; + const ds = dragState.current; + const delta = e.clientY - ds.startY; + const newTop = Math.max(TOP_MIN, Math.min(total - BOTTOM_MIN, ds.startHeight + delta)); + setTopHeight(newTop); + }; + + const handleMouseUp = () => { + setDragging(false); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [dragging]); + + // 分隔条样式 + const isActive = dragging; + const isHovered = hovered; + + return ( +
+ {/* 上方:模型选择(高度由拖拽控制) */} +
+ +
+ + {/* 纵向拖拽分隔条 */} +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {/* 拖拽手柄横线 */} +
+
+ + {/* 下方:任务记录(剩余高度) */} +
+ +
+
+ ); +} diff --git a/src/pages/home/ModelInputForm.tsx b/src/pages/home/ModelInputForm.tsx new file mode 100644 index 0000000..14d2055 --- /dev/null +++ b/src/pages/home/ModelInputForm.tsx @@ -0,0 +1,463 @@ +// ============================================================ +// ModelInputForm — 动态模型输入表单 +// 根据选中模型的 param_schema + ui_config 动态渲染表单控件 +// ============================================================ + +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { + Form, + Input, + InputNumber, + Select, + Slider, + Button, + Typography, + Empty, + Upload, + Switch, + theme as antTheme, + message, +} from 'antd'; +import { + SendOutlined, + UploadOutlined, + InboxOutlined, +} from '@ant-design/icons'; +import type { UploadFile } from 'antd/es/upload'; + +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'; + +const { Text, Title } = Typography; +const { TextArea } = Input; +const { Dragger } = Upload; + +// ---------- 参数类型映射 ---------- + +/** 从 ui_config 中解析的参数描述 */ +interface ParamDescriptor { + key: string; + label: string; + /** 控件类型 */ + controlType: 'text' | 'number' | 'textarea' | 'select' | 'slider' | 'switch' | 'image-upload'; + defaultValue?: unknown; + required?: boolean; + placeholder?: string; + /** 数值型:最小值 */ + min?: number; + /** 数值型:最大值 */ + max?: number; + /** 数值型:步长 */ + step?: number; + /** select 型:选项列表 */ + options?: Array<{ label: string; value: string | number }>; +} + +/** 从 ui_config 对象中解析控件类型 */ +function inferControlType(config: Record): ParamDescriptor['controlType'] { + const type = config.type as string | undefined; + if (!type) return 'text'; + + switch (type) { + case 'number': + case 'integer': + case 'float': + return 'number'; + case 'textarea': + case 'text_area': + case 'prompt': + return 'textarea'; + case 'select': + case 'dropdown': + case 'enum': + return 'select'; + case 'slider': + case 'range': + return 'slider'; + case 'switch': + case 'boolean': + case 'bool': + return 'switch'; + case 'image': + case 'image-upload': + case 'file': + return 'image-upload'; + default: + return 'text'; + } +} + +/** 从 param_schema + ui_config 构建参数描述符列表 */ +function buildParamDescriptors( + paramSchema: string[], + uiConfig: Record, +): ParamDescriptor[] { + return paramSchema.map((key) => { + const config = (uiConfig[key] || {}) as Record; + const controlType = inferControlType(config); + return { + key, + label: (config.label as string) || key, + controlType, + defaultValue: config.default ?? config.defaultValue, + required: (config.required as boolean) || false, + placeholder: (config.placeholder as string) || `请输入${config.label || key}`, + min: config.min as number | undefined, + max: config.max as number | undefined, + step: config.step as number | undefined, + options: (config.options as ParamDescriptor['options']) || undefined, + }; + }); +} + +// ---------- 组件 ---------- + +export function ModelInputForm() { + const { token } = antTheme.useToken(); + const { isLoggedIn } = useAppContext(); + const { selectedModel } = useHomeContext(); + const [form] = Form.useForm(); + const [referenceFiles, setReferenceFiles] = useState([]); + + // 提交突变 Hook(自动处理 loading / error / 日志) + const { execute: submitTask, loading: submitting, errorMessage } = useSubmitTask({ + onSuccess: useCallback( + (_data: TaskInfoItemResponseBody) => { + message.success('任务已提交'); + // 通过事件总线通知 TaskHistory 刷新(替代 Context 中的 refreshTaskSignal) + emit(EVENTS.TASK_SUBMITTED); + }, + [], + ), + }); + + // 选中模型时重置表单(仅当有选中模型时操作,避免 form 未连接警告) + useEffect(() => { + if (selectedModel) { + form.resetFields(); + } + setReferenceFiles([]); + }, [selectedModel?.id]); // eslint-disable-line react-hooks/exhaustive-deps + + // 参数描述符 + const paramDescriptors = useMemo(() => { + if (!selectedModel) return []; + return buildParamDescriptors( + selectedModel.param_schema || [], + selectedModel.ui_config || {}, + ); + }, [selectedModel]); + + // 提交 + const handleSubmit = async () => { + if (!isLoggedIn) { + message.warning('请先登录后再提交任务'); + return; + } + if (!selectedModel) return; + + try { + const values = await form.validateFields(); + + // 分离固定字段和模型动态参数 + const { prompt, negative_prompt, num_outputs, ...dynamicParams } = values; + + // 构建提交参数(全部转为字符串值) + const params: Record = { + prompt: String(prompt ?? ''), + negative_prompt: String(negative_prompt ?? ''), + num_outputs: String(num_outputs ?? 1), + }; + + // 动态参数统一转字符串 + for (const [key, value] of Object.entries(dynamicParams)) { + if (value !== undefined && value !== null) { + params[key] = String(value); + } + } + + // 参考图 + if (referenceFiles.length > 0) { + const refUrls = referenceFiles + .filter((f) => f.status === 'done') + .map((f) => f.response?.url || f.url || f.name) + .filter(Boolean) as string[]; + if (refUrls.length > 0) { + params.reference_images = JSON.stringify(refUrls); + } + } + + // 使用 mutation Hook 提交(自动处理 loading / error / 日志) + await submitTask({ model_id: selectedModel.id, params }); + } catch (err) { + if (err && typeof err === 'object' && 'errorFields' in err) { + // 表单验证错误,antd 会自动提示 + return; + } + // 错误消息由 Hook 自动通过 errorMessage 提供 + message.error(errorMessage || '任务提交失败,请重试'); + } + }; + + // 渲染单个参数控件 + const renderParamControl = (desc: ParamDescriptor) => { + const commonProps = { + placeholder: desc.placeholder, + style: { width: '100%' }, + }; + + switch (desc.controlType) { + case 'number': + return ( + + ); + + case 'textarea': + return