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:
164
src/hooks/use-api.ts
Normal file
164
src/hooks/use-api.ts
Normal 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' },
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 任务详情
|
||||
// ============================================================
|
||||
|
||||
/** 任务详情 Hook(taskId 为 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 };
|
||||
254
src/hooks/use-async.ts
Normal file
254
src/hooks/use-async.ts
Normal file
@@ -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<T> {
|
||||
/** 响应数据 */
|
||||
data: T | null;
|
||||
/** 是否正在加载(含首次请求和后续刷新) */
|
||||
loading: boolean;
|
||||
/** 错误对象(RequestError 或普通 Error) */
|
||||
error: Error | null;
|
||||
/** 用户可读的错误信息 */
|
||||
errorMessage: string | null;
|
||||
/** 手动触发重新请求 */
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用数据获取 Hook。
|
||||
*
|
||||
* - 当 `deps` 变化或 `refetch()` 调用时自动重新请求
|
||||
* - 自动处理竞态条件(仅最后一次请求的结果生效)
|
||||
* - `enabled: false` 时不发起请求(适用于需登录后请求的场景)
|
||||
*/
|
||||
export function useAsyncData<T>(
|
||||
fetcher: () => Promise<T>,
|
||||
deps: unknown[],
|
||||
options: UseAsyncDataOptions = {},
|
||||
): UseAsyncDataReturn<T> {
|
||||
const { enabled = true, label } = options;
|
||||
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(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<T, Args extends unknown[]> {
|
||||
/** 请求标识(用于日志) */
|
||||
label?: string;
|
||||
/** 成功回调 */
|
||||
onSuccess?: (data: T, ...args: Args) => void;
|
||||
/** 失败回调 */
|
||||
onError?: (err: Error, ...args: Args) => void;
|
||||
}
|
||||
|
||||
export interface UseAsyncMutationReturn<T, Args extends unknown[]> {
|
||||
/** 执行变更 */
|
||||
execute: (...args: Args) => Promise<T | undefined>;
|
||||
/** 是否正在执行 */
|
||||
loading: boolean;
|
||||
/** 最近一次错误 */
|
||||
error: Error | null;
|
||||
/** 用户可读的错误信息 */
|
||||
errorMessage: string | null;
|
||||
/** 清除错误状态 */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用数据变更 Hook(POST / PUT / DELETE 等)。
|
||||
*
|
||||
* - 不自动执行,需手动调用 `execute()`
|
||||
* - 返回 Promise,调用方可 await 获取结果
|
||||
* - 自动错误日志记录
|
||||
*/
|
||||
export function useAsyncMutation<T, Args extends unknown[] = []>(
|
||||
mutator: (...args: Args) => Promise<T>,
|
||||
options: UseAsyncMutationOptions<T, Args> = {},
|
||||
): UseAsyncMutationReturn<T, Args> {
|
||||
const { label, onSuccess, onError } = options;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => { mountedRef.current = false; };
|
||||
}, []);
|
||||
|
||||
const execute = useCallback(
|
||||
async (...args: Args): Promise<T | undefined> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user