feat: 实现业务首页(三栏布局 + 轮播图)

## 首页整体架构
  - 新增 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 动态计算(修复分页器遮挡)
This commit is contained in:
2026-06-05 16:24:17 +08:00
parent 6204c14b88
commit 1da95eaef3
31 changed files with 2639 additions and 470 deletions

164
src/hooks/use-api.ts Normal file
View File

@@ -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<Banner[]>(
fetchBanners,
[],
{ enabled: isLoggedIn, label: 'banner-list' },
);
}
// ============================================================
// 模型
// ============================================================
/** 模型列表数据 Hook按类别分别请求后合并 */
export function useModelList() {
const { isLoggedIn } = useAppContext();
// 使用单个 fetcher 返回合并后的结果
const result = useAsyncData<ModelsListResponseBody[]>(
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<string, ModelsListResponseBody[]>();
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' },
);
}
// ============================================================
// 任务详情
// ============================================================
/** 任务详情 HooktaskId 为 null 时不请求) */
export function useTaskDetail(taskId: string | null | undefined) {
const { isLoggedIn } = useAppContext();
return useAsyncData<TaskInfoItemResponseBody>(
() => 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<TaskInfoItemResponseBody, [CreatTaskRequestBody]>(
(body) => TaskAPI.submitTask(body),
{
label: 'submit-task',
onSuccess: options?.onSuccess,
onError: options?.onError,
},
);
}
// ---------- 重新导出类型和工具 ----------
export { MODEL_CATEGORY_LABELS };
export type { ModelCategory, ModelsListResponseBody, TaskInfoItemResponseBody, Banner };