Compare commits
3 Commits
main
...
0d3d4fcd6a
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d3d4fcd6a | |||
| af85d31035 | |||
| 1da95eaef3 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"pid": 91784,
|
"pid": 145380,
|
||||||
"version": "0.9.9",
|
"version": "0.9.9",
|
||||||
"socketPath": "\\\\.\\pipe\\codegraph-aef859f0205bacea",
|
"socketPath": "\\\\.\\pipe\\codegraph-aef859f0205bacea",
|
||||||
"startedAt": 1780539931451
|
"startedAt": 1780622650728
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
# 渲染进程 API 地址(Vite 编译时注入,不可在主进程中使用)
|
# 渲染进程 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(指向本地测试服务器)
|
# 主进程 — 更新检查 API(指向本地测试服务器)
|
||||||
UPDATE_API_URL=http://127.0.0.1:8000
|
UPDATE_API_URL=http://127.0.0.1:8000
|
||||||
|
|||||||
11
PROJECT.md
11
PROJECT.md
@@ -169,6 +169,10 @@
|
|||||||
|
|
||||||
- 错误日志:request.ts 拦截器根据错误类型自动分级记录(AUTH_EXPIRED → debug,5xx → error,其余 → warn)。
|
- 错误日志:request.ts 拦截器根据错误类型自动分级记录(AUTH_EXPIRED → debug,5xx → error,其余 → warn)。
|
||||||
|
|
||||||
|
- 401 判断策略:HTTP 401 拦截处双重校验——同时检查 HTTP 状态码和响应体中的业务码 `data.code`。
|
||||||
|
仅 `status === 401 && data?.code === 401` 才走 token 刷新流程;其他业务码(如密码错误)穿透到正常错误处理,
|
||||||
|
透传后端的 `data.message`。这确保登录接口返回的"账号或密码错误"不被 RefreshError("登录已过期") 覆盖。
|
||||||
|
|
||||||
- 脱敏:shared/utils/sanitize.ts 在日志写入前自动处理 ID(哈希)、邮箱/手机/名称/Token 等字段。
|
- 脱敏:shared/utils/sanitize.ts 在日志写入前自动处理 ID(哈希)、邮箱/手机/名称/Token 等字段。
|
||||||
|
|
||||||
# 样式与主题
|
# 样式与主题
|
||||||
@@ -176,8 +180,11 @@
|
|||||||
- 优先使用 Tailwind 工具类 完成布局与常规样式,自定义设计令牌应映射为 Tailwind 扩展(在 tailwind.config.ts 中配置
|
- 优先使用 Tailwind 工具类 完成布局与常规样式,自定义设计令牌应映射为 Tailwind 扩展(在 tailwind.config.ts 中配置
|
||||||
theme.extend)。
|
theme.extend)。
|
||||||
|
|
||||||
- Ant Design 组件样式通过 ConfigProvider 的 theme 属性覆盖,确保与 Tailwind 主题令牌同步。在 src/theme/adapters/ 中创建
|
- Ant Design 组件样式通过 ConfigProvider 的 theme 属性覆盖,确保与 Tailwind 主题令牌同步。
|
||||||
antd-theme.ts,导出主题配置对象,引用 tokens.ts 中的设计变量。
|
`ConfigProvider` 同时配置 `locale={zhCN}` 实现全局中文(分页器"条/页"等无需逐个组件设置)。
|
||||||
|
在 src/theme/ 中创建 antd-theme.ts,导出主题配置对象,引用 tokens.ts 中的设计变量。
|
||||||
|
组件级 token(如 Pagination.itemActiveColor / itemActiveBg)在 sharedComponents 中统一管理,
|
||||||
|
亮色/暗色主题共享,避免 CSS !important hack。
|
||||||
|
|
||||||
- 禁止在组件中硬编码颜色、字号等设计令牌,一律使用主题变量或 Tailwind 类名。
|
- 禁止在组件中硬编码颜色、字号等设计令牌,一律使用主题变量或 Tailwind 类名。
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
default-src 'self';
|
default-src 'self';
|
||||||
script-src 'self' 'unsafe-inline';
|
script-src 'self' 'unsafe-inline';
|
||||||
style-src 'self' 'unsafe-inline';
|
style-src 'self' 'unsafe-inline';
|
||||||
img-src 'self' data:;
|
img-src 'self' data: https://rh-images-1252422369.cos.ap-beijing.myqcloud.com https://www.heixiu.net ; ;
|
||||||
connect-src 'self' http://8.160.179.64:8000 http://localhost:5173 ws://localhost:5173 https://www.heixiu.net ;
|
connect-src 'self' http://8.160.179.64:8000 http://localhost:5173 ws://localhost:5173 https://www.heixiu.net ;
|
||||||
">
|
">
|
||||||
<title>船长·HeiXiu</title>
|
<title>船长·HeiXiu</title>
|
||||||
|
|||||||
14
package-lock.json
generated
14
package-lock.json
generated
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "ele-heixiu",
|
"name": "ele-heixiu",
|
||||||
"version": "0.0.1",
|
"version": "0.0.12",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ele-heixiu",
|
"name": "ele-heixiu",
|
||||||
"version": "0.0.1",
|
"version": "0.0.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@react-buddy/ide-toolbox": "^2.5.0",
|
||||||
"antd": "^6.4.3",
|
"antd": "^6.4.3",
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.16.1",
|
||||||
"electron-updater": "^6.8.3",
|
"electron-updater": "^6.8.3",
|
||||||
@@ -1949,6 +1950,15 @@
|
|||||||
"react-dom": ">=18.0.0"
|
"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": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
||||||
|
|||||||
@@ -31,7 +31,8 @@
|
|||||||
"electron-updater": "^6.8.3",
|
"electron-updater": "^6.8.3",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^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": {
|
"build": {
|
||||||
"appId": "com.heixiu.electron",
|
"appId": "com.heixiu.electron",
|
||||||
|
|||||||
@@ -2,11 +2,10 @@
|
|||||||
// AppProvider — 应用状态 Provider 组件
|
// AppProvider — 应用状态 Provider 组件
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, type ReactNode } from 'react';
|
import { useState, useMemo, useCallback, useEffect, type ReactNode } from 'react';
|
||||||
import type { Platform, Edition } from '@shared/types';
|
import type { Platform, Edition } from '@shared/types';
|
||||||
|
|
||||||
import { getPlatform, isDev } from '@/utils/platform';
|
import { getPlatform, isDev } from '@/utils/platform';
|
||||||
import { parseEdition } from '@shared/constants/app';
|
|
||||||
import { safeIpcOn, safeIpcOff } from '@/utils/ipc';
|
import { safeIpcOn, safeIpcOff } from '@/utils/ipc';
|
||||||
import { AppContext } from '@/contexts/app-context';
|
import { AppContext } from '@/contexts/app-context';
|
||||||
import type { AppContextValue } from '@/contexts/app-context';
|
import type { AppContextValue } from '@/contexts/app-context';
|
||||||
@@ -30,12 +29,25 @@ interface AppProviderProps {
|
|||||||
|
|
||||||
export function AppProvider({ children }: AppProviderProps) {
|
export function AppProvider({ children }: AppProviderProps) {
|
||||||
const [platform] = useState<Platform>(getPlatform);
|
const [platform] = useState<Platform>(getPlatform);
|
||||||
const [edition] = useState<Edition>(() => {
|
|
||||||
return parseEdition(import.meta.env.VITE_EDITION);
|
|
||||||
});
|
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
const [user, setUser] = useState<LoginResponseBody | null>(null);
|
const [user, setUser] = useState<LoginResponseBody | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* edition 从登录用户的 account_type 派生(运行时判断,无需构建变量)
|
||||||
|
*
|
||||||
|
* 映射关系:
|
||||||
|
* "individual" → personal ← 个人用户
|
||||||
|
* "company" | "employee" → enterprise ← 企业用户
|
||||||
|
* null(未登录) → personal ← 默认最小功能集
|
||||||
|
*
|
||||||
|
* 安全性:edition 是 useMemo 派生值,不可独立赋值;
|
||||||
|
* 不写入 localStorage,重启/登出后回退默认值,必须重新登录才能确权。
|
||||||
|
*/
|
||||||
|
const edition: Edition = useMemo(() => {
|
||||||
|
if (!user) return 'personal';
|
||||||
|
return user.account_type === 'individual' ? 'personal' : 'enterprise';
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
// ---------- 登录 / 退出 ----------
|
// ---------- 登录 / 退出 ----------
|
||||||
|
|
||||||
const login = useCallback(async (auth: LoginResponseBody) => {
|
const login = useCallback(async (auth: LoginResponseBody) => {
|
||||||
|
|||||||
@@ -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 { theme as antTheme } from 'antd';
|
||||||
import { NavBar } from './navbar';
|
import { NavBar } from './navbar';
|
||||||
|
import { BannerCarousel } from '@/pages/home/components/BannerCarousel';
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const { token } = antTheme.useToken();
|
const { token } = antTheme.useToken();
|
||||||
|
const location = useLocation();
|
||||||
|
const isHomePage = location.pathname === '/' || location.pathname === '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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 }}
|
style={{ background: token.colorBgLayout }}
|
||||||
>
|
>
|
||||||
|
{isHomePage && <BannerCarousel />}
|
||||||
<NavBar />
|
<NavBar />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback, type ReactNode } from 'react';
|
import { useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||||
import { ConfigProvider } from 'antd';
|
import { ConfigProvider } from 'antd';
|
||||||
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
|
|
||||||
import { getThemeConfig } from '../theme/antd-theme';
|
import { getThemeConfig } from '../theme/antd-theme';
|
||||||
import {
|
import {
|
||||||
@@ -66,7 +67,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider value={ctx}>
|
<ThemeContext.Provider value={ctx}>
|
||||||
<ConfigProvider theme={getThemeConfig(isDark)}>{children}</ConfigProvider>
|
<ConfigProvider theme={getThemeConfig(isDark)} locale={zhCN}>{children}</ConfigProvider>
|
||||||
</ThemeContext.Provider>
|
</ThemeContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
43
src/contexts/home-context.ts
Normal file
43
src/contexts/home-context.ts
Normal 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;
|
||||||
|
}
|
||||||
166
src/hooks/use-api.ts
Normal file
166
src/hooks/use-api.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 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 {
|
||||||
|
/** 完整的 API 请求参数(由调用方根据 antd Table onChange 构建) */
|
||||||
|
requestParams: TaskListRequestParams;
|
||||||
|
/** 外部传入的刷新信号(任意变化触发重新请求) */
|
||||||
|
refreshSignal?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务列表 Hook(分页 + 筛选 + 排序)
|
||||||
|
*
|
||||||
|
* 调用方负责构建 TaskListRequestParams(包括 edition-aware 的 user_ids 逻辑),
|
||||||
|
* 本 Hook 仅负责数据获取、竞态控制、错误日志。
|
||||||
|
*/
|
||||||
|
export function useTaskList(params: UseTaskListParams) {
|
||||||
|
const { isLoggedIn } = useAppContext();
|
||||||
|
const { requestParams, refreshSignal = 0 } = params;
|
||||||
|
|
||||||
|
return useAsyncData(
|
||||||
|
() => TaskAPI.getTaskList(requestParams),
|
||||||
|
[requestParams, 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
270
src/pages/home/HomeContent.tsx
Normal file
270
src/pages/home/HomeContent.tsx
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
// ============================================================
|
||||||
|
// HomeContent — 首页三列正文布局 + HomeProvider
|
||||||
|
// 可拖拽调整三栏宽度,所有数据请求在登录后发送
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef, useEffect, useMemo } 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 './components/LeftPanel';
|
||||||
|
import { CenterPanel } from './components/CenterPanel';
|
||||||
|
import { RightPanel } from './components/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 = useMemo(() => ({
|
||||||
|
selectedModel,
|
||||||
|
setSelectedModel: handleSetSelectedModel,
|
||||||
|
taskPage,
|
||||||
|
taskPageSize,
|
||||||
|
setTaskPage,
|
||||||
|
setTaskPageSize,
|
||||||
|
selectedTask,
|
||||||
|
setSelectedTask: handleSetSelectedTask,
|
||||||
|
isLeftCollapsed,
|
||||||
|
toggleLeftCollapsed,
|
||||||
|
}), [
|
||||||
|
selectedModel,
|
||||||
|
handleSetSelectedModel,
|
||||||
|
taskPage,
|
||||||
|
taskPageSize,
|
||||||
|
selectedTask,
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,277 +1,14 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// HomePage — 首页仪表盘(从 App.tsx 提取)
|
// HomePage — 首页(业务入口)
|
||||||
|
// 组合:BannerCarousel(在 Layout 中)+ HomeContent(三列正文)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { HomeContent } from './HomeContent';
|
||||||
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;
|
|
||||||
|
|
||||||
export function HomePage() {
|
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 (
|
return (
|
||||||
<div className="flex-1 p-8">
|
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
<div className="mx-auto max-w-4xl space-y-6">
|
<HomeContent />
|
||||||
{/* ---- 标题区 + 主题开关 ---- */}
|
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
74
src/pages/home/components/BannerCarousel.tsx
Normal file
74
src/pages/home/components/BannerCarousel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/pages/home/components/CenterPanel.tsx
Normal file
23
src/pages/home/components/CenterPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
179
src/pages/home/components/LeftPanel.tsx
Normal file
179
src/pages/home/components/LeftPanel.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
// ============================================================
|
||||||
|
// LeftPanel — 左列容器(模型选择 + 可拖拽分割线 + 任务搜索 + 任务记录)
|
||||||
|
// 纵向拖拽分隔线支持鼠标调整上下区域高度比例
|
||||||
|
//
|
||||||
|
// 职责:布局 + edition 分发,子组件自行管理数据和搜索状态
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
463
src/pages/home/components/ModelInputForm.tsx
Normal file
463
src/pages/home/components/ModelInputForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
src/pages/home/components/ModelSelector.tsx
Normal file
122
src/pages/home/components/ModelSelector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
src/pages/home/components/OutputPreview.tsx
Normal file
165
src/pages/home/components/OutputPreview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/pages/home/components/RightPanel.tsx
Normal file
23
src/pages/home/components/RightPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
745
src/pages/home/components/TaskHistory.tsx
Normal file
745
src/pages/home/components/TaskHistory.tsx
Normal file
@@ -0,0 +1,745 @@
|
|||||||
|
// ============================================================
|
||||||
|
// TaskHistory — 任务记录列表(分页 + 列头筛选 + 排序 + 滚动)
|
||||||
|
//
|
||||||
|
// 筛选策略(混合模式):
|
||||||
|
// - 服务端筛选(传 API):status、model_id、user_id(企业版)
|
||||||
|
// - 客户端筛选(antd onFilter):output_type、provider_name
|
||||||
|
// - 服务端不支持日期查询,时间范围筛选暂用 sorter 排序替代
|
||||||
|
// - 企业版才会在"用户"列显示筛选菜单
|
||||||
|
//
|
||||||
|
// 使用 antd Table onChange 统一管理 pagination + filters + sorter,
|
||||||
|
// 参考 table-test.tsx 示例 4 的模式。
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { Table, Tag, Typography, theme as antTheme, Popover, Checkbox, Button, Space } from 'antd';
|
||||||
|
import type { ColumnsType, TableProps } from 'antd/es/table';
|
||||||
|
import type { FilterValue, SorterResult } from 'antd/es/table/interface';
|
||||||
|
import { HistoryOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { useTaskList, useModelList } from '@/hooks/use-api';
|
||||||
|
import { useAppContext } from '@/contexts/app-context';
|
||||||
|
import { useHomeContext } from '@/contexts/home-context';
|
||||||
|
import { useTheme } from '@/hooks/use-theme';
|
||||||
|
import { on, off, EVENTS } from '@/utils/event-bus';
|
||||||
|
import type {
|
||||||
|
TaskInfoItemResponseBody,
|
||||||
|
TaskListRequestParams,
|
||||||
|
TaskStatusString,
|
||||||
|
} from '@/services/modules';
|
||||||
|
import { TaskStatusMap, OutputTypeMap } from '@/services/modules';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
// ---------- 辅助类型 ----------
|
||||||
|
|
||||||
|
/** antd Table onChange 中 filters 的类型 */
|
||||||
|
type TableFilters = Record<string, FilterValue | null>;
|
||||||
|
|
||||||
|
/** antd Table onChange 中 sorter 的类型(单列排序) */
|
||||||
|
interface TableSorter {
|
||||||
|
field?: React.Key | readonly React.Key[];
|
||||||
|
order?: 'ascend' | 'descend';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 表格参数(缓存 antd onChange 的结果,用于构建 API 请求) */
|
||||||
|
interface TableParams {
|
||||||
|
pagination: { current: number; pageSize: number };
|
||||||
|
filters: TableFilters;
|
||||||
|
sorter: TableSorter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 常量 ----------
|
||||||
|
|
||||||
|
/** 状态筛选选项(来自 API 枚举) */
|
||||||
|
const STATUS_FILTERS = Object.entries(TaskStatusMap).map(([value, label]) => ({
|
||||||
|
text: label,
|
||||||
|
value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** 输出类型筛选选项(从 OutputTypeMap 派生) */
|
||||||
|
const OUTPUT_TYPE_FILTERS = Object.entries(OutputTypeMap).map(([value, text]) => ({
|
||||||
|
text,
|
||||||
|
value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** 状态标签配置(渲染用,含未来未知状态的降级处理) */
|
||||||
|
const STATUS_CONFIG: Record<string, { color: string; label: string }> = {
|
||||||
|
pending: { color: 'blue', label: '排队中' },
|
||||||
|
submitted: { color: 'cyan', label: '已提交' },
|
||||||
|
processing: { color: 'orange', label: '处理中' },
|
||||||
|
success: { color: 'green', label: '已完成' },
|
||||||
|
failed: { color: 'red', label: '失败' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 未知状态降级:后端新增状态时自动回退显示 key 原文 */
|
||||||
|
function resolveStatus(s: string): { color: string; label: string } {
|
||||||
|
return STATUS_CONFIG[s] || { color: 'default', label: s };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 列可见性持久化 ----------
|
||||||
|
|
||||||
|
/** localStorage 键名(遵循 ele-heixiu-* 规范) */
|
||||||
|
const COLUMN_VISIBILITY_KEY = 'ele-heixiu-table-columns';
|
||||||
|
|
||||||
|
/** 列元数据(定义所有可用列 + 默认可见性) */
|
||||||
|
interface ColumnMeta { key: string; title: string; defaultVisible: boolean }
|
||||||
|
|
||||||
|
const ALL_COLUMNS: ColumnMeta[] = [
|
||||||
|
{ key: 'id', title: '任务 ID', defaultVisible: true },
|
||||||
|
{ key: 'created_at', title: '创建时间', defaultVisible: true },
|
||||||
|
{ key: 'model_name', title: '模型', defaultVisible: true },
|
||||||
|
{ key: 'output_type', title: '输出类型', defaultVisible: true },
|
||||||
|
{ key: 'status', title: '状态', defaultVisible: true },
|
||||||
|
{ key: 'prompt', title: '提示词', defaultVisible: true },
|
||||||
|
{ key: 'duration', title: '时长', defaultVisible: true },
|
||||||
|
{ key: 'cost_cent', title: '费用', defaultVisible: true },
|
||||||
|
{ key: 'user_display_name', title: '用户', defaultVisible: true },
|
||||||
|
{ key: 'provider_name', title: '供应商', defaultVisible: false },
|
||||||
|
{ key: 'fps', title: '帧率', defaultVisible: false },
|
||||||
|
{ key: 'file_size', title: '文件大小', defaultVisible: false },
|
||||||
|
{ key: 'tags', title: '标签', defaultVisible: false },
|
||||||
|
{ key: 'error_message', title: '错误信息', defaultVisible: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
function readStoredColumns(): Set<string> | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(COLUMN_VISIBILITY_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const arr: unknown = JSON.parse(raw);
|
||||||
|
if (Array.isArray(arr) && arr.every((v) => typeof v === 'string')) return new Set(arr);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStoredColumns(visible: Set<string>): void {
|
||||||
|
try { localStorage.setItem(COLUMN_VISIBILITY_KEY, JSON.stringify([...visible])); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 工具函数 ----------
|
||||||
|
|
||||||
|
/** 从 filters 中提取单值筛选(如 status 单选) */
|
||||||
|
function pickOne(filters: TableFilters, key: string): string | undefined {
|
||||||
|
const v = filters[key];
|
||||||
|
if (Array.isArray(v) && v.length > 0) return v[0] as string;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 filters 中提取多值筛选(如 model_ids、user_ids) */
|
||||||
|
function pickMany(filters: TableFilters, key: string): string[] | undefined {
|
||||||
|
const v = filters[key];
|
||||||
|
if (Array.isArray(v) && v.length > 0) return v as string[];
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 组件 ----------
|
||||||
|
|
||||||
|
export function TaskHistory() {
|
||||||
|
const { token } = antTheme.useToken();
|
||||||
|
const { edition } = useAppContext();
|
||||||
|
const {
|
||||||
|
selectedTask,
|
||||||
|
setSelectedTask,
|
||||||
|
taskPage,
|
||||||
|
taskPageSize,
|
||||||
|
setTaskPage,
|
||||||
|
setTaskPageSize,
|
||||||
|
} = useHomeContext();
|
||||||
|
|
||||||
|
const isEnterprise = edition === 'enterprise';
|
||||||
|
|
||||||
|
// -------- 列可见性 --------
|
||||||
|
|
||||||
|
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(() => {
|
||||||
|
const stored = readStoredColumns();
|
||||||
|
if (stored) return stored;
|
||||||
|
return new Set(ALL_COLUMNS.filter((c) => c.defaultVisible).map((c) => c.key));
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleColumnToggle = useCallback((key: string, checked: boolean) => {
|
||||||
|
setVisibleColumns((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (checked) next.add(key); else next.delete(key);
|
||||||
|
writeStoredColumns(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// -------- 主题感知斑马纹 / hover / 选中行 CSS --------
|
||||||
|
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
|
||||||
|
const tableCss = useMemo(() => {
|
||||||
|
const searchIconColor = token.colorPrimary;
|
||||||
|
if (isDark) {
|
||||||
|
return `
|
||||||
|
.task-table .task-row-odd > td { background: rgba(255,255,255,0.02) !important; }
|
||||||
|
.task-table .task-row-even > td { background: transparent !important; }
|
||||||
|
.task-table .task-row-selected > td { background: ${token.blue}1A !important; }
|
||||||
|
.task-table .task-row-selected.task-row-odd > td { background: ${token.blue}22 !important; }
|
||||||
|
.task-table .ant-table-row:hover > td { background: rgba(255,255,255,0.05) !important; }
|
||||||
|
.task-table .task-row-selected:hover > td { background: ${token.blue}28 !important; }
|
||||||
|
.task-table .prompt-search-icon.filtered { color: ${searchIconColor} !important; }
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
.task-table .task-row-odd > td { background: #fafafa !important; }
|
||||||
|
.task-table .task-row-even > td { background: #ffffff !important; }
|
||||||
|
.task-table .task-row-selected > td { background: #e6f4ff !important; }
|
||||||
|
.task-table .task-row-selected.task-row-odd > td { background: #dceeff !important; }
|
||||||
|
.task-table .ant-table-row:hover > td { background: #f0f5ff !important; }
|
||||||
|
.task-table .task-row-selected:hover > td { background: #d6ebff !important; }
|
||||||
|
.task-table .prompt-search-icon.filtered { color: ${searchIconColor} !important; }
|
||||||
|
`;
|
||||||
|
}, [isDark, token.blue, token.colorPrimary]);
|
||||||
|
|
||||||
|
// -------- antd Table onChange 参数缓存 --------
|
||||||
|
|
||||||
|
const [tableParams, setTableParams] = useState<TableParams>({
|
||||||
|
pagination: { current: taskPage, pageSize: taskPageSize },
|
||||||
|
filters: {},
|
||||||
|
sorter: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 同步 context 分页 ↔ tableParams
|
||||||
|
useEffect(() => {
|
||||||
|
setTableParams((prev) => ({
|
||||||
|
...prev,
|
||||||
|
pagination: { current: taskPage, pageSize: taskPageSize },
|
||||||
|
}));
|
||||||
|
}, [taskPage, taskPageSize]);
|
||||||
|
|
||||||
|
// -------- 模型列表(用于模型列筛选选项 + 模型名→ID 映射)--------
|
||||||
|
|
||||||
|
// TODO: 与 ModelSelector 中的 useModelList 重复请求,后续统一缓存层
|
||||||
|
const { data: allModels } = useModelList();
|
||||||
|
const modelFilters = useMemo(() => {
|
||||||
|
if (!allModels) return [];
|
||||||
|
return allModels.map((m) => ({ text: m.name, value: m.id }));
|
||||||
|
}, [allModels]);
|
||||||
|
|
||||||
|
// -------- 供应商列表(从模型数据中提取)--------
|
||||||
|
|
||||||
|
const providerFilters = useMemo(() => {
|
||||||
|
if (!allModels) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
allModels.forEach((m) => {
|
||||||
|
if (m.provider_name) seen.add(m.provider_name);
|
||||||
|
});
|
||||||
|
return Array.from(seen).sort().map((name) => ({ text: name, value: name }));
|
||||||
|
}, [allModels]);
|
||||||
|
|
||||||
|
// -------- 用户列表(从当前任务数据中提取,仅企业版使用)--------
|
||||||
|
// TODO: 替换为独立的用户列表 API
|
||||||
|
|
||||||
|
const [userFilters, setUserFilters] = useState<Array<{ text: string; value: string }>>([]);
|
||||||
|
|
||||||
|
// -------- 构建 API 请求参数 --------
|
||||||
|
|
||||||
|
const requestParams = useMemo<TaskListRequestParams>(() => {
|
||||||
|
const req: TaskListRequestParams = {
|
||||||
|
page: tableParams.pagination.current,
|
||||||
|
page_size: tableParams.pagination.pageSize,
|
||||||
|
};
|
||||||
|
const { filters } = tableParams;
|
||||||
|
|
||||||
|
// 服务端筛选字段
|
||||||
|
const status = pickOne(filters, 'status');
|
||||||
|
if (status) req.status = status as TaskStatusString;
|
||||||
|
|
||||||
|
const modelIds = pickMany(filters, 'model_name');
|
||||||
|
if (modelIds) req.model_ids = modelIds;
|
||||||
|
|
||||||
|
// user_ids:仅企业版传入
|
||||||
|
if (isEnterprise) {
|
||||||
|
const userIds = pickMany(filters, 'user_id');
|
||||||
|
if (userIds) req.user_ids = userIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
return req;
|
||||||
|
}, [tableParams, isEnterprise]);
|
||||||
|
|
||||||
|
// -------- 数据获取 --------
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: taskData,
|
||||||
|
loading,
|
||||||
|
errorMessage,
|
||||||
|
} = useTaskList({
|
||||||
|
requestParams,
|
||||||
|
refreshSignal: refreshTick,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tasks = taskData?.items ?? [];
|
||||||
|
const total = taskData?.total ?? 0;
|
||||||
|
|
||||||
|
// 从任务数据中提取用户列表(企业版筛选用)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEnterprise || !taskData?.items) return;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
taskData.items.forEach((t) => {
|
||||||
|
if (t.user_id && t.user_display_name) {
|
||||||
|
seen.add(JSON.stringify({ text: t.user_display_name, value: t.user_id }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const newList = Array.from(seen).map((s) => JSON.parse(s) as { text: string; value: string });
|
||||||
|
setUserFilters((prev) => {
|
||||||
|
if (prev.length === newList.length) return prev; // 避免不必要的重渲染
|
||||||
|
return newList;
|
||||||
|
});
|
||||||
|
}, [isEnterprise, taskData]);
|
||||||
|
|
||||||
|
// -------- antd Table onChange --------
|
||||||
|
|
||||||
|
const handleTableChange: TableProps<TaskInfoItemResponseBody>['onChange'] = (
|
||||||
|
pagination,
|
||||||
|
filters,
|
||||||
|
sorter,
|
||||||
|
) => {
|
||||||
|
// 同步分页到 Context
|
||||||
|
if (pagination.current && pagination.current !== taskPage) setTaskPage(pagination.current);
|
||||||
|
if (pagination.pageSize && pagination.pageSize !== taskPageSize) setTaskPageSize(pagination.pageSize);
|
||||||
|
|
||||||
|
// 缓存筛选和排序参数
|
||||||
|
const s = sorter as SorterResult<TaskInfoItemResponseBody>;
|
||||||
|
setTableParams({
|
||||||
|
pagination: {
|
||||||
|
current: pagination.current || taskPage,
|
||||||
|
pageSize: pagination.pageSize || taskPageSize,
|
||||||
|
},
|
||||||
|
filters: filters as TableFilters,
|
||||||
|
sorter: !Array.isArray(s) && s.field
|
||||||
|
? { field: s.field, order: s.order as 'ascend' | 'descend' | undefined }
|
||||||
|
: {},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------- 表格列定义(含列头筛选/排序 + 可见性过滤)--------
|
||||||
|
//
|
||||||
|
// 列顺序:任务 ID(固定) → 创建时间 → 模型 → 输出类型 → 状态 → 提示词 →
|
||||||
|
// 时长(视频) → 费用 → 供应商 → 帧率/文件大小/标签/错误信息 → 用户(企业版)
|
||||||
|
//
|
||||||
|
// 可见性:通过标题栏齿轮图标控制,持久化到 localStorage
|
||||||
|
//
|
||||||
|
// ⚠️ 重要:columns 依赖中不出 token.colorPrimary,否则主题切换时 columns 重新生成
|
||||||
|
// 会导致 Table 内部状态重置(页码、筛选、排序等丢失)。
|
||||||
|
// 主题色通过 searchIconColorRef 传递,不触发 columns 重建。
|
||||||
|
|
||||||
|
/** 提示词搜索图标颜色(通过 CSS class 承载,避免 columns 依赖 token 导致主题切换重置状态) */
|
||||||
|
|
||||||
|
/** 提取视频时长(秒),非视频输出类型返回 null → 渲染 "-" */
|
||||||
|
function getVideoDuration(record: TaskInfoItemResponseBody): number | null {
|
||||||
|
if (record.output_type === 'video') {
|
||||||
|
return record.ui_params.duration ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化时长:<60s 显示秒,≥60s 显示分秒 */
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
if (seconds < 60) return `${seconds} 秒`;
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
return s > 0 ? `${m} 分 ${s} 秒` : `${m} 分钟`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allColumns = useMemo<ColumnsType<TaskInfoItemResponseBody>>(() => {
|
||||||
|
const cols: ColumnsType<TaskInfoItemResponseBody> = [
|
||||||
|
// ---- 任务 ID(固定列) ----
|
||||||
|
{
|
||||||
|
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: 'created_at',
|
||||||
|
key: 'created_at',
|
||||||
|
width: 155,
|
||||||
|
sorter: (a, b) => {
|
||||||
|
const da = a.created_at ? new Date(a.created_at).getTime() : 0;
|
||||||
|
const db = b.created_at ? new Date(b.created_at).getTime() : 0;
|
||||||
|
return da - db;
|
||||||
|
},
|
||||||
|
sortOrder: tableParams.sorter.field === 'created_at' ? tableParams.sorter.order : null,
|
||||||
|
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: '模型',
|
||||||
|
dataIndex: 'model_name',
|
||||||
|
key: 'model_name',
|
||||||
|
width: 130,
|
||||||
|
ellipsis: true,
|
||||||
|
filters: modelFilters,
|
||||||
|
filteredValue: (tableParams.filters.model_name as string[]) || null,
|
||||||
|
onFilter: () => true, // 服务端筛选
|
||||||
|
filterSearch: true,
|
||||||
|
filterMode: 'menu' as const,
|
||||||
|
},
|
||||||
|
// ---- 输出类型 ----
|
||||||
|
{
|
||||||
|
title: '输出类型',
|
||||||
|
dataIndex: 'output_type',
|
||||||
|
key: 'output_type',
|
||||||
|
width: 90,
|
||||||
|
filters: OUTPUT_TYPE_FILTERS,
|
||||||
|
filteredValue: (tableParams.filters.output_type as string[]) || null,
|
||||||
|
onFilter: (value, record) => record.output_type === value, // 客户端筛选
|
||||||
|
render: (t: string) => (
|
||||||
|
<Tag style={{ fontSize: 10 }}>
|
||||||
|
{OutputTypeMap[t as keyof typeof OutputTypeMap] || t}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// ---- 状态 ----
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
width: 80,
|
||||||
|
filters: STATUS_FILTERS,
|
||||||
|
filteredValue: (tableParams.filters.status as string[]) || null,
|
||||||
|
onFilter: () => true, // 服务端筛选
|
||||||
|
render: (s: string) => {
|
||||||
|
const cfg = resolveStatus(s);
|
||||||
|
return <Tag color={cfg.color} style={{ fontSize: 10 }}>{cfg.label}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// ---- 提示词 ----
|
||||||
|
{
|
||||||
|
title: '提示词',
|
||||||
|
dataIndex: 'prompt',
|
||||||
|
key: 'prompt',
|
||||||
|
width: 160,
|
||||||
|
ellipsis: true,
|
||||||
|
filterIcon: (filtered: boolean) => (
|
||||||
|
<SearchOutlined className={`prompt-search-icon${filtered ? ' filtered' : ''}`} style={{ fontSize: 12 }} />
|
||||||
|
),
|
||||||
|
filterDropdown: undefined, // TODO: 后续加自定义搜索框
|
||||||
|
render: (p: string | undefined) => (
|
||||||
|
<Text style={{ fontSize: 11 }} title={p}>{p || '-'}</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// ---- 时长(仅视频有数据,否则 "-") ----
|
||||||
|
{
|
||||||
|
title: '时长',
|
||||||
|
key: 'duration',
|
||||||
|
width: 75,
|
||||||
|
sorter: (a, b) => {
|
||||||
|
const da = getVideoDuration(a) ?? -1;
|
||||||
|
const db = getVideoDuration(b) ?? -1;
|
||||||
|
return da - db;
|
||||||
|
},
|
||||||
|
sortOrder: tableParams.sorter.field === 'duration' ? tableParams.sorter.order : null,
|
||||||
|
render: (_: unknown, record: TaskInfoItemResponseBody) => {
|
||||||
|
const sec = getVideoDuration(record);
|
||||||
|
return <Text style={{ fontSize: 11 }}>{sec !== null ? formatDuration(sec) : '-'}</Text>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// ---- 费用 ----
|
||||||
|
{
|
||||||
|
title: '费用',
|
||||||
|
dataIndex: 'cost_cent',
|
||||||
|
key: 'cost_cent',
|
||||||
|
width: 75,
|
||||||
|
sorter: (a, b) => a.cost_cent - b.cost_cent,
|
||||||
|
sortOrder: tableParams.sorter.field === 'cost_cent' ? tableParams.sorter.order : null,
|
||||||
|
render: (c: number) => (
|
||||||
|
<Text style={{ fontSize: 11 }}>¥{(c / 100).toFixed(2)}</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// ---- 供应商(默认隐藏) ----
|
||||||
|
{
|
||||||
|
title: '供应商',
|
||||||
|
dataIndex: 'provider_name',
|
||||||
|
key: 'provider_name',
|
||||||
|
width: 100,
|
||||||
|
ellipsis: true,
|
||||||
|
filters: providerFilters,
|
||||||
|
filteredValue: (tableParams.filters.provider_name as string[]) || null,
|
||||||
|
onFilter: (value, record) => record.provider_name === value, // 客户端筛选
|
||||||
|
filterSearch: true,
|
||||||
|
},
|
||||||
|
// ---- 帧率(占位列,待后端支持) ----
|
||||||
|
{
|
||||||
|
title: '帧率',
|
||||||
|
key: 'fps',
|
||||||
|
width: 70,
|
||||||
|
render: () => <Text type="secondary" style={{ fontSize: 11 }}>-</Text>,
|
||||||
|
},
|
||||||
|
// ---- 文件大小(占位列,待后端支持) ----
|
||||||
|
{
|
||||||
|
title: '文件大小',
|
||||||
|
key: 'file_size',
|
||||||
|
width: 85,
|
||||||
|
render: () => <Text type="secondary" style={{ fontSize: 11 }}>-</Text>,
|
||||||
|
},
|
||||||
|
// ---- 标签 ----
|
||||||
|
{
|
||||||
|
title: '标签',
|
||||||
|
dataIndex: 'tags',
|
||||||
|
key: 'tags',
|
||||||
|
width: 100,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (tags: Array<string | null>) => {
|
||||||
|
const filtered = tags?.filter((t): t is string => t !== null) ?? [];
|
||||||
|
if (filtered.length === 0) return <Text type="secondary" style={{ fontSize: 11 }}>-</Text>;
|
||||||
|
return (
|
||||||
|
<Space size={2} wrap>
|
||||||
|
{filtered.slice(0, 2).map((t) => (
|
||||||
|
<Tag key={t} style={{ fontSize: 9, lineHeight: '14px', margin: 0 }}>{t}</Tag>
|
||||||
|
))}
|
||||||
|
{filtered.length > 2 && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 9 }}>+{filtered.length - 2}</Text>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// ---- 错误信息(失败任务时展示) ----
|
||||||
|
{
|
||||||
|
title: '错误信息',
|
||||||
|
dataIndex: 'error_message',
|
||||||
|
key: 'error_message',
|
||||||
|
width: 140,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (msg: string) => msg
|
||||||
|
? <Text type="danger" style={{ fontSize: 11 }} title={msg}>{msg}</Text>
|
||||||
|
: <Text type="secondary" style={{ fontSize: 11 }}>-</Text>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 用户列:仅企业版可筛选
|
||||||
|
if (isEnterprise) {
|
||||||
|
cols.push({
|
||||||
|
title: '用户',
|
||||||
|
dataIndex: 'user_display_name',
|
||||||
|
key: 'user_id',
|
||||||
|
width: 100,
|
||||||
|
ellipsis: true,
|
||||||
|
filters: userFilters,
|
||||||
|
filteredValue: (tableParams.filters.user_id as string[]) || null,
|
||||||
|
onFilter: () => true, // 服务端筛选(传 user_ids)
|
||||||
|
render: (n: string | null) => (
|
||||||
|
<Text style={{ fontSize: 11 }}>{n || '-'}</Text>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
cols.push({
|
||||||
|
title: '用户',
|
||||||
|
dataIndex: 'user_display_name',
|
||||||
|
key: 'user_id',
|
||||||
|
width: 100,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (n: string | null) => (
|
||||||
|
<Text style={{ fontSize: 11 }}>{n || '-'}</Text>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return cols;
|
||||||
|
}, [
|
||||||
|
modelFilters,
|
||||||
|
providerFilters,
|
||||||
|
userFilters,
|
||||||
|
tableParams.filters,
|
||||||
|
tableParams.sorter,
|
||||||
|
isEnterprise,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 按 visibleColumns 过滤实际展示的列
|
||||||
|
const columns = useMemo(
|
||||||
|
() => allColumns.filter((c) => visibleColumns.has(c.key as string)),
|
||||||
|
[allColumns, visibleColumns],
|
||||||
|
);
|
||||||
|
|
||||||
|
// -------- 表格高度 --------
|
||||||
|
//
|
||||||
|
// 核心策略:固定预留 pagination 区域高度,确保无论数据加载时机如何,
|
||||||
|
// 分页器始终有空间渲染,不会溢出可视区。
|
||||||
|
//
|
||||||
|
// scroll.y = wrapper高度 - thead高度 - PAGINATION_RESERVE - 水平滚动条高度
|
||||||
|
//
|
||||||
|
// 为什么固定预留 pagination 而不是动态测量?
|
||||||
|
// - 无数据/加载中时 pagination DOM 尚不存在 → offsetHeight=0 → scroll.y 偏大
|
||||||
|
// - 翻页/切换 pageSize 时 pagination 异步渲染 → 测量时机不可控
|
||||||
|
// - 固定预留值(48px 覆盖 small-size pagination + 上下间距)避免上述所有时序问题
|
||||||
|
|
||||||
|
/** 预留 pagination 高度(small-size: ~32px + 上下 padding/margin 8px × 2) */
|
||||||
|
const PAGINATION_RESERVE = 48;
|
||||||
|
|
||||||
|
const tableWrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [tableBodyHeight, setTableBodyHeight] = useState(400);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const wrapper = tableWrapperRef.current;
|
||||||
|
if (!wrapper) return;
|
||||||
|
|
||||||
|
const calcHeight = () => {
|
||||||
|
const thead = wrapper.querySelector('.ant-table-thead') as HTMLElement | null;
|
||||||
|
const body = wrapper.querySelector('.ant-table-body') as HTMLElement | null;
|
||||||
|
const theadH = thead?.offsetHeight ?? 0;
|
||||||
|
|
||||||
|
// 水平滚动条占用高度(antd Table scroll.x 触发时出现)
|
||||||
|
let hScrollH = 0;
|
||||||
|
if (body && body.scrollWidth > body.clientWidth + 1) {
|
||||||
|
hScrollH = body.offsetHeight - body.clientHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const available = wrapper.clientHeight - theadH - PAGINATION_RESERVE - hScrollH;
|
||||||
|
setTableBodyHeight(Math.max(120, available));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ResizeObserver:容器尺寸变化(窗口拉伸、面板拖拽)
|
||||||
|
const resizeObserver = new ResizeObserver(calcHeight);
|
||||||
|
resizeObserver.observe(wrapper);
|
||||||
|
|
||||||
|
// MutationObserver:内部 DOM 变化(thead 高度变化如筛选菜单弹出、
|
||||||
|
// 数据加载后水平滚动条出现等)
|
||||||
|
const mutationObserver = new MutationObserver(calcHeight);
|
||||||
|
mutationObserver.observe(wrapper, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
calcHeight();
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
mutationObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, [taskPageSize]);
|
||||||
|
|
||||||
|
// -------- 行样式:斑马纹 + 选中行 --------
|
||||||
|
|
||||||
|
const rowClassName = (_record: TaskInfoItemResponseBody, index: number) => {
|
||||||
|
const base = index % 2 === 1 ? 'task-row-odd' : 'task-row-even';
|
||||||
|
return selectedTask?.id === _record.id ? `${base} task-row-selected` : base;
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------- 是否有活跃筛选 --------
|
||||||
|
|
||||||
|
const hasActiveFilters = Object.values(tableParams.filters).some(
|
||||||
|
(v) => Array.isArray(v) && v.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ======== 渲染 ========
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
|
||||||
|
{/* 标题栏 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '6px 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>
|
||||||
|
)}
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<Tag color="blue" style={{ fontSize: 10, lineHeight: '16px', padding: '0 4px' }}>
|
||||||
|
已筛选
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
{errorMessage && (
|
||||||
|
<Text type="danger" style={{ fontSize: 11, marginLeft: 'auto' }}>{errorMessage}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 列可见性控制 — 齿轮图标 */}
|
||||||
|
<Popover
|
||||||
|
trigger="click"
|
||||||
|
placement="bottomRight"
|
||||||
|
title="显示/隐藏列"
|
||||||
|
content={
|
||||||
|
<Checkbox.Group
|
||||||
|
value={[...visibleColumns]}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: 2 }}
|
||||||
|
>
|
||||||
|
{ALL_COLUMNS.map((col) => (
|
||||||
|
<Checkbox
|
||||||
|
key={col.key}
|
||||||
|
value={col.key}
|
||||||
|
disabled={col.key === 'id'}
|
||||||
|
onChange={(e) => handleColumnToggle(col.key, e.target.checked)}
|
||||||
|
>
|
||||||
|
{col.title}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</Checkbox.Group>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<SettingOutlined style={{ fontSize: 13 }} />}
|
||||||
|
style={{ marginLeft: 'auto' }}
|
||||||
|
title="列设置"
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 表格 — 横向 + 纵向滚动,筛选/排序通过列头操作 */}
|
||||||
|
<div ref={tableWrapperRef} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||||
|
<style>{tableCss}</style>
|
||||||
|
<Table<TaskInfoItemResponseBody>
|
||||||
|
className="task-table"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={tasks}
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
loading={loading}
|
||||||
|
showHeader={true}
|
||||||
|
scroll={{ x: isEnterprise ? 1350 : 1250, y: tableBodyHeight }}
|
||||||
|
rowClassName={rowClassName}
|
||||||
|
onRow={(record) => ({
|
||||||
|
onClick: () => setSelectedTask(record),
|
||||||
|
style: { cursor: 'pointer' },
|
||||||
|
})}
|
||||||
|
onChange={handleTableChange}
|
||||||
|
pagination={{
|
||||||
|
current: tableParams.pagination.current,
|
||||||
|
pageSize: tableParams.pagination.pageSize,
|
||||||
|
total,
|
||||||
|
size: 'small',
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: ['10', '20', '50'],
|
||||||
|
placement: ['bottomCenter'],
|
||||||
|
style: { marginBottom: 0 },
|
||||||
|
}}
|
||||||
|
locale={{ emptyText: '暂无任务记录', filterReset: '重置', filterConfirm: '确定' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -111,7 +111,6 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
|
|||||||
const loginTab = (
|
const loginTab = (
|
||||||
<LoginForm
|
<LoginForm
|
||||||
initialUsername={authState.username}
|
initialUsername={authState.username}
|
||||||
|
|
||||||
initialRemember={authState.remember}
|
initialRemember={authState.remember}
|
||||||
initialAutoLogin={authState.autoLogin}
|
initialAutoLogin={authState.autoLogin}
|
||||||
submitting={submitting}
|
submitting={submitting}
|
||||||
|
|||||||
@@ -149,42 +149,42 @@ export interface UsePhoneResetPasswordRequestBody {
|
|||||||
|
|
||||||
export class AuthAPI {
|
export class AuthAPI {
|
||||||
/** 登录 */
|
/** 登录 */
|
||||||
static login(data: LoginRequestBody): Promise<LoginResponseBody> {
|
static async login(data: LoginRequestBody): Promise<LoginResponseBody> {
|
||||||
return post<LoginResponseBody>(AuthUrlObj.login, data);
|
return await post<LoginResponseBody>(AuthUrlObj.login, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 注册 */
|
/** 注册 */
|
||||||
static register(data: RegisterRequestBody): Promise<RegisterResponseBody> {
|
static async register(data: RegisterRequestBody): Promise<RegisterResponseBody> {
|
||||||
return post<RegisterResponseBody>(AuthUrlObj.register, data);
|
return await post<RegisterResponseBody>(AuthUrlObj.register, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 发送短信验证码 */
|
/** 发送短信验证码 */
|
||||||
static sendSms(data: SendSMSRequestBody): Promise<void> {
|
static async sendSms(data: SendSMSRequestBody): Promise<void> {
|
||||||
return post<void>(AuthUrlObj.sendSms, data);
|
return await post<void>(AuthUrlObj.sendSms, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 刷新 Token */
|
/** 刷新 Token */
|
||||||
static refreshToken(data: RefreshTokenRequestBody): Promise<RefreshTokenResponseBody> {
|
static async refreshToken(data: RefreshTokenRequestBody): Promise<RefreshTokenResponseBody> {
|
||||||
return post<RefreshTokenResponseBody>(AuthUrlObj.refreshToken, data);
|
return await post<RefreshTokenResponseBody>(AuthUrlObj.refreshToken, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取当前用户信息 */
|
/** 获取当前用户信息 */
|
||||||
static getUserInfo(): Promise<UserInfoBody> {
|
static async getUserInfo(): Promise<UserInfoBody> {
|
||||||
return get<UserInfoBody>(AuthUrlObj.userInfo);
|
return await get<UserInfoBody>(AuthUrlObj.userInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 修改密码(已登录) */
|
/** 修改密码(已登录) */
|
||||||
static changePassword(data: ChangePasswordRequestBody): Promise<void> {
|
static async changePassword(data: ChangePasswordRequestBody): Promise<void> {
|
||||||
return post<void>(AuthUrlObj.changePassword, data);
|
return await post<void>(AuthUrlObj.changePassword, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 手机验证码重置密码 */
|
/** 手机验证码重置密码 */
|
||||||
static resetPassword(data: UsePhoneResetPasswordRequestBody): Promise<void> {
|
static async resetPassword(data: UsePhoneResetPasswordRequestBody): Promise<void> {
|
||||||
return post<void>(AuthUrlObj.resetPassword, data);
|
return await post<void>(AuthUrlObj.resetPassword, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 退出登录 */
|
/** 退出登录 */
|
||||||
static logout(): Promise<void> {
|
static async logout(): Promise<void> {
|
||||||
return post<void>(AuthUrlObj.logout);
|
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);
|
||||||
|
}
|
||||||
@@ -22,3 +22,37 @@ export {
|
|||||||
type ChangePasswordRequestBody,
|
type ChangePasswordRequestBody,
|
||||||
type UsePhoneResetPasswordRequestBody,
|
type UsePhoneResetPasswordRequestBody,
|
||||||
} from './auth';
|
} 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';
|
||||||
|
// 请注意后端没有关于字段category的查询
|
||||||
|
// ---------- 路由常量 ----------
|
||||||
|
|
||||||
|
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 {
|
||||||
|
image = '图片',
|
||||||
|
video = '视频',
|
||||||
|
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>);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -177,7 +177,7 @@ instance.interceptors.response.use(
|
|||||||
|
|
||||||
// HTTP 200 但业务码非 0 → 业务错误
|
// HTTP 200 但业务码非 0 → 业务错误
|
||||||
if (data && data.code !== 0) {
|
if (data && data.code !== 0) {
|
||||||
// 401 → 尝试刷新 token 并重试(但 refresh 接口自身 401 必须直接拒绝,否则死循环)
|
// 业务码 401 → 尝试刷新 token 并重试(refresh 接口自身 401 直接拒绝)
|
||||||
if (data.code === 401 && response.config) {
|
if (data.code === 401 && response.config) {
|
||||||
if (isRefreshRequest(response.config)) {
|
if (isRefreshRequest(response.config)) {
|
||||||
return Promise.reject(new RefreshError());
|
return Promise.reject(new RefreshError());
|
||||||
@@ -246,8 +246,11 @@ instance.interceptors.response.use(
|
|||||||
// HTTP 状态码错误
|
// HTTP 状态码错误
|
||||||
const { status, data } = error.response;
|
const { status, data } = error.response;
|
||||||
|
|
||||||
// 401 → 尝试刷新 token 并重试(refresh 接口自身 401 直接拒绝,防死循环)
|
// 401 → 尝试刷新 token 并重试
|
||||||
if (status === 401 && error.config) {
|
// ⚠️ 同时检查响应体中的业务码 data.code:
|
||||||
|
// data.code === 401 → token 过期 → 走刷新流程
|
||||||
|
// data.code !== 401 → 其他认证错误(如密码错误)→ 透传后端 message
|
||||||
|
if (status === 401 && error.config && data?.code === 401) {
|
||||||
if (isRefreshRequest(error.config)) {
|
if (isRefreshRequest(error.config)) {
|
||||||
const refreshErr = new RefreshError();
|
const refreshErr = new RefreshError();
|
||||||
logger.debug('request', 'Refresh endpoint returned 401', {
|
logger.debug('request', 'Refresh endpoint returned 401', {
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ const sharedComponents: ThemeConfig['components'] = {
|
|||||||
},
|
},
|
||||||
Pagination: {
|
Pagination: {
|
||||||
itemActiveBg: colorPrimary,
|
itemActiveBg: colorPrimary,
|
||||||
|
itemActiveColor: '#fff',
|
||||||
colorPrimary,
|
colorPrimary,
|
||||||
colorPrimaryHover: colorPrimaryLight,
|
colorPrimaryHover: colorPrimaryLight,
|
||||||
},
|
},
|
||||||
@@ -277,24 +278,24 @@ export const darkTheme: ThemeConfig = {
|
|||||||
defaultBorderColor: darkColorBorder,
|
defaultBorderColor: darkColorBorder,
|
||||||
},
|
},
|
||||||
Menu: {
|
Menu: {
|
||||||
itemSelectedBg: '#312E81',
|
itemSelectedBg: '#1E2460',
|
||||||
itemSelectedColor: colorPrimaryLight,
|
itemSelectedColor: colorPrimaryLight,
|
||||||
itemHoverBg: '#242040',
|
itemHoverBg: '#151A48',
|
||||||
itemActiveBg: '#1E1B4B',
|
itemActiveBg: '#0F1442',
|
||||||
},
|
},
|
||||||
Layout: {
|
Layout: {
|
||||||
siderBg: `linear-gradient(180deg, #0B0A15 0%, #1A1730 100%)`,
|
siderBg: `linear-gradient(180deg, #050818 0%, #0D1140 100%)`,
|
||||||
headerBg: darkColorBgContainer,
|
headerBg: darkColorBgContainer,
|
||||||
bodyBg: darkColorBgLayout,
|
bodyBg: darkColorBgLayout,
|
||||||
},
|
},
|
||||||
Table: {
|
Table: {
|
||||||
headerBg: '#1E1B4B',
|
headerBg: '#0F1340',
|
||||||
headerColor: darkColorTextBase,
|
headerColor: darkColorTextBase,
|
||||||
rowHoverBg: '#242040',
|
rowHoverBg: '#181D52',
|
||||||
borderColor: darkColorBorderSecondary,
|
borderColor: darkColorBorderSecondary,
|
||||||
},
|
},
|
||||||
Tag: {
|
Tag: {
|
||||||
defaultBg: '#1E1B4B',
|
defaultBg: '#1A1F54',
|
||||||
defaultColor: colorPrimaryLight,
|
defaultColor: colorPrimaryLight,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -94,16 +94,16 @@
|
|||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
|
|
||||||
[data-theme='dark'] {
|
[data-theme='dark'] {
|
||||||
/* 中性色 — 暗色 */
|
/* 中性色 — 暗色(科技蓝紫) */
|
||||||
--color-text-base: #e8e6f0;
|
--color-text-base: #e8e8fa;
|
||||||
--color-text-secondary: #a5a0c9;
|
--color-text-secondary: #a0a8d8;
|
||||||
--color-text-tertiary: #6e6998;
|
--color-text-tertiary: #6e78b0;
|
||||||
--color-bg-base: #0f0d1a;
|
--color-bg-base: #080c24;
|
||||||
--color-bg-container: #1a1730;
|
--color-bg-container: #0f1340;
|
||||||
--color-bg-layout: #0b0a15;
|
--color-bg-layout: #050818;
|
||||||
--color-bg-elevated: #242040;
|
--color-bg-elevated: #171b52;
|
||||||
--color-border-base: #2e2a4a;
|
--color-border-base: #2a3278;
|
||||||
--color-border-secondary: #1f1c38;
|
--color-border-secondary: #1c2250;
|
||||||
|
|
||||||
/* 暗色模式下品牌色提亮以保持对比度 */
|
/* 暗色模式下品牌色提亮以保持对比度 */
|
||||||
--color-primary: #818cf8;
|
--color-primary: #818cf8;
|
||||||
|
|||||||
@@ -8,28 +8,14 @@
|
|||||||
|
|
||||||
// ---------- 品牌色(蓝紫渐变) ----------
|
// ---------- 品牌色(蓝紫渐变) ----------
|
||||||
|
|
||||||
/** 品牌主色 — Indigo-500 */
|
|
||||||
export const colorPrimary = '#6366F1';
|
export const colorPrimary = '#6366F1';
|
||||||
|
|
||||||
/** 品牌主色浅色变体 */
|
|
||||||
export const colorPrimaryLight = '#818CF8';
|
export const colorPrimaryLight = '#818CF8';
|
||||||
|
|
||||||
/** 品牌主色深色变体 */
|
|
||||||
export const colorPrimaryDark = '#4F46E5';
|
export const colorPrimaryDark = '#4F46E5';
|
||||||
|
|
||||||
/** 品牌辅助色 — Violet-500(渐变另一端) */
|
|
||||||
export const colorSecondary = '#8B5CF6';
|
export const colorSecondary = '#8B5CF6';
|
||||||
|
|
||||||
/** 品牌渐变色起始值 */
|
export const gradientStart = '#4F46E5';
|
||||||
export const gradientStart = '#4F46E5'; // Indigo-600
|
export const gradientEnd = '#7C3AED';
|
||||||
|
|
||||||
/** 品牌渐变色结束值 */
|
|
||||||
export const gradientEnd = '#7C3AED'; // Violet-600
|
|
||||||
|
|
||||||
/** 品牌渐变色(CSS 值,可直接用于 background) */
|
|
||||||
export const gradientPrimary = `linear-gradient(135deg, ${gradientStart}, ${gradientEnd})`;
|
export const gradientPrimary = `linear-gradient(135deg, ${gradientStart}, ${gradientEnd})`;
|
||||||
|
|
||||||
/** 品牌渐变悬停态 */
|
|
||||||
export const gradientPrimaryHover = `linear-gradient(135deg, ${colorPrimary}, ${colorSecondary})`;
|
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 colorTextSecondary = '#6B7280';
|
||||||
export const colorTextTertiary = '#9CA3AF';
|
export const colorTextTertiary = '#9CA3AF';
|
||||||
|
|
||||||
export const colorBgBase = '#FFFFFF';
|
export const colorBgBase = '#FFFFFF';
|
||||||
export const colorBgContainer = '#F9FAFB';
|
export const colorBgContainer = '#F9FAFB';
|
||||||
export const colorBgElevated = '#FFFFFF';
|
export const colorBgElevated = '#FFFFFF';
|
||||||
export const colorBgLayout = '#F5F3FF'; // Violet-50
|
export const colorBgLayout = '#F5F3FF';
|
||||||
export const colorBgSpotlight = '#F3F4F6';
|
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 darkColorBgBase = '#080C24';
|
||||||
/** 暗色模式 — 容器背景 */
|
/** 暗色模式 — 卡片/面板背景(暗蓝紫) */
|
||||||
export const darkColorBgContainer = '#1A1730'; // 抬升一层
|
export const darkColorBgContainer = '#0F1340';
|
||||||
/** 暗色模式 — 悬浮背景 */
|
/** 暗色模式 — 悬浮层背景(微亮蓝紫) */
|
||||||
export const darkColorBgElevated = '#242040'; // 再抬升一层
|
export const darkColorBgElevated = '#171B52';
|
||||||
/** 暗色模式 — 布局背景 */
|
/** 暗色模式 — 页面布局底层 */
|
||||||
export const darkColorBgLayout = '#0B0A15'; // 最深背景
|
export const darkColorBgLayout = '#050818';
|
||||||
|
|
||||||
/** 暗色模式 — 边框 */
|
/** 暗色模式 — 面板外框(蓝紫边框,可见但不刺眼) */
|
||||||
export const darkColorBorder = '#2E2A4A';
|
export const darkColorBorder = '#2A3278';
|
||||||
/** 暗色模式 — 次要边框 */
|
/** 暗色模式 — 内部分隔线(柔和蓝紫,常驻可见) */
|
||||||
export const darkColorBorderSecondary = '#1F1C38';
|
export const darkColorBorderSecondary = '#1C2250';
|
||||||
|
|
||||||
// ---------- 圆角 ----------
|
// ---------- 圆角 ----------
|
||||||
|
|
||||||
|
|||||||
@@ -69,4 +69,6 @@ export const EVENTS = {
|
|||||||
LOGIN_SUCCESS: 'auth:login-success',
|
LOGIN_SUCCESS: 'auth:login-success',
|
||||||
/** 退出登录 */
|
/** 退出登录 */
|
||||||
LOGOUT: 'auth:logout',
|
LOGOUT: 'auth:logout',
|
||||||
|
/** 任务提交成功(→ 刷新任务列表) */
|
||||||
|
TASK_SUBMITTED: 'task:submitted',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -67,8 +67,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
host: 'localhost',
|
host: "0.0.0.0",
|
||||||
// 文件监听(Windows 优化)
|
|
||||||
watch: {
|
watch: {
|
||||||
// Windows 上默认的 fs.watch 有时不稳定,
|
// Windows 上默认的 fs.watch 有时不稳定,
|
||||||
// 如果文件保存后界面无反应,可临时开启 usePolling
|
// 如果文件保存后界面无反应,可临时开启 usePolling
|
||||||
@@ -81,8 +80,20 @@ export default defineConfig(({ mode }) => {
|
|||||||
hmr: {
|
hmr: {
|
||||||
// 在浏览器控制台显示 HMR 覆盖层(错误/警告时)
|
// 在浏览器控制台显示 HMR 覆盖层(错误/警告时)
|
||||||
overlay: true,
|
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: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
|
|||||||
Reference in New Issue
Block a user