From 904edecd90438411bc8ebb899f6b7b5aa4bca2d0 Mon Sep 17 00:00:00 2001 From: YoungestSongMo <2130460579@qq.com> Date: Tue, 30 Jun 2026 13:39:38 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20Home=20=E6=A8=A1=E5=9D=97=20VCHSM?= =?UTF-8?q?=20=E4=B8=89=E6=A0=8F=E9=87=8D=E6=9E=84=20+=20Services=20?= =?UTF-8?q?=E8=BF=81=E5=9B=9E=20+=20=E5=8F=B3=E9=94=AE=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E4=BD=93=E7=B3=BB=20+=20=E5=85=AC=E5=91=8A=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 行纯视图 ## 右键菜单体系完善 - 共享 组件(antd Dropdown 封装,防 Select 冲突) - 四组菜单定义:任务列表 / 文本输入 / 媒体参考 / 预览区域 - 统一的 ContextMenuItems → AntD MenuProps 转换 ## 公告功能修复 - useAnnouncements 增加 isLoggedIn 登录门控,避免未登录时 401 双重请求 - 抽屉打开时触发 refetch(),确保拿到最新数据 Co-Authored-By: Claude --- src/app/App.tsx | 2 +- src/app/layout/Layout.tsx | 2 +- src/app/providers/AppProvider.tsx | 10 +- .../account/views/billing/BillingPage.tsx | 2 +- .../account/views/info/AccountPage.tsx | 2 +- .../account/views/orders/OrdersPage.tsx | 6 +- .../account/views/recharge/RechargePage.tsx | 2 +- src/modules/account/views/vip/VipPage.tsx | 4 +- .../views/wechat-work/WechatWorkPage.tsx | 2 +- src/modules/announcement/index.ts | 6 +- .../announcement/views/AnnouncementDrawer.tsx | 2 +- src/modules/auth/hooks/use-auth-state.ts | 4 +- src/modules/auth/index.ts | 8 +- src/modules/auth/views/AuthPage.tsx | 2 +- .../views/components/ChangePasswordModal.tsx | 2 +- .../views/components/ForgotPasswordModal.tsx | 2 +- .../{ => center}/controllers/menus/index.ts | 8 +- .../controllers/menus/media-ref-menu.ts | 0 .../controllers/menus/text-input-menu.ts | 0 .../controllers/menus/text-input-ops.ts | 0 .../home/{ => center}/hooks/useModelUI.ts | 6 +- src/modules/home/center/index.ts | 27 + .../center => center/views}/CenterPanel.tsx | 2 +- .../right => center/views}/ModelInputForm.tsx | 12 +- .../views/widgets}/SeedanceContent.tsx | 2 +- .../views/widgets}/index.ts | 0 .../views/widgets}/list-upload-widgets.tsx | 0 .../views/widgets}/simple-widgets.tsx | 0 .../views/widgets}/types.ts | 0 .../views/widgets}/upload-widgets.tsx | 0 src/modules/home/index.ts | 14 + .../home/left/controllers/menus/index.ts | 8 + .../controllers/menus/task-list-actions.ts | 131 +++ .../controllers/menus/task-list-menu.ts | 0 .../left/controllers/task-table-config.ts | 137 +++ src/modules/home/left/hooks/useTaskTable.tsx | 641 ++++++++++++ src/modules/home/left/index.ts | 39 + .../{views/left => left/views}/LeftPanel.tsx | 4 +- .../right => left/views}/ModelSelector.tsx | 4 +- src/modules/home/left/views/TaskHistory.tsx | 285 ++++++ .../home/right/controllers/menus/index.ts | 6 + .../controllers/menus/preview-area-menu.ts | 0 src/modules/home/right/index.ts | 14 + .../right => right/views}/OutputPreview.tsx | 6 +- .../right => right/views}/RightPanel.tsx | 0 src/modules/home/shared/index.ts | 30 + .../home/{ => shared}/stores/HomeContext.tsx | 4 +- .../right => shared/views}/BannerCarousel.tsx | 2 +- .../home/{ => shared}/views/HomeContent.tsx | 10 +- .../home/{ => shared}/views/HomePage.tsx | 0 src/modules/home/views/right/TaskHistory.tsx | 926 ------------------ src/modules/navbar/views/NavBar.tsx | 5 +- .../auth-persistence.ts | 0 .../auth-token.ts} | 6 +- .../services/cache/model-cache.ts | 2 +- .../modules/account/billing.ts} | 2 +- .../modules/account/payments.ts} | 2 +- .../modules/account}/vip-api.ts | 4 +- .../modules/announcement/announcement.ts} | 2 +- .../modules/auth/auth.ts} | 2 +- .../modules/home/banner.ts} | 2 +- .../modules/home/models.ts} | 2 +- .../modules/home/task.ts} | 2 +- .../services/http.ts => services/request.ts} | 6 +- src/{shared => }/services/types.ts | 0 src/shared/hooks/use-announcements.ts | 18 +- src/shared/hooks/use-api.ts | 16 +- src/shared/hooks/use-async.ts | 2 +- .../storage/services/data-sync.ts | 6 +- .../storage/services/media-loader.ts | 2 +- .../storage/stores/task-store.ts | 2 +- src/shared/utils/enum-resolver.ts | 16 +- src/shared/utils/path-join.ts | 37 + 73 files changed, 1478 insertions(+), 1034 deletions(-) rename src/modules/home/{ => center}/controllers/menus/index.ts (55%) rename src/modules/home/{ => center}/controllers/menus/media-ref-menu.ts (100%) rename src/modules/home/{ => center}/controllers/menus/text-input-menu.ts (100%) rename src/modules/home/{ => center}/controllers/menus/text-input-ops.ts (100%) rename src/modules/home/{ => center}/hooks/useModelUI.ts (96%) create mode 100644 src/modules/home/center/index.ts rename src/modules/home/{views/center => center/views}/CenterPanel.tsx (90%) rename src/modules/home/{views/right => center/views}/ModelInputForm.tsx (98%) rename src/modules/home/{views/left/controls => center/views/widgets}/SeedanceContent.tsx (99%) rename src/modules/home/{views/left/controls => center/views/widgets}/index.ts (100%) rename src/modules/home/{views/left/controls => center/views/widgets}/list-upload-widgets.tsx (100%) rename src/modules/home/{views/left/controls => center/views/widgets}/simple-widgets.tsx (100%) rename src/modules/home/{views/left/controls => center/views/widgets}/types.ts (100%) rename src/modules/home/{views/left/controls => center/views/widgets}/upload-widgets.tsx (100%) create mode 100644 src/modules/home/index.ts create mode 100644 src/modules/home/left/controllers/menus/index.ts create mode 100644 src/modules/home/left/controllers/menus/task-list-actions.ts rename src/modules/home/{ => left}/controllers/menus/task-list-menu.ts (100%) create mode 100644 src/modules/home/left/controllers/task-table-config.ts create mode 100644 src/modules/home/left/hooks/useTaskTable.tsx create mode 100644 src/modules/home/left/index.ts rename src/modules/home/{views/left => left/views}/LeftPanel.tsx (98%) rename src/modules/home/{views/right => left/views}/ModelSelector.tsx (98%) create mode 100644 src/modules/home/left/views/TaskHistory.tsx create mode 100644 src/modules/home/right/controllers/menus/index.ts rename src/modules/home/{ => right}/controllers/menus/preview-area-menu.ts (100%) create mode 100644 src/modules/home/right/index.ts rename src/modules/home/{views/right => right/views}/OutputPreview.tsx (97%) rename src/modules/home/{views/right => right/views}/RightPanel.tsx (100%) create mode 100644 src/modules/home/shared/index.ts rename src/modules/home/{ => shared}/stores/HomeContext.tsx (89%) rename src/modules/home/{views/right => shared/views}/BannerCarousel.tsx (98%) rename src/modules/home/{ => shared}/views/HomeContent.tsx (97%) rename src/modules/home/{ => shared}/views/HomePage.tsx (100%) delete mode 100644 src/modules/home/views/right/TaskHistory.tsx rename src/{modules/auth/controllers => services}/auth-persistence.ts (100%) rename src/{modules/auth/controllers/token-manager.ts => services/auth-token.ts} (97%) rename src/{shared => }/services/cache/model-cache.ts (95%) rename src/{modules/account/controllers/billing-api.ts => services/modules/account/billing.ts} (98%) rename src/{modules/account/controllers/payment-api.ts => services/modules/account/payments.ts} (98%) rename src/{modules/account/controllers => services/modules/account}/vip-api.ts (97%) rename src/{modules/announcement/controllers/announcement-api.ts => services/modules/announcement/announcement.ts} (97%) rename src/{modules/auth/controllers/auth-api.ts => services/modules/auth/auth.ts} (99%) rename src/{modules/home/controllers/banner-api.ts => services/modules/home/banner.ts} (97%) rename src/{modules/home/controllers/model-api.ts => services/modules/home/models.ts} (99%) rename src/{modules/home/controllers/task-api.ts => services/modules/home/task.ts} (99%) rename src/{shared/services/http.ts => services/request.ts} (99%) rename src/{shared => }/services/types.ts (100%) create mode 100644 src/shared/utils/path-join.ts diff --git a/src/app/App.tsx b/src/app/App.tsx index fd489a4..d1f3e00 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -7,7 +7,7 @@ import { SettingsPage } from '@/modules/settings'; import { on, off, emit, EVENTS } from '@/shared/utils/bus'; import type { BusinessErrorEvent } from '@/shared/utils/bus'; 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'; /** diff --git a/src/app/layout/Layout.tsx b/src/app/layout/Layout.tsx index 8d15ef4..0e5c5e9 100644 --- a/src/app/layout/Layout.tsx +++ b/src/app/layout/Layout.tsx @@ -6,7 +6,7 @@ import type { ReactNode } from 'react'; import { theme as antTheme } from 'antd'; 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 }) { const { token } = antTheme.useToken(); diff --git a/src/app/providers/AppProvider.tsx b/src/app/providers/AppProvider.tsx index 3293a30..537b78b 100644 --- a/src/app/providers/AppProvider.tsx +++ b/src/app/providers/AppProvider.tsx @@ -12,11 +12,11 @@ import type { Platform, Edition } from '@shared/types'; import { getPlatform, isDev } from '@/shared/utils/env'; import { safeIpcOn, safeIpcOff } from '@/shared/utils/bus'; -import { AuthAPI, type LoginResponseBody } from '@/modules/auth/controllers/auth-api'; -import { setToken, clearToken, initTokenStore } from '@/shared/services/http'; +import { AuthAPI, type LoginResponseBody } from '@/services/modules/auth/auth'; +import { setToken, clearToken, initTokenStore } from '@/services/request'; import { emit, EVENTS } 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 { initAuthRefresh, getStoredRefreshToken, @@ -25,8 +25,8 @@ import { initRefreshTokenStore, scheduleProactiveRefresh, clearProactiveRefresh, -} from '@/modules/auth/controllers/token-manager'; -import {initModelCache} from '@/shared/services/cache/model-cache'; +} from '@/services/auth-token'; +import {initModelCache} from '@/services/cache/model-cache'; import {initStorage} from '@/shared/infrastructure/storage'; // ---------- Context 类型 ---------- diff --git a/src/modules/account/views/billing/BillingPage.tsx b/src/modules/account/views/billing/BillingPage.tsx index a367c2c..6f9f769 100644 --- a/src/modules/account/views/billing/BillingPage.tsx +++ b/src/modules/account/views/billing/BillingPage.tsx @@ -16,7 +16,7 @@ import { ApiOutlined, } from '@ant-design/icons'; 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 {centToYuan, formatDate} from '@/shared/utils/display'; diff --git a/src/modules/account/views/info/AccountPage.tsx b/src/modules/account/views/info/AccountPage.tsx index 995039e..e712ea7 100644 --- a/src/modules/account/views/info/AccountPage.tsx +++ b/src/modules/account/views/info/AccountPage.tsx @@ -18,7 +18,7 @@ import { RightOutlined, } from '@ant-design/icons'; 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 { centToYuan, formatDate } from '@/shared/utils/display'; diff --git a/src/modules/account/views/orders/OrdersPage.tsx b/src/modules/account/views/orders/OrdersPage.tsx index 2d5362f..b907a20 100644 --- a/src/modules/account/views/orders/OrdersPage.tsx +++ b/src/modules/account/views/orders/OrdersPage.tsx @@ -21,16 +21,16 @@ import { type PaymentStatus, type OrderListResponseBody, type OrderInfoResponseBody, -} from '@/modules/account/controllers/payment-api'; +} from '@/services/modules/account/payments'; import { VipAPI, VipOrderStatus, type VipOrderStatusValue, type VipOrderRead, -} from '@/modules/account/controllers/vip-api'; +} from '@/services/modules/account/vip-api'; import { centToYuan, formatDate } from '@/shared/utils/display'; import { EnumResolver } from '@/shared/utils/enum-resolver'; -import { RequestError, RequestErrorType } from '@/shared/services/http'; +import { RequestError, RequestErrorType } from '@/services/request'; const { Text } = Typography; diff --git a/src/modules/account/views/recharge/RechargePage.tsx b/src/modules/account/views/recharge/RechargePage.tsx index b63aaac..8001b3f 100644 --- a/src/modules/account/views/recharge/RechargePage.tsx +++ b/src/modules/account/views/recharge/RechargePage.tsx @@ -39,7 +39,7 @@ import { PAYMENT_STATUS, getPaymentStatusText, type PaymentStatus, -} from '@/modules/account/controllers/payment-api'; +} from '@/services/modules/account/payments'; import { emit, EVENTS } from '@/shared/utils/bus'; const { Text } = Typography; diff --git a/src/modules/account/views/vip/VipPage.tsx b/src/modules/account/views/vip/VipPage.tsx index 2eeb5ac..8cc029b 100644 --- a/src/modules/account/views/vip/VipPage.tsx +++ b/src/modules/account/views/vip/VipPage.tsx @@ -34,11 +34,11 @@ import { VipOrderStatus, type VipLevelsItem, type VipOrderStatusValue, -} from '@/modules/account/controllers/vip-api'; +} from '@/services/modules/account/vip-api'; import {centToYuan} from '@/shared/utils/display'; import {EnumResolver} from '@/shared/utils/enum-resolver'; import {emit, EVENTS} from '@/shared/utils/bus'; -import {RequestError, RequestErrorType} from '@/shared/services/http'; +import {RequestError, RequestErrorType} from '@/services/request'; const {Text} = Typography; diff --git a/src/modules/account/views/wechat-work/WechatWorkPage.tsx b/src/modules/account/views/wechat-work/WechatWorkPage.tsx index 7cfdcb4..0354c30 100644 --- a/src/modules/account/views/wechat-work/WechatWorkPage.tsx +++ b/src/modules/account/views/wechat-work/WechatWorkPage.tsx @@ -12,7 +12,7 @@ import { useEffect, useState, useCallback } from 'react'; import { logger } from '@/shared/utils/bus'; import { Spin, Empty } from 'antd'; 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() { const [banner, setBanner] = useState(null); diff --git a/src/modules/announcement/index.ts b/src/modules/announcement/index.ts index 280323b..793818c 100644 --- a/src/modules/announcement/index.ts +++ b/src/modules/announcement/index.ts @@ -1,5 +1,5 @@ // announcement 模块 — 公告 export { AnnouncementDrawer } from './views/AnnouncementDrawer'; -export { fetchAnnouncements } from './controllers/announcement-api'; -export type { Announcement, AnnouncementType, AnnouncementTypeValue, AnnouncementListResponse } from './controllers/announcement-api'; -export { AnnouncementTypeEnum, AnnouncementTypeLabelMap, AnnouncementTypeColorMap } from './controllers/announcement-api'; +export { fetchAnnouncements } from '@/services/modules/announcement/announcement'; +export type { Announcement, AnnouncementType, AnnouncementTypeValue, AnnouncementListResponse } from '@/services/modules/announcement/announcement'; +export { AnnouncementTypeEnum, AnnouncementTypeLabelMap, AnnouncementTypeColorMap } from '@/services/modules/announcement/announcement'; diff --git a/src/modules/announcement/views/AnnouncementDrawer.tsx b/src/modules/announcement/views/AnnouncementDrawer.tsx index 7ddc085..0c33aaa 100644 --- a/src/modules/announcement/views/AnnouncementDrawer.tsx +++ b/src/modules/announcement/views/AnnouncementDrawer.tsx @@ -10,7 +10,7 @@ import {useState} from 'react'; import {Drawer, Card, Typography, Empty, Tag, Spin, Modal, Tooltip} from 'antd'; 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'; const {Text, Paragraph} = Typography; diff --git a/src/modules/auth/hooks/use-auth-state.ts b/src/modules/auth/hooks/use-auth-state.ts index ae57878..60241ee 100644 --- a/src/modules/auth/hooks/use-auth-state.ts +++ b/src/modules/auth/hooks/use-auth-state.ts @@ -19,9 +19,9 @@ import { getSavedUsername, getSavedRemember, 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 ---------- diff --git a/src/modules/auth/index.ts b/src/modules/auth/index.ts index c842f39..b74af31 100644 --- a/src/modules/auth/index.ts +++ b/src/modules/auth/index.ts @@ -1,6 +1,6 @@ // auth 模块 — 认证(登录/注册/密码/Token) export { LoginPage } from './views/AuthPage'; -export { AuthAPI } from './controllers/auth-api'; -export type { LoginResponseBody } from './controllers/auth-api'; -export { initAuthRefresh } from './controllers/token-manager'; -export { getAutoLoginFlag, clearAutoLoginFlag } from './controllers/auth-persistence'; +export { AuthAPI } from '@/services/modules/auth/auth'; +export type { LoginResponseBody } from '@/services/modules/auth/auth'; +export { initAuthRefresh } from '@/services/auth-token'; +export { getAutoLoginFlag, clearAutoLoginFlag } from '@/services/auth-persistence'; diff --git a/src/modules/auth/views/AuthPage.tsx b/src/modules/auth/views/AuthPage.tsx index 755d416..f243ef2 100644 --- a/src/modules/auth/views/AuthPage.tsx +++ b/src/modules/auth/views/AuthPage.tsx @@ -17,7 +17,7 @@ import { LoginForm } from './components/LoginForm'; import { RegisterForm } from './components/RegisterForm'; import { ForgotPasswordModal } from './components/ForgotPasswordModal'; 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 type { LoginFormValues, RegisterFormValues } from '../model/types'; diff --git a/src/modules/auth/views/components/ChangePasswordModal.tsx b/src/modules/auth/views/components/ChangePasswordModal.tsx index c685ee0..2e21550 100644 --- a/src/modules/auth/views/components/ChangePasswordModal.tsx +++ b/src/modules/auth/views/components/ChangePasswordModal.tsx @@ -14,7 +14,7 @@ import { Modal, Input, Button, Typography, App } from 'antd'; import { LockOutlined, KeyOutlined, SafetyOutlined } from '@ant-design/icons'; 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'; const { Text, Title } = Typography; diff --git a/src/modules/auth/views/components/ForgotPasswordModal.tsx b/src/modules/auth/views/components/ForgotPasswordModal.tsx index 8843c35..ca0da63 100644 --- a/src/modules/auth/views/components/ForgotPasswordModal.tsx +++ b/src/modules/auth/views/components/ForgotPasswordModal.tsx @@ -20,7 +20,7 @@ import { } from '@ant-design/icons'; import { useTheme } from '@/app/providers/ThemeProvider'; -import { AuthAPI } from '@/modules/auth/controllers/auth-api'; +import { AuthAPI } from '@/services/modules/auth/auth'; import { validatePhone, validateSmsCode, diff --git a/src/modules/home/controllers/menus/index.ts b/src/modules/home/center/controllers/menus/index.ts similarity index 55% rename from src/modules/home/controllers/menus/index.ts rename to src/modules/home/center/controllers/menus/index.ts index 458fef0..797757f 100644 --- a/src/modules/home/controllers/menus/index.ts +++ b/src/modules/home/center/controllers/menus/index.ts @@ -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 type { TextInputMenuOptions } from './text-input-menu'; export { getMediaRefImageMenu, getMediaRefBlankMenu } 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'; diff --git a/src/modules/home/controllers/menus/media-ref-menu.ts b/src/modules/home/center/controllers/menus/media-ref-menu.ts similarity index 100% rename from src/modules/home/controllers/menus/media-ref-menu.ts rename to src/modules/home/center/controllers/menus/media-ref-menu.ts diff --git a/src/modules/home/controllers/menus/text-input-menu.ts b/src/modules/home/center/controllers/menus/text-input-menu.ts similarity index 100% rename from src/modules/home/controllers/menus/text-input-menu.ts rename to src/modules/home/center/controllers/menus/text-input-menu.ts diff --git a/src/modules/home/controllers/menus/text-input-ops.ts b/src/modules/home/center/controllers/menus/text-input-ops.ts similarity index 100% rename from src/modules/home/controllers/menus/text-input-ops.ts rename to src/modules/home/center/controllers/menus/text-input-ops.ts diff --git a/src/modules/home/hooks/useModelUI.ts b/src/modules/home/center/hooks/useModelUI.ts similarity index 96% rename from src/modules/home/hooks/useModelUI.ts rename to src/modules/home/center/hooks/useModelUI.ts index 04fe9c7..2689a65 100644 --- a/src/modules/home/hooks/useModelUI.ts +++ b/src/modules/home/center/hooks/useModelUI.ts @@ -14,9 +14,9 @@ import { useMemo } from 'react'; import type { ComponentType } from 'react'; -import type { ModelsListResponseBody } from '@/modules/home/controllers/model-api'; -import { WIDGET_MAP, FALLBACK_WIDGET } from '../views/left/controls'; -import type { WidgetProps } from '../views/left/controls'; +import type { ModelsListResponseBody } from '@/services/modules/home/models'; +import { WIDGET_MAP, FALLBACK_WIDGET } from '../views/widgets'; +import type { WidgetProps } from '../views/widgets'; // ---------- 输出类型 ---------- diff --git a/src/modules/home/center/index.ts b/src/modules/home/center/index.ts new file mode 100644 index 0000000..9b919d5 --- /dev/null +++ b/src/modules/home/center/index.ts @@ -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'; diff --git a/src/modules/home/views/center/CenterPanel.tsx b/src/modules/home/center/views/CenterPanel.tsx similarity index 90% rename from src/modules/home/views/center/CenterPanel.tsx rename to src/modules/home/center/views/CenterPanel.tsx index ab1d75a..dff7c1b 100644 --- a/src/modules/home/views/center/CenterPanel.tsx +++ b/src/modules/home/center/views/CenterPanel.tsx @@ -3,7 +3,7 @@ // ============================================================ import { theme as antTheme } from 'antd'; -import { ModelInputForm } from '../right/ModelInputForm'; +import { ModelInputForm } from './ModelInputForm'; export function CenterPanel() { const { token } = antTheme.useToken(); diff --git a/src/modules/home/views/right/ModelInputForm.tsx b/src/modules/home/center/views/ModelInputForm.tsx similarity index 98% rename from src/modules/home/views/right/ModelInputForm.tsx rename to src/modules/home/center/views/ModelInputForm.tsx index 8efa13b..32fd343 100644 --- a/src/modules/home/views/right/ModelInputForm.tsx +++ b/src/modules/home/center/views/ModelInputForm.tsx @@ -28,26 +28,26 @@ import type { FormInstance } from 'antd'; import { SendOutlined, ClockCircleOutlined, ReloadOutlined } from '@ant-design/icons'; 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 { useSubmitTask } from '@/shared/hooks/use-api'; import { emit, EVENTS } from '@/shared/utils/bus'; -import { OutputTypeMap } from '@/modules/home/controllers/task-api'; -import type { OutputTypeString, TaskInfoItemResponseBody } from '@/modules/home/controllers/task-api'; -import { useModelUI, isValueEmpty, type UIFieldConfig } from '../../hooks/useModelUI'; +import { OutputTypeMap } from '@/services/modules/home/task'; +import type { OutputTypeString, TaskInfoItemResponseBody } from '@/services/modules/home/task'; +import { useModelUI, isValueEmpty, type UIFieldConfig } from '../hooks/useModelUI'; import { calculateCost, formatYuan, type ModelDefinition, type ModelPricingConfig, } from '@/shared/utils/pricing'; -import { getTextInputMenuItems } from '../../controllers/menus'; +import { getTextInputMenuItems } from '../controllers/menus'; import type { ContextMenuItems } from '@/shared/components/ContextMenu'; import { execUndo, execRedo, execCut, execCopy, execPaste, execClear, execSelectAll, execIncreaseFont, execDecreaseFont, execResetFont, hasTextSelection, isInputEmpty, getFontSizeLevel, -} from '../../controllers/menus/text-input-ops'; +} from '../controllers/menus/text-input-ops'; const { Text, Title } = Typography; diff --git a/src/modules/home/views/left/controls/SeedanceContent.tsx b/src/modules/home/center/views/widgets/SeedanceContent.tsx similarity index 99% rename from src/modules/home/views/left/controls/SeedanceContent.tsx rename to src/modules/home/center/views/widgets/SeedanceContent.tsx index d85f91f..7a148c8 100644 --- a/src/modules/home/views/left/controls/SeedanceContent.tsx +++ b/src/modules/home/center/views/widgets/SeedanceContent.tsx @@ -21,7 +21,7 @@ import type { UploadFile, RcFile } from 'antd/es/upload'; import type { WidgetProps } from './types'; import { useFileUpload } from '@/shared/hooks/use-api'; 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'; const { TextArea } = Input; diff --git a/src/modules/home/views/left/controls/index.ts b/src/modules/home/center/views/widgets/index.ts similarity index 100% rename from src/modules/home/views/left/controls/index.ts rename to src/modules/home/center/views/widgets/index.ts diff --git a/src/modules/home/views/left/controls/list-upload-widgets.tsx b/src/modules/home/center/views/widgets/list-upload-widgets.tsx similarity index 100% rename from src/modules/home/views/left/controls/list-upload-widgets.tsx rename to src/modules/home/center/views/widgets/list-upload-widgets.tsx diff --git a/src/modules/home/views/left/controls/simple-widgets.tsx b/src/modules/home/center/views/widgets/simple-widgets.tsx similarity index 100% rename from src/modules/home/views/left/controls/simple-widgets.tsx rename to src/modules/home/center/views/widgets/simple-widgets.tsx diff --git a/src/modules/home/views/left/controls/types.ts b/src/modules/home/center/views/widgets/types.ts similarity index 100% rename from src/modules/home/views/left/controls/types.ts rename to src/modules/home/center/views/widgets/types.ts diff --git a/src/modules/home/views/left/controls/upload-widgets.tsx b/src/modules/home/center/views/widgets/upload-widgets.tsx similarity index 100% rename from src/modules/home/views/left/controls/upload-widgets.tsx rename to src/modules/home/center/views/widgets/upload-widgets.tsx diff --git a/src/modules/home/index.ts b/src/modules/home/index.ts new file mode 100644 index 0000000..295c491 --- /dev/null +++ b/src/modules/home/index.ts @@ -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'; diff --git a/src/modules/home/left/controllers/menus/index.ts b/src/modules/home/left/controllers/menus/index.ts new file mode 100644 index 0000000..7254273 --- /dev/null +++ b/src/modules/home/left/controllers/menus/index.ts @@ -0,0 +1,8 @@ +// ============================================================ +// menus — 左栏右键菜单定义统一导出 +// ============================================================ + +export { getTaskListMenuItems } from './task-list-menu'; +export type { TaskListMenuOptions } from './task-list-menu'; + +export { buildTaskActions } from './task-list-actions'; diff --git a/src/modules/home/left/controllers/menus/task-list-actions.ts b/src/modules/home/left/controllers/menus/task-list-actions.ts new file mode 100644 index 0000000..aa9c6f1 --- /dev/null +++ b/src/modules/home/left/controllers/menus/task-list-actions.ts @@ -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 { + 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 { + 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('删除记录功能待实现'); }, + }; +} diff --git a/src/modules/home/controllers/menus/task-list-menu.ts b/src/modules/home/left/controllers/menus/task-list-menu.ts similarity index 100% rename from src/modules/home/controllers/menus/task-list-menu.ts rename to src/modules/home/left/controllers/menus/task-list-menu.ts diff --git a/src/modules/home/left/controllers/task-table-config.ts b/src/modules/home/left/controllers/task-table-config.ts new file mode 100644 index 0000000..5ce4fc1 --- /dev/null +++ b/src/modules/home/left/controllers/task-table-config.ts @@ -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; + +/** 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 = { + 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 | 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): 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} 分钟`; +} diff --git a/src/modules/home/left/hooks/useTaskTable.tsx b/src/modules/home/left/hooks/useTaskTable.tsx new file mode 100644 index 0000000..9afa0cc --- /dev/null +++ b/src/modules/home/left/hooks/useTaskTable.tsx @@ -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; + tableCss: string; + scrollX: number; + + // DOM refs(View 层绑定到容器元素) + tableWrapperRef: React.RefObject; + hScrollRef: React.RefObject; + + // 状态 & 回调 + tableParams: TableParams; + tableBodyHeight: number; + handleTableChange: TableProps['onChange']; + rowClassName: (record: TaskInfoItemResponseBody, index: number) => string; + + // 列可见性 + visibleColumns: Set; + handleColumnToggle: (key: string, checked: boolean) => void; + + // 派生 + hasActiveFilters: boolean; + setTableParams: React.Dispatch>; +} + +// ── 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>(() => { + 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({ + 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(); + 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>([]); + + // ════════════════════════════════════════════ + // API 请求参数构建 + // ════════════════════════════════════════════ + + const requestParams = useMemo(() => { + 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(); + 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['onChange'] = ( + _pagination, + filters, + sorter, + ) => { + const s = sorter as SorterResult; + 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>(() => { + const cols: ColumnsType = [ + { + title: '任务 ID', + dataIndex: 'id', + key: 'id', + width: 140, + ellipsis: true, + fixed: 'left', + render: (id: string) => ( + + {id.slice(0, 10)}... + + ), + }, + { + 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) => ( + + {v ? new Date(v).toLocaleString('zh-CN', { + month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + }) : '-'} + + ), + }, + { + 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) => ( + + {OutputTypeMap[t as keyof typeof OutputTypeMap] || t} + + ), + }, + { + 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 {cfg.label}; + }, + }, + { + title: '提示词', + dataIndex: 'prompt', + key: 'prompt', + width: 160, + ellipsis: true, + filterIcon: (filtered: boolean) => ( + + ), + filterDropdown: undefined, + render: (p: string | undefined) => ( + {p || '-'} + ), + }, + { + 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 {sec !== null ? formatDuration(sec) : '-'}; + }, + }, + { + 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) => ( + ¥{(c / 100).toFixed(2)} + ), + }, + { + 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: () => -, + }, + { + title: '文件大小', + key: 'file_size', + width: 85, + render: () => -, + }, + { + title: '标签', + dataIndex: 'tags', + key: 'tags', + width: 100, + ellipsis: true, + render: (tags: Array) => { + const filtered = tags?.filter((t): t is string => t !== null) ?? []; + if (filtered.length === 0) return -; + return ( + + {filtered.slice(0, 2).map((t) => ( + {t} + ))} + {filtered.length > 2 && ( + +{filtered.length - 2} + )} + + ); + }, + }, + { + title: '错误信息', + dataIndex: 'error_message', + key: 'error_message', + width: 140, + ellipsis: true, + render: (msg: string) => msg + ? {msg} + : -, + }, + ]; + + // 用户列:仅企业版可筛选 + 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) => ( + {n || '-'} + ), + }); + } else { + cols.push({ + title: '用户', + dataIndex: 'user_display_name', + key: 'user_id', + width: 100, + ellipsis: true, + render: (n: string | null) => ( + {n || '-'} + ), + }); + } + + 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(null); + const hScrollRef = useRef(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, + }; +} diff --git a/src/modules/home/left/index.ts b/src/modules/home/left/index.ts new file mode 100644 index 0000000..4fbca15 --- /dev/null +++ b/src/modules/home/left/index.ts @@ -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'; diff --git a/src/modules/home/views/left/LeftPanel.tsx b/src/modules/home/left/views/LeftPanel.tsx similarity index 98% rename from src/modules/home/views/left/LeftPanel.tsx rename to src/modules/home/left/views/LeftPanel.tsx index 875a7df..34d4565 100644 --- a/src/modules/home/views/left/LeftPanel.tsx +++ b/src/modules/home/left/views/LeftPanel.tsx @@ -7,8 +7,8 @@ import { useState, useRef, useEffect, useCallback } from 'react'; import { theme as antTheme } from 'antd'; -import { ModelSelector } from '../right/ModelSelector'; -import { TaskHistory } from '../right/TaskHistory'; +import { ModelSelector } from './ModelSelector'; +import { TaskHistory } from './TaskHistory'; import { useModelList } from '@/shared/hooks/use-api'; // ---------- 常量 ---------- diff --git a/src/modules/home/views/right/ModelSelector.tsx b/src/modules/home/left/views/ModelSelector.tsx similarity index 98% rename from src/modules/home/views/right/ModelSelector.tsx rename to src/modules/home/left/views/ModelSelector.tsx index c85c694..66fd62b 100644 --- a/src/modules/home/views/right/ModelSelector.tsx +++ b/src/modules/home/left/views/ModelSelector.tsx @@ -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 { AppstoreOutlined } from '@ant-design/icons'; -import { useHomeContext } from '@/modules/home/stores/HomeContext'; -import { MODEL_CATEGORIES, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/modules/home/controllers/model-api'; +import { useHomeContext } from '@/modules/home/shared/stores/HomeContext'; +import { MODEL_CATEGORIES, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/home/models'; import { EnumResolver } from '@/shared/utils/enum-resolver'; const { Text } = Typography; diff --git a/src/modules/home/left/views/TaskHistory.tsx b/src/modules/home/left/views/TaskHistory.tsx new file mode 100644 index 0000000..0933e1a --- /dev/null +++ b/src/modules/home/left/views/TaskHistory.tsx @@ -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 ( +
+ {/* ── 标题栏 ── */} +
+ + 任务记录 + {total > 0 && ( + 共 {total} 条 + )} + {hasActiveFilters && ( + + 已筛选 + + )} + {errorMessage && ( + {errorMessage} + )} + + {/* 列可见性控制 — 齿轮图标 */} + + {ALL_COLUMNS.map((col) => ( + handleColumnToggle(col.key, e.target.checked)} + > + {col.title} + + ))} + + } + > +
+ + {/* ── 固定水平滚动条 ── */} +
+
+
+ + {/* ── 表格 ── */} +
+ + + 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: '确定' }} + /> +
+ + {/* ── 分页器 ── */} +
+ { + if (page !== taskPage) setTaskPage(page); + if (pageSize !== taskPageSize) setTaskPageSize(pageSize); + setTableParams((prev: TableParams) => ({ + ...prev, + pagination: { current: page, pageSize }, + })); + }} + /> +
+ + {/* ── 悬浮右键菜单 ── */} + { if (!open) setContextMenu(null); }} + menu={{ + items: contextMenu ? buildTaskMenuItems(contextMenu.record) : [], + onClick: () => setContextMenu(null), + }} + trigger={[]} + > +
{ e.preventDefault(); setContextMenu(null); }} + style={{ + position: 'fixed', + left: contextMenu?.x ?? -9999, + top: contextMenu?.y ?? -9999, + width: 1, + height: 1, + }} + /> + +
+ ); +} diff --git a/src/modules/home/right/controllers/menus/index.ts b/src/modules/home/right/controllers/menus/index.ts new file mode 100644 index 0000000..f45cb5f --- /dev/null +++ b/src/modules/home/right/controllers/menus/index.ts @@ -0,0 +1,6 @@ +// ============================================================ +// menus — 右栏右键菜单定义统一导出 +// ============================================================ + +export { getPreviewImageMenu, getPreviewEmptyMenu } from './preview-area-menu'; +export type { PreviewAreaMenuOptions } from './preview-area-menu'; diff --git a/src/modules/home/controllers/menus/preview-area-menu.ts b/src/modules/home/right/controllers/menus/preview-area-menu.ts similarity index 100% rename from src/modules/home/controllers/menus/preview-area-menu.ts rename to src/modules/home/right/controllers/menus/preview-area-menu.ts diff --git a/src/modules/home/right/index.ts b/src/modules/home/right/index.ts new file mode 100644 index 0000000..ae5ac7c --- /dev/null +++ b/src/modules/home/right/index.ts @@ -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'; diff --git a/src/modules/home/views/right/OutputPreview.tsx b/src/modules/home/right/views/OutputPreview.tsx similarity index 97% rename from src/modules/home/views/right/OutputPreview.tsx rename to src/modules/home/right/views/OutputPreview.tsx index 8f59cee..6c1a5a4 100644 --- a/src/modules/home/views/right/OutputPreview.tsx +++ b/src/modules/home/right/views/OutputPreview.tsx @@ -20,11 +20,11 @@ import { CloseCircleOutlined, } 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 { 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 { getPreviewImageMenu, getPreviewEmptyMenu } from '../../controllers/menus'; +import { getPreviewImageMenu, getPreviewEmptyMenu } from '../controllers/menus'; const { Text } = Typography; diff --git a/src/modules/home/views/right/RightPanel.tsx b/src/modules/home/right/views/RightPanel.tsx similarity index 100% rename from src/modules/home/views/right/RightPanel.tsx rename to src/modules/home/right/views/RightPanel.tsx diff --git a/src/modules/home/shared/index.ts b/src/modules/home/shared/index.ts new file mode 100644 index 0000000..abf8e1d --- /dev/null +++ b/src/modules/home/shared/index.ts @@ -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'; diff --git a/src/modules/home/stores/HomeContext.tsx b/src/modules/home/shared/stores/HomeContext.tsx similarity index 89% rename from src/modules/home/stores/HomeContext.tsx rename to src/modules/home/shared/stores/HomeContext.tsx index 7fe3c60..4ac36d0 100644 --- a/src/modules/home/stores/HomeContext.tsx +++ b/src/modules/home/shared/stores/HomeContext.tsx @@ -7,8 +7,8 @@ // ============================================================ import { createContext, useContext } from 'react'; -import type { ModelsListResponseBody } from '@/modules/home/controllers/model-api'; -import type { TaskInfoItemResponseBody } from '@/modules/home/controllers/task-api'; +import type { ModelsListResponseBody } from '@/services/modules/home/models'; +import type { TaskInfoItemResponseBody } from '@/services/modules/home/task'; // ---------- Context 类型 ---------- diff --git a/src/modules/home/views/right/BannerCarousel.tsx b/src/modules/home/shared/views/BannerCarousel.tsx similarity index 98% rename from src/modules/home/views/right/BannerCarousel.tsx rename to src/modules/home/shared/views/BannerCarousel.tsx index 46f4193..d5002e9 100644 --- a/src/modules/home/views/right/BannerCarousel.tsx +++ b/src/modules/home/shared/views/BannerCarousel.tsx @@ -14,7 +14,7 @@ import { CloseOutlined } from '@ant-design/icons'; import type { CarouselRef } from 'antd/es/carousel'; 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 ---------- diff --git a/src/modules/home/views/HomeContent.tsx b/src/modules/home/shared/views/HomeContent.tsx similarity index 97% rename from src/modules/home/views/HomeContent.tsx rename to src/modules/home/shared/views/HomeContent.tsx index bee7806..d2fe45d 100644 --- a/src/modules/home/views/HomeContent.tsx +++ b/src/modules/home/shared/views/HomeContent.tsx @@ -7,11 +7,11 @@ import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react' import { theme as antTheme, Empty } from 'antd'; import { useAppContext } from '@/app/providers/AppProvider'; -import type { ModelsListResponseBody } from '@/modules/home/controllers/model-api'; -import type { TaskInfoItemResponseBody } from '@/modules/home/controllers/task-api'; -import { LeftPanel } from './left/LeftPanel'; -import { CenterPanel } from './center/CenterPanel'; -import { RightPanel } from './right/RightPanel'; +import type { ModelsListResponseBody } from '@/services/modules/home/models'; +import type { TaskInfoItemResponseBody } from '@/services/modules/home/task'; +import { LeftPanel } from '../../left/views/LeftPanel'; +import { CenterPanel } from '../../center/views/CenterPanel'; +import { RightPanel } from '../../right/views/RightPanel'; import { Typography } from 'antd'; diff --git a/src/modules/home/views/HomePage.tsx b/src/modules/home/shared/views/HomePage.tsx similarity index 100% rename from src/modules/home/views/HomePage.tsx rename to src/modules/home/shared/views/HomePage.tsx diff --git a/src/modules/home/views/right/TaskHistory.tsx b/src/modules/home/views/right/TaskHistory.tsx deleted file mode 100644 index 086f246..0000000 --- a/src/modules/home/views/right/TaskHistory.tsx +++ /dev/null @@ -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; - -/** 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 = { - 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 | 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): 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>(() => { - 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({ - 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(); - 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>([]); - - // -------- 构建 API 请求参数 -------- - - const requestParams = useMemo(() => { - 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(); - 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['onChange'] = ( - _pagination, - filters, - sorter, - ) => { - // 缓存筛选和排序参数(分页由独立 Pagination 组件管理) - const s = sorter as SorterResult; - 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>(() => { - const cols: ColumnsType = [ - // ---- 任务 ID(固定列) ---- - { - title: '任务 ID', - dataIndex: 'id', - key: 'id', - width: 140, - ellipsis: true, - fixed: 'left', - render: (id: string) => ( - - {id.slice(0, 10)}... - - ), - }, - // ---- 创建时间 ---- - { - 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) => ( - - {v ? new Date(v).toLocaleString('zh-CN', { - month: '2-digit', day: '2-digit', - hour: '2-digit', minute: '2-digit', second: '2-digit', - }) : '-'} - - ), - }, - // ---- 模型 ---- - { - 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) => ( - - {OutputTypeMap[t as keyof typeof OutputTypeMap] || t} - - ), - }, - // ---- 状态 ---- - { - 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 {cfg.label}; - }, - }, - // ---- 提示词 ---- - { - title: '提示词', - dataIndex: 'prompt', - key: 'prompt', - width: 160, - ellipsis: true, - filterIcon: (filtered: boolean) => ( - - ), - filterDropdown: undefined, // TODO: 后续加自定义搜索框 - render: (p: string | undefined) => ( - {p || '-'} - ), - }, - // ---- 时长(仅视频有数据,否则 "-") ---- - { - 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 {sec !== null ? formatDuration(sec) : '-'}; - }, - }, - // ---- 费用 ---- - { - 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) => ( - ¥{(c / 100).toFixed(2)} - ), - }, - // ---- 供应商(默认隐藏) ---- - { - 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: () => -, - }, - // ---- 文件大小(占位列,待后端支持) ---- - { - title: '文件大小', - key: 'file_size', - width: 85, - render: () => -, - }, - // ---- 标签 ---- - { - title: '标签', - dataIndex: 'tags', - key: 'tags', - width: 100, - ellipsis: true, - render: (tags: Array) => { - const filtered = tags?.filter((t): t is string => t !== null) ?? []; - if (filtered.length === 0) return -; - return ( - - {filtered.slice(0, 2).map((t) => ( - {t} - ))} - {filtered.length > 2 && ( - +{filtered.length - 2} - )} - - ); - }, - }, - // ---- 错误信息(失败任务时展示) ---- - { - title: '错误信息', - dataIndex: 'error_message', - key: 'error_message', - width: 140, - ellipsis: true, - render: (msg: string) => msg - ? {msg} - : -, - }, - ]; - - // 用户列:仅企业版可筛选 - 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) => ( - {n || '-'} - ), - }); - } else { - cols.push({ - title: '用户', - dataIndex: 'user_display_name', - key: 'user_id', - width: 100, - ellipsis: true, - render: (n: string | null) => ( - {n || '-'} - ), - }); - } - - 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(null); - const hScrollRef = useRef(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 ( -
- {/* 标题栏 — 左侧色条 + 背景,视觉权重高于表格列头 */} -
- - 任务记录 - {total > 0 && ( - 共 {total} 条 - )} - {hasActiveFilters && ( - - 已筛选 - - )} - {errorMessage && ( - {errorMessage} - )} - - {/* 列可见性控制 — 齿轮图标 */} - - {ALL_COLUMNS.map((col) => ( - handleColumnToggle(col.key, e.target.checked)} - > - {col.title} - - ))} - - } - > -
- - {/* 固定水平滚动条 — 在表格上方,始终可见,与表格 body 双向同步 */} -
-
-
- - {/* 表格 — 纵向滚动由 scroll.y 控制,横向滚动由上方固定滚动条控制 */} -
- - - 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: '确定' }} - /> -
- - {/* 独立分页器 — 固定在底部,不随表格数据量移动 */} -
- { - if (page !== taskPage) setTaskPage(page); - if (pageSize !== taskPageSize) setTaskPageSize(pageSize); - setTableParams((prev) => ({ - ...prev, - pagination: { current: page, pageSize }, - })); - }} - /> -
- - {/* 悬浮右键菜单 — 手动控制 open,trigger=[] 避免 AntD 自动监听干扰 onRow.onContextMenu */} - { if (!open) setContextMenu(null); }} - menu={{ - items: contextMenu ? buildTaskMenuItems(contextMenu.record) : [], - onClick: () => setContextMenu(null), - }} - trigger={[]} - > -
{ e.preventDefault(); setContextMenu(null); }} - style={{ - position: 'fixed', - left: contextMenu?.x ?? -9999, - top: contextMenu?.y ?? -9999, - width: 1, - height: 1, - }} - /> - -
- ); -} diff --git a/src/modules/navbar/views/NavBar.tsx b/src/modules/navbar/views/NavBar.tsx index 0e11891..d963fb4 100644 --- a/src/modules/navbar/views/NavBar.tsx +++ b/src/modules/navbar/views/NavBar.tsx @@ -30,7 +30,7 @@ export function NavBar() { 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(() => { @@ -76,6 +76,7 @@ export function NavBar() { case 'drawer': if (item.key === 'announcements') { setDrawerOpen(true); + refetchAnnouncements(); markAsRead(); } break; @@ -113,7 +114,7 @@ export function NavBar() { break; } }, - [isLoggedIn, logout, message, markAsRead], + [isLoggedIn, logout, message, markAsRead, refetchAnnouncements], ); const macPaddingLeft = platform === 'darwin' ? 80 : 12; diff --git a/src/modules/auth/controllers/auth-persistence.ts b/src/services/auth-persistence.ts similarity index 100% rename from src/modules/auth/controllers/auth-persistence.ts rename to src/services/auth-persistence.ts diff --git a/src/modules/auth/controllers/token-manager.ts b/src/services/auth-token.ts similarity index 97% rename from src/modules/auth/controllers/token-manager.ts rename to src/services/auth-token.ts index d6bebad..e2fdfbc 100644 --- a/src/modules/auth/controllers/token-manager.ts +++ b/src/services/auth-token.ts @@ -16,9 +16,9 @@ // - 内存缓存供同步读取(setTokenRefreshHandler 回调内使用) // ============================================================ -import { setToken, setTokenRefreshHandler, RequestError, RequestErrorType } from '@/shared/services/http'; -import { AuthAPI, type RefreshTokenResponseBody } from './auth-api'; -import { isRefreshError } from '@/shared/services/types'; +import { setToken, setTokenRefreshHandler, RequestError, RequestErrorType } from '@/services/request'; +import { AuthAPI, type RefreshTokenResponseBody } from './modules/auth/auth'; +import { isRefreshError } from '@/services/types'; import { getSafeStore, setSafeStore, diff --git a/src/shared/services/cache/model-cache.ts b/src/services/cache/model-cache.ts similarity index 95% rename from src/shared/services/cache/model-cache.ts rename to src/services/cache/model-cache.ts index ef62f73..7bbccce 100644 --- a/src/shared/services/cache/model-cache.ts +++ b/src/services/cache/model-cache.ts @@ -9,7 +9,7 @@ // ============================================================ 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 加密存储一致) */ const MODEL_CACHE_KEY = 'ele-heixiu-model-cache'; diff --git a/src/modules/account/controllers/billing-api.ts b/src/services/modules/account/billing.ts similarity index 98% rename from src/modules/account/controllers/billing-api.ts rename to src/services/modules/account/billing.ts index 9e52db9..2d9c077 100644 --- a/src/modules/account/controllers/billing-api.ts +++ b/src/services/modules/account/billing.ts @@ -1,5 +1,5 @@ import {type Nullable} from "@/shared/utils/display"; -import {get} from "@/shared/services/http.ts"; +import {get} from "@/services/request.ts"; const BillingUrlObj = { getBalance: '/api/v1/billing/balance', diff --git a/src/modules/account/controllers/payment-api.ts b/src/services/modules/account/payments.ts similarity index 98% rename from src/modules/account/controllers/payment-api.ts rename to src/services/modules/account/payments.ts index 7f50274..ff29dae 100644 --- a/src/modules/account/controllers/payment-api.ts +++ b/src/services/modules/account/payments.ts @@ -1,4 +1,4 @@ -import { get, post } from '@/shared/services/http'; +import { get, post } from '@/services/request'; export const PAYMENT_METHODS = { Alipay: 'alipay', diff --git a/src/modules/account/controllers/vip-api.ts b/src/services/modules/account/vip-api.ts similarity index 97% rename from src/modules/account/controllers/vip-api.ts rename to src/services/modules/account/vip-api.ts index 786d37d..9484383 100644 --- a/src/modules/account/controllers/vip-api.ts +++ b/src/services/modules/account/vip-api.ts @@ -1,5 +1,5 @@ -import {post, get} from '@/shared/services/http'; -import {type UserInfoBody} from "@/modules/auth/controllers/auth-api"; +import {post, get} from '@/services/request'; +import {type UserInfoBody} from "@/services/modules/auth/auth"; import {type Nullable} from "@/shared/utils/display"; diff --git a/src/modules/announcement/controllers/announcement-api.ts b/src/services/modules/announcement/announcement.ts similarity index 97% rename from src/modules/announcement/controllers/announcement-api.ts rename to src/services/modules/announcement/announcement.ts index e4d8911..ab48f23 100644 --- a/src/modules/announcement/controllers/announcement-api.ts +++ b/src/services/modules/announcement/announcement.ts @@ -3,7 +3,7 @@ // GET /api/v1/announcements // ============================================================ -import { get } from '@/shared/services/http'; +import { get } from '@/services/request'; // ============================================================ // AnnouncementUrlObj — 公告模块路由常量 diff --git a/src/modules/auth/controllers/auth-api.ts b/src/services/modules/auth/auth.ts similarity index 99% rename from src/modules/auth/controllers/auth-api.ts rename to src/services/modules/auth/auth.ts index 67d13b5..809ae62 100644 --- a/src/modules/auth/controllers/auth-api.ts +++ b/src/services/modules/auth/auth.ts @@ -1,4 +1,4 @@ -import {post, get} from '@/shared/services/http'; +import {post, get} from '@/services/request'; // ============================================================ // AuthUrlObj — 认证模块路由常量 diff --git a/src/modules/home/controllers/banner-api.ts b/src/services/modules/home/banner.ts similarity index 97% rename from src/modules/home/controllers/banner-api.ts rename to src/services/modules/home/banner.ts index 6fa8123..6ef940c 100644 --- a/src/modules/home/controllers/banner-api.ts +++ b/src/services/modules/home/banner.ts @@ -3,7 +3,7 @@ // GET /api/v1/banners // ============================================================ -import {get} from '@/shared/services/http'; +import {get} from '@/services/request'; // ---------- 路由常量 ---------- diff --git a/src/modules/home/controllers/model-api.ts b/src/services/modules/home/models.ts similarity index 99% rename from src/modules/home/controllers/model-api.ts rename to src/services/modules/home/models.ts index f18b7c4..f150aa9 100644 --- a/src/modules/home/controllers/model-api.ts +++ b/src/services/modules/home/models.ts @@ -1,4 +1,4 @@ -import {get} from '@/shared/services/http'; +import {get} from '@/services/request'; // 注意:后端 /api/v1/models 的 category 查询参数实际不可用(传参返回空列表), // 因此客户端采用"全量拉取 + 按 category_name 字段分组"的策略。 // 后端分类 slug 格式为 img2img / txt2img 等,前端通过 CATEGORY_SLUG_MAP 映射。 diff --git a/src/modules/home/controllers/task-api.ts b/src/services/modules/home/task.ts similarity index 99% rename from src/modules/home/controllers/task-api.ts rename to src/services/modules/home/task.ts index 2aa725a..c8699da 100644 --- a/src/modules/home/controllers/task-api.ts +++ b/src/services/modules/home/task.ts @@ -10,7 +10,7 @@ // 不做额外的 async/await 包装,由调用方决定是否 await。 // ============================================================ -import {get, post} from '@/shared/services/http'; +import {get, post} from '@/services/request'; import {getDeviceId} from '@/shared/utils/env'; import {Nullable} from "@/shared/utils/display"; diff --git a/src/shared/services/http.ts b/src/services/request.ts similarity index 99% rename from src/shared/services/http.ts rename to src/services/request.ts index 9c766dc..abaa9e9 100644 --- a/src/shared/services/http.ts +++ b/src/services/request.ts @@ -17,9 +17,9 @@ import axios, { } from 'axios'; import type { ApiResponse } from './types'; import { RequestError, RequestErrorType, RefreshError, isRefreshError } from './types'; -import { emit, EVENTS } from '../utils/bus'; -import { logger } from '../utils/bus'; -import { getSafeStore, setSafeStore, clearSafeStore, initSafeStore } from '../utils/safe-storage'; +import { emit, EVENTS } from '@/shared/utils/bus'; +import { logger } from '@/shared/utils/bus'; +import { getSafeStore, setSafeStore, clearSafeStore, initSafeStore } from '@/shared/utils/safe-storage'; // ---------- 配置 ---------- diff --git a/src/shared/services/types.ts b/src/services/types.ts similarity index 100% rename from src/shared/services/types.ts rename to src/services/types.ts diff --git a/src/shared/hooks/use-announcements.ts b/src/shared/hooks/use-announcements.ts index c851bc2..d00b865 100644 --- a/src/shared/hooks/use-announcements.ts +++ b/src/shared/hooks/use-announcements.ts @@ -2,14 +2,16 @@ // useAnnouncements — 公告轮询 + 未读缓存 Hook // // 行为: -// - 挂载时立即拉取公告列表 -// - 之后每 15 分钟自动轮询 +// - 登录后立即拉取公告列表(未登录时不发送请求) +// - 登录态下每 15 分钟自动轮询 // - 通过 localStorage 缓存已读公告 ID,跨会话持久化 // - 提供 markAsRead() 在用户查看后清除未读标记 +// - 提供 refetch() 手动拉取(抽屉打开时调用) // ============================================================ 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'; const SEEN_IDS_KEY = 'heixiu:announcement:seen-ids'; @@ -47,9 +49,12 @@ export interface AnnouncementsState { loading: boolean; /** 标记全部已读(打开抽屉时调用) */ markAsRead: () => void; + /** 手动重新拉取(打开抽屉时调用,确保拿到最新数据) */ + refetch: () => void; } export function useAnnouncements(): AnnouncementsState { + const { isLoggedIn } = useAppContext(); const [announcements, setAnnouncements] = useState([]); const [unreadCount, setUnreadCount] = useState(0); const [loading, setLoading] = useState(false); @@ -70,12 +75,13 @@ export function useAnnouncements(): AnnouncementsState { } }, []); - // 挂载时首次拉取 + 每 15 分钟定时轮询 + // 登录后首次拉取 + 每 15 分钟定时轮询(未登录时不发请求) useEffect(() => { + if (!isLoggedIn) return; fetchAndCompare(); const timer = setInterval(fetchAndCompare, POLL_INTERVAL); return () => clearInterval(timer); - }, [fetchAndCompare]); + }, [isLoggedIn, fetchAndCompare]); // 标记全部已读:将当前公告 ID 写入缓存 const markAsRead = useCallback(() => { @@ -84,5 +90,5 @@ export function useAnnouncements(): AnnouncementsState { setUnreadCount(0); }, [announcements]); - return { announcements, unreadCount, loading, markAsRead }; + return { announcements, unreadCount, loading, markAsRead, refetch: fetchAndCompare }; } diff --git a/src/shared/hooks/use-api.ts b/src/shared/hooks/use-api.ts index 9902e90..0fbff7c 100644 --- a/src/shared/hooks/use-api.ts +++ b/src/shared/hooks/use-api.ts @@ -14,15 +14,15 @@ import {useMemo, useState, useRef, useEffect, useCallback} from 'react'; 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 { ModelAPI, MODEL_CATEGORY_LABELS, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/modules/home/controllers/model-api'; -import { fetchBanners, type Banner } from '@/modules/home/controllers/banner-api'; -import { AuthAPI, type UserInfoBody } from '@/modules/auth/controllers/auth-api'; -import { VipAPI, type VipLevelsItem } from '@/modules/account/controllers/vip-api'; -import { BillingAPI, type BalanceResponseBody, type LedgerItemResponseBody, type LedgerReqeustParam } from '@/modules/account/controllers/billing-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 '@/services/modules/home/models'; +import { fetchBanners, type Banner } from '@/services/modules/home/banner'; +import { AuthAPI, type UserInfoBody } from '@/services/modules/auth/auth'; +import { VipAPI, type VipLevelsItem } from '@/services/modules/account/vip-api'; +import { BillingAPI, type BalanceResponseBody, type LedgerItemResponseBody, type LedgerReqeustParam } from '@/services/modules/account/billing'; import {useAsyncData, UseAsyncDataReturn, useAsyncMutation} from './use-async'; -import {getCachedModels, setCachedModels} from '@/shared/services/cache/model-cache'; -import { upload } from '@/shared/services/http'; +import {getCachedModels, setCachedModels} from '@/services/cache/model-cache'; +import { upload } from '@/services/request'; // ============================================================ // Banner diff --git a/src/shared/hooks/use-async.ts b/src/shared/hooks/use-async.ts index c308b21..b39d4cb 100644 --- a/src/shared/hooks/use-async.ts +++ b/src/shared/hooks/use-async.ts @@ -21,7 +21,7 @@ // ============================================================ 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'; // ---------- 错误信息映射 ---------- diff --git a/src/shared/infrastructure/storage/services/data-sync.ts b/src/shared/infrastructure/storage/services/data-sync.ts index 6669c81..e21f5ce 100644 --- a/src/shared/infrastructure/storage/services/data-sync.ts +++ b/src/shared/infrastructure/storage/services/data-sync.ts @@ -13,9 +13,9 @@ import { syncTasks, syncRechargeOrders, syncVipOrders, hasMore, nextPage, updateCursor, } from '@/shared/infrastructure/storage'; -import { TaskAPI } from '@/modules/home/controllers/task-api'; -import { PaymentAPI } from '@/modules/account/controllers/payment-api'; -import { VipAPI } from '@/modules/account/controllers/vip-api'; +import { TaskAPI } from '@/services/modules/home/task'; +import { PaymentAPI } from '@/services/modules/account/payments'; +import { VipAPI } from '@/services/modules/account/vip-api'; import { logger } from '@/shared/utils/bus'; const PAGE_SIZE = 100; diff --git a/src/shared/infrastructure/storage/services/media-loader.ts b/src/shared/infrastructure/storage/services/media-loader.ts index e3f28ae..14d42ea 100644 --- a/src/shared/infrastructure/storage/services/media-loader.ts +++ b/src/shared/infrastructure/storage/services/media-loader.ts @@ -15,7 +15,7 @@ // ============================================================ import axios from 'axios'; -import { axiosInstance } from '@/shared/services/http'; +import { axiosInstance } from '@/services/request'; import { logger } from '@/shared/utils/bus'; import { hasIpc } from '@/shared/utils/bus'; import { getCurrentUserId, hashURL, getTodayKey } from './user-scope'; diff --git a/src/shared/infrastructure/storage/stores/task-store.ts b/src/shared/infrastructure/storage/stores/task-store.ts index 6089c0a..0a95205 100644 --- a/src/shared/infrastructure/storage/stores/task-store.ts +++ b/src/shared/infrastructure/storage/stores/task-store.ts @@ -10,7 +10,7 @@ import { execute, queryAll, queryOne } from '../core/db-core'; import { getCurrentUserId } from '../services/user-scope'; 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'; // ---------- 序列化 ---------- diff --git a/src/shared/utils/enum-resolver.ts b/src/shared/utils/enum-resolver.ts index fbc9eb8..49a4454 100644 --- a/src/shared/utils/enum-resolver.ts +++ b/src/shared/utils/enum-resolver.ts @@ -23,51 +23,51 @@ import { PAYMENT_STATUS_TEXT_MAP, type PaymentStatus, -} from '@/modules/account/controllers/payment-api'; +} from '@/services/modules/account/payments'; import { EntryTypeLabelMap, EntryTypeColorMap, type EntryTypeValue, -} from '@/modules/account/controllers/billing-api'; +} from '@/services/modules/account/billing'; import { UserRoleTypeLabelMap, UserAccountTypeLabelMap, type UserRoleTypeValue, type UserAccountTypeValue, -} from '@/modules/auth/controllers/auth-api'; +} from '@/services/modules/auth/auth'; import { VipOrderStatusLabelMap, TargetAccountTypeLabelMap, type VipOrderStatusValue, type TargetAccountTypeValue, -} from '@/modules/account/controllers/vip-api'; +} from '@/services/modules/account/vip-api'; import { OutputTypeMap, TaskStatusMap, type OutputTypeString, type TaskStatusString, -} from '@/modules/home/controllers/task-api'; +} from '@/services/modules/home/task'; import { BannerCategoryLabelMap, type BannerCategoryValue, -} from '@/modules/home/controllers/banner-api'; +} from '@/services/modules/home/banner'; import { AnnouncementTypeLabelMap, AnnouncementTypeColorMap, type AnnouncementTypeValue, -} from '@/modules/announcement/controllers/announcement-api'; +} from '@/services/modules/announcement/announcement'; import { MODEL_CATEGORY_LABELS, CATEGORY_SLUG_MAP, type ModelCategoryStringEnum, -} from '@/modules/home/controllers/model-api'; +} from '@/services/modules/home/models'; // ============================================================ // 模型分类 slug → 前端标识反向映射 diff --git a/src/shared/utils/path-join.ts b/src/shared/utils/path-join.ts new file mode 100644 index 0000000..c27a122 --- /dev/null +++ b/src/shared/utils/path-join.ts @@ -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;