refactor: Home 模块 VCHSM 三栏重构 + Services 迁回 + 右键菜单体系 + 公告修复
## Home 模块三栏 VCHSM 重组
- 按渲染归属重新分配文件:ModelSelector/TaskHistory → left/,ModelInputForm/widgets → center/,OutputPreview → right/
- BannerCarousel 移至 shared/views/(全局组件)
- controls/ → widgets/ 重命名,消除与 controllers/ 的命名歧义
- 新增 barrel 文件(left/center/right/shared 各级 index.ts)
## Services 层迁回 src/services/modules/
- 全部 API 类型定义与请求函数从 modules/*/controllers/ 迁回 services/modules/{domain}/
- 恢复原始文件名(model-api.ts → models.ts,task-api.ts → task.ts 等)
- 新增 domain 子文件夹(home/auth/announcement/account)
- 更新全局 ~70 处导入路径
## TaskHistory 提取 Controller + Hook 层
- 新建 left/controllers/task-table-config.ts(常量 + 纯工具函数,零 React 依赖)
- 新建 left/hooks/useTaskTable.tsx(状态管理 + 数据获取 + 列定义 + 滚动同步)
- TaskHistory.tsx 从 915 行精简为 265 行纯视图
## 右键菜单体系完善
- 共享 <ContextMenu> 组件(antd Dropdown 封装,防 Select 冲突)
- 四组菜单定义:任务列表 / 文本输入 / 媒体参考 / 预览区域
- 统一的 ContextMenuItems → AntD MenuProps 转换
## 公告功能修复
- useAnnouncements 增加 isLoggedIn 登录门控,避免未登录时 401 双重请求
- 抽屉打开时触发 refetch(),确保拿到最新数据
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@ import { SettingsPage } from '@/modules/settings';
|
|||||||
import { on, off, emit, EVENTS } from '@/shared/utils/bus';
|
import { on, off, emit, EVENTS } from '@/shared/utils/bus';
|
||||||
import type { BusinessErrorEvent } from '@/shared/utils/bus';
|
import type { BusinessErrorEvent } from '@/shared/utils/bus';
|
||||||
import { Layout } from '@/app/layout/Layout';
|
import { Layout } from '@/app/layout/Layout';
|
||||||
import { HomePage } from '@/modules/home/views/HomePage';
|
import { HomePage } from '@/modules/home/shared/views/HomePage';
|
||||||
import { PageDispatcher } from '@/app/layout/PageDispatcher';
|
import { PageDispatcher } from '@/app/layout/PageDispatcher';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { theme as antTheme } from 'antd';
|
import { theme as antTheme } from 'antd';
|
||||||
import { NavBar } from '@/modules/navbar';
|
import { NavBar } from '@/modules/navbar';
|
||||||
import { BannerCarousel } from '@/modules/home/views/right/BannerCarousel';
|
import { BannerCarousel } from '@/modules/home/shared/views/BannerCarousel';
|
||||||
|
|
||||||
export function Layout({ children }: { children: ReactNode }) {
|
export function Layout({ children }: { children: ReactNode }) {
|
||||||
const { token } = antTheme.useToken();
|
const { token } = antTheme.useToken();
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ import type { Platform, Edition } from '@shared/types';
|
|||||||
|
|
||||||
import { getPlatform, isDev } from '@/shared/utils/env';
|
import { getPlatform, isDev } from '@/shared/utils/env';
|
||||||
import { safeIpcOn, safeIpcOff } from '@/shared/utils/bus';
|
import { safeIpcOn, safeIpcOff } from '@/shared/utils/bus';
|
||||||
import { AuthAPI, type LoginResponseBody } from '@/modules/auth/controllers/auth-api';
|
import { AuthAPI, type LoginResponseBody } from '@/services/modules/auth/auth';
|
||||||
import { setToken, clearToken, initTokenStore } from '@/shared/services/http';
|
import { setToken, clearToken, initTokenStore } from '@/services/request';
|
||||||
import { emit, EVENTS } from '@/shared/utils/bus';
|
import { emit, EVENTS } from '@/shared/utils/bus';
|
||||||
import { logger } from '@/shared/utils/bus';
|
import { logger } from '@/shared/utils/bus';
|
||||||
import { getAutoLoginFlag, clearAutoLoginFlag } from '@/modules/auth/controllers/auth-persistence';
|
import { getAutoLoginFlag, clearAutoLoginFlag } from '@/services/auth-persistence';
|
||||||
import {
|
import {
|
||||||
initAuthRefresh,
|
initAuthRefresh,
|
||||||
getStoredRefreshToken,
|
getStoredRefreshToken,
|
||||||
@@ -25,8 +25,8 @@ import {
|
|||||||
initRefreshTokenStore,
|
initRefreshTokenStore,
|
||||||
scheduleProactiveRefresh,
|
scheduleProactiveRefresh,
|
||||||
clearProactiveRefresh,
|
clearProactiveRefresh,
|
||||||
} from '@/modules/auth/controllers/token-manager';
|
} from '@/services/auth-token';
|
||||||
import {initModelCache} from '@/shared/services/cache/model-cache';
|
import {initModelCache} from '@/services/cache/model-cache';
|
||||||
import {initStorage} from '@/shared/infrastructure/storage';
|
import {initStorage} from '@/shared/infrastructure/storage';
|
||||||
|
|
||||||
// ---------- Context 类型 ----------
|
// ---------- Context 类型 ----------
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
ApiOutlined,
|
ApiOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import {useBillingBalance, useBillingLedger} from '@/shared/hooks/use-api';
|
import {useBillingBalance, useBillingLedger} from '@/shared/hooks/use-api';
|
||||||
import type {EntryTypeValue, LedgerItemResponseBody, LedgerReqeustParam} from '@/modules/account/controllers/billing-api';
|
import type {EntryTypeValue, LedgerItemResponseBody, LedgerReqeustParam} from '@/services/modules/account/billing';
|
||||||
import {EnumResolver} from '@/shared/utils/enum-resolver';
|
import {EnumResolver} from '@/shared/utils/enum-resolver';
|
||||||
import {centToYuan, formatDate} from '@/shared/utils/display';
|
import {centToYuan, formatDate} from '@/shared/utils/display';
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
RightOutlined,
|
RightOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import {EnumResolver} from '@/shared/utils/enum-resolver';
|
import {EnumResolver} from '@/shared/utils/enum-resolver';
|
||||||
import type { VipLevelsItem } from '@/modules/account/controllers/vip-api';
|
import type { VipLevelsItem } from '@/services/modules/account/vip-api';
|
||||||
import { useAccountInfo } from '@/shared/hooks/use-api';
|
import { useAccountInfo } from '@/shared/hooks/use-api';
|
||||||
import { centToYuan, formatDate } from '@/shared/utils/display';
|
import { centToYuan, formatDate } from '@/shared/utils/display';
|
||||||
|
|
||||||
|
|||||||
@@ -21,16 +21,16 @@ import {
|
|||||||
type PaymentStatus,
|
type PaymentStatus,
|
||||||
type OrderListResponseBody,
|
type OrderListResponseBody,
|
||||||
type OrderInfoResponseBody,
|
type OrderInfoResponseBody,
|
||||||
} from '@/modules/account/controllers/payment-api';
|
} from '@/services/modules/account/payments';
|
||||||
import {
|
import {
|
||||||
VipAPI,
|
VipAPI,
|
||||||
VipOrderStatus,
|
VipOrderStatus,
|
||||||
type VipOrderStatusValue,
|
type VipOrderStatusValue,
|
||||||
type VipOrderRead,
|
type VipOrderRead,
|
||||||
} from '@/modules/account/controllers/vip-api';
|
} from '@/services/modules/account/vip-api';
|
||||||
import { centToYuan, formatDate } from '@/shared/utils/display';
|
import { centToYuan, formatDate } from '@/shared/utils/display';
|
||||||
import { EnumResolver } from '@/shared/utils/enum-resolver';
|
import { EnumResolver } from '@/shared/utils/enum-resolver';
|
||||||
import { RequestError, RequestErrorType } from '@/shared/services/http';
|
import { RequestError, RequestErrorType } from '@/services/request';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import {
|
|||||||
PAYMENT_STATUS,
|
PAYMENT_STATUS,
|
||||||
getPaymentStatusText,
|
getPaymentStatusText,
|
||||||
type PaymentStatus,
|
type PaymentStatus,
|
||||||
} from '@/modules/account/controllers/payment-api';
|
} from '@/services/modules/account/payments';
|
||||||
import { emit, EVENTS } from '@/shared/utils/bus';
|
import { emit, EVENTS } from '@/shared/utils/bus';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|||||||
@@ -34,11 +34,11 @@ import {
|
|||||||
VipOrderStatus,
|
VipOrderStatus,
|
||||||
type VipLevelsItem,
|
type VipLevelsItem,
|
||||||
type VipOrderStatusValue,
|
type VipOrderStatusValue,
|
||||||
} from '@/modules/account/controllers/vip-api';
|
} from '@/services/modules/account/vip-api';
|
||||||
import {centToYuan} from '@/shared/utils/display';
|
import {centToYuan} from '@/shared/utils/display';
|
||||||
import {EnumResolver} from '@/shared/utils/enum-resolver';
|
import {EnumResolver} from '@/shared/utils/enum-resolver';
|
||||||
import {emit, EVENTS} from '@/shared/utils/bus';
|
import {emit, EVENTS} from '@/shared/utils/bus';
|
||||||
import {RequestError, RequestErrorType} from '@/shared/services/http';
|
import {RequestError, RequestErrorType} from '@/services/request';
|
||||||
|
|
||||||
const {Text} = Typography;
|
const {Text} = Typography;
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { useEffect, useState, useCallback } from 'react';
|
|||||||
import { logger } from '@/shared/utils/bus';
|
import { logger } from '@/shared/utils/bus';
|
||||||
import { Spin, Empty } from 'antd';
|
import { Spin, Empty } from 'antd';
|
||||||
import { WechatOutlined } from '@ant-design/icons';
|
import { WechatOutlined } from '@ant-design/icons';
|
||||||
import { fetchBanners, type Banner } from '@/modules/home/controllers/banner-api';
|
import { fetchBanners, type Banner } from '@/services/modules/home/banner';
|
||||||
|
|
||||||
export function WechatWorkPage() {
|
export function WechatWorkPage() {
|
||||||
const [banner, setBanner] = useState<Banner | null>(null);
|
const [banner, setBanner] = useState<Banner | null>(null);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// announcement 模块 — 公告
|
// announcement 模块 — 公告
|
||||||
export { AnnouncementDrawer } from './views/AnnouncementDrawer';
|
export { AnnouncementDrawer } from './views/AnnouncementDrawer';
|
||||||
export { fetchAnnouncements } from './controllers/announcement-api';
|
export { fetchAnnouncements } from '@/services/modules/announcement/announcement';
|
||||||
export type { Announcement, AnnouncementType, AnnouncementTypeValue, AnnouncementListResponse } from './controllers/announcement-api';
|
export type { Announcement, AnnouncementType, AnnouncementTypeValue, AnnouncementListResponse } from '@/services/modules/announcement/announcement';
|
||||||
export { AnnouncementTypeEnum, AnnouncementTypeLabelMap, AnnouncementTypeColorMap } from './controllers/announcement-api';
|
export { AnnouncementTypeEnum, AnnouncementTypeLabelMap, AnnouncementTypeColorMap } from '@/services/modules/announcement/announcement';
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {useState} from 'react';
|
|||||||
import {Drawer, Card, Typography, Empty, Tag, Spin, Modal, Tooltip} from 'antd';
|
import {Drawer, Card, Typography, Empty, Tag, Spin, Modal, Tooltip} from 'antd';
|
||||||
import {NotificationOutlined} from '@ant-design/icons';
|
import {NotificationOutlined} from '@ant-design/icons';
|
||||||
|
|
||||||
import type { Announcement, AnnouncementTypeValue } from '@/modules/announcement/controllers/announcement-api';
|
import type { Announcement, AnnouncementTypeValue } from '@/services/modules/announcement/announcement';
|
||||||
import { EnumResolver } from '@/shared/utils/enum-resolver';
|
import { EnumResolver } from '@/shared/utils/enum-resolver';
|
||||||
|
|
||||||
const {Text, Paragraph} = Typography;
|
const {Text, Paragraph} = Typography;
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ import {
|
|||||||
getSavedUsername,
|
getSavedUsername,
|
||||||
getSavedRemember,
|
getSavedRemember,
|
||||||
getSavedAutoLogin,
|
getSavedAutoLogin,
|
||||||
} from '@/modules/auth/controllers/auth-persistence';
|
} from '@/services/auth-persistence';
|
||||||
|
|
||||||
export { getAutoLoginFlag, clearAutoLoginFlag, clearSavedAuth } from '@/modules/auth/controllers/auth-persistence';
|
export { getAutoLoginFlag, clearAutoLoginFlag, clearSavedAuth } from '@/services/auth-persistence';
|
||||||
|
|
||||||
// ---------- Hook ----------
|
// ---------- Hook ----------
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// auth 模块 — 认证(登录/注册/密码/Token)
|
// auth 模块 — 认证(登录/注册/密码/Token)
|
||||||
export { LoginPage } from './views/AuthPage';
|
export { LoginPage } from './views/AuthPage';
|
||||||
export { AuthAPI } from './controllers/auth-api';
|
export { AuthAPI } from '@/services/modules/auth/auth';
|
||||||
export type { LoginResponseBody } from './controllers/auth-api';
|
export type { LoginResponseBody } from '@/services/modules/auth/auth';
|
||||||
export { initAuthRefresh } from './controllers/token-manager';
|
export { initAuthRefresh } from '@/services/auth-token';
|
||||||
export { getAutoLoginFlag, clearAutoLoginFlag } from './controllers/auth-persistence';
|
export { getAutoLoginFlag, clearAutoLoginFlag } from '@/services/auth-persistence';
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { LoginForm } from './components/LoginForm';
|
|||||||
import { RegisterForm } from './components/RegisterForm';
|
import { RegisterForm } from './components/RegisterForm';
|
||||||
import { ForgotPasswordModal } from './components/ForgotPasswordModal';
|
import { ForgotPasswordModal } from './components/ForgotPasswordModal';
|
||||||
import { useAuthState } from '../hooks/use-auth-state';
|
import { useAuthState } from '../hooks/use-auth-state';
|
||||||
import { AuthAPI, type LoginResponseBody } from '../controllers/auth-api';
|
import { AuthAPI, type LoginResponseBody } from '@/services/modules/auth/auth';
|
||||||
import { getDeviceId } from '@/shared/utils/env';
|
import { getDeviceId } from '@/shared/utils/env';
|
||||||
import type { LoginFormValues, RegisterFormValues } from '../model/types';
|
import type { LoginFormValues, RegisterFormValues } from '../model/types';
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { Modal, Input, Button, Typography, App } from 'antd';
|
|||||||
import { LockOutlined, KeyOutlined, SafetyOutlined } from '@ant-design/icons';
|
import { LockOutlined, KeyOutlined, SafetyOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { useTheme } from '@/app/providers/ThemeProvider';
|
import { useTheme } from '@/app/providers/ThemeProvider';
|
||||||
import { AuthAPI } from '@/modules/auth/controllers/auth-api';
|
import { AuthAPI } from '@/services/modules/auth/auth';
|
||||||
import { validatePassword, validateConfirmPassword, runFieldChecks } from '../../settings/validators';
|
import { validatePassword, validateConfirmPassword, runFieldChecks } from '../../settings/validators';
|
||||||
|
|
||||||
const { Text, Title } = Typography;
|
const { Text, Title } = Typography;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
import { useTheme } from '@/app/providers/ThemeProvider';
|
import { useTheme } from '@/app/providers/ThemeProvider';
|
||||||
import { AuthAPI } from '@/modules/auth/controllers/auth-api';
|
import { AuthAPI } from '@/services/modules/auth/auth';
|
||||||
import {
|
import {
|
||||||
validatePhone,
|
validatePhone,
|
||||||
validateSmsCode,
|
validateSmsCode,
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// menus — 首页右键菜单定义统一导出
|
// menus — 中栏右键菜单定义统一导出
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
export { getTaskListMenuItems } from './task-list-menu';
|
|
||||||
export type { TaskListMenuOptions } from './task-list-menu';
|
|
||||||
|
|
||||||
export { getTextInputMenuItems } from './text-input-menu';
|
export { getTextInputMenuItems } from './text-input-menu';
|
||||||
export type { TextInputMenuOptions } from './text-input-menu';
|
export type { TextInputMenuOptions } from './text-input-menu';
|
||||||
|
|
||||||
export { getMediaRefImageMenu, getMediaRefBlankMenu } from './media-ref-menu';
|
export { getMediaRefImageMenu, getMediaRefBlankMenu } from './media-ref-menu';
|
||||||
export type { MediaRefMenuOptions } from './media-ref-menu';
|
export type { MediaRefMenuOptions } from './media-ref-menu';
|
||||||
|
|
||||||
export { getPreviewImageMenu, getPreviewEmptyMenu } from './preview-area-menu';
|
|
||||||
export type { PreviewAreaMenuOptions } from './preview-area-menu';
|
|
||||||
@@ -14,9 +14,9 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type { ComponentType } from 'react';
|
import type { ComponentType } from 'react';
|
||||||
|
|
||||||
import type { ModelsListResponseBody } from '@/modules/home/controllers/model-api';
|
import type { ModelsListResponseBody } from '@/services/modules/home/models';
|
||||||
import { WIDGET_MAP, FALLBACK_WIDGET } from '../views/left/controls';
|
import { WIDGET_MAP, FALLBACK_WIDGET } from '../views/widgets';
|
||||||
import type { WidgetProps } from '../views/left/controls';
|
import type { WidgetProps } from '../views/widgets';
|
||||||
|
|
||||||
// ---------- 输出类型 ----------
|
// ---------- 输出类型 ----------
|
||||||
|
|
||||||
27
src/modules/home/center/index.ts
Normal file
27
src/modules/home/center/index.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// ============================================================
|
||||||
|
// home/center — 首页中栏(模型输入表单)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// Views
|
||||||
|
export { CenterPanel } from './views/CenterPanel';
|
||||||
|
export { ModelInputForm } from './views/ModelInputForm';
|
||||||
|
|
||||||
|
// Widgets (来自 API param_schema 的表单控件注册表)
|
||||||
|
export { WIDGET_MAP, FALLBACK_WIDGET } from './views/widgets';
|
||||||
|
export type { WidgetType } from './views/widgets';
|
||||||
|
export type { WidgetProps } from './views/widgets/types';
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
export { useModelUI, isValueEmpty } from './hooks/useModelUI';
|
||||||
|
export type { UIFieldConfig } from './hooks/useModelUI';
|
||||||
|
|
||||||
|
// Controllers
|
||||||
|
export {
|
||||||
|
getTextInputMenuItems,
|
||||||
|
getMediaRefImageMenu,
|
||||||
|
getMediaRefBlankMenu,
|
||||||
|
} from './controllers/menus';
|
||||||
|
export type {
|
||||||
|
TextInputMenuOptions,
|
||||||
|
MediaRefMenuOptions,
|
||||||
|
} from './controllers/menus';
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { theme as antTheme } from 'antd';
|
import { theme as antTheme } from 'antd';
|
||||||
import { ModelInputForm } from '../right/ModelInputForm';
|
import { ModelInputForm } from './ModelInputForm';
|
||||||
|
|
||||||
export function CenterPanel() {
|
export function CenterPanel() {
|
||||||
const { token } = antTheme.useToken();
|
const { token } = antTheme.useToken();
|
||||||
@@ -28,26 +28,26 @@ import type { FormInstance } from 'antd';
|
|||||||
import { SendOutlined, ClockCircleOutlined, ReloadOutlined } from '@ant-design/icons';
|
import { SendOutlined, ClockCircleOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
import type { UploadFile } from 'antd/es/upload';
|
import type { UploadFile } from 'antd/es/upload';
|
||||||
|
|
||||||
import { useHomeContext } from '@/modules/home/stores/HomeContext';
|
import { useHomeContext } from '@/modules/home/shared/stores/HomeContext';
|
||||||
import { useAppContext } from '@/app/providers/AppProvider';
|
import { useAppContext } from '@/app/providers/AppProvider';
|
||||||
import { useSubmitTask } from '@/shared/hooks/use-api';
|
import { useSubmitTask } from '@/shared/hooks/use-api';
|
||||||
import { emit, EVENTS } from '@/shared/utils/bus';
|
import { emit, EVENTS } from '@/shared/utils/bus';
|
||||||
import { OutputTypeMap } from '@/modules/home/controllers/task-api';
|
import { OutputTypeMap } from '@/services/modules/home/task';
|
||||||
import type { OutputTypeString, TaskInfoItemResponseBody } from '@/modules/home/controllers/task-api';
|
import type { OutputTypeString, TaskInfoItemResponseBody } from '@/services/modules/home/task';
|
||||||
import { useModelUI, isValueEmpty, type UIFieldConfig } from '../../hooks/useModelUI';
|
import { useModelUI, isValueEmpty, type UIFieldConfig } from '../hooks/useModelUI';
|
||||||
import {
|
import {
|
||||||
calculateCost,
|
calculateCost,
|
||||||
formatYuan,
|
formatYuan,
|
||||||
type ModelDefinition,
|
type ModelDefinition,
|
||||||
type ModelPricingConfig,
|
type ModelPricingConfig,
|
||||||
} from '@/shared/utils/pricing';
|
} from '@/shared/utils/pricing';
|
||||||
import { getTextInputMenuItems } from '../../controllers/menus';
|
import { getTextInputMenuItems } from '../controllers/menus';
|
||||||
import type { ContextMenuItems } from '@/shared/components/ContextMenu';
|
import type { ContextMenuItems } from '@/shared/components/ContextMenu';
|
||||||
import {
|
import {
|
||||||
execUndo, execRedo, execCut, execCopy, execPaste,
|
execUndo, execRedo, execCut, execCopy, execPaste,
|
||||||
execClear, execSelectAll, execIncreaseFont, execDecreaseFont,
|
execClear, execSelectAll, execIncreaseFont, execDecreaseFont,
|
||||||
execResetFont, hasTextSelection, isInputEmpty, getFontSizeLevel,
|
execResetFont, hasTextSelection, isInputEmpty, getFontSizeLevel,
|
||||||
} from '../../controllers/menus/text-input-ops';
|
} from '../controllers/menus/text-input-ops';
|
||||||
|
|
||||||
const { Text, Title } = Typography;
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ import type { UploadFile, RcFile } from 'antd/es/upload';
|
|||||||
import type { WidgetProps } from './types';
|
import type { WidgetProps } from './types';
|
||||||
import { useFileUpload } from '@/shared/hooks/use-api';
|
import { useFileUpload } from '@/shared/hooks/use-api';
|
||||||
import { ContextMenu } from '@/shared/components/ContextMenu';
|
import { ContextMenu } from '@/shared/components/ContextMenu';
|
||||||
import { getMediaRefImageMenu, getMediaRefBlankMenu } from '../../../controllers/menus';
|
import { getMediaRefImageMenu, getMediaRefBlankMenu } from '../../controllers/menus';
|
||||||
import type { ContextMenuItems } from '@/shared/components/ContextMenu';
|
import type { ContextMenuItems } from '@/shared/components/ContextMenu';
|
||||||
|
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
14
src/modules/home/index.ts
Normal file
14
src/modules/home/index.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// ============================================================
|
||||||
|
// home — 首页模块统一导出
|
||||||
|
//
|
||||||
|
// 结构:三栏 → 五层嵌套
|
||||||
|
// left/ — 左栏 VCHSM(模型选择 + 任务记录)
|
||||||
|
// center/ — 中栏 VCHSM(模型输入表单 + 参数控件)
|
||||||
|
// right/ — 右栏 VCHSM(输出预览)
|
||||||
|
// shared/ — 跨栏共享(全局视图 + API + 状态)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export { LeftPanel } from './left';
|
||||||
|
export { CenterPanel } from './center';
|
||||||
|
export { RightPanel } from './right';
|
||||||
|
export { HomePage, HomeContent } from './shared';
|
||||||
8
src/modules/home/left/controllers/menus/index.ts
Normal file
8
src/modules/home/left/controllers/menus/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// ============================================================
|
||||||
|
// menus — 左栏右键菜单定义统一导出
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export { getTaskListMenuItems } from './task-list-menu';
|
||||||
|
export type { TaskListMenuOptions } from './task-list-menu';
|
||||||
|
|
||||||
|
export { buildTaskActions } from './task-list-actions';
|
||||||
131
src/modules/home/left/controllers/menus/task-list-actions.ts
Normal file
131
src/modules/home/left/controllers/menus/task-list-actions.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// ============================================================
|
||||||
|
// task-list-actions.ts — 任务列表右键菜单操作实现
|
||||||
|
//
|
||||||
|
// 职责:
|
||||||
|
// - 接收 TaskInfoItemResponseBody,返回 TaskListMenuOptions
|
||||||
|
// - 所有回调在此文件中实现(IPC 调用、日志记录、用户提示)
|
||||||
|
// - View 层只需调用 buildTaskActions(record) 即可获得完整菜单配置
|
||||||
|
//
|
||||||
|
// 设计原则:
|
||||||
|
// - View 不关心 IPC 细节、路径拼接逻辑、错误处理
|
||||||
|
// - 每个操作独立 async 函数,便于单独测试和复用
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { message } from 'antd';
|
||||||
|
import { safeIpcInvoke, logger } from '@/shared/utils/bus';
|
||||||
|
import { BIDIRECTIONAL } from '@shared/constants/ipc-channels';
|
||||||
|
import { joinPaths } from '@/shared/utils/path-join';
|
||||||
|
import type { TaskInfoItemResponseBody } from '@/services/modules/home/task';
|
||||||
|
import type { TaskListMenuOptions } from './task-list-menu';
|
||||||
|
|
||||||
|
// ── 内部工具 ──
|
||||||
|
|
||||||
|
/** user_id → 文件系统安全标识:去非字母数字 → 小写 → 前 8 位 */
|
||||||
|
function cleanUserId(raw: string): string {
|
||||||
|
return raw
|
||||||
|
.replace(/[^a-zA-Z0-9]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISO 日期 → "YYYY-MM" */
|
||||||
|
function formatYearMonth(d: Date): string {
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISO 日期 → "DD" */
|
||||||
|
function formatDay(d: Date): string {
|
||||||
|
return String(d.getDate()).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** task_id → 任务目录键:前 8 字符 → t_{key} */
|
||||||
|
function taskDirKey(taskId: string): string {
|
||||||
|
return `t_${taskId.slice(0, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 操作实现 ──
|
||||||
|
|
||||||
|
/** 打开任务输出目录 */
|
||||||
|
async function openOutputDir(record: TaskInfoItemResponseBody): Promise<void> {
|
||||||
|
const d = new Date(record.created_at);
|
||||||
|
if (isNaN(d.getTime())) {
|
||||||
|
logger.warn('ui', '任务创建时间无效', undefined, { taskId: record.id });
|
||||||
|
message.warning('任务创建时间无效,无法定位输出目录');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = (await safeIpcInvoke(BIDIRECTIONAL.GET_DATA_DIR)) as
|
||||||
|
| { success: boolean; data: string }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
if (!result?.data) {
|
||||||
|
logger.warn('ui', 'GET_DATA_DIR 返回空', undefined, { taskId: record.id });
|
||||||
|
message.warning('无法获取本地数据目录');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputsDir = joinPaths(
|
||||||
|
result.data,
|
||||||
|
'repository',
|
||||||
|
cleanUserId(record.user_id),
|
||||||
|
formatYearMonth(d),
|
||||||
|
formatDay(d),
|
||||||
|
taskDirKey(record.id),
|
||||||
|
'outputs',
|
||||||
|
);
|
||||||
|
|
||||||
|
await safeIpcInvoke(BIDIRECTIONAL.SHOW_ITEM_IN_FOLDER, {
|
||||||
|
filePath: outputsDir,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('ui', '打开输出目录失败', err instanceof Error ? err : new Error(String(err)), {
|
||||||
|
taskId: record.id,
|
||||||
|
});
|
||||||
|
message.warning('输出目录不存在或无法访问');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 复制第一个输出资源的链接 */
|
||||||
|
async function copyResourceLink(record: TaskInfoItemResponseBody): Promise<void> {
|
||||||
|
const assets = record.output_assets;
|
||||||
|
if (!assets || assets.length === 0) {
|
||||||
|
message.warning('该任务无输出资源');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(assets[0].url);
|
||||||
|
message.success('已复制资源链接');
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('ui', '复制资源链接失败', err instanceof Error ? err : new Error(String(err)));
|
||||||
|
message.error('复制失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 公开入口 ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据任务记录构建完整的右键菜单操作配置。
|
||||||
|
*
|
||||||
|
* View 层调用此函数获取 TaskListMenuOptions,传入 getTaskListMenuItems()
|
||||||
|
* 即可得到渲染就绪的 ContextMenuItems。
|
||||||
|
*/
|
||||||
|
export function buildTaskActions(record: TaskInfoItemResponseBody): TaskListMenuOptions {
|
||||||
|
return {
|
||||||
|
canOpenOutput: record.status === 'success',
|
||||||
|
canReedit: true,
|
||||||
|
canRegen: true,
|
||||||
|
canPullResult: record.status === 'success',
|
||||||
|
canRedownload: record.status === 'success',
|
||||||
|
isFavorited: record.is_favorite,
|
||||||
|
|
||||||
|
onOpenOutputDir: () => void openOutputDir(record),
|
||||||
|
onCopyResourceLink: () => void copyResourceLink(record),
|
||||||
|
onReedit: () => { message.info('重新编辑功能待实现'); },
|
||||||
|
onRegen: () => { message.info('再次生成功能待实现'); },
|
||||||
|
onPullResult: () => { message.info('拉取结果功能待实现'); },
|
||||||
|
onRedownload: () => { message.info('重新下载功能待实现'); },
|
||||||
|
onToggleFavorite: () => { message.info('收藏功能待实现'); },
|
||||||
|
onDelete: () => { message.info('删除记录功能待实现'); },
|
||||||
|
};
|
||||||
|
}
|
||||||
137
src/modules/home/left/controllers/task-table-config.ts
Normal file
137
src/modules/home/left/controllers/task-table-config.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// ============================================================
|
||||||
|
// task-table-config.ts — 任务记录表格的纯配置、常量和工具函数
|
||||||
|
//
|
||||||
|
// 职责(无 React 依赖,可被任意层级引用):
|
||||||
|
// - 类型定义(TableFilters / TableSorter / TableParams / ColumnMeta)
|
||||||
|
// - 状态/输出类型枚举常量
|
||||||
|
// - 列可见性 localStorage 持久化
|
||||||
|
// - 筛选值提取工具
|
||||||
|
// - 时长格式化工具
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import type { FilterValue } from 'antd/es/table/interface';
|
||||||
|
import { TaskStatusMap, OutputTypeMap } from '@/services/modules/home/task';
|
||||||
|
import type { TaskInfoItemResponseBody } from '@/services/modules/home/task';
|
||||||
|
|
||||||
|
// ── 类型 ──
|
||||||
|
|
||||||
|
/** antd Table onChange 中 filters 的类型 */
|
||||||
|
export type TableFilters = Record<string, FilterValue | null>;
|
||||||
|
|
||||||
|
/** antd Table onChange 中 sorter 的类型(单列排序) */
|
||||||
|
export interface TableSorter {
|
||||||
|
field?: React.Key | readonly React.Key[];
|
||||||
|
order?: 'ascend' | 'descend';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 表格参数(缓存 antd onChange 的结果,用于构建 API 请求) */
|
||||||
|
export interface TableParams {
|
||||||
|
pagination: { current: number; pageSize: number };
|
||||||
|
filters: TableFilters;
|
||||||
|
sorter: TableSorter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列元数据(定义所有可用列 + 默认可见性) */
|
||||||
|
export interface ColumnMeta {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
defaultVisible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 常量 ──
|
||||||
|
|
||||||
|
/** 状态筛选选项(来自 API 枚举) */
|
||||||
|
export const STATUS_FILTERS = Object.entries(TaskStatusMap).map(([value, label]) => ({
|
||||||
|
text: label,
|
||||||
|
value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** 输出类型筛选选项(从 OutputTypeMap 派生) */
|
||||||
|
export const OUTPUT_TYPE_FILTERS = Object.entries(OutputTypeMap).map(([value, text]) => ({
|
||||||
|
text,
|
||||||
|
value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** 状态标签配置(渲染用,含未来未知状态的降级处理) */
|
||||||
|
export 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: '失败' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** localStorage 键名(遵循 ele-heixiu-* 规范) */
|
||||||
|
export const COLUMN_VISIBILITY_KEY = 'ele-heixiu-table-columns';
|
||||||
|
|
||||||
|
/** 所有可用列定义 */
|
||||||
|
export 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 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── 纯工具函数 ──
|
||||||
|
|
||||||
|
/** 未知状态降级:后端新增状态时自动回退显示 key 原文 */
|
||||||
|
export function resolveStatus(s: string): { color: string; label: string } {
|
||||||
|
return STATUS_CONFIG[s] || { color: 'default', label: s };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 localStorage 读取已保存的列可见性 */
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将列可见性持久化到 localStorage */
|
||||||
|
export function writeStoredColumns(visible: Set<string>): void {
|
||||||
|
try { localStorage.setItem(COLUMN_VISIBILITY_KEY, JSON.stringify([...visible])); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 filters 中提取单值筛选(如 status 单选) */
|
||||||
|
export 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) */
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提取视频时长(秒),非视频输出类型返回 null */
|
||||||
|
export function getVideoDuration(record: TaskInfoItemResponseBody): number | null {
|
||||||
|
if (record.output_type === 'video') {
|
||||||
|
return record.ui_params.duration ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化时长:<60s 显示秒,≥60s 显示分秒 */
|
||||||
|
export 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} 分钟`;
|
||||||
|
}
|
||||||
641
src/modules/home/left/hooks/useTaskTable.tsx
Normal file
641
src/modules/home/left/hooks/useTaskTable.tsx
Normal file
@@ -0,0 +1,641 @@
|
|||||||
|
// ============================================================
|
||||||
|
// useTaskTable — 任务记录表格的核心状态、副作用与派生数据 Hook
|
||||||
|
//
|
||||||
|
// 职责:
|
||||||
|
// - 筛选 / 排序 / 分页状态管理
|
||||||
|
// - 列可见性(localStorage 持久化)
|
||||||
|
// - 主题感知 CSS 生成
|
||||||
|
// - API 请求参数构建
|
||||||
|
// - 数据获取 + 非终态任务轮询 + 事件总线监听
|
||||||
|
// - 列定义构建(含渲染函数)
|
||||||
|
// - 表格高度自适应 + 水平滚动同步
|
||||||
|
//
|
||||||
|
// TaskHistory 视图只需解构返回值并渲染 JSX。
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
Space,
|
||||||
|
theme as antTheme,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType, TableProps } from 'antd/es/table';
|
||||||
|
import type { SorterResult } from 'antd/es/table/interface';
|
||||||
|
import { SearchOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { useTaskList, useTaskPolling } from '@/shared/hooks/use-api';
|
||||||
|
import type { ModelsListResponseBody } from '@/services/modules/home/models';
|
||||||
|
import { useAppContext } from '@/app/providers/AppProvider';
|
||||||
|
import { useHomeContext } from '@/modules/home/shared/stores/HomeContext';
|
||||||
|
import { useTheme } from '@/app/providers/ThemeProvider';
|
||||||
|
import { on, off, EVENTS } from '@/shared/utils/bus';
|
||||||
|
import { OutputTypeMap } from '@/services/modules/home/task';
|
||||||
|
import type {
|
||||||
|
TaskInfoItemResponseBody,
|
||||||
|
TaskListRequestParams,
|
||||||
|
TaskStatusString,
|
||||||
|
} from '@/services/modules/home/task';
|
||||||
|
|
||||||
|
import {
|
||||||
|
STATUS_FILTERS,
|
||||||
|
OUTPUT_TYPE_FILTERS,
|
||||||
|
ALL_COLUMNS,
|
||||||
|
readStoredColumns,
|
||||||
|
writeStoredColumns,
|
||||||
|
resolveStatus,
|
||||||
|
pickOne,
|
||||||
|
pickMany,
|
||||||
|
getVideoDuration,
|
||||||
|
formatDuration,
|
||||||
|
type TableFilters,
|
||||||
|
type TableParams,
|
||||||
|
} from '../controllers/task-table-config';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
// ── Hook 入参 ──
|
||||||
|
|
||||||
|
interface UseTaskTableOptions {
|
||||||
|
/** 模型全量数据,由父组件 LeftPanel 传入 */
|
||||||
|
allModels: ModelsListResponseBody[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hook 返回值 ──
|
||||||
|
|
||||||
|
export interface UseTaskTableReturn {
|
||||||
|
// 数据
|
||||||
|
polledTasks: TaskInfoItemResponseBody[];
|
||||||
|
loading: boolean;
|
||||||
|
errorMessage: string | null;
|
||||||
|
total: number;
|
||||||
|
|
||||||
|
// 表格配置
|
||||||
|
columns: ColumnsType<TaskInfoItemResponseBody>;
|
||||||
|
tableCss: string;
|
||||||
|
scrollX: number;
|
||||||
|
|
||||||
|
// DOM refs(View 层绑定到容器元素)
|
||||||
|
tableWrapperRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
hScrollRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
|
||||||
|
// 状态 & 回调
|
||||||
|
tableParams: TableParams;
|
||||||
|
tableBodyHeight: number;
|
||||||
|
handleTableChange: TableProps<TaskInfoItemResponseBody>['onChange'];
|
||||||
|
rowClassName: (record: TaskInfoItemResponseBody, index: number) => string;
|
||||||
|
|
||||||
|
// 列可见性
|
||||||
|
visibleColumns: Set<string>;
|
||||||
|
handleColumnToggle: (key: string, checked: boolean) => void;
|
||||||
|
|
||||||
|
// 派生
|
||||||
|
hasActiveFilters: boolean;
|
||||||
|
setTableParams: React.Dispatch<React.SetStateAction<TableParams>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hook ──
|
||||||
|
|
||||||
|
export function useTaskTable({ allModels }: UseTaskTableOptions): UseTaskTableReturn {
|
||||||
|
const { token } = antTheme.useToken();
|
||||||
|
const { edition } = useAppContext();
|
||||||
|
const {
|
||||||
|
selectedTask,
|
||||||
|
setSelectedTask,
|
||||||
|
taskPage,
|
||||||
|
taskPageSize,
|
||||||
|
} = 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;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════
|
||||||
|
// 主题感知 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; }
|
||||||
|
.task-table .ant-table-body { overflow-x: hidden !important; }
|
||||||
|
.task-table .ant-table-thead > tr > th { font-weight: normal !important; }
|
||||||
|
.task-table .ant-table-body::-webkit-scrollbar { width: 6px; }
|
||||||
|
.task-table .ant-table-body::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.task-table .ant-table-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 3px; }
|
||||||
|
.task-table .ant-table-body::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.35); }
|
||||||
|
.task-hscroll::-webkit-scrollbar { height: 6px; }
|
||||||
|
.task-hscroll::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.task-hscroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 3px; }
|
||||||
|
.task-hscroll::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.35); }
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
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; }
|
||||||
|
.task-table .ant-table-body { overflow-x: hidden !important; }
|
||||||
|
.task-table .ant-table-thead > tr > th { font-weight: normal !important; }
|
||||||
|
.task-table .ant-table-body::-webkit-scrollbar { width: 6px; }
|
||||||
|
.task-table .ant-table-body::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.task-table .ant-table-body::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); border-radius: 3px; }
|
||||||
|
.task-table .ant-table-body::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.25); }
|
||||||
|
.task-hscroll::-webkit-scrollbar { height: 6px; }
|
||||||
|
.task-hscroll::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.task-hscroll::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); border-radius: 3px; }
|
||||||
|
.task-hscroll::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.25); }
|
||||||
|
`;
|
||||||
|
}, [isDark, token.blue, token.colorPrimary]);
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════
|
||||||
|
// 表格参数(分页 + 筛选 + 排序)
|
||||||
|
// ════════════════════════════════════════════
|
||||||
|
|
||||||
|
const [tableParams, setTableParams] = useState<TableParams>({
|
||||||
|
pagination: { current: taskPage, pageSize: taskPageSize },
|
||||||
|
filters: {},
|
||||||
|
sorter: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 同步 context 分页 ↔ tableParams
|
||||||
|
useEffect(() => {
|
||||||
|
setTableParams((prev: TableParams) => ({
|
||||||
|
...prev,
|
||||||
|
pagination: { current: taskPage, pageSize: taskPageSize },
|
||||||
|
}));
|
||||||
|
}, [taskPage, taskPageSize]);
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════
|
||||||
|
// 筛选项(从模型和任务数据派生)
|
||||||
|
// ════════════════════════════════════════════
|
||||||
|
|
||||||
|
const modelFilters: { text: string; value: string }[] = 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]);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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 handleTaskTerminal = useCallback(() => {
|
||||||
|
setRefreshTick((t) => t + 1);
|
||||||
|
}, []);
|
||||||
|
const polledTasks = useTaskPolling(tasks, handleTaskTerminal);
|
||||||
|
|
||||||
|
// 轮询更新后同步选中的任务到 Context
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedTask || polledTasks.length === 0) return;
|
||||||
|
const updated = polledTasks.find(t => t.id === selectedTask.id);
|
||||||
|
if (updated && updated.status !== selectedTask.status) {
|
||||||
|
setSelectedTask(updated);
|
||||||
|
}
|
||||||
|
}, [polledTasks, selectedTask, setSelectedTask]);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════
|
||||||
|
// Table onChange 处理
|
||||||
|
// ════════════════════════════════════════════
|
||||||
|
|
||||||
|
const handleTableChange: TableProps<TaskInfoItemResponseBody>['onChange'] = (
|
||||||
|
_pagination,
|
||||||
|
filters,
|
||||||
|
sorter,
|
||||||
|
) => {
|
||||||
|
const s = sorter as SorterResult<TaskInfoItemResponseBody>;
|
||||||
|
setTableParams((prev) => ({
|
||||||
|
...prev,
|
||||||
|
filters: filters as TableFilters,
|
||||||
|
sorter: !Array.isArray(s) && s.field
|
||||||
|
? { field: s.field, order: s.order as 'ascend' | 'descend' | undefined }
|
||||||
|
: {},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════
|
||||||
|
// 列定义
|
||||||
|
// ════════════════════════════════════════════
|
||||||
|
|
||||||
|
const allColumnsDef = useMemo<ColumnsType<TaskInfoItemResponseBody>>(() => {
|
||||||
|
const cols: ColumnsType<TaskInfoItemResponseBody> = [
|
||||||
|
{
|
||||||
|
title: '任务 ID',
|
||||||
|
dataIndex: 'id',
|
||||||
|
key: 'id',
|
||||||
|
width: 140,
|
||||||
|
ellipsis: true,
|
||||||
|
fixed: 'left',
|
||||||
|
render: (id: string) => (
|
||||||
|
<Text copyable={{ text: id }} style={{ fontSize: 12, fontFamily: 'monospace' }}>
|
||||||
|
{id.slice(0, 10)}...
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: '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: 120,
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
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(
|
||||||
|
() => allColumnsDef.filter((c) => visibleColumns.has(c.key as string)),
|
||||||
|
[allColumnsDef, visibleColumns],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════
|
||||||
|
// 表格高度自适应
|
||||||
|
// ════════════════════════════════════════════
|
||||||
|
|
||||||
|
const scrollX = isEnterprise ? 1350 : 1250;
|
||||||
|
const tableWrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const hScrollRef = 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 theadH = thead?.offsetHeight ?? 0;
|
||||||
|
const available = wrapper.clientHeight - theadH;
|
||||||
|
setTableBodyHeight(Math.max(120, available));
|
||||||
|
};
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(calcHeight);
|
||||||
|
resizeObserver.observe(wrapper);
|
||||||
|
|
||||||
|
const mutationObserver = new MutationObserver(calcHeight);
|
||||||
|
mutationObserver.observe(wrapper, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
calcHeight();
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
mutationObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, [taskPageSize]);
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════
|
||||||
|
// 固定水平滚动条 ↔ 表格 body 双向同步
|
||||||
|
// ════════════════════════════════════════════
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const wrapper = tableWrapperRef.current;
|
||||||
|
const hScroll = hScrollRef.current;
|
||||||
|
if (!wrapper || !hScroll) return;
|
||||||
|
|
||||||
|
const body = wrapper.querySelector('.ant-table-body') as HTMLElement | null;
|
||||||
|
if (!body) return;
|
||||||
|
|
||||||
|
const syncBodyToHScroll = () => {
|
||||||
|
hScroll.scrollLeft = body.scrollLeft;
|
||||||
|
};
|
||||||
|
const syncHScrollToBody = () => {
|
||||||
|
body.scrollLeft = hScroll.scrollLeft;
|
||||||
|
};
|
||||||
|
|
||||||
|
body.addEventListener('scroll', syncBodyToHScroll);
|
||||||
|
hScroll.addEventListener('scroll', syncHScrollToBody);
|
||||||
|
syncBodyToHScroll();
|
||||||
|
|
||||||
|
const observer = new MutationObserver(syncBodyToHScroll);
|
||||||
|
observer.observe(body, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
body.removeEventListener('scroll', syncBodyToHScroll);
|
||||||
|
hScroll.removeEventListener('scroll', syncHScrollToBody);
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [polledTasks, tableBodyHeight]);
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════
|
||||||
|
// 行样式 & 筛选状态
|
||||||
|
// ════════════════════════════════════════════
|
||||||
|
|
||||||
|
const rowClassName = useCallback(
|
||||||
|
(_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;
|
||||||
|
},
|
||||||
|
[selectedTask?.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasActiveFilters = Object.values(tableParams.filters).some(
|
||||||
|
(v) => Array.isArray(v) && v.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
polledTasks,
|
||||||
|
loading,
|
||||||
|
errorMessage,
|
||||||
|
total,
|
||||||
|
columns,
|
||||||
|
tableCss,
|
||||||
|
scrollX,
|
||||||
|
tableWrapperRef,
|
||||||
|
hScrollRef,
|
||||||
|
tableParams,
|
||||||
|
tableBodyHeight,
|
||||||
|
handleTableChange,
|
||||||
|
rowClassName,
|
||||||
|
visibleColumns,
|
||||||
|
handleColumnToggle,
|
||||||
|
hasActiveFilters,
|
||||||
|
setTableParams,
|
||||||
|
};
|
||||||
|
}
|
||||||
39
src/modules/home/left/index.ts
Normal file
39
src/modules/home/left/index.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// ============================================================
|
||||||
|
// home/left — 首页左栏(模型选择 + 任务记录)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// Views
|
||||||
|
export { LeftPanel } from './views/LeftPanel';
|
||||||
|
export { ModelSelector } from './views/ModelSelector';
|
||||||
|
export { TaskHistory } from './views/TaskHistory';
|
||||||
|
|
||||||
|
// Controllers
|
||||||
|
export {
|
||||||
|
getTaskListMenuItems,
|
||||||
|
buildTaskActions,
|
||||||
|
} from './controllers/menus';
|
||||||
|
export type { TaskListMenuOptions } from './controllers/menus';
|
||||||
|
|
||||||
|
export {
|
||||||
|
STATUS_FILTERS,
|
||||||
|
OUTPUT_TYPE_FILTERS,
|
||||||
|
STATUS_CONFIG,
|
||||||
|
ALL_COLUMNS,
|
||||||
|
resolveStatus,
|
||||||
|
readStoredColumns,
|
||||||
|
writeStoredColumns,
|
||||||
|
pickOne,
|
||||||
|
pickMany,
|
||||||
|
getVideoDuration,
|
||||||
|
formatDuration,
|
||||||
|
} from './controllers/task-table-config';
|
||||||
|
export type {
|
||||||
|
TableFilters,
|
||||||
|
TableSorter,
|
||||||
|
TableParams,
|
||||||
|
ColumnMeta,
|
||||||
|
} from './controllers/task-table-config';
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
export { useTaskTable } from './hooks/useTaskTable';
|
||||||
|
export type { UseTaskTableReturn } from './hooks/useTaskTable';
|
||||||
@@ -7,8 +7,8 @@
|
|||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import { theme as antTheme } from 'antd';
|
import { theme as antTheme } from 'antd';
|
||||||
import { ModelSelector } from '../right/ModelSelector';
|
import { ModelSelector } from './ModelSelector';
|
||||||
import { TaskHistory } from '../right/TaskHistory';
|
import { TaskHistory } from './TaskHistory';
|
||||||
import { useModelList } from '@/shared/hooks/use-api';
|
import { useModelList } from '@/shared/hooks/use-api';
|
||||||
|
|
||||||
// ---------- 常量 ----------
|
// ---------- 常量 ----------
|
||||||
@@ -7,8 +7,8 @@ import React, { useState, useMemo, useCallback } from 'react';
|
|||||||
import { Tabs, Spin, Empty, Tag, Typography, Button, Result, message, theme as antTheme } from 'antd';
|
import { Tabs, Spin, Empty, Tag, Typography, Button, Result, message, theme as antTheme } from 'antd';
|
||||||
import { AppstoreOutlined } from '@ant-design/icons';
|
import { AppstoreOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { useHomeContext } from '@/modules/home/stores/HomeContext';
|
import { useHomeContext } from '@/modules/home/shared/stores/HomeContext';
|
||||||
import { MODEL_CATEGORIES, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/modules/home/controllers/model-api';
|
import { MODEL_CATEGORIES, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/home/models';
|
||||||
import { EnumResolver } from '@/shared/utils/enum-resolver';
|
import { EnumResolver } from '@/shared/utils/enum-resolver';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
285
src/modules/home/left/views/TaskHistory.tsx
Normal file
285
src/modules/home/left/views/TaskHistory.tsx
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
// ============================================================
|
||||||
|
// TaskHistory — 任务记录列表(纯 View 层)
|
||||||
|
//
|
||||||
|
// 职责:
|
||||||
|
// - 右键菜单状态(useState)与 DOM 事件绑定
|
||||||
|
// - 使用 useTaskTable Hook 获取所有数据与配置
|
||||||
|
// - 纯 JSX 渲染(Table + Pagination + Dropdown + 标题栏)
|
||||||
|
//
|
||||||
|
// 所有业务逻辑(筛选/排序/数据获取/列定义/滚动同步等)已提取到:
|
||||||
|
// - left/controllers/task-table-config.ts (常量 + 纯工具函数)
|
||||||
|
// - left/hooks/useTaskTable.ts (状态 + 副作用 + 派生数据)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
theme as antTheme,
|
||||||
|
Popover,
|
||||||
|
Checkbox,
|
||||||
|
Button,
|
||||||
|
Pagination,
|
||||||
|
Dropdown,
|
||||||
|
} from 'antd';
|
||||||
|
import { HistoryOutlined, SettingOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { useHomeContext } from '@/modules/home/shared/stores/HomeContext';
|
||||||
|
import { getTaskListMenuItems, buildTaskActions } from '@/modules/home/left';
|
||||||
|
import type { ContextMenuItems } from '@/shared/components/ContextMenu';
|
||||||
|
import type { TaskInfoItemResponseBody } from '@/services/modules/home/task';
|
||||||
|
|
||||||
|
import { useTaskTable } from '@/modules/home/left';
|
||||||
|
import { ALL_COLUMNS } from '@/modules/home/left';
|
||||||
|
import type { TableParams } from '@/modules/home/left';
|
||||||
|
import type { ModelsListResponseBody } from '@/services/modules/home/models';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
// ── 组件 Props ──
|
||||||
|
|
||||||
|
interface TaskHistoryProps {
|
||||||
|
/** 模型全量数据,由父组件 LeftPanel 传入 */
|
||||||
|
allModels: ModelsListResponseBody[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 组件 ──
|
||||||
|
|
||||||
|
export function TaskHistory({ allModels }: TaskHistoryProps) {
|
||||||
|
const { token } = antTheme.useToken();
|
||||||
|
const {
|
||||||
|
setSelectedTask,
|
||||||
|
taskPage,
|
||||||
|
taskPageSize,
|
||||||
|
setTaskPage,
|
||||||
|
setTaskPageSize,
|
||||||
|
} = useHomeContext();
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════
|
||||||
|
// 数据 & 配置(Hook 驱动)
|
||||||
|
// ════════════════════════════════════════════
|
||||||
|
|
||||||
|
const {
|
||||||
|
polledTasks,
|
||||||
|
loading,
|
||||||
|
errorMessage,
|
||||||
|
total,
|
||||||
|
columns,
|
||||||
|
tableCss,
|
||||||
|
scrollX,
|
||||||
|
tableWrapperRef,
|
||||||
|
hScrollRef,
|
||||||
|
tableParams,
|
||||||
|
tableBodyHeight,
|
||||||
|
handleTableChange,
|
||||||
|
rowClassName,
|
||||||
|
visibleColumns,
|
||||||
|
handleColumnToggle,
|
||||||
|
hasActiveFilters,
|
||||||
|
setTableParams,
|
||||||
|
} = useTaskTable({ allModels });
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════
|
||||||
|
// 右键菜单(View 层职责:状态 + DOM 事件)
|
||||||
|
// ════════════════════════════════════════════
|
||||||
|
|
||||||
|
const [contextMenu, setContextMenu] = useState<{
|
||||||
|
record: TaskInfoItemResponseBody;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
/** ContextMenuItems → AntD MenuProps['items'] */
|
||||||
|
const toAntdMenu = useCallback((items: ContextMenuItems) =>
|
||||||
|
items.map((item) => {
|
||||||
|
if (item === 'divider') return { type: 'divider' as const };
|
||||||
|
return {
|
||||||
|
key: item.key,
|
||||||
|
label: item.label,
|
||||||
|
onClick: item.onClick,
|
||||||
|
disabled: item.disabled,
|
||||||
|
danger: item.danger,
|
||||||
|
};
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
/** 根据任务记录生成右键菜单项 */
|
||||||
|
const buildTaskMenuItems = useCallback((record: TaskInfoItemResponseBody) => {
|
||||||
|
return toAntdMenu(getTaskListMenuItems(buildTaskActions(record)));
|
||||||
|
}, [toAntdMenu]);
|
||||||
|
|
||||||
|
// 菜单打开时监听全局 mousedown → 点击菜单外区域自动关闭
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contextMenu) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
const menuEl = document.querySelector('.ant-dropdown');
|
||||||
|
if (menuEl && !menuEl.contains(e.target as Node)) {
|
||||||
|
setContextMenu(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
document.addEventListener('mousedown', handler, true);
|
||||||
|
}, 0);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
document.removeEventListener('mousedown', handler, true);
|
||||||
|
};
|
||||||
|
}, [contextMenu]);
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════
|
||||||
|
// 渲染
|
||||||
|
// ════════════════════════════════════════════
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
|
||||||
|
{/* ── 标题栏 ── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px 8px 9px',
|
||||||
|
borderLeft: `3px solid ${token.colorPrimary}`,
|
||||||
|
background: token.colorFillSecondary,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HistoryOutlined style={{ fontSize: 14, color: token.colorPrimary }} />
|
||||||
|
<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={hScrollRef}
|
||||||
|
className="task-hscroll"
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
overflowX: 'auto',
|
||||||
|
overflowY: 'hidden',
|
||||||
|
height: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ width: scrollX, height: 1 }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 表格 ── */}
|
||||||
|
<div ref={tableWrapperRef} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||||
|
<style>{tableCss}</style>
|
||||||
|
<Table<TaskInfoItemResponseBody>
|
||||||
|
className="task-table"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={polledTasks}
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
loading={loading}
|
||||||
|
showHeader={true}
|
||||||
|
scroll={{ x: scrollX, y: tableBodyHeight }}
|
||||||
|
rowClassName={rowClassName}
|
||||||
|
onRow={(record) => ({
|
||||||
|
onClick: () => setSelectedTask(record),
|
||||||
|
onContextMenu: (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedTask(record);
|
||||||
|
setContextMenu({ record, x: e.clientX, y: e.clientY });
|
||||||
|
},
|
||||||
|
style: { cursor: 'pointer' },
|
||||||
|
})}
|
||||||
|
onChange={handleTableChange}
|
||||||
|
pagination={false}
|
||||||
|
locale={{ emptyText: '暂无任务记录', filterReset: '重置', filterConfirm: '确定' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 分页器 ── */}
|
||||||
|
<div style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '4px 0 0',
|
||||||
|
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
}}>
|
||||||
|
<Pagination
|
||||||
|
current={tableParams.pagination.current}
|
||||||
|
pageSize={tableParams.pagination.pageSize}
|
||||||
|
total={total}
|
||||||
|
size="small"
|
||||||
|
showSizeChanger
|
||||||
|
pageSizeOptions={[10, 20, 50]}
|
||||||
|
onChange={(page, pageSize) => {
|
||||||
|
if (page !== taskPage) setTaskPage(page);
|
||||||
|
if (pageSize !== taskPageSize) setTaskPageSize(pageSize);
|
||||||
|
setTableParams((prev: TableParams) => ({
|
||||||
|
...prev,
|
||||||
|
pagination: { current: page, pageSize },
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 悬浮右键菜单 ── */}
|
||||||
|
<Dropdown
|
||||||
|
open={!!contextMenu}
|
||||||
|
onOpenChange={(open) => { if (!open) setContextMenu(null); }}
|
||||||
|
menu={{
|
||||||
|
items: contextMenu ? buildTaskMenuItems(contextMenu.record) : [],
|
||||||
|
onClick: () => setContextMenu(null),
|
||||||
|
}}
|
||||||
|
trigger={[]}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onContextMenu={(e) => { e.preventDefault(); setContextMenu(null); }}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: contextMenu?.x ?? -9999,
|
||||||
|
top: contextMenu?.y ?? -9999,
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
src/modules/home/right/controllers/menus/index.ts
Normal file
6
src/modules/home/right/controllers/menus/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// ============================================================
|
||||||
|
// menus — 右栏右键菜单定义统一导出
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export { getPreviewImageMenu, getPreviewEmptyMenu } from './preview-area-menu';
|
||||||
|
export type { PreviewAreaMenuOptions } from './preview-area-menu';
|
||||||
14
src/modules/home/right/index.ts
Normal file
14
src/modules/home/right/index.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// ============================================================
|
||||||
|
// home/right — 首页右栏(输出预览)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// Views
|
||||||
|
export { RightPanel } from './views/RightPanel';
|
||||||
|
export { OutputPreview } from './views/OutputPreview';
|
||||||
|
|
||||||
|
// Controllers
|
||||||
|
export {
|
||||||
|
getPreviewImageMenu,
|
||||||
|
getPreviewEmptyMenu,
|
||||||
|
} from './controllers/menus';
|
||||||
|
export type { PreviewAreaMenuOptions } from './controllers/menus';
|
||||||
@@ -20,11 +20,11 @@ import {
|
|||||||
CloseCircleOutlined,
|
CloseCircleOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
import { useHomeContext } from '@/modules/home/stores/HomeContext';
|
import { useHomeContext } from '@/modules/home/shared/stores/HomeContext';
|
||||||
import { useTaskDetail } from '@/shared/hooks/use-api';
|
import { useTaskDetail } from '@/shared/hooks/use-api';
|
||||||
import { POLLING_STATUSES, type TaskStatusString } from '@/modules/home/controllers/task-api';
|
import { POLLING_STATUSES, type TaskStatusString } from '@/services/modules/home/task';
|
||||||
import { ContextMenu } from '@/shared/components/ContextMenu';
|
import { ContextMenu } from '@/shared/components/ContextMenu';
|
||||||
import { getPreviewImageMenu, getPreviewEmptyMenu } from '../../controllers/menus';
|
import { getPreviewImageMenu, getPreviewEmptyMenu } from '../controllers/menus';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
30
src/modules/home/shared/index.ts
Normal file
30
src/modules/home/shared/index.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// ============================================================
|
||||||
|
// home/shared — 首页跨栏共享(全局视图 + API + 状态)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// Views
|
||||||
|
export { HomePage } from './views/HomePage';
|
||||||
|
export { HomeContent } from './views/HomeContent';
|
||||||
|
|
||||||
|
// Controllers (API)
|
||||||
|
export { TaskAPI, TaskStatusMap, OutputTypeMap, POLLING_STATUSES } from '@/services/modules/home/task';
|
||||||
|
export type {
|
||||||
|
TaskInfoItemResponseBody,
|
||||||
|
TaskListRequestParams,
|
||||||
|
TaskStatusString,
|
||||||
|
CreatTaskRequestBody,
|
||||||
|
} from '@/services/modules/home/task';
|
||||||
|
|
||||||
|
export { ModelAPI, MODEL_CATEGORIES, MODEL_CATEGORY_LABELS, CATEGORY_SLUG_MAP } from '@/services/modules/home/models';
|
||||||
|
export type {
|
||||||
|
ModelsListResponseBody,
|
||||||
|
ParamSchemaItem,
|
||||||
|
ModelCategoryStringEnum,
|
||||||
|
} from '@/services/modules/home/models';
|
||||||
|
|
||||||
|
export { fetchBanners } from '@/services/modules/home/banner';
|
||||||
|
export type { BannerListResponse } from '@/services/modules/home/banner';
|
||||||
|
|
||||||
|
// Stores
|
||||||
|
export { HomeContext, useHomeContext } from './stores/HomeContext';
|
||||||
|
export type { HomeContextValue } from './stores/HomeContext';
|
||||||
@@ -7,8 +7,8 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { createContext, useContext } from 'react';
|
import { createContext, useContext } from 'react';
|
||||||
import type { ModelsListResponseBody } from '@/modules/home/controllers/model-api';
|
import type { ModelsListResponseBody } from '@/services/modules/home/models';
|
||||||
import type { TaskInfoItemResponseBody } from '@/modules/home/controllers/task-api';
|
import type { TaskInfoItemResponseBody } from '@/services/modules/home/task';
|
||||||
|
|
||||||
// ---------- Context 类型 ----------
|
// ---------- Context 类型 ----------
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ import { CloseOutlined } from '@ant-design/icons';
|
|||||||
import type { CarouselRef } from 'antd/es/carousel';
|
import type { CarouselRef } from 'antd/es/carousel';
|
||||||
|
|
||||||
import { useBannerList } from '@/shared/hooks/use-api';
|
import { useBannerList } from '@/shared/hooks/use-api';
|
||||||
import type { Banner } from '@/modules/home/controllers/banner-api';
|
import type { Banner } from '@/services/modules/home/banner';
|
||||||
|
|
||||||
// ---------- 组件 Props ----------
|
// ---------- 组件 Props ----------
|
||||||
|
|
||||||
@@ -7,11 +7,11 @@ import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
|||||||
import { theme as antTheme, Empty } from 'antd';
|
import { theme as antTheme, Empty } from 'antd';
|
||||||
|
|
||||||
import { useAppContext } from '@/app/providers/AppProvider';
|
import { useAppContext } from '@/app/providers/AppProvider';
|
||||||
import type { ModelsListResponseBody } from '@/modules/home/controllers/model-api';
|
import type { ModelsListResponseBody } from '@/services/modules/home/models';
|
||||||
import type { TaskInfoItemResponseBody } from '@/modules/home/controllers/task-api';
|
import type { TaskInfoItemResponseBody } from '@/services/modules/home/task';
|
||||||
import { LeftPanel } from './left/LeftPanel';
|
import { LeftPanel } from '../../left/views/LeftPanel';
|
||||||
import { CenterPanel } from './center/CenterPanel';
|
import { CenterPanel } from '../../center/views/CenterPanel';
|
||||||
import { RightPanel } from './right/RightPanel';
|
import { RightPanel } from '../../right/views/RightPanel';
|
||||||
|
|
||||||
import { Typography } from 'antd';
|
import { Typography } from 'antd';
|
||||||
|
|
||||||
@@ -1,926 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// 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, Pagination, Dropdown, message } 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, useTaskPolling } from '@/shared/hooks/use-api';
|
|
||||||
import type { ModelsListResponseBody } from '@/modules/home/controllers/model-api';
|
|
||||||
import { useAppContext } from '@/app/providers/AppProvider';
|
|
||||||
import { useHomeContext } from '@/modules/home/stores/HomeContext';
|
|
||||||
import { useTheme } from '@/app/providers/ThemeProvider';
|
|
||||||
import { on, off, EVENTS } from '@/shared/utils/bus';
|
|
||||||
import type {
|
|
||||||
TaskInfoItemResponseBody,
|
|
||||||
TaskListRequestParams,
|
|
||||||
TaskStatusString,
|
|
||||||
} from '@/modules/home/controllers/task-api';
|
|
||||||
import { TaskStatusMap, OutputTypeMap } from '@/modules/home/controllers/task-api';
|
|
||||||
import { getTaskListMenuItems } from '../../controllers/menus';
|
|
||||||
import type { ContextMenuItems } from '@/shared/components/ContextMenu';
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 组件 ----------
|
|
||||||
|
|
||||||
interface TaskHistoryProps {
|
|
||||||
/** 模型全量数据,由父组件 LeftPanel 传入(避免兄弟组件各自请求) */
|
|
||||||
allModels: ModelsListResponseBody[] | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TaskHistory({ allModels }: TaskHistoryProps) {
|
|
||||||
const { token } = antTheme.useToken();
|
|
||||||
const { edition } = useAppContext();
|
|
||||||
const {
|
|
||||||
selectedTask,
|
|
||||||
setSelectedTask,
|
|
||||||
taskPage,
|
|
||||||
taskPageSize,
|
|
||||||
setTaskPage,
|
|
||||||
setTaskPageSize,
|
|
||||||
} = useHomeContext();
|
|
||||||
|
|
||||||
const isEnterprise = edition === 'enterprise';
|
|
||||||
|
|
||||||
// -------- 右键菜单 --------
|
|
||||||
|
|
||||||
const [contextMenu, setContextMenu] = useState<{
|
|
||||||
record: TaskInfoItemResponseBody;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
/** ContextMenuItems → AntD MenuProps['items'] 转换 */
|
|
||||||
const toAntdMenu = useCallback((items: ContextMenuItems) =>
|
|
||||||
items.map((item) => {
|
|
||||||
if (item === 'divider') return { type: 'divider' as const };
|
|
||||||
return {
|
|
||||||
key: item.key,
|
|
||||||
label: item.label,
|
|
||||||
onClick: item.onClick,
|
|
||||||
disabled: item.disabled,
|
|
||||||
danger: item.danger,
|
|
||||||
};
|
|
||||||
}), []);
|
|
||||||
|
|
||||||
/** 根据任务记录生成右键菜单项 */
|
|
||||||
const buildTaskMenuItems = useCallback((record: TaskInfoItemResponseBody) => {
|
|
||||||
return toAntdMenu(getTaskListMenuItems({
|
|
||||||
canOpenOutput: record.status === 'success',
|
|
||||||
canReedit: true,
|
|
||||||
canRegen: true,
|
|
||||||
canPullResult: record.status === 'success',
|
|
||||||
canRedownload: record.status === 'success',
|
|
||||||
isFavorited: false,
|
|
||||||
onOpenOutputDir: () => { message.info('打开输出目录功能待实现'); },
|
|
||||||
onCopyResourceLink: () => {
|
|
||||||
const assets = record.output_assets;
|
|
||||||
if (assets && assets.length > 0) {
|
|
||||||
navigator.clipboard.writeText(assets[0].url).then(
|
|
||||||
() => message.success('已复制资源链接'),
|
|
||||||
() => message.error('复制失败'),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
message.warning('该任务无输出资源');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onReedit: () => { message.info('重新编辑功能待实现'); },
|
|
||||||
onRegen: () => { message.info('再次生成功能待实现'); },
|
|
||||||
onPullResult: () => { message.info('拉取结果功能待实现'); },
|
|
||||||
onRedownload: () => { message.info('重新下载功能待实现'); },
|
|
||||||
onToggleFavorite: () => { message.info('收藏功能待实现'); },
|
|
||||||
onDelete: () => { message.info('删除记录功能待实现'); },
|
|
||||||
}));
|
|
||||||
}, [toAntdMenu, message]);
|
|
||||||
|
|
||||||
// 菜单打开时监听全局 mousedown → 点击菜单外区域自动关闭
|
|
||||||
useEffect(() => {
|
|
||||||
if (!contextMenu) return;
|
|
||||||
const handler = (e: MouseEvent) => {
|
|
||||||
const menuEl = document.querySelector('.ant-dropdown');
|
|
||||||
if (menuEl && !menuEl.contains(e.target as Node)) {
|
|
||||||
setContextMenu(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// setTimeout 0 避免捕获打开菜单的同一次右击 mousedown
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
document.addEventListener('mousedown', handler, true);
|
|
||||||
}, 0);
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
document.removeEventListener('mousedown', handler, true);
|
|
||||||
};
|
|
||||||
}, [contextMenu]);
|
|
||||||
|
|
||||||
// -------- 列可见性 --------
|
|
||||||
|
|
||||||
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; }
|
|
||||||
.task-table .ant-table-body { overflow-x: hidden !important; }
|
|
||||||
.task-table .ant-table-thead > tr > th { font-weight: normal !important; }
|
|
||||||
/* 纵向滚动条(表格 body) */
|
|
||||||
.task-table .ant-table-body::-webkit-scrollbar { width: 6px; }
|
|
||||||
.task-table .ant-table-body::-webkit-scrollbar-track { background: transparent; }
|
|
||||||
.task-table .ant-table-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 3px; }
|
|
||||||
.task-table .ant-table-body::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.35); }
|
|
||||||
/* 横向滚动条(固定面板) */
|
|
||||||
.task-hscroll::-webkit-scrollbar { height: 6px; }
|
|
||||||
.task-hscroll::-webkit-scrollbar-track { background: transparent; }
|
|
||||||
.task-hscroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 3px; }
|
|
||||||
.task-hscroll::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.35); }
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
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; }
|
|
||||||
.task-table .ant-table-body { overflow-x: hidden !important; }
|
|
||||||
.task-table .ant-table-thead > tr > th { font-weight: normal !important; }
|
|
||||||
/* 纵向滚动条(表格 body) */
|
|
||||||
.task-table .ant-table-body::-webkit-scrollbar { width: 6px; }
|
|
||||||
.task-table .ant-table-body::-webkit-scrollbar-track { background: transparent; }
|
|
||||||
.task-table .ant-table-body::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); border-radius: 3px; }
|
|
||||||
.task-table .ant-table-body::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.25); }
|
|
||||||
/* 横向滚动条(固定面板) */
|
|
||||||
.task-hscroll::-webkit-scrollbar { height: 6px; }
|
|
||||||
.task-hscroll::-webkit-scrollbar-track { background: transparent; }
|
|
||||||
.task-hscroll::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); border-radius: 3px; }
|
|
||||||
.task-hscroll::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.25); }
|
|
||||||
`;
|
|
||||||
}, [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:TableParams) => ({
|
|
||||||
...prev,
|
|
||||||
pagination: { current: taskPage, pageSize: taskPageSize },
|
|
||||||
}));
|
|
||||||
}, [taskPage, taskPageSize]);
|
|
||||||
|
|
||||||
// -------- 模型列表(用于模型列筛选选项 + 模型名→ID 映射,由父组件传入)--------
|
|
||||||
|
|
||||||
const modelFilters:{text:string, value:string}[] = 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 handleTaskTerminal = useCallback(() => {
|
|
||||||
setRefreshTick((t) => t + 1);
|
|
||||||
}, []);
|
|
||||||
const polledTasks = useTaskPolling(tasks, handleTaskTerminal);
|
|
||||||
|
|
||||||
// 轮询更新后同步选中的任务到 Context
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedTask || polledTasks.length === 0) return;
|
|
||||||
const updated = polledTasks.find(t => t.id === selectedTask.id);
|
|
||||||
if (updated && updated.status !== selectedTask.status) {
|
|
||||||
setSelectedTask(updated);
|
|
||||||
}
|
|
||||||
}, [polledTasks, selectedTask, setSelectedTask]);
|
|
||||||
|
|
||||||
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,
|
|
||||||
) => {
|
|
||||||
// 缓存筛选和排序参数(分页由独立 Pagination 组件管理)
|
|
||||||
const s = sorter as SorterResult<TaskInfoItemResponseBody>;
|
|
||||||
setTableParams((prev) => ({
|
|
||||||
...prev,
|
|
||||||
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: 120,
|
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
// -------- 表格高度 --------
|
|
||||||
//
|
|
||||||
// 核心策略:分页器 + 水平滚动条均已独立到表格 wrapper 之外,
|
|
||||||
// 因此 scroll.y 使用 wrapper 完整高度(仅减去 thead)。
|
|
||||||
|
|
||||||
const scrollX = isEnterprise ? 1350 : 1250;
|
|
||||||
const tableWrapperRef = useRef<HTMLDivElement>(null);
|
|
||||||
const hScrollRef = 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 theadH = thead?.offsetHeight ?? 0;
|
|
||||||
const available = wrapper.clientHeight - theadH;
|
|
||||||
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]);
|
|
||||||
|
|
||||||
// -------- 固定水平滚动条 ↔ 表格 body 双向同步 --------
|
|
||||||
//
|
|
||||||
// .ant-table-body 原生水平滚动条被 CSS 隐藏(overflow-x: hidden),
|
|
||||||
// 改为由独立的固定水平滚动条控制;两者通过 scroll 事件双向同步。
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const wrapper = tableWrapperRef.current;
|
|
||||||
const hScroll = hScrollRef.current;
|
|
||||||
if (!wrapper || !hScroll) return;
|
|
||||||
|
|
||||||
const body = wrapper.querySelector('.ant-table-body') as HTMLElement | null;
|
|
||||||
if (!body) return;
|
|
||||||
|
|
||||||
const syncBodyToHScroll = () => {
|
|
||||||
hScroll.scrollLeft = body.scrollLeft;
|
|
||||||
};
|
|
||||||
const syncHScrollToBody = () => {
|
|
||||||
body.scrollLeft = hScroll.scrollLeft;
|
|
||||||
};
|
|
||||||
|
|
||||||
body.addEventListener('scroll', syncBodyToHScroll);
|
|
||||||
hScroll.addEventListener('scroll', syncHScrollToBody);
|
|
||||||
syncBodyToHScroll();
|
|
||||||
|
|
||||||
// 数据变化(加载/筛选/翻页)后重同步位置
|
|
||||||
const observer = new MutationObserver(syncBodyToHScroll);
|
|
||||||
observer.observe(body, { childList: true, subtree: true });
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
body.removeEventListener('scroll', syncBodyToHScroll);
|
|
||||||
hScroll.removeEventListener('scroll', syncHScrollToBody);
|
|
||||||
observer.disconnect();
|
|
||||||
};
|
|
||||||
}, [polledTasks, tableBodyHeight]);
|
|
||||||
|
|
||||||
// -------- 行样式:斑马纹 + 选中行 --------
|
|
||||||
|
|
||||||
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: '8px 12px 8px 9px',
|
|
||||||
borderLeft: `3px solid ${token.colorPrimary}`,
|
|
||||||
background: token.colorFillSecondary,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<HistoryOutlined style={{ fontSize: 14, color: token.colorPrimary }} />
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* 固定水平滚动条 — 在表格上方,始终可见,与表格 body 双向同步 */}
|
|
||||||
<div
|
|
||||||
ref={hScrollRef}
|
|
||||||
className="task-hscroll"
|
|
||||||
style={{
|
|
||||||
flexShrink: 0,
|
|
||||||
overflowX: 'auto',
|
|
||||||
overflowY: 'hidden',
|
|
||||||
height: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ width: scrollX, height: 1 }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 表格 — 纵向滚动由 scroll.y 控制,横向滚动由上方固定滚动条控制 */}
|
|
||||||
<div ref={tableWrapperRef} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
|
||||||
<style>{tableCss}</style>
|
|
||||||
<Table<TaskInfoItemResponseBody>
|
|
||||||
className="task-table"
|
|
||||||
columns={columns}
|
|
||||||
dataSource={polledTasks}
|
|
||||||
rowKey="id"
|
|
||||||
size="small"
|
|
||||||
loading={loading}
|
|
||||||
showHeader={true}
|
|
||||||
scroll={{ x: scrollX, y: tableBodyHeight }}
|
|
||||||
rowClassName={rowClassName}
|
|
||||||
onRow={(record) => ({
|
|
||||||
onClick: () => setSelectedTask(record),
|
|
||||||
onContextMenu: (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setSelectedTask(record);
|
|
||||||
setContextMenu({ record, x: e.clientX, y: e.clientY });
|
|
||||||
},
|
|
||||||
style: { cursor: 'pointer' },
|
|
||||||
})}
|
|
||||||
onChange={handleTableChange}
|
|
||||||
pagination={false}
|
|
||||||
locale={{ emptyText: '暂无任务记录', filterReset: '重置', filterConfirm: '确定' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 独立分页器 — 固定在底部,不随表格数据量移动 */}
|
|
||||||
<div style={{ flexShrink: 0, display: 'flex', justifyContent: 'center', padding: '4px 0 0', borderTop: `1px solid ${token.colorBorderSecondary}` }}>
|
|
||||||
<Pagination
|
|
||||||
current={tableParams.pagination.current}
|
|
||||||
pageSize={tableParams.pagination.pageSize}
|
|
||||||
total={total}
|
|
||||||
size="small"
|
|
||||||
showSizeChanger
|
|
||||||
pageSizeOptions={[10, 20, 50]}
|
|
||||||
onChange={(page, pageSize) => {
|
|
||||||
if (page !== taskPage) setTaskPage(page);
|
|
||||||
if (pageSize !== taskPageSize) setTaskPageSize(pageSize);
|
|
||||||
setTableParams((prev) => ({
|
|
||||||
...prev,
|
|
||||||
pagination: { current: page, pageSize },
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 悬浮右键菜单 — 手动控制 open,trigger=[] 避免 AntD 自动监听干扰 onRow.onContextMenu */}
|
|
||||||
<Dropdown
|
|
||||||
open={!!contextMenu}
|
|
||||||
onOpenChange={(open) => { if (!open) setContextMenu(null); }}
|
|
||||||
menu={{
|
|
||||||
items: contextMenu ? buildTaskMenuItems(contextMenu.record) : [],
|
|
||||||
onClick: () => setContextMenu(null),
|
|
||||||
}}
|
|
||||||
trigger={[]}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onContextMenu={(e) => { e.preventDefault(); setContextMenu(null); }}
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
left: contextMenu?.x ?? -9999,
|
|
||||||
top: contextMenu?.y ?? -9999,
|
|
||||||
width: 1,
|
|
||||||
height: 1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -30,7 +30,7 @@ export function NavBar() {
|
|||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
|
||||||
// 公告轮询 + 未读缓存
|
// 公告轮询 + 未读缓存
|
||||||
const { announcements, unreadCount, loading: announcementsLoading, markAsRead } = useAnnouncements();
|
const { announcements, unreadCount, loading: announcementsLoading, markAsRead, refetch: refetchAnnouncements } = useAnnouncements();
|
||||||
|
|
||||||
// 根据版本选择导航项
|
// 根据版本选择导航项
|
||||||
const {leftItems, rightItems} = useMemo(() => {
|
const {leftItems, rightItems} = useMemo(() => {
|
||||||
@@ -76,6 +76,7 @@ export function NavBar() {
|
|||||||
case 'drawer':
|
case 'drawer':
|
||||||
if (item.key === 'announcements') {
|
if (item.key === 'announcements') {
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
|
refetchAnnouncements();
|
||||||
markAsRead();
|
markAsRead();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -113,7 +114,7 @@ export function NavBar() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isLoggedIn, logout, message, markAsRead],
|
[isLoggedIn, logout, message, markAsRead, refetchAnnouncements],
|
||||||
);
|
);
|
||||||
|
|
||||||
const macPaddingLeft = platform === 'darwin' ? 80 : 12;
|
const macPaddingLeft = platform === 'darwin' ? 80 : 12;
|
||||||
|
|||||||
@@ -16,9 +16,9 @@
|
|||||||
// - 内存缓存供同步读取(setTokenRefreshHandler 回调内使用)
|
// - 内存缓存供同步读取(setTokenRefreshHandler 回调内使用)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { setToken, setTokenRefreshHandler, RequestError, RequestErrorType } from '@/shared/services/http';
|
import { setToken, setTokenRefreshHandler, RequestError, RequestErrorType } from '@/services/request';
|
||||||
import { AuthAPI, type RefreshTokenResponseBody } from './auth-api';
|
import { AuthAPI, type RefreshTokenResponseBody } from './modules/auth/auth';
|
||||||
import { isRefreshError } from '@/shared/services/types';
|
import { isRefreshError } from '@/services/types';
|
||||||
import {
|
import {
|
||||||
getSafeStore,
|
getSafeStore,
|
||||||
setSafeStore,
|
setSafeStore,
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import {initSafeStore, getSafeStore, setSafeStore} from '@/shared/utils/safe-storage';
|
import {initSafeStore, getSafeStore, setSafeStore} from '@/shared/utils/safe-storage';
|
||||||
import type {ModelsListResponseBody} from '@/modules/home/controllers/model-api';
|
import type {ModelsListResponseBody} from '@/services/modules/home/models';
|
||||||
|
|
||||||
/** 缓存键名(与 safe-storage 加密存储一致) */
|
/** 缓存键名(与 safe-storage 加密存储一致) */
|
||||||
const MODEL_CACHE_KEY = 'ele-heixiu-model-cache';
|
const MODEL_CACHE_KEY = 'ele-heixiu-model-cache';
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import {type Nullable} from "@/shared/utils/display";
|
import {type Nullable} from "@/shared/utils/display";
|
||||||
import {get} from "@/shared/services/http.ts";
|
import {get} from "@/services/request.ts";
|
||||||
|
|
||||||
const BillingUrlObj = {
|
const BillingUrlObj = {
|
||||||
getBalance: '/api/v1/billing/balance',
|
getBalance: '/api/v1/billing/balance',
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { get, post } from '@/shared/services/http';
|
import { get, post } from '@/services/request';
|
||||||
|
|
||||||
export const PAYMENT_METHODS = {
|
export const PAYMENT_METHODS = {
|
||||||
Alipay: 'alipay',
|
Alipay: 'alipay',
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import {post, get} from '@/shared/services/http';
|
import {post, get} from '@/services/request';
|
||||||
import {type UserInfoBody} from "@/modules/auth/controllers/auth-api";
|
import {type UserInfoBody} from "@/services/modules/auth/auth";
|
||||||
import {type Nullable} from "@/shared/utils/display";
|
import {type Nullable} from "@/shared/utils/display";
|
||||||
|
|
||||||
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
// GET /api/v1/announcements
|
// GET /api/v1/announcements
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { get } from '@/shared/services/http';
|
import { get } from '@/services/request';
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// AnnouncementUrlObj — 公告模块路由常量
|
// AnnouncementUrlObj — 公告模块路由常量
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import {post, get} from '@/shared/services/http';
|
import {post, get} from '@/services/request';
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// AuthUrlObj — 认证模块路由常量
|
// AuthUrlObj — 认证模块路由常量
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
// GET /api/v1/banners
|
// GET /api/v1/banners
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import {get} from '@/shared/services/http';
|
import {get} from '@/services/request';
|
||||||
|
|
||||||
// ---------- 路由常量 ----------
|
// ---------- 路由常量 ----------
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import {get} from '@/shared/services/http';
|
import {get} from '@/services/request';
|
||||||
// 注意:后端 /api/v1/models 的 category 查询参数实际不可用(传参返回空列表),
|
// 注意:后端 /api/v1/models 的 category 查询参数实际不可用(传参返回空列表),
|
||||||
// 因此客户端采用"全量拉取 + 按 category_name 字段分组"的策略。
|
// 因此客户端采用"全量拉取 + 按 category_name 字段分组"的策略。
|
||||||
// 后端分类 slug 格式为 img2img / txt2img 等,前端通过 CATEGORY_SLUG_MAP 映射。
|
// 后端分类 slug 格式为 img2img / txt2img 等,前端通过 CATEGORY_SLUG_MAP 映射。
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
// 不做额外的 async/await 包装,由调用方决定是否 await。
|
// 不做额外的 async/await 包装,由调用方决定是否 await。
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import {get, post} from '@/shared/services/http';
|
import {get, post} from '@/services/request';
|
||||||
import {getDeviceId} from '@/shared/utils/env';
|
import {getDeviceId} from '@/shared/utils/env';
|
||||||
import {Nullable} from "@/shared/utils/display";
|
import {Nullable} from "@/shared/utils/display";
|
||||||
|
|
||||||
@@ -17,9 +17,9 @@ import axios, {
|
|||||||
} from 'axios';
|
} from 'axios';
|
||||||
import type { ApiResponse } from './types';
|
import type { ApiResponse } from './types';
|
||||||
import { RequestError, RequestErrorType, RefreshError, isRefreshError } from './types';
|
import { RequestError, RequestErrorType, RefreshError, isRefreshError } from './types';
|
||||||
import { emit, EVENTS } from '../utils/bus';
|
import { emit, EVENTS } from '@/shared/utils/bus';
|
||||||
import { logger } from '../utils/bus';
|
import { logger } from '@/shared/utils/bus';
|
||||||
import { getSafeStore, setSafeStore, clearSafeStore, initSafeStore } from '../utils/safe-storage';
|
import { getSafeStore, setSafeStore, clearSafeStore, initSafeStore } from '@/shared/utils/safe-storage';
|
||||||
|
|
||||||
// ---------- 配置 ----------
|
// ---------- 配置 ----------
|
||||||
|
|
||||||
@@ -2,14 +2,16 @@
|
|||||||
// useAnnouncements — 公告轮询 + 未读缓存 Hook
|
// useAnnouncements — 公告轮询 + 未读缓存 Hook
|
||||||
//
|
//
|
||||||
// 行为:
|
// 行为:
|
||||||
// - 挂载时立即拉取公告列表
|
// - 登录后立即拉取公告列表(未登录时不发送请求)
|
||||||
// - 之后每 15 分钟自动轮询
|
// - 登录态下每 15 分钟自动轮询
|
||||||
// - 通过 localStorage 缓存已读公告 ID,跨会话持久化
|
// - 通过 localStorage 缓存已读公告 ID,跨会话持久化
|
||||||
// - 提供 markAsRead() 在用户查看后清除未读标记
|
// - 提供 markAsRead() 在用户查看后清除未读标记
|
||||||
|
// - 提供 refetch() 手动拉取(抽屉打开时调用)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { fetchAnnouncements, type Announcement } from '@/modules/announcement/controllers/announcement-api';
|
import { fetchAnnouncements, type Announcement } from '@/services/modules/announcement/announcement';
|
||||||
|
import { useAppContext } from '@/app/providers/AppProvider';
|
||||||
import { logger } from '@/shared/utils/bus';
|
import { logger } from '@/shared/utils/bus';
|
||||||
|
|
||||||
const SEEN_IDS_KEY = 'heixiu:announcement:seen-ids';
|
const SEEN_IDS_KEY = 'heixiu:announcement:seen-ids';
|
||||||
@@ -47,9 +49,12 @@ export interface AnnouncementsState {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
/** 标记全部已读(打开抽屉时调用) */
|
/** 标记全部已读(打开抽屉时调用) */
|
||||||
markAsRead: () => void;
|
markAsRead: () => void;
|
||||||
|
/** 手动重新拉取(打开抽屉时调用,确保拿到最新数据) */
|
||||||
|
refetch: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAnnouncements(): AnnouncementsState {
|
export function useAnnouncements(): AnnouncementsState {
|
||||||
|
const { isLoggedIn } = useAppContext();
|
||||||
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
|
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
|
||||||
const [unreadCount, setUnreadCount] = useState(0);
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -70,12 +75,13 @@ export function useAnnouncements(): AnnouncementsState {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 挂载时首次拉取 + 每 15 分钟定时轮询
|
// 登录后首次拉取 + 每 15 分钟定时轮询(未登录时不发请求)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isLoggedIn) return;
|
||||||
fetchAndCompare();
|
fetchAndCompare();
|
||||||
const timer = setInterval(fetchAndCompare, POLL_INTERVAL);
|
const timer = setInterval(fetchAndCompare, POLL_INTERVAL);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [fetchAndCompare]);
|
}, [isLoggedIn, fetchAndCompare]);
|
||||||
|
|
||||||
// 标记全部已读:将当前公告 ID 写入缓存
|
// 标记全部已读:将当前公告 ID 写入缓存
|
||||||
const markAsRead = useCallback(() => {
|
const markAsRead = useCallback(() => {
|
||||||
@@ -84,5 +90,5 @@ export function useAnnouncements(): AnnouncementsState {
|
|||||||
setUnreadCount(0);
|
setUnreadCount(0);
|
||||||
}, [announcements]);
|
}, [announcements]);
|
||||||
|
|
||||||
return { announcements, unreadCount, loading, markAsRead };
|
return { announcements, unreadCount, loading, markAsRead, refetch: fetchAndCompare };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,15 +14,15 @@
|
|||||||
|
|
||||||
import {useMemo, useState, useRef, useEffect, useCallback} from 'react';
|
import {useMemo, useState, useRef, useEffect, useCallback} from 'react';
|
||||||
import {useAppContext} from '@/app/providers/AppProvider';
|
import {useAppContext} from '@/app/providers/AppProvider';
|
||||||
import { TaskAPI, POLLING_STATUSES, type TaskListRequestParams, type TaskInfoItemResponseBody, type CreatTaskRequestBody, type TaskStatusResponseBody, type TaskStatusString } from '@/modules/home/controllers/task-api';
|
import { TaskAPI, POLLING_STATUSES, type TaskListRequestParams, type TaskInfoItemResponseBody, type CreatTaskRequestBody, type TaskStatusResponseBody, type TaskStatusString } from '@/services/modules/home/task';
|
||||||
import { ModelAPI, MODEL_CATEGORY_LABELS, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/modules/home/controllers/model-api';
|
import { ModelAPI, MODEL_CATEGORY_LABELS, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/home/models';
|
||||||
import { fetchBanners, type Banner } from '@/modules/home/controllers/banner-api';
|
import { fetchBanners, type Banner } from '@/services/modules/home/banner';
|
||||||
import { AuthAPI, type UserInfoBody } from '@/modules/auth/controllers/auth-api';
|
import { AuthAPI, type UserInfoBody } from '@/services/modules/auth/auth';
|
||||||
import { VipAPI, type VipLevelsItem } from '@/modules/account/controllers/vip-api';
|
import { VipAPI, type VipLevelsItem } from '@/services/modules/account/vip-api';
|
||||||
import { BillingAPI, type BalanceResponseBody, type LedgerItemResponseBody, type LedgerReqeustParam } from '@/modules/account/controllers/billing-api';
|
import { BillingAPI, type BalanceResponseBody, type LedgerItemResponseBody, type LedgerReqeustParam } from '@/services/modules/account/billing';
|
||||||
import {useAsyncData, UseAsyncDataReturn, useAsyncMutation} from './use-async';
|
import {useAsyncData, UseAsyncDataReturn, useAsyncMutation} from './use-async';
|
||||||
import {getCachedModels, setCachedModels} from '@/shared/services/cache/model-cache';
|
import {getCachedModels, setCachedModels} from '@/services/cache/model-cache';
|
||||||
import { upload } from '@/shared/services/http';
|
import { upload } from '@/services/request';
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Banner
|
// Banner
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { RequestError, RequestErrorType } from '@/shared/services/types';
|
import { RequestError, RequestErrorType } from '@/services/types';
|
||||||
import { logger } from '@/shared/utils/bus';
|
import { logger } from '@/shared/utils/bus';
|
||||||
|
|
||||||
// ---------- 错误信息映射 ----------
|
// ---------- 错误信息映射 ----------
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import {
|
|||||||
syncTasks, syncRechargeOrders, syncVipOrders,
|
syncTasks, syncRechargeOrders, syncVipOrders,
|
||||||
hasMore, nextPage, updateCursor,
|
hasMore, nextPage, updateCursor,
|
||||||
} from '@/shared/infrastructure/storage';
|
} from '@/shared/infrastructure/storage';
|
||||||
import { TaskAPI } from '@/modules/home/controllers/task-api';
|
import { TaskAPI } from '@/services/modules/home/task';
|
||||||
import { PaymentAPI } from '@/modules/account/controllers/payment-api';
|
import { PaymentAPI } from '@/services/modules/account/payments';
|
||||||
import { VipAPI } from '@/modules/account/controllers/vip-api';
|
import { VipAPI } from '@/services/modules/account/vip-api';
|
||||||
import { logger } from '@/shared/utils/bus';
|
import { logger } from '@/shared/utils/bus';
|
||||||
|
|
||||||
const PAGE_SIZE = 100;
|
const PAGE_SIZE = 100;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { axiosInstance } from '@/shared/services/http';
|
import { axiosInstance } from '@/services/request';
|
||||||
import { logger } from '@/shared/utils/bus';
|
import { logger } from '@/shared/utils/bus';
|
||||||
import { hasIpc } from '@/shared/utils/bus';
|
import { hasIpc } from '@/shared/utils/bus';
|
||||||
import { getCurrentUserId, hashURL, getTodayKey } from './user-scope';
|
import { getCurrentUserId, hashURL, getTodayKey } from './user-scope';
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
import { execute, queryAll, queryOne } from '../core/db-core';
|
import { execute, queryAll, queryOne } from '../core/db-core';
|
||||||
import { getCurrentUserId } from '../services/user-scope';
|
import { getCurrentUserId } from '../services/user-scope';
|
||||||
import type { TaskRecordEntry, TaskQueryFilter } from '../core/types';
|
import type { TaskRecordEntry, TaskQueryFilter } from '../core/types';
|
||||||
import type { TaskInfoItemResponseBody } from '@/modules/home/controllers/task-api';
|
import type { TaskInfoItemResponseBody } from '@/services/modules/home/task';
|
||||||
import { logger } from '@/shared/utils/bus';
|
import { logger } from '@/shared/utils/bus';
|
||||||
|
|
||||||
// ---------- 序列化 ----------
|
// ---------- 序列化 ----------
|
||||||
|
|||||||
@@ -23,51 +23,51 @@
|
|||||||
import {
|
import {
|
||||||
PAYMENT_STATUS_TEXT_MAP,
|
PAYMENT_STATUS_TEXT_MAP,
|
||||||
type PaymentStatus,
|
type PaymentStatus,
|
||||||
} from '@/modules/account/controllers/payment-api';
|
} from '@/services/modules/account/payments';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EntryTypeLabelMap,
|
EntryTypeLabelMap,
|
||||||
EntryTypeColorMap,
|
EntryTypeColorMap,
|
||||||
type EntryTypeValue,
|
type EntryTypeValue,
|
||||||
} from '@/modules/account/controllers/billing-api';
|
} from '@/services/modules/account/billing';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
UserRoleTypeLabelMap,
|
UserRoleTypeLabelMap,
|
||||||
UserAccountTypeLabelMap,
|
UserAccountTypeLabelMap,
|
||||||
type UserRoleTypeValue,
|
type UserRoleTypeValue,
|
||||||
type UserAccountTypeValue,
|
type UserAccountTypeValue,
|
||||||
} from '@/modules/auth/controllers/auth-api';
|
} from '@/services/modules/auth/auth';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
VipOrderStatusLabelMap,
|
VipOrderStatusLabelMap,
|
||||||
TargetAccountTypeLabelMap,
|
TargetAccountTypeLabelMap,
|
||||||
type VipOrderStatusValue,
|
type VipOrderStatusValue,
|
||||||
type TargetAccountTypeValue,
|
type TargetAccountTypeValue,
|
||||||
} from '@/modules/account/controllers/vip-api';
|
} from '@/services/modules/account/vip-api';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
OutputTypeMap,
|
OutputTypeMap,
|
||||||
TaskStatusMap,
|
TaskStatusMap,
|
||||||
type OutputTypeString,
|
type OutputTypeString,
|
||||||
type TaskStatusString,
|
type TaskStatusString,
|
||||||
} from '@/modules/home/controllers/task-api';
|
} from '@/services/modules/home/task';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BannerCategoryLabelMap,
|
BannerCategoryLabelMap,
|
||||||
type BannerCategoryValue,
|
type BannerCategoryValue,
|
||||||
} from '@/modules/home/controllers/banner-api';
|
} from '@/services/modules/home/banner';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AnnouncementTypeLabelMap,
|
AnnouncementTypeLabelMap,
|
||||||
AnnouncementTypeColorMap,
|
AnnouncementTypeColorMap,
|
||||||
type AnnouncementTypeValue,
|
type AnnouncementTypeValue,
|
||||||
} from '@/modules/announcement/controllers/announcement-api';
|
} from '@/services/modules/announcement/announcement';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
MODEL_CATEGORY_LABELS,
|
MODEL_CATEGORY_LABELS,
|
||||||
CATEGORY_SLUG_MAP,
|
CATEGORY_SLUG_MAP,
|
||||||
type ModelCategoryStringEnum,
|
type ModelCategoryStringEnum,
|
||||||
} from '@/modules/home/controllers/model-api';
|
} from '@/services/modules/home/models';
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 模型分类 slug → 前端标识反向映射
|
// 模型分类 slug → 前端标识反向映射
|
||||||
|
|||||||
37
src/shared/utils/path-join.ts
Normal file
37
src/shared/utils/path-join.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// ============================================================
|
||||||
|
// path-join.ts — 渲染进程安全路径拼接
|
||||||
|
//
|
||||||
|
// Electron 渲染进程沙箱中不可用 Node path 模块。
|
||||||
|
// 此工具提供 path.join 等效功能,自动处理平台分隔符差异。
|
||||||
|
//
|
||||||
|
// Windows 上 Electron 的 shell.showItemInFolder 等 API 能自动
|
||||||
|
// 处理正斜杠路径(如 C:/Users/...),但拼接时仍应统一使用
|
||||||
|
// 平台正确的分隔符以避免边缘情况。
|
||||||
|
//
|
||||||
|
// 用法:
|
||||||
|
// import { joinPaths } from '@/shared/utils/path-join';
|
||||||
|
// const dir = joinPaths(dataDir, 'repository', userKey, 'outputs');
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** 当前平台路径分隔符(渲染进程可用 navigator 判断) */
|
||||||
|
const SEP: string =
|
||||||
|
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win')
|
||||||
|
? '\\'
|
||||||
|
: '/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跨平台路径拼接(等效 Node path.join)。
|
||||||
|
*
|
||||||
|
* - 自动使用当前平台的路径分隔符
|
||||||
|
* - 去除各段首尾空格
|
||||||
|
* - 去除重复分隔符
|
||||||
|
*/
|
||||||
|
export function joinPaths(...segments: string[]): string {
|
||||||
|
return segments
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0)
|
||||||
|
.join(SEP)
|
||||||
|
.replace(/[\\/]{2,}/g, SEP); // 去重连续分隔符
|
||||||
|
}
|
||||||
|
|
||||||
|
export default joinPaths;
|
||||||
Reference in New Issue
Block a user