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:
@@ -149,42 +149,42 @@ export interface UsePhoneResetPasswordRequestBody {
|
||||
|
||||
export class AuthAPI {
|
||||
/** 登录 */
|
||||
static login(data: LoginRequestBody): Promise<LoginResponseBody> {
|
||||
return post<LoginResponseBody>(AuthUrlObj.login, data);
|
||||
static async login(data: LoginRequestBody): Promise<LoginResponseBody> {
|
||||
return await post<LoginResponseBody>(AuthUrlObj.login, data);
|
||||
}
|
||||
|
||||
/** 注册 */
|
||||
static register(data: RegisterRequestBody): Promise<RegisterResponseBody> {
|
||||
return post<RegisterResponseBody>(AuthUrlObj.register, data);
|
||||
static async register(data: RegisterRequestBody): Promise<RegisterResponseBody> {
|
||||
return await post<RegisterResponseBody>(AuthUrlObj.register, data);
|
||||
}
|
||||
|
||||
/** 发送短信验证码 */
|
||||
static sendSms(data: SendSMSRequestBody): Promise<void> {
|
||||
return post<void>(AuthUrlObj.sendSms, data);
|
||||
static async sendSms(data: SendSMSRequestBody): Promise<void> {
|
||||
return await post<void>(AuthUrlObj.sendSms, data);
|
||||
}
|
||||
|
||||
/** 刷新 Token */
|
||||
static refreshToken(data: RefreshTokenRequestBody): Promise<RefreshTokenResponseBody> {
|
||||
return post<RefreshTokenResponseBody>(AuthUrlObj.refreshToken, data);
|
||||
static async refreshToken(data: RefreshTokenRequestBody): Promise<RefreshTokenResponseBody> {
|
||||
return await post<RefreshTokenResponseBody>(AuthUrlObj.refreshToken, data);
|
||||
}
|
||||
|
||||
/** 获取当前用户信息 */
|
||||
static getUserInfo(): Promise<UserInfoBody> {
|
||||
return get<UserInfoBody>(AuthUrlObj.userInfo);
|
||||
static async getUserInfo(): Promise<UserInfoBody> {
|
||||
return await get<UserInfoBody>(AuthUrlObj.userInfo);
|
||||
}
|
||||
|
||||
/** 修改密码(已登录) */
|
||||
static changePassword(data: ChangePasswordRequestBody): Promise<void> {
|
||||
return post<void>(AuthUrlObj.changePassword, data);
|
||||
static async changePassword(data: ChangePasswordRequestBody): Promise<void> {
|
||||
return await post<void>(AuthUrlObj.changePassword, data);
|
||||
}
|
||||
|
||||
/** 手机验证码重置密码 */
|
||||
static resetPassword(data: UsePhoneResetPasswordRequestBody): Promise<void> {
|
||||
return post<void>(AuthUrlObj.resetPassword, data);
|
||||
static async resetPassword(data: UsePhoneResetPasswordRequestBody): Promise<void> {
|
||||
return await post<void>(AuthUrlObj.resetPassword, data);
|
||||
}
|
||||
|
||||
/** 退出登录 */
|
||||
static logout(): Promise<void> {
|
||||
return post<void>(AuthUrlObj.logout);
|
||||
static async logout(): Promise<void> {
|
||||
return await post<void>(AuthUrlObj.logout);
|
||||
}
|
||||
}
|
||||
|
||||
43
src/services/modules/banner.ts
Normal file
43
src/services/modules/banner.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// ============================================================
|
||||
// Banner 模块 — 首页轮播图 API
|
||||
// GET /api/v1/banners
|
||||
// ============================================================
|
||||
|
||||
import {get} from '../request';
|
||||
|
||||
// ---------- 路由常量 ----------
|
||||
|
||||
const BannerUrlObj = {
|
||||
list: '/api/v1/creatives',
|
||||
} as const;
|
||||
|
||||
// ---------- 类型 ----------
|
||||
|
||||
/** 单条 Banner */
|
||||
export interface Banner {
|
||||
id: number;
|
||||
title: string;
|
||||
content: "真人素材提示";
|
||||
image_url: string;
|
||||
link_url?: string;
|
||||
category: "promotion" | "banner";
|
||||
vip_level_id?: number;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
created_by: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
/** Banner 列表响应 */
|
||||
export type BannerListResponse = Banner[];
|
||||
|
||||
// ---------- API ----------
|
||||
|
||||
/**
|
||||
* 获取 Banner 轮播图列表
|
||||
* GET /api/v1/banners
|
||||
*/
|
||||
export function fetchBanners(): Promise<BannerListResponse> {
|
||||
return get<BannerListResponse>(BannerUrlObj.list);
|
||||
}
|
||||
@@ -3,22 +3,56 @@
|
||||
// ============================================================
|
||||
|
||||
export {
|
||||
fetchAnnouncements,
|
||||
type Announcement,
|
||||
type AnnouncementType,
|
||||
type AnnouncementListResponse,
|
||||
fetchAnnouncements,
|
||||
type Announcement,
|
||||
type AnnouncementType,
|
||||
type AnnouncementListResponse,
|
||||
} from './announcement';
|
||||
|
||||
export {
|
||||
AuthAPI,
|
||||
type SendSMSRequestBody,
|
||||
type RegisterRequestBody,
|
||||
type RegisterResponseBody,
|
||||
type LoginRequestBody,
|
||||
type LoginResponseBody,
|
||||
type RefreshTokenRequestBody,
|
||||
type RefreshTokenResponseBody,
|
||||
type UserInfoBody,
|
||||
type ChangePasswordRequestBody,
|
||||
type UsePhoneResetPasswordRequestBody,
|
||||
AuthAPI,
|
||||
type SendSMSRequestBody,
|
||||
type RegisterRequestBody,
|
||||
type RegisterResponseBody,
|
||||
type LoginRequestBody,
|
||||
type LoginResponseBody,
|
||||
type RefreshTokenRequestBody,
|
||||
type RefreshTokenResponseBody,
|
||||
type UserInfoBody,
|
||||
type ChangePasswordRequestBody,
|
||||
type UsePhoneResetPasswordRequestBody,
|
||||
} from './auth';
|
||||
|
||||
export {
|
||||
fetchBanners,
|
||||
type Banner,
|
||||
type BannerListResponse,
|
||||
} from './banner';
|
||||
|
||||
export {
|
||||
ModelAPI,
|
||||
MODEL_CATEGORY_LABELS,
|
||||
type ModelsListReqeustParams,
|
||||
type ModelsListResponseBody,
|
||||
type ModelInfoRequestParams,
|
||||
type ModelInfoResponseBody,
|
||||
type ModelCategory,
|
||||
} from './models';
|
||||
|
||||
export {
|
||||
TaskAPI,
|
||||
OutputTypeMap,
|
||||
TaskStatusMap,
|
||||
type OutputTypeString,
|
||||
type TaskStatusString,
|
||||
type TaskListRequestParams,
|
||||
type CreatTaskRequestBody,
|
||||
type ExportTaskCSVParams,
|
||||
type ContentItem,
|
||||
type ImageUiParams,
|
||||
type VideoUiParams,
|
||||
type AudioUiParams,
|
||||
type TaskInfoItemResponseBody,
|
||||
type TaskInfoDataResponseBody,
|
||||
type TaskStatusResponseBody,
|
||||
} from './task';
|
||||
|
||||
87
src/services/modules/models.ts
Normal file
87
src/services/modules/models.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import {get} from '../request';
|
||||
|
||||
// ---------- 路由常量 ----------
|
||||
|
||||
const ModelsUrlObj = {
|
||||
modelList: '/api/v1/models',
|
||||
getModelInfo: '/api/v1/models/{model_id}',
|
||||
estimateCost: '/api/v1/models/{model_id}/estimate'
|
||||
} as const;
|
||||
|
||||
// ---------- 类型 ----------
|
||||
|
||||
/** 模型类别 */
|
||||
export type ModelCategory = 'img2img' | 'txt2img' | 'img2video' | 'txt2audio';
|
||||
|
||||
/** 模型类别中文映射 */
|
||||
export const MODEL_CATEGORY_LABELS: Record<ModelCategory, string> = {
|
||||
img2img: '图生图',
|
||||
txt2img: '文生图',
|
||||
img2video: '图生视频',
|
||||
txt2audio: '文生音频',
|
||||
};
|
||||
|
||||
/**
|
||||
* 查询可用模型的请求参数
|
||||
*/
|
||||
export interface ModelsListReqeustParams {
|
||||
provider_name?: string;
|
||||
category?: ModelCategory;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询可用模型的响应体
|
||||
*/
|
||||
export interface ModelsListResponseBody {
|
||||
id: string;
|
||||
name: string;
|
||||
provider_name: string;
|
||||
model_type: string;
|
||||
category_name: string;
|
||||
status: string;
|
||||
priority: number;
|
||||
response_mode: string;
|
||||
pricing_config: Record<string, unknown>;
|
||||
param_schema: string[];
|
||||
quota_config: Record<string, unknown>;
|
||||
ui_config: Record<string, unknown>;
|
||||
meta_config: Record<string, unknown>;
|
||||
constraints_config: Record<string, unknown>;
|
||||
expires_at: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型信息请求参数
|
||||
*/
|
||||
export interface ModelInfoRequestParams {
|
||||
model_id: string;
|
||||
}
|
||||
|
||||
/** 模型详情响应体(与列表项结构一致) */
|
||||
export type ModelInfoResponseBody = ModelsListResponseBody;
|
||||
|
||||
// ---------- API ----------
|
||||
|
||||
|
||||
export class ModelAPI {
|
||||
/**
|
||||
* 获取可用模型列表
|
||||
* GET /api/v1/models
|
||||
*/
|
||||
static fetchModels(params: ModelsListReqeustParams): Promise<ModelsListResponseBody[]> {
|
||||
return get<ModelsListResponseBody[]>(ModelsUrlObj.modelList, params as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个模型详情
|
||||
* GET /api/v1/models/:id
|
||||
*/
|
||||
static fetchModelInfo(modelId: string): Promise<ModelInfoResponseBody> {
|
||||
const url = ModelsUrlObj.getModelInfo.replace('{model_id}', modelId);
|
||||
return get<ModelInfoResponseBody>(url);
|
||||
}
|
||||
}
|
||||
232
src/services/modules/task.ts
Normal file
232
src/services/modules/task.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
// ============================================================
|
||||
// 任务模块 — 任务记录 CRUD API
|
||||
// GET /api/v1/tasks → 任务列表(分页/游标)
|
||||
// POST /api/v1/tasks → 提交任务
|
||||
// GET /api/v1/tasks/:id → 任务详情
|
||||
// GET /api/v1/tasks/:id/status → 轻量轮询状态
|
||||
// GET /api/v1/tasks/export → 导出 CSV
|
||||
//
|
||||
// TaskAPI 的静态方法直接返回 get/post 的 Promise<T>,
|
||||
// 不做额外的 async/await 包装,由调用方决定是否 await。
|
||||
// ============================================================
|
||||
|
||||
import {get, post} from '../request';
|
||||
|
||||
// ============================================================
|
||||
// 基础枚举 / 字面量类型
|
||||
// ============================================================
|
||||
|
||||
/** 输出类型映射 */
|
||||
export enum OutputTypeMap {
|
||||
video = '图片',
|
||||
image = '视频',
|
||||
audio = '音频',
|
||||
}
|
||||
|
||||
export type OutputTypeString = keyof typeof OutputTypeMap;
|
||||
|
||||
/** 任务状态 */
|
||||
export enum TaskStatusMap {
|
||||
pending = '待处理',
|
||||
submitted = '已提交',
|
||||
processing = '处理中',
|
||||
success = '成功',
|
||||
failed = '失败',
|
||||
}
|
||||
|
||||
export type TaskStatusString = keyof typeof TaskStatusMap;
|
||||
|
||||
// ============================================================
|
||||
// 请求体 / 参数类型
|
||||
// ============================================================
|
||||
|
||||
/** 任务列表请求参数 */
|
||||
export interface TaskListRequestParams {
|
||||
status?: TaskStatusString;
|
||||
user_ids?: string[];
|
||||
model_ids?: string[];
|
||||
/** 游标分页:上次最后一条的 created_at(ISO 8601) */
|
||||
cursor?: string;
|
||||
/** 页码(OFFSET 分页模式),default: 1 */
|
||||
page?: number;
|
||||
/** 每页条数,default: 20 */
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
/** 创建任务请求体 */
|
||||
export interface CreatTaskRequestBody {
|
||||
model_id: string;
|
||||
params: Record<string, string>;
|
||||
device_id?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/** 导出 CSV 参数 */
|
||||
export interface ExportTaskCSVParams {
|
||||
start_date: Date;
|
||||
end_date: Date;
|
||||
status?: TaskStatusString;
|
||||
user_ids?: string[];
|
||||
model_ids?: string[];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 内容项 — 带判别标签的联合类型
|
||||
// ============================================================
|
||||
|
||||
interface BaseContentItem {
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface TextContent extends BaseContentItem {
|
||||
type: 'text';
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface ImageUrlContent extends BaseContentItem {
|
||||
type: 'image_url';
|
||||
role: 'reference_image';
|
||||
image_url: { url: string };
|
||||
}
|
||||
|
||||
interface VideoUrlContent extends BaseContentItem {
|
||||
type: 'video_url';
|
||||
role: 'reference_video';
|
||||
video_url: { url: string };
|
||||
}
|
||||
|
||||
interface AudioContent extends BaseContentItem {
|
||||
type: 'audio_url';
|
||||
role: 'reference_audio';
|
||||
audio_url: { url: string };
|
||||
}
|
||||
|
||||
export type ContentItem =
|
||||
| TextContent
|
||||
| ImageUrlContent
|
||||
| VideoUrlContent
|
||||
| AudioContent;
|
||||
|
||||
// ============================================================
|
||||
// 各输出类型的 UI 参数
|
||||
// ============================================================
|
||||
|
||||
export interface ImageUiParams {
|
||||
imageUrls?: string[];
|
||||
prompt: string;
|
||||
aspectRatio: string;
|
||||
resolution?: string;
|
||||
}
|
||||
|
||||
export interface VideoUiParams {
|
||||
resolution?: string;
|
||||
ratio?: string;
|
||||
model?: string;
|
||||
generate_audio: boolean;
|
||||
duration: number;
|
||||
content: ContentItem[];
|
||||
}
|
||||
|
||||
export interface AudioUiParams {
|
||||
format?: string;
|
||||
sampleRate?: number;
|
||||
bitrate?: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 响应体 — 用判别联合按 output_type 分发
|
||||
// ============================================================
|
||||
|
||||
/** 任务条目基类(不含 output_type 特有字段) */
|
||||
interface BaseTaskItemResponseBody {
|
||||
id: string;
|
||||
user_id: string;
|
||||
model_id: string;
|
||||
model_name: string;
|
||||
provider_name: string;
|
||||
status: TaskStatusString;
|
||||
external_task_id: string;
|
||||
cost_cent: number;
|
||||
estimated_cost_cent: number;
|
||||
error_code: string;
|
||||
poll_attempts: number;
|
||||
is_favorite: boolean;
|
||||
submitted_at: Date;
|
||||
finished_at: Date;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
prompt?: string;
|
||||
output_assets?: Array<{ url: string }>;
|
||||
tags: Array<string | null>;
|
||||
error_message: string;
|
||||
user_display_name: string | null;
|
||||
}
|
||||
|
||||
/** 判别联合:根据 output_type 收窄 ui_params */
|
||||
export type TaskInfoItemResponseBody =
|
||||
| (BaseTaskItemResponseBody & { output_type: 'image'; ui_params: ImageUiParams })
|
||||
| (BaseTaskItemResponseBody & { output_type: 'video'; ui_params: VideoUiParams })
|
||||
| (BaseTaskItemResponseBody & { output_type: 'audio'; ui_params: AudioUiParams });
|
||||
|
||||
/** 任务列表分页响应 */
|
||||
export interface TaskInfoDataResponseBody {
|
||||
has_more: boolean;
|
||||
next_cursor: string;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total: number;
|
||||
items: TaskInfoItemResponseBody[];
|
||||
}
|
||||
|
||||
/** 轻量轮询状态响应 */
|
||||
export interface TaskStatusResponseBody {
|
||||
task_id: string;
|
||||
status: TaskStatusString;
|
||||
progress: number;
|
||||
output_assets?: string[];
|
||||
error_code?: string;
|
||||
error_message?: string;
|
||||
cost_cent?: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 路由常量
|
||||
// ============================================================
|
||||
|
||||
const TaskUrlObj = {
|
||||
task: '/api/v1/tasks',
|
||||
taskInfo: (task_id: string) => `/api/v1/tasks/${task_id}`,
|
||||
taskStatus: (task_id: string) => `/api/v1/tasks/${task_id}/status`,
|
||||
tasksCSV: '/api/v1/tasks/export',
|
||||
} as const;
|
||||
|
||||
// ============================================================
|
||||
// TaskAPI — 静态方法直接返回 Promise<T>,不做二次 async 包装
|
||||
// ============================================================
|
||||
|
||||
export class TaskAPI {
|
||||
/** 获取任务列表(分页/游标) */
|
||||
static getTaskList(params: TaskListRequestParams): Promise<TaskInfoDataResponseBody> {
|
||||
return get<TaskInfoDataResponseBody>(TaskUrlObj.task, params as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/** 提交新任务 */
|
||||
static submitTask(body: CreatTaskRequestBody): Promise<TaskInfoItemResponseBody> {
|
||||
return post<TaskInfoItemResponseBody>(TaskUrlObj.task, body);
|
||||
}
|
||||
|
||||
/** 获取任务详情 */
|
||||
static getTaskInfo(task_id: string): Promise<TaskInfoItemResponseBody> {
|
||||
return get<TaskInfoItemResponseBody>(TaskUrlObj.taskInfo(task_id));
|
||||
}
|
||||
|
||||
/** 轻量轮询任务状态 */
|
||||
static getTaskStatus(task_id: string): Promise<TaskStatusResponseBody> {
|
||||
return get<TaskStatusResponseBody>(TaskUrlObj.taskStatus(task_id));
|
||||
}
|
||||
|
||||
/** 导出任务 CSV */
|
||||
static exportTaskCSV(params: ExportTaskCSVParams): Promise<File> {
|
||||
return get<File>(TaskUrlObj.tasksCSV, params as unknown as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user