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

View File

@@ -1,6 +1,6 @@
{
"pid": 91784,
"pid": 145380,
"version": "0.9.9",
"socketPath": "\\\\.\\pipe\\codegraph-aef859f0205bacea",
"startedAt": 1780539931451
"startedAt": 1780622650728
}

View File

@@ -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

14
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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 (
<div
className="min-h-screen flex flex-col transition-colors duration-300"
className="h-screen flex flex-col transition-colors duration-300"
style={{ background: token.colorBgLayout }}
>
{isHomePage && <BannerCarousel />}
<NavBar />
<Outlet />
</div>

View File

@@ -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<HomeContextValue | null>(null);
// ---------- Consumer Hook ----------
export function useHomeContext(): HomeContextValue {
const ctx = useContext(HomeContext);
if (!ctx) {
throw new Error('useHomeContext() 必须在 <HomeProvider> 内部调用');
}
return ctx;
}

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 };

254
src/hooks/use-async.ts Normal file
View 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;
}
/**
* 通用数据变更 HookPOST / 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,
};
}

View File

@@ -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<CarouselRef>(null);
const { data: rawBanners, loading } = useBannerList();
// 过滤 + 排序(纯计算,无副作用)
const banners = useMemo<Banner[]>(() => {
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 (
<div className={className} style={{ width: '100%' }}>
<Skeleton.Input active block style={{ height: 90 }} />
</div>
);
}
return (
<div
className={`banner-carousel ${className ?? ''}`}
style={{ position: 'relative', width: '100%', overflow: 'hidden', lineHeight: 0 }}
>
<Carousel
ref={carouselRef}
autoplay
autoplaySpeed={5000}
effect="fade"
pauseOnHover
adaptiveHeight={true}
dots={banners.length > 1}
>
{banners.map((banner) => (
<div key={banner.id}>
<img
src={banner.image_url}
alt={banner.title}
style={{ width: '100%', height: 'auto', display: 'block' }}
/>
</div>
))}
</Carousel>
</div>
);
}

View File

@@ -0,0 +1,23 @@
// ============================================================
// CenterPanel — 中列容器(模型输入表单)
// ============================================================
import { theme as antTheme } from 'antd';
import { ModelInputForm } from './ModelInputForm';
export function CenterPanel() {
const { token } = antTheme.useToken();
return (
<div
style={{
height: '100%',
overflow: 'hidden',
borderRadius: 8,
border: `1px solid ${token.colorBorderSecondary}`,
}}
>
<ModelInputForm />
</div>
);
}

View File

@@ -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<ModelsListResponseBody | null>(null);
const [selectedTask, setSelectedTask] = useState<TaskInfoItemResponseBody | null>(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<HTMLDivElement>(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 (
<HomeContext.Provider value={contextValue}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flex: 1,
background: token.colorBgLayout,
}}
>
<Empty
description={
<Text type="secondary">
使
</Text>
}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</div>
</HomeContext.Provider>
);
}
// ======== 三列布局(可拖拽) ========
return (
<HomeContext.Provider value={contextValue}>
<div
ref={containerRef}
style={{
display: 'flex',
padding: 12,
gap: 0,
flex: 1,
overflow: 'hidden',
background: token.colorBgLayout,
cursor: !!dragging ? 'col-resize' : undefined,
userSelect: !!dragging ? 'none' : undefined,
}}
>
{/* 左列 */}
{!isLeftCollapsed && (
<>
<div style={{ width: leftWidth, overflow: 'hidden', flexShrink: 0 }}>
<LeftPanel />
</div>
{/* 左分隔条 */}
<div
onMouseDown={handleDividerMouseDown('left')}
style={dividerStyle('left')}
onMouseEnter={() => setHoveredDivider('left')}
onMouseLeave={() => setHoveredDivider(null)}
>
{/* 拖拽手柄竖线 */}
<div style={{
width: 2,
height: 32,
borderRadius: 1,
background: dragging === 'left'
? '#fff'
: hoveredDivider === 'left'
? token.colorPrimary
: token.colorBorder,
transition: 'background 0.2s ease',
}} />
</div>
</>
)}
{/* 中列 */}
<div style={{ flex: 1, overflow: 'hidden', minWidth: 300 }}>
<CenterPanel />
</div>
{/* 右分隔条 */}
<div
onMouseDown={handleDividerMouseDown('right')}
style={{
...dividerStyle('right'),
marginLeft: 12,
}}
onMouseEnter={() => setHoveredDivider('right')}
onMouseLeave={() => setHoveredDivider(null)}
>
{/* 拖拽手柄竖线 */}
<div style={{
width: 2,
height: 32,
borderRadius: 1,
background: dragging === 'right'
? '#fff'
: hoveredDivider === 'right'
? token.colorPrimary
: token.colorBorder,
transition: 'background 0.2s ease',
}} />
</div>
{/* 右列 */}
<div style={{ width: rightWidth, overflow: 'hidden', flexShrink: 0, marginLeft: 6 }}>
<RightPanel />
</div>
</div>
</HomeContext.Provider>
);
}

View File

@@ -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 (
<div className="flex-1 p-8">
<div className="mx-auto max-w-4xl space-y-6">
{/* ---- 标题区 + 主题开关 ---- */}
<div className="text-center space-y-2">
<div className="flex items-center justify-center gap-4">
<Title level={1} className="mb-0! gradient-primary-text">
HeiXiu ·
</Title>
<AntSwitch
checked={isDark}
onChange={toggleTheme}
checkedChildren={<BulbFilled />}
unCheckedChildren={<BulbOutlined />}
title={isDark ? '切换亮色模式' : '切换暗色模式'}
/>
</div>
<Text type="secondary">
Electron + React 19 + TypeScript + Ant Design + Tailwind CSS
</Text>
<br />
<Space size={4}>
<Tag color={isDark ? 'blue' : 'purple'}>
{isDark ? '🌙 暗色' : '☀️ 亮色'}
</Tag>
<Tag color={edition === 'enterprise' ? 'gold' : 'green'}>
{edition === 'enterprise' ? '企业版' : '个人版'}
</Tag>
{isLoggedIn ? (
<>
<Tag color="blue"></Tag>
<Button size="small" type="link" danger onClick={logout}>
退
</Button>
</>
) : (
<Tag color="default"></Tag>
)}
</Space>
</div>
{/* ---- 导航栏状态提示 ---- */}
<Card size="small" variant="borderless" className="rounded-lg">
<Text type="secondary" className="text-xs">
💡 使 <Text strong>H5 </Text>
{platform === 'darwin'
? ' macOS 保留了最小 Edit 菜单以支持 Cmd+C/V 等快捷键。'
: ' Windows/Linux 已完全移除系统菜单栏。'}
</Text>
</Card>
{/* ---- 主题色预览 ---- */}
<Card
title="🎨 蓝紫渐变主题色预览"
variant="borderless"
className="rounded-lg shadow-lg"
>
<Space orientation="vertical" size="large" className="w-full">
{/* 渐变展示 */}
<div>
<Text strong></Text>
<div className="flex gap-3 mt-2">
<div
className="flex-1 h-16 rounded-lg flex items-center justify-center text-white font-medium shadow-md"
style={{ background: gradientPrimary }}
>
135°
</div>
<div
className="flex-1 h-16 rounded-lg flex items-center justify-center text-white font-medium shadow-md"
style={{ background: 'linear-gradient(90deg, #4F46E5, #7C3AED)' }}
>
90°
</div>
</div>
</div>
{/* 组件示例 */}
<div>
<Text strong>Ant Design </Text>
<Space wrap className="mt-2">
<Button type="primary" icon={<ThunderboltOutlined />}>
</Button>
<Button></Button>
<Button type="dashed">线</Button>
<Button type="text"></Button>
<Button type="link"></Button>
</Space>
</div>
{/* 滑块 + 进度条 */}
<div>
<Text strong></Text>
<div className="flex items-center gap-4 mt-2">
<span className="text-sm" style={{ color: token.colorTextSecondary }}>
:
</span>
<Slider
className="flex-1"
value={progress}
onChange={setProgress}
min={0}
max={100}
/>
<Progress
type="circle"
percent={progress}
size={48}
strokeColor={{ '0%': '#4F46E5', '100%': '#7C3AED' }}
/>
</div>
</div>
</Space>
</Card>
{/* ---- 平台信息 ---- */}
<Card title="🖥️ 系统信息" variant="borderless" className="rounded-lg shadow-lg">
<Space orientation="vertical" size="middle" className="w-full">
<div className="flex items-center gap-2">
<ApiOutlined style={{ color: token.colorPrimary }} />
<Text strong></Text>
<Tag color="purple">{getPlatformName()}</Tag>
</div>
<div className="flex items-center gap-2">
<ApiOutlined style={{ color: token.colorPrimary }} />
<Text strong></Text>
<Tag color={isDev() ? 'blue' : 'green'}>
{isDev() ? '开发模式' : '生产模式'}
</Tag>
</div>
<div className="flex items-center gap-2">
<ApiOutlined style={{ color: token.colorPrimary }} />
<Text strong></Text>
<Tag color="purple">
H5 {edition === 'enterprise' ? '企业版' : '个人版'}
</Tag>
</div>
</Space>
</Card>
{/* ---- 版本更新 ---- */}
<Card title="🔄 版本更新" variant="borderless" className="rounded-lg shadow-lg">
<Space orientation="vertical" size="middle" className="w-full">
{/* 状态提示 */}
{updateStatus === 'checking' && (
<div className="flex items-center gap-2">
<SyncOutlined spin style={{ color: token.colorPrimary }} />
<Text type="secondary">...</Text>
</div>
)}
{updateStatus === 'no-update' && (
<div className="flex items-center gap-2">
<CheckCircleOutlined style={{ color: token.colorSuccess }} />
<Text type="success"></Text>
</div>
)}
{updateStatus === 'error' && (
<div className="flex items-center gap-2">
<ExclamationCircleOutlined style={{ color: token.colorError }} />
<Text type="danger">{updateError?.message || '检查更新失败'}</Text>
</div>
)}
{/* 发现新版本 */}
{(updateStatus === 'available' || updateStatus === 'downloading') && (
<>
<div className="flex items-center gap-2">
<RocketOutlined style={{ color: token.colorPrimary }} />
<Text strong>{updateInfo?.version}</Text>
{updateInfo?.forceUpdate && <Tag color="red"></Tag>}
</div>
{updateInfo?.releaseNotes && (
<Text type="secondary" className="text-xs whitespace-pre-line">
{updateInfo.releaseNotes}
</Text>
)}
</>
)}
{/* 下载进度 */}
{updateStatus === 'downloading' && (
<Progress
percent={updateProgress.percent}
strokeColor={{ '0%': '#4F46E5', '100%': '#7C3AED' }}
/>
)}
{/* 下载完成 */}
{updateStatus === 'downloaded' && (
<>
<div className="flex items-center gap-2">
<CheckCircleOutlined style={{ color: token.colorSuccess }} />
<Text></Text>
</div>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={installUpdate}
>
</Button>
</>
)}
{/* 操作按钮 */}
<div className="flex items-center gap-3">
<Button
icon={<SyncOutlined spin={updateStatus === 'checking'} />}
onClick={checkForUpdates}
disabled={updateStatus === 'checking'}
>
</Button>
{isDev() && (
<Text type="secondary" className="text-xs">
5
</Text>
)}
</div>
</Space>
</Card>
</div>
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<HomeContent />
</div>
);
}

View File

@@ -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<HTMLDivElement>(null);
// 模型选择区高度(像素)
const [topHeight, setTopHeight] = useState<number | null>(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 (
<div
ref={containerRef}
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
overflow: 'hidden',
borderRadius: 8,
border: `1px solid ${token.colorBorderSecondary}`,
}}
>
{/* 上方:模型选择(高度由拖拽控制) */}
<div
style={{
height: topHeight ?? '40%',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
flexShrink: 0,
}}
>
<ModelSelector />
</div>
{/* 纵向拖拽分隔条 */}
<div
onMouseDown={handleDividerMouseDown}
style={{
height: DIVIDER_HEIGHT,
flexShrink: 0,
cursor: 'row-resize',
background: isActive
? token.colorPrimary
: isHovered
? `${token.colorPrimary}60`
: token.colorBorderSecondary,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 0.2s ease',
zIndex: 10,
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{/* 拖拽手柄横线 */}
<div style={{
height: 2,
width: 32,
borderRadius: 1,
background: isActive
? '#fff'
: isHovered
? token.colorPrimary
: token.colorBorder,
transition: 'background 0.2s ease',
}} />
</div>
{/* 下方:任务记录(剩余高度) */}
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<TaskHistory />
</div>
</div>
);
}

View File

@@ -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<string, unknown>): 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<string, unknown>,
): ParamDescriptor[] {
return paramSchema.map((key) => {
const config = (uiConfig[key] || {}) as Record<string, unknown>;
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<UploadFile[]>([]);
// 提交突变 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<string, string> = {
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 (
<InputNumber
{...commonProps}
min={desc.min}
max={desc.max}
step={desc.step}
style={{ width: '100%' }}
/>
);
case 'textarea':
return <TextArea rows={3} {...commonProps} />;
case 'select':
return (
<Select
{...commonProps}
options={desc.options}
allowClear
/>
);
case 'slider':
return (
<Slider
min={desc.min ?? 1}
max={desc.max ?? 100}
step={desc.step ?? 1}
marks={
desc.min !== undefined && desc.max !== undefined
? { [desc.min]: `${desc.min}`, [desc.max]: `${desc.max}` }
: undefined
}
tooltip={{ formatter: (v) => v }}
/>
);
case 'switch':
return <Switch />;
case 'image-upload':
return (
<Upload
listType="picture-card"
maxCount={1}
accept="image/png,image/jpg,image/jpeg,image/webp"
beforeUpload={(file) => {
const isImage = file.type.startsWith('image/');
const isLt10M = file.size / 1024 / 1024 < 10;
if (!isImage) {
message.error('只能上传图片文件');
return Upload.LIST_IGNORE;
}
if (!isLt10M) {
message.error('图片大小不能超过 10MB');
return Upload.LIST_IGNORE;
}
return false; // 手动上传
}}
>
<div>
<UploadOutlined />
<div style={{ marginTop: 4, fontSize: 12 }}></div>
</div>
</Upload>
);
default:
return <Input {...commonProps} />;
}
};
// ---- 未选择模型 ----
if (!selectedModel) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
}}
>
<Empty description="请在左侧选择一个模型" image={Empty.PRESENTED_IMAGE_SIMPLE} />
</div>
);
}
// ---- 表单 ----
return (
<div
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
{/* 模型标题 */}
<div
style={{
padding: '16px 20px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
flexShrink: 0,
}}
>
<Title level={4} style={{ margin: 0, fontSize: 18 }}>
{selectedModel.name}
</Title>
<Text type="secondary" style={{ fontSize: 12 }}>
{selectedModel.provider_name} · {selectedModel.category_name}
</Text>
</div>
{/* 可滚动表单区 */}
<div style={{ flex: 1, overflow: 'auto', padding: '16px 20px' }}>
<Form
form={form}
layout="vertical"
size="small"
initialValues={{ num_outputs: 1 }}
>
{/* ======== 固定字段 ======== */}
{/* 提示词 */}
<Form.Item
name="prompt"
label={<Text strong></Text>}
rules={[{ required: true, message: '请输入提示词' }]}
>
<TextArea
rows={4}
placeholder="描述你想要的画面、视频或音频内容..."
showCount
maxLength={2000}
/>
</Form.Item>
{/* 负面提示词 */}
<Form.Item
name="negative_prompt"
label={<Text type="secondary"></Text>}
>
<TextArea
rows={2}
placeholder="描述你不希望在结果中出现的内容(可选)"
maxLength={1000}
/>
</Form.Item>
{/* 参考图 */}
<Form.Item label={<Text strong></Text>}>
<Dragger
multiple
listType="picture"
fileList={referenceFiles}
onChange={({ fileList }) => setReferenceFiles(fileList)}
accept="image/png,image/jpg,image/jpeg,image/webp"
beforeUpload={(file) => {
const isImage = file.type.startsWith('image/');
const isLt10M = file.size / 1024 / 1024 < 10;
if (!isImage) {
message.error('只能上传图片文件');
return Upload.LIST_IGNORE;
}
if (!isLt10M) {
message.error('图片大小不能超过 10MB');
return Upload.LIST_IGNORE;
}
return false;
}}
maxCount={5}
style={{ marginBottom: 8 }}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint">
PNG / JPG / WebP 10MB
</p>
</Dragger>
</Form.Item>
{/* 生成数量 */}
<Form.Item
name="num_outputs"
label={<Text strong></Text>}
>
<InputNumber min={1} max={10} style={{ width: '100%' }} />
</Form.Item>
{/* ======== 模型动态参数 ======== */}
{paramDescriptors.length > 0 && (
<div
style={{
marginTop: 16,
paddingTop: 16,
borderTop: `1px solid ${token.colorBorderSecondary}`,
}}
>
<Text strong style={{ display: 'block', marginBottom: 12 }}>
</Text>
{paramDescriptors.map((desc) => (
<Form.Item
key={desc.key}
name={desc.key}
label={desc.label}
rules={desc.required ? [{ required: true, message: `请输入${desc.label}` }] : undefined}
initialValue={desc.defaultValue}
valuePropName={desc.controlType === 'switch' ? 'checked' : 'value'}
>
{renderParamControl(desc)}
</Form.Item>
))}
</div>
)}
</Form>
</div>
{/* 提交按钮 */}
<div
style={{
padding: '12px 20px',
borderTop: `1px solid ${token.colorBorderSecondary}`,
flexShrink: 0,
}}
>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSubmit}
loading={submitting}
disabled={!isLoggedIn || !selectedModel}
block
size="large"
style={{
background: token.colorPrimary,
height: 44,
fontSize: 16,
}}
>
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,122 @@
// ============================================================
// ModelSelector — 模型选择器(垂直单选列表,按类别分组)
// 使用 useModelList() Hook 获取数据,组件仅负责渲染
// ============================================================
import { useMemo } from 'react';
import { Menu, Spin, Empty, Tag, Typography, theme as antTheme } from 'antd';
import { AppstoreOutlined } from '@ant-design/icons';
import { useModelList, MODEL_CATEGORY_LABELS } from '@/hooks/use-api';
import { useHomeContext } from '@/contexts/home-context';
import type { ModelCategory } from '@/services/modules';
const { Text } = Typography;
// ---------- 组件 ----------
export function ModelSelector() {
const { token } = antTheme.useToken();
const { selectedModel, setSelectedModel } = useHomeContext();
const { data: models, loading, groupedModels } = useModelList();
// 构建 Menu items分组标题 + 模型项
const menuItems = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const items: any[] = [];
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>
),
})),
});
});
return items;
}, [groupedModels, token.colorTextSecondary]);
// 选中菜单项
const handleSelect = ({ key }: { key: string }) => {
const model = models?.find((m) => m.id === key);
if (model) {
setSelectedModel(model);
}
};
// 加载态
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
<Spin size="small" />
</div>
);
}
// 空态
if (!models || models.length === 0) {
return <Empty description="暂无可用模型" image={Empty.PRESENTED_IMAGE_SIMPLE} />;
}
// 选中 key
const selectedKeys = selectedModel ? [selectedModel.id] : [];
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>
);
}

View File

@@ -0,0 +1,165 @@
// ============================================================
// OutputPreview — 任务输出预览(图片 / 视频)
// 使用 useTaskDetail() Hook 获取任务详情,组件仅负责渲染
// ============================================================
import { useMemo } from 'react';
import { Image, Empty, Spin, Typography, Button, Tag, theme as antTheme } from 'antd';
import {
EyeOutlined,
DownloadOutlined,
CloseCircleOutlined,
} from '@ant-design/icons';
import { useHomeContext } from '@/contexts/home-context';
import { useTaskDetail } from '@/hooks/use-api';
import type { TaskStatusString } from '@/services/modules';
const { Text } = Typography;
// ---------- 状态标签配置 ----------
const STATUS_CONFIG: Record<TaskStatusString, { color: string; label: string }> = {
pending: { color: 'blue', label: '排队中' },
submitted: { color: 'cyan', label: '已提交' },
processing: { color: 'orange', label: '处理中' },
success: { color: 'green', label: '已完成' },
failed: { color: 'red', label: '失败' },
};
// ---------- 工具函数 ----------
function isVideoUrl(url: string): boolean {
return /\.(mp4|webm|mov|avi|mkv)(\?|$)/i.test(url);
}
function splitAssets(assets: Array<{ url: string }>): { images: string[]; videos: string[] } {
const images: string[] = [];
const videos: string[] = [];
assets.forEach(({ url }) => {
if (isVideoUrl(url)) videos.push(url);
else images.push(url);
});
return { images, videos };
}
// ---------- 组件 ----------
export function OutputPreview() {
const { token } = antTheme.useToken();
const { selectedTask } = useHomeContext();
// 数据获取Hook 自动处理taskId 为 null 时不请求、竞态、loading/error
const { data: detail, loading } = useTaskDetail(selectedTask?.id);
// 从 output_assets 提取图片/视频
const { images, videos } = useMemo(() => {
if (!detail?.output_assets || detail.output_assets.length === 0) {
return { images: [], videos: [] };
}
return splitAssets(detail.output_assets);
}, [detail]);
const status = selectedTask?.status as TaskStatusString | undefined;
const statusCfg = status
? (STATUS_CONFIG[status] || { color: 'default', label: status })
: { color: 'default', label: '未知' };
// ---- 无选中任务 ----
if (!selectedTask) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<Empty description="选择任务记录查看输出" image={Empty.PRESENTED_IMAGE_SIMPLE} />
</div>
);
}
// ---- 处理中 ----
if (status === 'pending' || status === 'submitted' || status === 'processing') {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 16 }}>
<Spin size="large" />
<Tag color={statusCfg.color}>{statusCfg.label}</Tag>
<Text type="secondary">...</Text>
</div>
);
}
// ---- 失败 ----
if (status === 'failed') {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 12 }}>
<CloseCircleOutlined style={{ fontSize: 48, color: '#ff4d4f' }} />
<Text type="danger" strong></Text>
{detail?.error_message && (
<Text type="secondary" style={{ maxWidth: 300, textAlign: 'center', fontSize: 12 }}>
{detail.error_message}
</Text>
)}
</div>
);
}
// ---- 加载中 ----
if (loading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<Spin size="large" />
</div>
);
}
// ---- 无结果 ----
if (!detail || images.length + videos.length === 0) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<Empty description="暂无输出结果" image={Empty.PRESENTED_IMAGE_SIMPLE} />
</div>
);
}
// ---- 展示 ----
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{
padding: '12px 16px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexShrink: 0,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<EyeOutlined style={{ fontSize: 14 }} />
<Text strong style={{ fontSize: 13 }}></Text>
<Tag color={statusCfg.color} style={{ fontSize: 11 }}>{statusCfg.label}</Tag>
</div>
{videos.length > 0 && (
<Button size="small" type="link" icon={<DownloadOutlined />} href={videos[0]} target="_blank">
</Button>
)}
</div>
<div style={{ flex: 1, overflow: 'auto', padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
{videos.map((url, i) => (
<div key={i} style={{ borderRadius: 6, overflow: 'hidden', background: '#000' }}>
<video controls style={{ width: '100%', maxHeight: 360, display: 'block' }} src={url}>
</video>
</div>
))}
{images.length === 1 ? (
<Image src={images[0]} alt="输出结果" style={{ borderRadius: 6, width: '100%', objectFit: 'contain' }} />
) : (
<Image.PreviewGroup>
{images.map((url, i) => (
<Image key={i} src={url} alt={`输出结果 ${i + 1}`}
style={{ borderRadius: 6, width: '100%', objectFit: 'contain', marginBottom: 8 }} />
))}
</Image.PreviewGroup>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
// ============================================================
// RightPanel — 右列容器(输出预览)
// ============================================================
import { theme as antTheme } from 'antd';
import { OutputPreview } from './OutputPreview';
export function RightPanel() {
const { token } = antTheme.useToken();
return (
<div
style={{
height: '100%',
overflow: 'hidden',
borderRadius: 8,
border: `1px solid ${token.colorBorderSecondary}`,
}}
>
<OutputPreview />
</div>
);
}

View File

@@ -0,0 +1,250 @@
// ============================================================
// TaskHistory — 任务记录列表(分页,横向+纵向滚动)
// 使用 useTaskList() Hook 获取数据,组件仅负责渲染
// ============================================================
import { useState, useRef, useEffect, useCallback } from 'react';
import { Table, Tag, Typography, theme as antTheme } from 'antd';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import { HistoryOutlined } from '@ant-design/icons';
import { useTaskList } from '@/hooks/use-api';
import type {
TaskInfoItemResponseBody,
TaskStatusString,
} from '@/services/modules';
import { useHomeContext } from '@/contexts/home-context';
import { on, off, EVENTS } from '@/utils/event-bus';
const { Text } = Typography;
// ---------- 状态标签配置 ----------
const STATUS_CONFIG: Record<TaskStatusString, { color: string; label: string }> = {
pending: { color: 'blue', label: '排队中' },
submitted: { color: 'cyan', label: '已提交' },
processing: { color: 'orange', label: '处理中' },
success: { color: 'green', label: '已完成' },
failed: { color: 'red', label: '失败' },
};
// ---------- 表格列定义 ----------
const columns: ColumnsType<TaskInfoItemResponseBody> = [
{
title: '任务 ID',
dataIndex: 'id',
key: 'id',
width: 140,
ellipsis: true,
fixed: 'left',
render: (id: string) => (
<Text copyable={{ text: id }} style={{ fontSize: 12, fontFamily: 'monospace' }}>
{id.slice(0, 10)}...
</Text>
),
},
{
title: '模型',
dataIndex: 'model_name',
key: 'model_name',
width: 120,
ellipsis: true,
},
{
title: '供应商',
dataIndex: 'provider_name',
key: 'provider_name',
width: 100,
ellipsis: true,
},
{
title: '输出类型',
dataIndex: 'output_type',
key: 'output_type',
width: 80,
render: (t: string) => <Tag>{t}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 80,
render: (s: TaskStatusString) => {
const cfg = STATUS_CONFIG[s] || { color: 'default', label: s };
return <Tag color={cfg.color} style={{ fontSize: 11 }}>{cfg.label}</Tag>;
},
},
{
title: '提示词',
dataIndex: 'prompt',
key: 'prompt',
width: 160,
ellipsis: true,
render: (p: string | undefined) => (
<Text style={{ fontSize: 11 }} title={p}>{p || '-'}</Text>
),
},
{
title: '提交时间',
dataIndex: 'submitted_at',
key: 'submitted_at',
width: 140,
render: (v: Date) => (
<Text style={{ fontSize: 11 }}>
{v ? new Date(v).toLocaleString('zh-CN', {
month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
}) : '-'}
</Text>
),
},
{
title: '耗时(分)',
key: 'duration',
width: 80,
render: (_: unknown, record: TaskInfoItemResponseBody) => {
if (!record.submitted_at || !record.finished_at) return <Text style={{ fontSize: 11 }}>-</Text>;
const min = Math.round(
(new Date(record.finished_at).getTime() - new Date(record.submitted_at).getTime()) / 60000,
);
return <Text style={{ fontSize: 11 }}>{min} </Text>;
},
},
{
title: '费用',
dataIndex: 'cost_cent',
key: 'cost_cent',
width: 80,
render: (c: number) => (
<Text style={{ fontSize: 11 }}>¥{(c / 100).toFixed(2)}</Text>
),
},
{
title: '用户',
dataIndex: 'user_display_name',
key: 'user_display_name',
width: 100,
ellipsis: true,
render: (n: string | null) => (
<Text style={{ fontSize: 11 }}>{n || '-'}</Text>
),
},
];
// ---------- 组件 ----------
export function TaskHistory() {
const { token } = antTheme.useToken();
const {
selectedTask,
setSelectedTask,
taskPage,
taskPageSize,
setTaskPage,
setTaskPageSize,
} = useHomeContext();
// 任务提交事件 → 刷新列表(替代 Context 中的 refreshTaskSignal
const [refreshTick, setRefreshTick] = useState(0);
const handleTaskSubmitted = useCallback(() => {
setRefreshTick((t) => t + 1);
}, []);
useEffect(() => {
on(EVENTS.TASK_SUBMITTED, handleTaskSubmitted);
return () => {
off(EVENTS.TASK_SUBMITTED, handleTaskSubmitted);
};
}, [handleTaskSubmitted]);
// 数据获取Hook 自动处理 loading / error / 竞态)
const {
data: taskData,
loading,
errorMessage,
} = useTaskList({ page: taskPage, pageSize: taskPageSize, refreshSignal: refreshTick });
const tasks = taskData?.items ?? [];
const total = taskData?.total ?? 0;
// 表格容器高度(用于 scroll.y
const tableWrapperRef = useRef<HTMLDivElement>(null);
const [tableBodyHeight, setTableBodyHeight] = useState(400);
useEffect(() => {
const el = tableWrapperRef.current;
if (!el) return;
const observer = new ResizeObserver(() => {
setTableBodyHeight(Math.max(200, el.clientHeight - 80));
});
observer.observe(el);
setTableBodyHeight(Math.max(200, el.clientHeight - 80));
return () => observer.disconnect();
}, []);
// 分页变化
const handleTableChange = (pagination: TablePaginationConfig) => {
if (pagination.current) setTaskPage(pagination.current);
if (pagination.pageSize) setTaskPageSize(pagination.pageSize);
};
// 行样式
const rowClassName = (record: TaskInfoItemResponseBody) =>
selectedTask?.id === record.id ? 'task-row-selected' : '';
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
{/* 标题栏 */}
<div
style={{
padding: '8px 12px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
display: 'flex',
alignItems: 'center',
gap: 6,
flexShrink: 0,
}}
>
<HistoryOutlined style={{ fontSize: 14 }} />
<Text strong style={{ fontSize: 13 }}></Text>
{total > 0 && (
<Text type="secondary" style={{ fontSize: 11 }}> {total} </Text>
)}
{errorMessage && (
<Text type="danger" style={{ fontSize: 11, marginLeft: 'auto' }}>{errorMessage}</Text>
)}
</div>
{/* 表格 — 横向 + 纵向滚动 */}
<div ref={tableWrapperRef} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
<Table<TaskInfoItemResponseBody>
columns={columns}
dataSource={tasks}
rowKey="id"
size="small"
loading={loading}
showHeader={true}
scroll={{ x: 1200, y: tableBodyHeight }}
rowClassName={rowClassName}
onRow={(record) => ({
onClick: () => setSelectedTask(record),
style: { cursor: 'pointer' },
})}
onChange={handleTableChange}
pagination={{
current: taskPage,
pageSize: taskPageSize,
total,
size: 'small',
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50'],
placement: ['bottomCenter'],
style: { marginBottom: 0 },
}}
locale={{ emptyText: '暂无任务记录' }}
/>
</div>
</div>
);
}

View File

@@ -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);
}
}

View 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);
}

View File

@@ -22,3 +22,37 @@ export {
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';

View 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);
}
}

View 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_atISO 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>);
}
}

View File

@@ -277,24 +277,24 @@ export const darkTheme: ThemeConfig = {
defaultBorderColor: darkColorBorder,
},
Menu: {
itemSelectedBg: '#312E81',
itemSelectedBg: '#1E2460',
itemSelectedColor: colorPrimaryLight,
itemHoverBg: '#242040',
itemActiveBg: '#1E1B4B',
itemHoverBg: '#151A48',
itemActiveBg: '#0F1442',
},
Layout: {
siderBg: `linear-gradient(180deg, #0B0A15 0%, #1A1730 100%)`,
siderBg: `linear-gradient(180deg, #050818 0%, #0D1140 100%)`,
headerBg: darkColorBgContainer,
bodyBg: darkColorBgLayout,
},
Table: {
headerBg: '#1E1B4B',
headerBg: '#0F1340',
headerColor: darkColorTextBase,
rowHoverBg: '#242040',
rowHoverBg: '#181D52',
borderColor: darkColorBorderSecondary,
},
Tag: {
defaultBg: '#1E1B4B',
defaultBg: '#1A1F54',
defaultColor: colorPrimaryLight,
},
},

View File

@@ -94,16 +94,16 @@
* ============================================================ */
[data-theme='dark'] {
/* 中性色 — 暗色 */
--color-text-base: #e8e6f0;
--color-text-secondary: #a5a0c9;
--color-text-tertiary: #6e6998;
--color-bg-base: #0f0d1a;
--color-bg-container: #1a1730;
--color-bg-layout: #0b0a15;
--color-bg-elevated: #242040;
--color-border-base: #2e2a4a;
--color-border-secondary: #1f1c38;
/* 中性色 — 暗色(科技蓝紫) */
--color-text-base: #e8e8fa;
--color-text-secondary: #a0a8d8;
--color-text-tertiary: #6e78b0;
--color-bg-base: #080c24;
--color-bg-container: #0f1340;
--color-bg-layout: #050818;
--color-bg-elevated: #171b52;
--color-border-base: #2a3278;
--color-border-secondary: #1c2250;
/* 暗色模式下品牌色提亮以保持对比度 */
--color-primary: #818cf8;

View File

@@ -8,28 +8,14 @@
// ---------- 品牌色(蓝紫渐变) ----------
/** 品牌主色 — Indigo-500 */
export const colorPrimary = '#6366F1';
/** 品牌主色浅色变体 */
export const colorPrimaryLight = '#818CF8';
/** 品牌主色深色变体 */
export const colorPrimaryDark = '#4F46E5';
/** 品牌辅助色 — Violet-500渐变另一端 */
export const colorSecondary = '#8B5CF6';
/** 品牌渐变色起始值 */
export const gradientStart = '#4F46E5'; // Indigo-600
/** 品牌渐变色结束值 */
export const gradientEnd = '#7C3AED'; // Violet-600
/** 品牌渐变色CSS 值,可直接用于 background */
export const gradientStart = '#4F46E5';
export const gradientEnd = '#7C3AED';
export const gradientPrimary = `linear-gradient(135deg, ${gradientStart}, ${gradientEnd})`;
/** 品牌渐变悬停态 */
export const gradientPrimaryHover = `linear-gradient(135deg, ${colorPrimary}, ${colorSecondary})`;
// ---------- 功能色 ----------
@@ -41,41 +27,43 @@ export const colorInfo = '#3B82F6';
// ---------- 中性色(亮色模式) ----------
export const colorTextBase = '#1E1B4B'; // 深蓝紫黑
export const colorTextBase = '#1E1B4B';
export const colorTextSecondary = '#6B7280';
export const colorTextTertiary = '#9CA3AF';
export const colorBgBase = '#FFFFFF';
export const colorBgContainer = '#F9FAFB';
export const colorBgElevated = '#FFFFFF';
export const colorBgLayout = '#F5F3FF'; // Violet-50
export const colorBgLayout = '#F5F3FF';
export const colorBgSpotlight = '#F3F4F6';
export const colorBorder = '#E5E7EB';
export const colorBorderSecondary = '#F3F4F6';
/** 亮色模式 — 面板边框(稍明显) */
export const colorBorder = '#E2E0F0';
/** 亮色模式 — 内部分隔线(可见但不强烈) */
export const colorBorderSecondary = '#EEECF6';
// ---------- 中性色(暗色模式) ----------
// ---------- 中性色(暗色模式 — 融入品牌蓝紫调 ----------
/** 暗色模式 — 主文字 */
export const darkColorTextBase = '#E8E6F0';
export const darkColorTextBase = '#E8E8FA';
/** 暗色模式 — 次要文字 */
export const darkColorTextSecondary = '#A5A0C9';
export const darkColorTextSecondary = '#A0A8D8';
/** 暗色模式 — 辅助文字 */
export const darkColorTextTertiary = '#6E6998';
export const darkColorTextTertiary = '#6E78B0';
/** 暗色模式 — 基础背景 */
export const darkColorBgBase = '#0F0D1A'; // 深蓝紫黑
/** 暗色模式 — 容器背景 */
export const darkColorBgContainer = '#1A1730'; // 抬升一层
/** 暗色模式 — 悬浮背景 */
export const darkColorBgElevated = '#242040'; // 再抬升一层
/** 暗色模式 — 布局背景 */
export const darkColorBgLayout = '#0B0A15'; // 最深背景
/** 暗色模式 — 页面底色(深邃蓝黑) */
export const darkColorBgBase = '#080C24';
/** 暗色模式 — 卡片/面板背景(暗蓝紫) */
export const darkColorBgContainer = '#0F1340';
/** 暗色模式 — 悬浮背景(微亮蓝紫) */
export const darkColorBgElevated = '#171B52';
/** 暗色模式 — 页面布局底层 */
export const darkColorBgLayout = '#050818';
/** 暗色模式 — 边框 */
export const darkColorBorder = '#2E2A4A';
/** 暗色模式 — 次要边框 */
export const darkColorBorderSecondary = '#1F1C38';
/** 暗色模式 — 面板外框(蓝紫边框,可见但不刺眼) */
export const darkColorBorder = '#2A3278';
/** 暗色模式 — 内部分隔线(柔和蓝紫,常驻可见) */
export const darkColorBorderSecondary = '#1C2250';
// ---------- 圆角 ----------

View File

@@ -69,4 +69,6 @@ export const EVENTS = {
LOGIN_SUCCESS: 'auth:login-success',
/** 退出登录 */
LOGOUT: 'auth:logout',
/** 任务提交成功(→ 刷新任务列表) */
TASK_SUBMITTED: 'task:submitted',
} as const;

File diff suppressed because one or more lines are too long

View File

@@ -67,8 +67,7 @@ export default defineConfig(({ mode }) => {
server: {
port: 5173,
strictPort: true,
host: 'localhost',
// 文件监听Windows 优化)
host: "0.0.0.0",
watch: {
// Windows 上默认的 fs.watch 有时不稳定,
// 如果文件保存后界面无反应,可临时开启 usePolling
@@ -81,8 +80,20 @@ export default defineConfig(({ mode }) => {
hmr: {
// 在浏览器控制台显示 HMR 覆盖层(错误/警告时)
overlay: true,
// 协议默认 ws://localhost 下无需额外配置
},
proxy: mode === "development" ? {
"/api": {
target: env.VITE_API_BASE_URL || "http://192.168.110.84:8000", // 从环境变量读取,默认为本地后端
changeOrigin: true,
bypass: (req) => {
if (req.headers.accept?.includes('text/html')) return '/index.html'
},
},
'/uploads': {
target: env.VITE_API_BASE_URL ?? 'http://192.168.110.84:8000',
changeOrigin: true,
},
} : undefined, // 生产环境不启用代理
},
build: {
rollupOptions: {