refactor: Home 模块 VCHSM 三栏重构 + Services 迁回 + 右键菜单体系 + 公告修复

## Home 模块三栏 VCHSM 重组
- 按渲染归属重新分配文件:ModelSelector/TaskHistory → left/,ModelInputForm/widgets → center/,OutputPreview → right/
- BannerCarousel 移至 shared/views/(全局组件)
- controls/ → widgets/ 重命名,消除与 controllers/ 的命名歧义
- 新增 barrel 文件(left/center/right/shared 各级 index.ts)

## Services 层迁回 src/services/modules/
- 全部 API 类型定义与请求函数从 modules/*/controllers/ 迁回 services/modules/{domain}/
- 恢复原始文件名(model-api.ts → models.ts,task-api.ts → task.ts 等)
- 新增 domain 子文件夹(home/auth/announcement/account)
- 更新全局 ~70 处导入路径

## TaskHistory 提取 Controller + Hook 层
- 新建 left/controllers/task-table-config.ts(常量 + 纯工具函数,零 React 依赖)
- 新建 left/hooks/useTaskTable.tsx(状态管理 + 数据获取 + 列定义 + 滚动同步)
- TaskHistory.tsx 从 915 行精简为 265 行纯视图

## 右键菜单体系完善
- 共享 <ContextMenu> 组件(antd Dropdown 封装,防 Select 冲突)
- 四组菜单定义:任务列表 / 文本输入 / 媒体参考 / 预览区域
- 统一的 ContextMenuItems → AntD MenuProps 转换

## 公告功能修复
- useAnnouncements 增加 isLoggedIn 登录门控,避免未登录时 401 双重请求
- 抽屉打开时触发 refetch(),确保拿到最新数据

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-30 13:39:38 +08:00
parent 337d6c1205
commit 904edecd90
73 changed files with 1478 additions and 1034 deletions

View File

@@ -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';
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Banner | null>(null);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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';
// ---------- 输出类型 ----------

View File

@@ -0,0 +1,27 @@
// ============================================================
// home/center — 首页中栏(模型输入表单)
// ============================================================
// Views
export { CenterPanel } from './views/CenterPanel';
export { ModelInputForm } from './views/ModelInputForm';
// Widgets (来自 API param_schema 的表单控件注册表)
export { WIDGET_MAP, FALLBACK_WIDGET } from './views/widgets';
export type { WidgetType } from './views/widgets';
export type { WidgetProps } from './views/widgets/types';
// Hooks
export { useModelUI, isValueEmpty } from './hooks/useModelUI';
export type { UIFieldConfig } from './hooks/useModelUI';
// Controllers
export {
getTextInputMenuItems,
getMediaRefImageMenu,
getMediaRefBlankMenu,
} from './controllers/menus';
export type {
TextInputMenuOptions,
MediaRefMenuOptions,
} from './controllers/menus';

View File

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

View File

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

View File

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

14
src/modules/home/index.ts Normal file
View File

@@ -0,0 +1,14 @@
// ============================================================
// home — 首页模块统一导出
//
// 结构:三栏 → 五层嵌套
// left/ — 左栏 VCHSM模型选择 + 任务记录)
// center/ — 中栏 VCHSM模型输入表单 + 参数控件)
// right/ — 右栏 VCHSM输出预览
// shared/ — 跨栏共享(全局视图 + API + 状态)
// ============================================================
export { LeftPanel } from './left';
export { CenterPanel } from './center';
export { RightPanel } from './right';
export { HomePage, HomeContent } from './shared';

View File

@@ -0,0 +1,8 @@
// ============================================================
// menus — 左栏右键菜单定义统一导出
// ============================================================
export { getTaskListMenuItems } from './task-list-menu';
export type { TaskListMenuOptions } from './task-list-menu';
export { buildTaskActions } from './task-list-actions';

View File

@@ -0,0 +1,131 @@
// ============================================================
// task-list-actions.ts — 任务列表右键菜单操作实现
//
// 职责:
// - 接收 TaskInfoItemResponseBody返回 TaskListMenuOptions
// - 所有回调在此文件中实现IPC 调用、日志记录、用户提示)
// - View 层只需调用 buildTaskActions(record) 即可获得完整菜单配置
//
// 设计原则:
// - View 不关心 IPC 细节、路径拼接逻辑、错误处理
// - 每个操作独立 async 函数,便于单独测试和复用
// ============================================================
import { message } from 'antd';
import { safeIpcInvoke, logger } from '@/shared/utils/bus';
import { BIDIRECTIONAL } from '@shared/constants/ipc-channels';
import { joinPaths } from '@/shared/utils/path-join';
import type { TaskInfoItemResponseBody } from '@/services/modules/home/task';
import type { TaskListMenuOptions } from './task-list-menu';
// ── 内部工具 ──
/** user_id → 文件系统安全标识:去非字母数字 → 小写 → 前 8 位 */
function cleanUserId(raw: string): string {
return raw
.replace(/[^a-zA-Z0-9]/g, '')
.toLowerCase()
.slice(0, 8);
}
/** ISO 日期 → "YYYY-MM" */
function formatYearMonth(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}
/** ISO 日期 → "DD" */
function formatDay(d: Date): string {
return String(d.getDate()).padStart(2, '0');
}
/** task_id → 任务目录键:前 8 字符 → t_{key} */
function taskDirKey(taskId: string): string {
return `t_${taskId.slice(0, 8)}`;
}
// ── 操作实现 ──
/** 打开任务输出目录 */
async function openOutputDir(record: TaskInfoItemResponseBody): Promise<void> {
const d = new Date(record.created_at);
if (isNaN(d.getTime())) {
logger.warn('ui', '任务创建时间无效', undefined, { taskId: record.id });
message.warning('任务创建时间无效,无法定位输出目录');
return;
}
try {
const result = (await safeIpcInvoke(BIDIRECTIONAL.GET_DATA_DIR)) as
| { success: boolean; data: string }
| undefined;
if (!result?.data) {
logger.warn('ui', 'GET_DATA_DIR 返回空', undefined, { taskId: record.id });
message.warning('无法获取本地数据目录');
return;
}
const outputsDir = joinPaths(
result.data,
'repository',
cleanUserId(record.user_id),
formatYearMonth(d),
formatDay(d),
taskDirKey(record.id),
'outputs',
);
await safeIpcInvoke(BIDIRECTIONAL.SHOW_ITEM_IN_FOLDER, {
filePath: outputsDir,
});
} catch (err) {
logger.warn('ui', '打开输出目录失败', err instanceof Error ? err : new Error(String(err)), {
taskId: record.id,
});
message.warning('输出目录不存在或无法访问');
}
}
/** 复制第一个输出资源的链接 */
async function copyResourceLink(record: TaskInfoItemResponseBody): Promise<void> {
const assets = record.output_assets;
if (!assets || assets.length === 0) {
message.warning('该任务无输出资源');
return;
}
try {
await navigator.clipboard.writeText(assets[0].url);
message.success('已复制资源链接');
} catch (err) {
logger.warn('ui', '复制资源链接失败', err instanceof Error ? err : new Error(String(err)));
message.error('复制失败');
}
}
// ── 公开入口 ──
/**
* 根据任务记录构建完整的右键菜单操作配置。
*
* View 层调用此函数获取 TaskListMenuOptions传入 getTaskListMenuItems()
* 即可得到渲染就绪的 ContextMenuItems。
*/
export function buildTaskActions(record: TaskInfoItemResponseBody): TaskListMenuOptions {
return {
canOpenOutput: record.status === 'success',
canReedit: true,
canRegen: true,
canPullResult: record.status === 'success',
canRedownload: record.status === 'success',
isFavorited: record.is_favorite,
onOpenOutputDir: () => void openOutputDir(record),
onCopyResourceLink: () => void copyResourceLink(record),
onReedit: () => { message.info('重新编辑功能待实现'); },
onRegen: () => { message.info('再次生成功能待实现'); },
onPullResult: () => { message.info('拉取结果功能待实现'); },
onRedownload: () => { message.info('重新下载功能待实现'); },
onToggleFavorite: () => { message.info('收藏功能待实现'); },
onDelete: () => { message.info('删除记录功能待实现'); },
};
}

View File

@@ -0,0 +1,137 @@
// ============================================================
// task-table-config.ts — 任务记录表格的纯配置、常量和工具函数
//
// 职责(无 React 依赖,可被任意层级引用):
// - 类型定义TableFilters / TableSorter / TableParams / ColumnMeta
// - 状态/输出类型枚举常量
// - 列可见性 localStorage 持久化
// - 筛选值提取工具
// - 时长格式化工具
// ============================================================
import type { FilterValue } from 'antd/es/table/interface';
import { TaskStatusMap, OutputTypeMap } from '@/services/modules/home/task';
import type { TaskInfoItemResponseBody } from '@/services/modules/home/task';
// ── 类型 ──
/** antd Table onChange 中 filters 的类型 */
export type TableFilters = Record<string, FilterValue | null>;
/** antd Table onChange 中 sorter 的类型(单列排序) */
export interface TableSorter {
field?: React.Key | readonly React.Key[];
order?: 'ascend' | 'descend';
}
/** 表格参数(缓存 antd onChange 的结果,用于构建 API 请求) */
export interface TableParams {
pagination: { current: number; pageSize: number };
filters: TableFilters;
sorter: TableSorter;
}
/** 列元数据(定义所有可用列 + 默认可见性) */
export interface ColumnMeta {
key: string;
title: string;
defaultVisible: boolean;
}
// ── 常量 ──
/** 状态筛选选项(来自 API 枚举) */
export const STATUS_FILTERS = Object.entries(TaskStatusMap).map(([value, label]) => ({
text: label,
value,
}));
/** 输出类型筛选选项(从 OutputTypeMap 派生) */
export const OUTPUT_TYPE_FILTERS = Object.entries(OutputTypeMap).map(([value, text]) => ({
text,
value,
}));
/** 状态标签配置(渲染用,含未来未知状态的降级处理) */
export const STATUS_CONFIG: Record<string, { color: string; label: string }> = {
pending: { color: 'blue', label: '排队中' },
submitted: { color: 'cyan', label: '已提交' },
processing: { color: 'orange', label: '处理中' },
success: { color: 'green', label: '已完成' },
failed: { color: 'red', label: '失败' },
};
/** localStorage 键名(遵循 ele-heixiu-* 规范) */
export const COLUMN_VISIBILITY_KEY = 'ele-heixiu-table-columns';
/** 所有可用列定义 */
export const ALL_COLUMNS: ColumnMeta[] = [
{ key: 'id', title: '任务 ID', defaultVisible: true },
{ key: 'created_at', title: '创建时间', defaultVisible: true },
{ key: 'model_name', title: '模型', defaultVisible: true },
{ key: 'output_type', title: '输出类型', defaultVisible: true },
{ key: 'status', title: '状态', defaultVisible: true },
{ key: 'prompt', title: '提示词', defaultVisible: true },
{ key: 'duration', title: '时长', defaultVisible: true },
{ key: 'cost_cent', title: '费用', defaultVisible: true },
{ key: 'user_display_name', title: '用户', defaultVisible: true },
{ key: 'provider_name', title: '供应商', defaultVisible: false },
{ key: 'fps', title: '帧率', defaultVisible: false },
{ key: 'file_size', title: '文件大小', defaultVisible: false },
{ key: 'tags', title: '标签', defaultVisible: false },
{ key: 'error_message', title: '错误信息', defaultVisible: false },
];
// ── 纯工具函数 ──
/** 未知状态降级:后端新增状态时自动回退显示 key 原文 */
export function resolveStatus(s: string): { color: string; label: string } {
return STATUS_CONFIG[s] || { color: 'default', label: s };
}
/** 从 localStorage 读取已保存的列可见性 */
export function readStoredColumns(): Set<string> | null {
try {
const raw = localStorage.getItem(COLUMN_VISIBILITY_KEY);
if (raw) {
const arr: unknown = JSON.parse(raw);
if (Array.isArray(arr) && arr.every((v) => typeof v === 'string')) return new Set(arr);
}
} catch { /* ignore */ }
return null;
}
/** 将列可见性持久化到 localStorage */
export function writeStoredColumns(visible: Set<string>): void {
try { localStorage.setItem(COLUMN_VISIBILITY_KEY, JSON.stringify([...visible])); } catch { /* ignore */ }
}
/** 从 filters 中提取单值筛选(如 status 单选) */
export function pickOne(filters: TableFilters, key: string): string | undefined {
const v = filters[key];
if (Array.isArray(v) && v.length > 0) return v[0] as string;
return undefined;
}
/** 从 filters 中提取多值筛选(如 model_ids、user_ids */
export function pickMany(filters: TableFilters, key: string): string[] | undefined {
const v = filters[key];
if (Array.isArray(v) && v.length > 0) return v as string[];
return undefined;
}
/** 提取视频时长(秒),非视频输出类型返回 null */
export function getVideoDuration(record: TaskInfoItemResponseBody): number | null {
if (record.output_type === 'video') {
return record.ui_params.duration ?? null;
}
return null;
}
/** 格式化时长:<60s 显示秒≥60s 显示分秒 */
export function formatDuration(seconds: number): string {
if (seconds < 60) return `${seconds}`;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return s > 0 ? `${m}${s}` : `${m} 分钟`;
}

View File

@@ -0,0 +1,641 @@
// ============================================================
// useTaskTable — 任务记录表格的核心状态、副作用与派生数据 Hook
//
// 职责:
// - 筛选 / 排序 / 分页状态管理
// - 列可见性localStorage 持久化)
// - 主题感知 CSS 生成
// - API 请求参数构建
// - 数据获取 + 非终态任务轮询 + 事件总线监听
// - 列定义构建(含渲染函数)
// - 表格高度自适应 + 水平滚动同步
//
// TaskHistory 视图只需解构返回值并渲染 JSX。
// ============================================================
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import {
Tag,
Typography,
Space,
theme as antTheme,
} from 'antd';
import type { ColumnsType, TableProps } from 'antd/es/table';
import type { SorterResult } from 'antd/es/table/interface';
import { SearchOutlined } from '@ant-design/icons';
import { useTaskList, useTaskPolling } from '@/shared/hooks/use-api';
import type { ModelsListResponseBody } from '@/services/modules/home/models';
import { useAppContext } from '@/app/providers/AppProvider';
import { useHomeContext } from '@/modules/home/shared/stores/HomeContext';
import { useTheme } from '@/app/providers/ThemeProvider';
import { on, off, EVENTS } from '@/shared/utils/bus';
import { OutputTypeMap } from '@/services/modules/home/task';
import type {
TaskInfoItemResponseBody,
TaskListRequestParams,
TaskStatusString,
} from '@/services/modules/home/task';
import {
STATUS_FILTERS,
OUTPUT_TYPE_FILTERS,
ALL_COLUMNS,
readStoredColumns,
writeStoredColumns,
resolveStatus,
pickOne,
pickMany,
getVideoDuration,
formatDuration,
type TableFilters,
type TableParams,
} from '../controllers/task-table-config';
const { Text } = Typography;
// ── Hook 入参 ──
interface UseTaskTableOptions {
/** 模型全量数据,由父组件 LeftPanel 传入 */
allModels: ModelsListResponseBody[] | null;
}
// ── Hook 返回值 ──
export interface UseTaskTableReturn {
// 数据
polledTasks: TaskInfoItemResponseBody[];
loading: boolean;
errorMessage: string | null;
total: number;
// 表格配置
columns: ColumnsType<TaskInfoItemResponseBody>;
tableCss: string;
scrollX: number;
// DOM refsView 层绑定到容器元素)
tableWrapperRef: React.RefObject<HTMLDivElement | null>;
hScrollRef: React.RefObject<HTMLDivElement | null>;
// 状态 & 回调
tableParams: TableParams;
tableBodyHeight: number;
handleTableChange: TableProps<TaskInfoItemResponseBody>['onChange'];
rowClassName: (record: TaskInfoItemResponseBody, index: number) => string;
// 列可见性
visibleColumns: Set<string>;
handleColumnToggle: (key: string, checked: boolean) => void;
// 派生
hasActiveFilters: boolean;
setTableParams: React.Dispatch<React.SetStateAction<TableParams>>;
}
// ── Hook ──
export function useTaskTable({ allModels }: UseTaskTableOptions): UseTaskTableReturn {
const { token } = antTheme.useToken();
const { edition } = useAppContext();
const {
selectedTask,
setSelectedTask,
taskPage,
taskPageSize,
} = useHomeContext();
const isEnterprise = edition === 'enterprise';
// ════════════════════════════════════════════
// 列可见性
// ════════════════════════════════════════════
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(() => {
const stored = readStoredColumns();
if (stored) return stored;
return new Set(ALL_COLUMNS.filter((c) => c.defaultVisible).map((c) => c.key));
});
const handleColumnToggle = useCallback((key: string, checked: boolean) => {
setVisibleColumns((prev) => {
const next = new Set(prev);
if (checked) next.add(key); else next.delete(key);
writeStoredColumns(next);
return next;
});
}, []);
// ════════════════════════════════════════════
// 主题感知 CSS
// ════════════════════════════════════════════
const { isDark } = useTheme();
const tableCss = useMemo(() => {
const searchIconColor = token.colorPrimary;
if (isDark) {
return `
.task-table .task-row-odd > td { background: rgba(255,255,255,0.02) !important; }
.task-table .task-row-even > td { background: transparent !important; }
.task-table .task-row-selected > td { background: ${token.blue}1A !important; }
.task-table .task-row-selected.task-row-odd > td { background: ${token.blue}22 !important; }
.task-table .ant-table-row:hover > td { background: rgba(255,255,255,0.05) !important; }
.task-table .task-row-selected:hover > td { background: ${token.blue}28 !important; }
.task-table .prompt-search-icon.filtered { color: ${searchIconColor} !important; }
.task-table .ant-table-body { overflow-x: hidden !important; }
.task-table .ant-table-thead > tr > th { font-weight: normal !important; }
.task-table .ant-table-body::-webkit-scrollbar { width: 6px; }
.task-table .ant-table-body::-webkit-scrollbar-track { background: transparent; }
.task-table .ant-table-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 3px; }
.task-table .ant-table-body::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.35); }
.task-hscroll::-webkit-scrollbar { height: 6px; }
.task-hscroll::-webkit-scrollbar-track { background: transparent; }
.task-hscroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 3px; }
.task-hscroll::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.35); }
`;
}
return `
.task-table .task-row-odd > td { background: #fafafa !important; }
.task-table .task-row-even > td { background: #ffffff !important; }
.task-table .task-row-selected > td { background: #e6f4ff !important; }
.task-table .task-row-selected.task-row-odd > td { background: #dceeff !important; }
.task-table .ant-table-row:hover > td { background: #f0f5ff !important; }
.task-table .task-row-selected:hover > td { background: #d6ebff !important; }
.task-table .prompt-search-icon.filtered { color: ${searchIconColor} !important; }
.task-table .ant-table-body { overflow-x: hidden !important; }
.task-table .ant-table-thead > tr > th { font-weight: normal !important; }
.task-table .ant-table-body::-webkit-scrollbar { width: 6px; }
.task-table .ant-table-body::-webkit-scrollbar-track { background: transparent; }
.task-table .ant-table-body::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); border-radius: 3px; }
.task-table .ant-table-body::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.25); }
.task-hscroll::-webkit-scrollbar { height: 6px; }
.task-hscroll::-webkit-scrollbar-track { background: transparent; }
.task-hscroll::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); border-radius: 3px; }
.task-hscroll::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.25); }
`;
}, [isDark, token.blue, token.colorPrimary]);
// ════════════════════════════════════════════
// 表格参数(分页 + 筛选 + 排序)
// ════════════════════════════════════════════
const [tableParams, setTableParams] = useState<TableParams>({
pagination: { current: taskPage, pageSize: taskPageSize },
filters: {},
sorter: {},
});
// 同步 context 分页 ↔ tableParams
useEffect(() => {
setTableParams((prev: TableParams) => ({
...prev,
pagination: { current: taskPage, pageSize: taskPageSize },
}));
}, [taskPage, taskPageSize]);
// ════════════════════════════════════════════
// 筛选项(从模型和任务数据派生)
// ════════════════════════════════════════════
const modelFilters: { text: string; value: string }[] = useMemo(() => {
if (!allModels) return [];
return allModels.map((m) => ({ text: m.name, value: m.id }));
}, [allModels]);
const providerFilters = useMemo(() => {
if (!allModels) return [];
const seen = new Set<string>();
allModels.forEach((m) => {
if (m.provider_name) seen.add(m.provider_name);
});
return Array.from(seen).sort().map((name) => ({ text: name, value: name }));
}, [allModels]);
const [userFilters, setUserFilters] = useState<Array<{ text: string; value: string }>>([]);
// ════════════════════════════════════════════
// API 请求参数构建
// ════════════════════════════════════════════
const requestParams = useMemo<TaskListRequestParams>(() => {
const req: TaskListRequestParams = {
page: tableParams.pagination.current,
page_size: tableParams.pagination.pageSize,
};
const { filters } = tableParams;
const status = pickOne(filters, 'status');
if (status) req.status = status as TaskStatusString;
const modelIds = pickMany(filters, 'model_name');
if (modelIds) req.model_ids = modelIds;
if (isEnterprise) {
const userIds = pickMany(filters, 'user_id');
if (userIds) req.user_ids = userIds;
}
return req;
}, [tableParams, isEnterprise]);
// ════════════════════════════════════════════
// 数据获取 + 轮询
// ════════════════════════════════════════════
const [refreshTick, setRefreshTick] = useState(0);
const handleTaskSubmitted = useCallback(() => {
setRefreshTick((t) => t + 1);
}, []);
useEffect(() => {
on(EVENTS.TASK_SUBMITTED, handleTaskSubmitted);
return () => { off(EVENTS.TASK_SUBMITTED, handleTaskSubmitted); };
}, [handleTaskSubmitted]);
const {
data: taskData,
loading,
errorMessage,
} = useTaskList({
requestParams,
refreshSignal: refreshTick,
});
const tasks = taskData?.items ?? [];
const handleTaskTerminal = useCallback(() => {
setRefreshTick((t) => t + 1);
}, []);
const polledTasks = useTaskPolling(tasks, handleTaskTerminal);
// 轮询更新后同步选中的任务到 Context
useEffect(() => {
if (!selectedTask || polledTasks.length === 0) return;
const updated = polledTasks.find(t => t.id === selectedTask.id);
if (updated && updated.status !== selectedTask.status) {
setSelectedTask(updated);
}
}, [polledTasks, selectedTask, setSelectedTask]);
const total = taskData?.total ?? 0;
// 从任务数据中提取用户列表(企业版筛选用)
useEffect(() => {
if (!isEnterprise || !taskData?.items) return;
const seen = new Set<string>();
taskData.items.forEach((t) => {
if (t.user_id && t.user_display_name) {
seen.add(JSON.stringify({ text: t.user_display_name, value: t.user_id }));
}
});
const newList = Array.from(seen).map((s) => JSON.parse(s) as { text: string; value: string });
setUserFilters((prev) => {
if (prev.length === newList.length) return prev;
return newList;
});
}, [isEnterprise, taskData]);
// ════════════════════════════════════════════
// Table onChange 处理
// ════════════════════════════════════════════
const handleTableChange: TableProps<TaskInfoItemResponseBody>['onChange'] = (
_pagination,
filters,
sorter,
) => {
const s = sorter as SorterResult<TaskInfoItemResponseBody>;
setTableParams((prev) => ({
...prev,
filters: filters as TableFilters,
sorter: !Array.isArray(s) && s.field
? { field: s.field, order: s.order as 'ascend' | 'descend' | undefined }
: {},
}));
};
// ════════════════════════════════════════════
// 列定义
// ════════════════════════════════════════════
const allColumnsDef = useMemo<ColumnsType<TaskInfoItemResponseBody>>(() => {
const cols: ColumnsType<TaskInfoItemResponseBody> = [
{
title: '任务 ID',
dataIndex: 'id',
key: 'id',
width: 140,
ellipsis: true,
fixed: 'left',
render: (id: string) => (
<Text copyable={{ text: id }} style={{ fontSize: 12, fontFamily: 'monospace' }}>
{id.slice(0, 10)}...
</Text>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 155,
sorter: (a, b) => {
const da = a.created_at ? new Date(a.created_at).getTime() : 0;
const db = b.created_at ? new Date(b.created_at).getTime() : 0;
return da - db;
},
sortOrder: tableParams.sorter.field === 'created_at' ? tableParams.sorter.order : null,
render: (v: Date) => (
<Text style={{ fontSize: 11 }}>
{v ? new Date(v).toLocaleString('zh-CN', {
month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
}) : '-'}
</Text>
),
},
{
title: '模型',
dataIndex: 'model_name',
key: 'model_name',
width: 130,
ellipsis: true,
filters: modelFilters,
filteredValue: (tableParams.filters.model_name as string[]) || null,
onFilter: () => true,
filterSearch: true,
filterMode: 'menu' as const,
},
{
title: '输出类型',
dataIndex: 'output_type',
key: 'output_type',
width: 120,
filters: OUTPUT_TYPE_FILTERS,
filteredValue: (tableParams.filters.output_type as string[]) || null,
onFilter: (value, record) => record.output_type === value,
render: (t: string) => (
<Tag style={{ fontSize: 10 }}>
{OutputTypeMap[t as keyof typeof OutputTypeMap] || t}
</Tag>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 80,
filters: STATUS_FILTERS,
filteredValue: (tableParams.filters.status as string[]) || null,
onFilter: () => true,
render: (s: string) => {
const cfg = resolveStatus(s);
return <Tag color={cfg.color} style={{ fontSize: 10 }}>{cfg.label}</Tag>;
},
},
{
title: '提示词',
dataIndex: 'prompt',
key: 'prompt',
width: 160,
ellipsis: true,
filterIcon: (filtered: boolean) => (
<SearchOutlined
className={`prompt-search-icon${filtered ? ' filtered' : ''}`}
style={{ fontSize: 12 }}
/>
),
filterDropdown: undefined,
render: (p: string | undefined) => (
<Text style={{ fontSize: 11 }} title={p}>{p || '-'}</Text>
),
},
{
title: '时长',
key: 'duration',
width: 75,
sorter: (a, b) => {
const da = getVideoDuration(a) ?? -1;
const db = getVideoDuration(b) ?? -1;
return da - db;
},
sortOrder: tableParams.sorter.field === 'duration' ? tableParams.sorter.order : null,
render: (_: unknown, record: TaskInfoItemResponseBody) => {
const sec = getVideoDuration(record);
return <Text style={{ fontSize: 11 }}>{sec !== null ? formatDuration(sec) : '-'}</Text>;
},
},
{
title: '费用',
dataIndex: 'cost_cent',
key: 'cost_cent',
width: 75,
sorter: (a, b) => a.cost_cent - b.cost_cent,
sortOrder: tableParams.sorter.field === 'cost_cent' ? tableParams.sorter.order : null,
render: (c: number) => (
<Text style={{ fontSize: 11 }}>¥{(c / 100).toFixed(2)}</Text>
),
},
{
title: '供应商',
dataIndex: 'provider_name',
key: 'provider_name',
width: 100,
ellipsis: true,
filters: providerFilters,
filteredValue: (tableParams.filters.provider_name as string[]) || null,
onFilter: (value, record) => record.provider_name === value,
filterSearch: true,
},
{
title: '帧率',
key: 'fps',
width: 70,
render: () => <Text type="secondary" style={{ fontSize: 11 }}>-</Text>,
},
{
title: '文件大小',
key: 'file_size',
width: 85,
render: () => <Text type="secondary" style={{ fontSize: 11 }}>-</Text>,
},
{
title: '标签',
dataIndex: 'tags',
key: 'tags',
width: 100,
ellipsis: true,
render: (tags: Array<string | null>) => {
const filtered = tags?.filter((t): t is string => t !== null) ?? [];
if (filtered.length === 0) return <Text type="secondary" style={{ fontSize: 11 }}>-</Text>;
return (
<Space size={2} wrap>
{filtered.slice(0, 2).map((t) => (
<Tag key={t} style={{ fontSize: 9, lineHeight: '14px', margin: 0 }}>{t}</Tag>
))}
{filtered.length > 2 && (
<Text type="secondary" style={{ fontSize: 9 }}>+{filtered.length - 2}</Text>
)}
</Space>
);
},
},
{
title: '错误信息',
dataIndex: 'error_message',
key: 'error_message',
width: 140,
ellipsis: true,
render: (msg: string) => msg
? <Text type="danger" style={{ fontSize: 11 }} title={msg}>{msg}</Text>
: <Text type="secondary" style={{ fontSize: 11 }}>-</Text>,
},
];
// 用户列:仅企业版可筛选
if (isEnterprise) {
cols.push({
title: '用户',
dataIndex: 'user_display_name',
key: 'user_id',
width: 100,
ellipsis: true,
filters: userFilters,
filteredValue: (tableParams.filters.user_id as string[]) || null,
onFilter: () => true,
render: (n: string | null) => (
<Text style={{ fontSize: 11 }}>{n || '-'}</Text>
),
});
} else {
cols.push({
title: '用户',
dataIndex: 'user_display_name',
key: 'user_id',
width: 100,
ellipsis: true,
render: (n: string | null) => (
<Text style={{ fontSize: 11 }}>{n || '-'}</Text>
),
});
}
return cols;
}, [
modelFilters,
providerFilters,
userFilters,
tableParams.filters,
tableParams.sorter,
isEnterprise,
]);
// 按 visibleColumns 过滤实际展示的列
const columns = useMemo(
() => allColumnsDef.filter((c) => visibleColumns.has(c.key as string)),
[allColumnsDef, visibleColumns],
);
// ════════════════════════════════════════════
// 表格高度自适应
// ════════════════════════════════════════════
const scrollX = isEnterprise ? 1350 : 1250;
const tableWrapperRef = useRef<HTMLDivElement>(null);
const hScrollRef = useRef<HTMLDivElement>(null);
const [tableBodyHeight, setTableBodyHeight] = useState(400);
useEffect(() => {
const wrapper = tableWrapperRef.current;
if (!wrapper) return;
const calcHeight = () => {
const thead = wrapper.querySelector('.ant-table-thead') as HTMLElement | null;
const theadH = thead?.offsetHeight ?? 0;
const available = wrapper.clientHeight - theadH;
setTableBodyHeight(Math.max(120, available));
};
const resizeObserver = new ResizeObserver(calcHeight);
resizeObserver.observe(wrapper);
const mutationObserver = new MutationObserver(calcHeight);
mutationObserver.observe(wrapper, { childList: true, subtree: true });
calcHeight();
return () => {
resizeObserver.disconnect();
mutationObserver.disconnect();
};
}, [taskPageSize]);
// ════════════════════════════════════════════
// 固定水平滚动条 ↔ 表格 body 双向同步
// ════════════════════════════════════════════
useEffect(() => {
const wrapper = tableWrapperRef.current;
const hScroll = hScrollRef.current;
if (!wrapper || !hScroll) return;
const body = wrapper.querySelector('.ant-table-body') as HTMLElement | null;
if (!body) return;
const syncBodyToHScroll = () => {
hScroll.scrollLeft = body.scrollLeft;
};
const syncHScrollToBody = () => {
body.scrollLeft = hScroll.scrollLeft;
};
body.addEventListener('scroll', syncBodyToHScroll);
hScroll.addEventListener('scroll', syncHScrollToBody);
syncBodyToHScroll();
const observer = new MutationObserver(syncBodyToHScroll);
observer.observe(body, { childList: true, subtree: true });
return () => {
body.removeEventListener('scroll', syncBodyToHScroll);
hScroll.removeEventListener('scroll', syncHScrollToBody);
observer.disconnect();
};
}, [polledTasks, tableBodyHeight]);
// ════════════════════════════════════════════
// 行样式 & 筛选状态
// ════════════════════════════════════════════
const rowClassName = useCallback(
(_record: TaskInfoItemResponseBody, index: number) => {
const base = index % 2 === 1 ? 'task-row-odd' : 'task-row-even';
return selectedTask?.id === _record.id ? `${base} task-row-selected` : base;
},
[selectedTask?.id],
);
const hasActiveFilters = Object.values(tableParams.filters).some(
(v) => Array.isArray(v) && v.length > 0,
);
return {
polledTasks,
loading,
errorMessage,
total,
columns,
tableCss,
scrollX,
tableWrapperRef,
hScrollRef,
tableParams,
tableBodyHeight,
handleTableChange,
rowClassName,
visibleColumns,
handleColumnToggle,
hasActiveFilters,
setTableParams,
};
}

View File

@@ -0,0 +1,39 @@
// ============================================================
// home/left — 首页左栏(模型选择 + 任务记录)
// ============================================================
// Views
export { LeftPanel } from './views/LeftPanel';
export { ModelSelector } from './views/ModelSelector';
export { TaskHistory } from './views/TaskHistory';
// Controllers
export {
getTaskListMenuItems,
buildTaskActions,
} from './controllers/menus';
export type { TaskListMenuOptions } from './controllers/menus';
export {
STATUS_FILTERS,
OUTPUT_TYPE_FILTERS,
STATUS_CONFIG,
ALL_COLUMNS,
resolveStatus,
readStoredColumns,
writeStoredColumns,
pickOne,
pickMany,
getVideoDuration,
formatDuration,
} from './controllers/task-table-config';
export type {
TableFilters,
TableSorter,
TableParams,
ColumnMeta,
} from './controllers/task-table-config';
// Hooks
export { useTaskTable } from './hooks/useTaskTable';
export type { UseTaskTableReturn } from './hooks/useTaskTable';

View File

@@ -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';
// ---------- 常量 ----------

View File

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

View File

@@ -0,0 +1,285 @@
// ============================================================
// TaskHistory — 任务记录列表(纯 View 层)
//
// 职责:
// - 右键菜单状态useState与 DOM 事件绑定
// - 使用 useTaskTable Hook 获取所有数据与配置
// - 纯 JSX 渲染Table + Pagination + Dropdown + 标题栏)
//
// 所有业务逻辑(筛选/排序/数据获取/列定义/滚动同步等)已提取到:
// - left/controllers/task-table-config.ts (常量 + 纯工具函数)
// - left/hooks/useTaskTable.ts (状态 + 副作用 + 派生数据)
// ============================================================
import { useState, useEffect, useCallback } from 'react';
import {
Table,
Tag,
Typography,
theme as antTheme,
Popover,
Checkbox,
Button,
Pagination,
Dropdown,
} from 'antd';
import { HistoryOutlined, SettingOutlined } from '@ant-design/icons';
import { useHomeContext } from '@/modules/home/shared/stores/HomeContext';
import { getTaskListMenuItems, buildTaskActions } from '@/modules/home/left';
import type { ContextMenuItems } from '@/shared/components/ContextMenu';
import type { TaskInfoItemResponseBody } from '@/services/modules/home/task';
import { useTaskTable } from '@/modules/home/left';
import { ALL_COLUMNS } from '@/modules/home/left';
import type { TableParams } from '@/modules/home/left';
import type { ModelsListResponseBody } from '@/services/modules/home/models';
const { Text } = Typography;
// ── 组件 Props ──
interface TaskHistoryProps {
/** 模型全量数据,由父组件 LeftPanel 传入 */
allModels: ModelsListResponseBody[] | null;
}
// ── 组件 ──
export function TaskHistory({ allModels }: TaskHistoryProps) {
const { token } = antTheme.useToken();
const {
setSelectedTask,
taskPage,
taskPageSize,
setTaskPage,
setTaskPageSize,
} = useHomeContext();
// ════════════════════════════════════════════
// 数据 & 配置Hook 驱动)
// ════════════════════════════════════════════
const {
polledTasks,
loading,
errorMessage,
total,
columns,
tableCss,
scrollX,
tableWrapperRef,
hScrollRef,
tableParams,
tableBodyHeight,
handleTableChange,
rowClassName,
visibleColumns,
handleColumnToggle,
hasActiveFilters,
setTableParams,
} = useTaskTable({ allModels });
// ════════════════════════════════════════════
// 右键菜单View 层职责:状态 + DOM 事件)
// ════════════════════════════════════════════
const [contextMenu, setContextMenu] = useState<{
record: TaskInfoItemResponseBody;
x: number;
y: number;
} | null>(null);
/** ContextMenuItems → AntD MenuProps['items'] */
const toAntdMenu = useCallback((items: ContextMenuItems) =>
items.map((item) => {
if (item === 'divider') return { type: 'divider' as const };
return {
key: item.key,
label: item.label,
onClick: item.onClick,
disabled: item.disabled,
danger: item.danger,
};
}), []);
/** 根据任务记录生成右键菜单项 */
const buildTaskMenuItems = useCallback((record: TaskInfoItemResponseBody) => {
return toAntdMenu(getTaskListMenuItems(buildTaskActions(record)));
}, [toAntdMenu]);
// 菜单打开时监听全局 mousedown → 点击菜单外区域自动关闭
useEffect(() => {
if (!contextMenu) return;
const handler = (e: MouseEvent) => {
const menuEl = document.querySelector('.ant-dropdown');
if (menuEl && !menuEl.contains(e.target as Node)) {
setContextMenu(null);
}
};
const timer = setTimeout(() => {
document.addEventListener('mousedown', handler, true);
}, 0);
return () => {
clearTimeout(timer);
document.removeEventListener('mousedown', handler, true);
};
}, [contextMenu]);
// ════════════════════════════════════════════
// 渲染
// ════════════════════════════════════════════
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
{/* ── 标题栏 ── */}
<div
style={{
padding: '8px 12px 8px 9px',
borderLeft: `3px solid ${token.colorPrimary}`,
background: token.colorFillSecondary,
display: 'flex',
alignItems: 'center',
gap: 6,
flexShrink: 0,
}}
>
<HistoryOutlined style={{ fontSize: 14, color: token.colorPrimary }} />
<Text strong style={{ fontSize: 13 }}></Text>
{total > 0 && (
<Text type="secondary" style={{ fontSize: 11 }}> {total} </Text>
)}
{hasActiveFilters && (
<Tag color="blue" style={{ fontSize: 10, lineHeight: '16px', padding: '0 4px' }}>
</Tag>
)}
{errorMessage && (
<Text type="danger" style={{ fontSize: 11, marginLeft: 'auto' }}>{errorMessage}</Text>
)}
{/* 列可见性控制 — 齿轮图标 */}
<Popover
trigger="click"
placement="bottomRight"
title="显示/隐藏列"
content={
<Checkbox.Group
value={[...visibleColumns]}
style={{ display: 'flex', flexDirection: 'column', gap: 2 }}
>
{ALL_COLUMNS.map((col) => (
<Checkbox
key={col.key}
value={col.key}
disabled={col.key === 'id'}
onChange={(e) => handleColumnToggle(col.key, e.target.checked)}
>
{col.title}
</Checkbox>
))}
</Checkbox.Group>
}
>
<Button
type="text"
size="small"
icon={<SettingOutlined style={{ fontSize: 13 }} />}
style={{ marginLeft: 'auto' }}
title="列设置"
/>
</Popover>
</div>
{/* ── 固定水平滚动条 ── */}
<div
ref={hScrollRef}
className="task-hscroll"
style={{
flexShrink: 0,
overflowX: 'auto',
overflowY: 'hidden',
height: 8,
}}
>
<div style={{ width: scrollX, height: 1 }} />
</div>
{/* ── 表格 ── */}
<div ref={tableWrapperRef} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
<style>{tableCss}</style>
<Table<TaskInfoItemResponseBody>
className="task-table"
columns={columns}
dataSource={polledTasks}
rowKey="id"
size="small"
loading={loading}
showHeader={true}
scroll={{ x: scrollX, y: tableBodyHeight }}
rowClassName={rowClassName}
onRow={(record) => ({
onClick: () => setSelectedTask(record),
onContextMenu: (e) => {
e.preventDefault();
setSelectedTask(record);
setContextMenu({ record, x: e.clientX, y: e.clientY });
},
style: { cursor: 'pointer' },
})}
onChange={handleTableChange}
pagination={false}
locale={{ emptyText: '暂无任务记录', filterReset: '重置', filterConfirm: '确定' }}
/>
</div>
{/* ── 分页器 ── */}
<div style={{
flexShrink: 0,
display: 'flex',
justifyContent: 'center',
padding: '4px 0 0',
borderTop: `1px solid ${token.colorBorderSecondary}`,
}}>
<Pagination
current={tableParams.pagination.current}
pageSize={tableParams.pagination.pageSize}
total={total}
size="small"
showSizeChanger
pageSizeOptions={[10, 20, 50]}
onChange={(page, pageSize) => {
if (page !== taskPage) setTaskPage(page);
if (pageSize !== taskPageSize) setTaskPageSize(pageSize);
setTableParams((prev: TableParams) => ({
...prev,
pagination: { current: page, pageSize },
}));
}}
/>
</div>
{/* ── 悬浮右键菜单 ── */}
<Dropdown
open={!!contextMenu}
onOpenChange={(open) => { if (!open) setContextMenu(null); }}
menu={{
items: contextMenu ? buildTaskMenuItems(contextMenu.record) : [],
onClick: () => setContextMenu(null),
}}
trigger={[]}
>
<div
onContextMenu={(e) => { e.preventDefault(); setContextMenu(null); }}
style={{
position: 'fixed',
left: contextMenu?.x ?? -9999,
top: contextMenu?.y ?? -9999,
width: 1,
height: 1,
}}
/>
</Dropdown>
</div>
);
}

View File

@@ -0,0 +1,6 @@
// ============================================================
// menus — 右栏右键菜单定义统一导出
// ============================================================
export { getPreviewImageMenu, getPreviewEmptyMenu } from './preview-area-menu';
export type { PreviewAreaMenuOptions } from './preview-area-menu';

View File

@@ -0,0 +1,14 @@
// ============================================================
// home/right — 首页右栏(输出预览)
// ============================================================
// Views
export { RightPanel } from './views/RightPanel';
export { OutputPreview } from './views/OutputPreview';
// Controllers
export {
getPreviewImageMenu,
getPreviewEmptyMenu,
} from './controllers/menus';
export type { PreviewAreaMenuOptions } from './controllers/menus';

View File

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

View File

@@ -0,0 +1,30 @@
// ============================================================
// home/shared — 首页跨栏共享(全局视图 + API + 状态)
// ============================================================
// Views
export { HomePage } from './views/HomePage';
export { HomeContent } from './views/HomeContent';
// Controllers (API)
export { TaskAPI, TaskStatusMap, OutputTypeMap, POLLING_STATUSES } from '@/services/modules/home/task';
export type {
TaskInfoItemResponseBody,
TaskListRequestParams,
TaskStatusString,
CreatTaskRequestBody,
} from '@/services/modules/home/task';
export { ModelAPI, MODEL_CATEGORIES, MODEL_CATEGORY_LABELS, CATEGORY_SLUG_MAP } from '@/services/modules/home/models';
export type {
ModelsListResponseBody,
ParamSchemaItem,
ModelCategoryStringEnum,
} from '@/services/modules/home/models';
export { fetchBanners } from '@/services/modules/home/banner';
export type { BannerListResponse } from '@/services/modules/home/banner';
// Stores
export { HomeContext, useHomeContext } from './stores/HomeContext';
export type { HomeContextValue } from './stores/HomeContext';

View File

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

View File

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

View File

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

View File

@@ -1,926 +0,0 @@
// ============================================================
// TaskHistory — 任务记录列表(分页 + 列头筛选 + 排序 + 滚动)
//
// 筛选策略(混合模式):
// - 服务端筛选(传 APIstatus、model_id、user_id企业版
// - 客户端筛选antd onFilteroutput_type、provider_name
// - 服务端不支持日期查询,时间范围筛选暂用 sorter 排序替代
// - 企业版才会在"用户"列显示筛选菜单
//
// 使用 antd Table onChange 统一管理 pagination + filters + sorter
// 参考 table-test.tsx 示例 4 的模式。
// ============================================================
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { Table, Tag, Typography, theme as antTheme, Popover, Checkbox, Button, Space, Pagination, Dropdown, message } from 'antd';
import type { ColumnsType, TableProps } from 'antd/es/table';
import type { FilterValue, SorterResult } from 'antd/es/table/interface';
import { HistoryOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons';
import { useTaskList, useTaskPolling } from '@/shared/hooks/use-api';
import type { ModelsListResponseBody } from '@/modules/home/controllers/model-api';
import { useAppContext } from '@/app/providers/AppProvider';
import { useHomeContext } from '@/modules/home/stores/HomeContext';
import { useTheme } from '@/app/providers/ThemeProvider';
import { on, off, EVENTS } from '@/shared/utils/bus';
import type {
TaskInfoItemResponseBody,
TaskListRequestParams,
TaskStatusString,
} from '@/modules/home/controllers/task-api';
import { TaskStatusMap, OutputTypeMap } from '@/modules/home/controllers/task-api';
import { getTaskListMenuItems } from '../../controllers/menus';
import type { ContextMenuItems } from '@/shared/components/ContextMenu';
const { Text } = Typography;
// ---------- 辅助类型 ----------
/** antd Table onChange 中 filters 的类型 */
type TableFilters = Record<string, FilterValue | null>;
/** antd Table onChange 中 sorter 的类型(单列排序) */
interface TableSorter {
field?: React.Key | readonly React.Key[];
order?: 'ascend' | 'descend';
}
/** 表格参数(缓存 antd onChange 的结果,用于构建 API 请求) */
interface TableParams {
pagination: { current: number; pageSize: number };
filters: TableFilters;
sorter: TableSorter;
}
// ---------- 常量 ----------
/** 状态筛选选项(来自 API 枚举) */
const STATUS_FILTERS = Object.entries(TaskStatusMap).map(([value, label]) => ({
text: label,
value,
}));
/** 输出类型筛选选项(从 OutputTypeMap 派生) */
const OUTPUT_TYPE_FILTERS = Object.entries(OutputTypeMap).map(([value, text]) => ({
text,
value,
}));
/** 状态标签配置(渲染用,含未来未知状态的降级处理) */
const STATUS_CONFIG: Record<string, { color: string; label: string }> = {
pending: { color: 'blue', label: '排队中' },
submitted: { color: 'cyan', label: '已提交' },
processing: { color: 'orange', label: '处理中' },
success: { color: 'green', label: '已完成' },
failed: { color: 'red', label: '失败' },
};
/** 未知状态降级:后端新增状态时自动回退显示 key 原文 */
function resolveStatus(s: string): { color: string; label: string } {
return STATUS_CONFIG[s] || { color: 'default', label: s };
}
// ---------- 列可见性持久化 ----------
/** localStorage 键名(遵循 ele-heixiu-* 规范) */
const COLUMN_VISIBILITY_KEY = 'ele-heixiu-table-columns';
/** 列元数据(定义所有可用列 + 默认可见性) */
interface ColumnMeta { key: string; title: string; defaultVisible: boolean }
const ALL_COLUMNS: ColumnMeta[] = [
{ key: 'id', title: '任务 ID', defaultVisible: true },
{ key: 'created_at', title: '创建时间', defaultVisible: true },
{ key: 'model_name', title: '模型', defaultVisible: true },
{ key: 'output_type', title: '输出类型', defaultVisible: true },
{ key: 'status', title: '状态', defaultVisible: true },
{ key: 'prompt', title: '提示词', defaultVisible: true },
{ key: 'duration', title: '时长', defaultVisible: true },
{ key: 'cost_cent', title: '费用', defaultVisible: true },
{ key: 'user_display_name', title: '用户', defaultVisible: true },
{ key: 'provider_name', title: '供应商', defaultVisible: false },
{ key: 'fps', title: '帧率', defaultVisible: false },
{ key: 'file_size', title: '文件大小', defaultVisible: false },
{ key: 'tags', title: '标签', defaultVisible: false },
{ key: 'error_message', title: '错误信息', defaultVisible: false },
];
function readStoredColumns(): Set<string> | null {
try {
const raw = localStorage.getItem(COLUMN_VISIBILITY_KEY);
if (raw) {
const arr: unknown = JSON.parse(raw);
if (Array.isArray(arr) && arr.every((v) => typeof v === 'string')) return new Set(arr);
}
} catch { /* ignore */ }
return null;
}
function writeStoredColumns(visible: Set<string>): void {
try { localStorage.setItem(COLUMN_VISIBILITY_KEY, JSON.stringify([...visible])); } catch { /* ignore */ }
}
// ---------- 工具函数 ----------
/** 从 filters 中提取单值筛选(如 status 单选) */
function pickOne(filters: TableFilters, key: string): string | undefined {
const v = filters[key];
if (Array.isArray(v) && v.length > 0) return v[0] as string;
return undefined;
}
/** 从 filters 中提取多值筛选(如 model_ids、user_ids */
function pickMany(filters: TableFilters, key: string): string[] | undefined {
const v = filters[key];
if (Array.isArray(v) && v.length > 0) return v as string[];
return undefined;
}
// ---------- 组件 ----------
interface TaskHistoryProps {
/** 模型全量数据,由父组件 LeftPanel 传入(避免兄弟组件各自请求) */
allModels: ModelsListResponseBody[] | null;
}
export function TaskHistory({ allModels }: TaskHistoryProps) {
const { token } = antTheme.useToken();
const { edition } = useAppContext();
const {
selectedTask,
setSelectedTask,
taskPage,
taskPageSize,
setTaskPage,
setTaskPageSize,
} = useHomeContext();
const isEnterprise = edition === 'enterprise';
// -------- 右键菜单 --------
const [contextMenu, setContextMenu] = useState<{
record: TaskInfoItemResponseBody;
x: number;
y: number;
} | null>(null);
/** ContextMenuItems → AntD MenuProps['items'] 转换 */
const toAntdMenu = useCallback((items: ContextMenuItems) =>
items.map((item) => {
if (item === 'divider') return { type: 'divider' as const };
return {
key: item.key,
label: item.label,
onClick: item.onClick,
disabled: item.disabled,
danger: item.danger,
};
}), []);
/** 根据任务记录生成右键菜单项 */
const buildTaskMenuItems = useCallback((record: TaskInfoItemResponseBody) => {
return toAntdMenu(getTaskListMenuItems({
canOpenOutput: record.status === 'success',
canReedit: true,
canRegen: true,
canPullResult: record.status === 'success',
canRedownload: record.status === 'success',
isFavorited: false,
onOpenOutputDir: () => { message.info('打开输出目录功能待实现'); },
onCopyResourceLink: () => {
const assets = record.output_assets;
if (assets && assets.length > 0) {
navigator.clipboard.writeText(assets[0].url).then(
() => message.success('已复制资源链接'),
() => message.error('复制失败'),
);
} else {
message.warning('该任务无输出资源');
}
},
onReedit: () => { message.info('重新编辑功能待实现'); },
onRegen: () => { message.info('再次生成功能待实现'); },
onPullResult: () => { message.info('拉取结果功能待实现'); },
onRedownload: () => { message.info('重新下载功能待实现'); },
onToggleFavorite: () => { message.info('收藏功能待实现'); },
onDelete: () => { message.info('删除记录功能待实现'); },
}));
}, [toAntdMenu, message]);
// 菜单打开时监听全局 mousedown → 点击菜单外区域自动关闭
useEffect(() => {
if (!contextMenu) return;
const handler = (e: MouseEvent) => {
const menuEl = document.querySelector('.ant-dropdown');
if (menuEl && !menuEl.contains(e.target as Node)) {
setContextMenu(null);
}
};
// setTimeout 0 避免捕获打开菜单的同一次右击 mousedown
const timer = setTimeout(() => {
document.addEventListener('mousedown', handler, true);
}, 0);
return () => {
clearTimeout(timer);
document.removeEventListener('mousedown', handler, true);
};
}, [contextMenu]);
// -------- 列可见性 --------
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(() => {
const stored = readStoredColumns();
if (stored) return stored;
return new Set(ALL_COLUMNS.filter((c) => c.defaultVisible).map((c) => c.key));
});
const handleColumnToggle = useCallback((key: string, checked: boolean) => {
setVisibleColumns((prev) => {
const next = new Set(prev);
if (checked) next.add(key); else next.delete(key);
writeStoredColumns(next);
return next;
});
}, []);
// -------- 主题感知斑马纹 / hover / 选中行 CSS --------
const { isDark } = useTheme();
const tableCss = useMemo(() => {
const searchIconColor = token.colorPrimary;
if (isDark) {
return `
.task-table .task-row-odd > td { background: rgba(255,255,255,0.02) !important; }
.task-table .task-row-even > td { background: transparent !important; }
.task-table .task-row-selected > td { background: ${token.blue}1A !important; }
.task-table .task-row-selected.task-row-odd > td { background: ${token.blue}22 !important; }
.task-table .ant-table-row:hover > td { background: rgba(255,255,255,0.05) !important; }
.task-table .task-row-selected:hover > td { background: ${token.blue}28 !important; }
.task-table .prompt-search-icon.filtered { color: ${searchIconColor} !important; }
.task-table .ant-table-body { overflow-x: hidden !important; }
.task-table .ant-table-thead > tr > th { font-weight: normal !important; }
/* 纵向滚动条(表格 body */
.task-table .ant-table-body::-webkit-scrollbar { width: 6px; }
.task-table .ant-table-body::-webkit-scrollbar-track { background: transparent; }
.task-table .ant-table-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 3px; }
.task-table .ant-table-body::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.35); }
/* 横向滚动条(固定面板) */
.task-hscroll::-webkit-scrollbar { height: 6px; }
.task-hscroll::-webkit-scrollbar-track { background: transparent; }
.task-hscroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 3px; }
.task-hscroll::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.35); }
`;
}
return `
.task-table .task-row-odd > td { background: #fafafa !important; }
.task-table .task-row-even > td { background: #ffffff !important; }
.task-table .task-row-selected > td { background: #e6f4ff !important; }
.task-table .task-row-selected.task-row-odd > td { background: #dceeff !important; }
.task-table .ant-table-row:hover > td { background: #f0f5ff !important; }
.task-table .task-row-selected:hover > td { background: #d6ebff !important; }
.task-table .prompt-search-icon.filtered { color: ${searchIconColor} !important; }
.task-table .ant-table-body { overflow-x: hidden !important; }
.task-table .ant-table-thead > tr > th { font-weight: normal !important; }
/* 纵向滚动条(表格 body */
.task-table .ant-table-body::-webkit-scrollbar { width: 6px; }
.task-table .ant-table-body::-webkit-scrollbar-track { background: transparent; }
.task-table .ant-table-body::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); border-radius: 3px; }
.task-table .ant-table-body::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.25); }
/* 横向滚动条(固定面板) */
.task-hscroll::-webkit-scrollbar { height: 6px; }
.task-hscroll::-webkit-scrollbar-track { background: transparent; }
.task-hscroll::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); border-radius: 3px; }
.task-hscroll::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.25); }
`;
}, [isDark, token.blue, token.colorPrimary]);
// -------- antd Table onChange 参数缓存 --------
const [tableParams, setTableParams] = useState<TableParams>({
pagination: { current: taskPage, pageSize: taskPageSize },
filters: {},
sorter: {},
});
// 同步 context 分页 ↔ tableParams
useEffect(() => {
setTableParams((prev:TableParams) => ({
...prev,
pagination: { current: taskPage, pageSize: taskPageSize },
}));
}, [taskPage, taskPageSize]);
// -------- 模型列表(用于模型列筛选选项 + 模型名→ID 映射,由父组件传入)--------
const modelFilters:{text:string, value:string}[] = useMemo(() => {
if (!allModels) return [];
return allModels.map((m) => ({ text: m.name, value: m.id }));
}, [allModels]);
// -------- 供应商列表(从模型数据中提取)--------
const providerFilters = useMemo(() => {
if (!allModels) return [];
const seen = new Set<string>();
allModels.forEach((m) => {
if (m.provider_name) seen.add(m.provider_name);
});
return Array.from(seen).sort().map((name) => ({ text: name, value: name }));
}, [allModels]);
// -------- 用户列表(从当前任务数据中提取,仅企业版使用)--------
// TODO: 替换为独立的用户列表 API
const [userFilters, setUserFilters] = useState<Array<{ text: string; value: string }>>([]);
// -------- 构建 API 请求参数 --------
const requestParams = useMemo<TaskListRequestParams>(() => {
const req: TaskListRequestParams = {
page: tableParams.pagination.current,
page_size: tableParams.pagination.pageSize,
};
const { filters } = tableParams;
// 服务端筛选字段
const status = pickOne(filters, 'status');
if (status) req.status = status as TaskStatusString;
const modelIds = pickMany(filters, 'model_name');
if (modelIds) req.model_ids = modelIds;
// user_ids仅企业版传入
if (isEnterprise) {
const userIds = pickMany(filters, 'user_id');
if (userIds) req.user_ids = userIds;
}
return req;
}, [tableParams, isEnterprise]);
// -------- 数据获取 --------
const [refreshTick, setRefreshTick] = useState(0);
const handleTaskSubmitted = useCallback(() => {
setRefreshTick((t) => t + 1);
}, []);
useEffect(() => {
on(EVENTS.TASK_SUBMITTED, handleTaskSubmitted);
return () => { off(EVENTS.TASK_SUBMITTED, handleTaskSubmitted); };
}, [handleTaskSubmitted]);
const {
data: taskData,
loading,
errorMessage,
} = useTaskList({
requestParams,
refreshSignal: refreshTick,
});
const tasks = taskData?.items ?? [];
// 批量轮询非终态任务状态
const handleTaskTerminal = useCallback(() => {
setRefreshTick((t) => t + 1);
}, []);
const polledTasks = useTaskPolling(tasks, handleTaskTerminal);
// 轮询更新后同步选中的任务到 Context
useEffect(() => {
if (!selectedTask || polledTasks.length === 0) return;
const updated = polledTasks.find(t => t.id === selectedTask.id);
if (updated && updated.status !== selectedTask.status) {
setSelectedTask(updated);
}
}, [polledTasks, selectedTask, setSelectedTask]);
const total = taskData?.total ?? 0;
// 从任务数据中提取用户列表(企业版筛选用)
useEffect(() => {
if (!isEnterprise || !taskData?.items) return;
const seen = new Set<string>();
taskData.items.forEach((t) => {
if (t.user_id && t.user_display_name) {
seen.add(JSON.stringify({ text: t.user_display_name, value: t.user_id }));
}
});
const newList = Array.from(seen).map((s) => JSON.parse(s) as { text: string; value: string });
setUserFilters((prev) => {
if (prev.length === newList.length) return prev; // 避免不必要的重渲染
return newList;
});
}, [isEnterprise, taskData]);
// -------- antd Table onChange --------
const handleTableChange: TableProps<TaskInfoItemResponseBody>['onChange'] = (
_pagination,
filters,
sorter,
) => {
// 缓存筛选和排序参数(分页由独立 Pagination 组件管理)
const s = sorter as SorterResult<TaskInfoItemResponseBody>;
setTableParams((prev) => ({
...prev,
filters: filters as TableFilters,
sorter: !Array.isArray(s) && s.field
? { field: s.field, order: s.order as 'ascend' | 'descend' | undefined }
: {},
}));
};
// -------- 表格列定义(含列头筛选/排序 + 可见性过滤)--------
//
// 列顺序:任务 ID(固定) → 创建时间 → 模型 → 输出类型 → 状态 → 提示词 →
// 时长(视频) → 费用 → 供应商 → 帧率/文件大小/标签/错误信息 → 用户(企业版)
//
// 可见性:通过标题栏齿轮图标控制,持久化到 localStorage
//
// ⚠️ 重要columns 依赖中不出 token.colorPrimary否则主题切换时 columns 重新生成
// 会导致 Table 内部状态重置(页码、筛选、排序等丢失)。
// 主题色通过 searchIconColorRef 传递,不触发 columns 重建。
/** 提示词搜索图标颜色(通过 CSS class 承载,避免 columns 依赖 token 导致主题切换重置状态) */
/** 提取视频时长(秒),非视频输出类型返回 null → 渲染 "-" */
function getVideoDuration(record: TaskInfoItemResponseBody): number | null {
if (record.output_type === 'video') {
return record.ui_params.duration ?? null;
}
return null;
}
/** 格式化时长:<60s 显示秒≥60s 显示分秒 */
function formatDuration(seconds: number): string {
if (seconds < 60) return `${seconds}`;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return s > 0 ? `${m}${s}` : `${m} 分钟`;
}
const allColumns = useMemo<ColumnsType<TaskInfoItemResponseBody>>(() => {
const cols: ColumnsType<TaskInfoItemResponseBody> = [
// ---- 任务 ID固定列 ----
{
title: '任务 ID',
dataIndex: 'id',
key: 'id',
width: 140,
ellipsis: true,
fixed: 'left',
render: (id: string) => (
<Text copyable={{ text: id }} style={{ fontSize: 12, fontFamily: 'monospace' }}>
{id.slice(0, 10)}...
</Text>
),
},
// ---- 创建时间 ----
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 155,
sorter: (a, b) => {
const da = a.created_at ? new Date(a.created_at).getTime() : 0;
const db = b.created_at ? new Date(b.created_at).getTime() : 0;
return da - db;
},
sortOrder: tableParams.sorter.field === 'created_at' ? tableParams.sorter.order : null,
render: (v: Date) => (
<Text style={{ fontSize: 11 }}>
{v ? new Date(v).toLocaleString('zh-CN', {
month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
}) : '-'}
</Text>
),
},
// ---- 模型 ----
{
title: '模型',
dataIndex: 'model_name',
key: 'model_name',
width: 130,
ellipsis: true,
filters: modelFilters,
filteredValue: (tableParams.filters.model_name as string[]) || null,
onFilter: () => true, // 服务端筛选
filterSearch: true,
filterMode: 'menu' as const,
},
// ---- 输出类型 ----
{
title: '输出类型',
dataIndex: 'output_type',
key: 'output_type',
width: 120,
filters: OUTPUT_TYPE_FILTERS,
filteredValue: (tableParams.filters.output_type as string[]) || null,
onFilter: (value, record) => record.output_type === value, // 客户端筛选
render: (t: string) => (
<Tag style={{ fontSize: 10 }}>
{OutputTypeMap[t as keyof typeof OutputTypeMap] || t}
</Tag>
),
},
// ---- 状态 ----
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 80,
filters: STATUS_FILTERS,
filteredValue: (tableParams.filters.status as string[]) || null,
onFilter: () => true, // 服务端筛选
render: (s: string) => {
const cfg = resolveStatus(s);
return <Tag color={cfg.color} style={{ fontSize: 10 }}>{cfg.label}</Tag>;
},
},
// ---- 提示词 ----
{
title: '提示词',
dataIndex: 'prompt',
key: 'prompt',
width: 160,
ellipsis: true,
filterIcon: (filtered: boolean) => (
<SearchOutlined className={`prompt-search-icon${filtered ? ' filtered' : ''}`} style={{ fontSize: 12 }} />
),
filterDropdown: undefined, // TODO: 后续加自定义搜索框
render: (p: string | undefined) => (
<Text style={{ fontSize: 11 }} title={p}>{p || '-'}</Text>
),
},
// ---- 时长(仅视频有数据,否则 "-" ----
{
title: '时长',
key: 'duration',
width: 75,
sorter: (a, b) => {
const da = getVideoDuration(a) ?? -1;
const db = getVideoDuration(b) ?? -1;
return da - db;
},
sortOrder: tableParams.sorter.field === 'duration' ? tableParams.sorter.order : null,
render: (_: unknown, record: TaskInfoItemResponseBody) => {
const sec = getVideoDuration(record);
return <Text style={{ fontSize: 11 }}>{sec !== null ? formatDuration(sec) : '-'}</Text>;
},
},
// ---- 费用 ----
{
title: '费用',
dataIndex: 'cost_cent',
key: 'cost_cent',
width: 75,
sorter: (a, b) => a.cost_cent - b.cost_cent,
sortOrder: tableParams.sorter.field === 'cost_cent' ? tableParams.sorter.order : null,
render: (c: number) => (
<Text style={{ fontSize: 11 }}>¥{(c / 100).toFixed(2)}</Text>
),
},
// ---- 供应商(默认隐藏) ----
{
title: '供应商',
dataIndex: 'provider_name',
key: 'provider_name',
width: 100,
ellipsis: true,
filters: providerFilters,
filteredValue: (tableParams.filters.provider_name as string[]) || null,
onFilter: (value, record) => record.provider_name === value, // 客户端筛选
filterSearch: true,
},
// ---- 帧率(占位列,待后端支持) ----
{
title: '帧率',
key: 'fps',
width: 70,
render: () => <Text type="secondary" style={{ fontSize: 11 }}>-</Text>,
},
// ---- 文件大小(占位列,待后端支持) ----
{
title: '文件大小',
key: 'file_size',
width: 85,
render: () => <Text type="secondary" style={{ fontSize: 11 }}>-</Text>,
},
// ---- 标签 ----
{
title: '标签',
dataIndex: 'tags',
key: 'tags',
width: 100,
ellipsis: true,
render: (tags: Array<string | null>) => {
const filtered = tags?.filter((t): t is string => t !== null) ?? [];
if (filtered.length === 0) return <Text type="secondary" style={{ fontSize: 11 }}>-</Text>;
return (
<Space size={2} wrap>
{filtered.slice(0, 2).map((t) => (
<Tag key={t} style={{ fontSize: 9, lineHeight: '14px', margin: 0 }}>{t}</Tag>
))}
{filtered.length > 2 && (
<Text type="secondary" style={{ fontSize: 9 }}>+{filtered.length - 2}</Text>
)}
</Space>
);
},
},
// ---- 错误信息(失败任务时展示) ----
{
title: '错误信息',
dataIndex: 'error_message',
key: 'error_message',
width: 140,
ellipsis: true,
render: (msg: string) => msg
? <Text type="danger" style={{ fontSize: 11 }} title={msg}>{msg}</Text>
: <Text type="secondary" style={{ fontSize: 11 }}>-</Text>,
},
];
// 用户列:仅企业版可筛选
if (isEnterprise) {
cols.push({
title: '用户',
dataIndex: 'user_display_name',
key: 'user_id',
width: 100,
ellipsis: true,
filters: userFilters,
filteredValue: (tableParams.filters.user_id as string[]) || null,
onFilter: () => true, // 服务端筛选(传 user_ids
render: (n: string | null) => (
<Text style={{ fontSize: 11 }}>{n || '-'}</Text>
),
});
} else {
cols.push({
title: '用户',
dataIndex: 'user_display_name',
key: 'user_id',
width: 100,
ellipsis: true,
render: (n: string | null) => (
<Text style={{ fontSize: 11 }}>{n || '-'}</Text>
),
});
}
return cols;
}, [
modelFilters,
providerFilters,
userFilters,
tableParams.filters,
tableParams.sorter,
isEnterprise,
]);
// 按 visibleColumns 过滤实际展示的列
const columns = useMemo(
() => allColumns.filter((c) => visibleColumns.has(c.key as string)),
[allColumns, visibleColumns],
);
// -------- 表格高度 --------
//
// 核心策略:分页器 + 水平滚动条均已独立到表格 wrapper 之外,
// 因此 scroll.y 使用 wrapper 完整高度(仅减去 thead
const scrollX = isEnterprise ? 1350 : 1250;
const tableWrapperRef = useRef<HTMLDivElement>(null);
const hScrollRef = useRef<HTMLDivElement>(null);
const [tableBodyHeight, setTableBodyHeight] = useState(400);
useEffect(() => {
const wrapper = tableWrapperRef.current;
if (!wrapper) return;
const calcHeight = () => {
const thead = wrapper.querySelector('.ant-table-thead') as HTMLElement | null;
const theadH = thead?.offsetHeight ?? 0;
const available = wrapper.clientHeight - theadH;
setTableBodyHeight(Math.max(120, available));
};
// ResizeObserver容器尺寸变化窗口拉伸、面板拖拽
const resizeObserver = new ResizeObserver(calcHeight);
resizeObserver.observe(wrapper);
// MutationObserver内部 DOM 变化thead 高度变化如筛选菜单弹出、
// 数据加载后水平滚动条出现等)
const mutationObserver = new MutationObserver(calcHeight);
mutationObserver.observe(wrapper, { childList: true, subtree: true });
calcHeight();
return () => {
resizeObserver.disconnect();
mutationObserver.disconnect();
};
}, [taskPageSize]);
// -------- 固定水平滚动条 ↔ 表格 body 双向同步 --------
//
// .ant-table-body 原生水平滚动条被 CSS 隐藏overflow-x: hidden
// 改为由独立的固定水平滚动条控制;两者通过 scroll 事件双向同步。
useEffect(() => {
const wrapper = tableWrapperRef.current;
const hScroll = hScrollRef.current;
if (!wrapper || !hScroll) return;
const body = wrapper.querySelector('.ant-table-body') as HTMLElement | null;
if (!body) return;
const syncBodyToHScroll = () => {
hScroll.scrollLeft = body.scrollLeft;
};
const syncHScrollToBody = () => {
body.scrollLeft = hScroll.scrollLeft;
};
body.addEventListener('scroll', syncBodyToHScroll);
hScroll.addEventListener('scroll', syncHScrollToBody);
syncBodyToHScroll();
// 数据变化(加载/筛选/翻页)后重同步位置
const observer = new MutationObserver(syncBodyToHScroll);
observer.observe(body, { childList: true, subtree: true });
return () => {
body.removeEventListener('scroll', syncBodyToHScroll);
hScroll.removeEventListener('scroll', syncHScrollToBody);
observer.disconnect();
};
}, [polledTasks, tableBodyHeight]);
// -------- 行样式:斑马纹 + 选中行 --------
const rowClassName = (_record: TaskInfoItemResponseBody, index: number) => {
const base = index % 2 === 1 ? 'task-row-odd' : 'task-row-even';
return selectedTask?.id === _record.id ? `${base} task-row-selected` : base;
};
// -------- 是否有活跃筛选 --------
const hasActiveFilters = Object.values(tableParams.filters).some(
(v) => Array.isArray(v) && v.length > 0,
);
// ======== 渲染 ========
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
{/* 标题栏 — 左侧色条 + 背景,视觉权重高于表格列头 */}
<div
style={{
padding: '8px 12px 8px 9px',
borderLeft: `3px solid ${token.colorPrimary}`,
background: token.colorFillSecondary,
display: 'flex',
alignItems: 'center',
gap: 6,
flexShrink: 0,
}}
>
<HistoryOutlined style={{ fontSize: 14, color: token.colorPrimary }} />
<Text strong style={{ fontSize: 13 }}></Text>
{total > 0 && (
<Text type="secondary" style={{ fontSize: 11 }}> {total} </Text>
)}
{hasActiveFilters && (
<Tag color="blue" style={{ fontSize: 10, lineHeight: '16px', padding: '0 4px' }}>
</Tag>
)}
{errorMessage && (
<Text type="danger" style={{ fontSize: 11, marginLeft: 'auto' }}>{errorMessage}</Text>
)}
{/* 列可见性控制 — 齿轮图标 */}
<Popover
trigger="click"
placement="bottomRight"
title="显示/隐藏列"
content={
<Checkbox.Group
value={[...visibleColumns]}
style={{ display: 'flex', flexDirection: 'column', gap: 2 }}
>
{ALL_COLUMNS.map((col) => (
<Checkbox
key={col.key}
value={col.key}
disabled={col.key === 'id'}
onChange={(e) => handleColumnToggle(col.key, e.target.checked)}
>
{col.title}
</Checkbox>
))}
</Checkbox.Group>
}
>
<Button
type="text"
size="small"
icon={<SettingOutlined style={{ fontSize: 13 }} />}
style={{ marginLeft: 'auto' }}
title="列设置"
/>
</Popover>
</div>
{/* 固定水平滚动条 — 在表格上方,始终可见,与表格 body 双向同步 */}
<div
ref={hScrollRef}
className="task-hscroll"
style={{
flexShrink: 0,
overflowX: 'auto',
overflowY: 'hidden',
height: 8,
}}
>
<div style={{ width: scrollX, height: 1 }} />
</div>
{/* 表格 — 纵向滚动由 scroll.y 控制,横向滚动由上方固定滚动条控制 */}
<div ref={tableWrapperRef} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
<style>{tableCss}</style>
<Table<TaskInfoItemResponseBody>
className="task-table"
columns={columns}
dataSource={polledTasks}
rowKey="id"
size="small"
loading={loading}
showHeader={true}
scroll={{ x: scrollX, y: tableBodyHeight }}
rowClassName={rowClassName}
onRow={(record) => ({
onClick: () => setSelectedTask(record),
onContextMenu: (e) => {
e.preventDefault();
setSelectedTask(record);
setContextMenu({ record, x: e.clientX, y: e.clientY });
},
style: { cursor: 'pointer' },
})}
onChange={handleTableChange}
pagination={false}
locale={{ emptyText: '暂无任务记录', filterReset: '重置', filterConfirm: '确定' }}
/>
</div>
{/* 独立分页器 — 固定在底部,不随表格数据量移动 */}
<div style={{ flexShrink: 0, display: 'flex', justifyContent: 'center', padding: '4px 0 0', borderTop: `1px solid ${token.colorBorderSecondary}` }}>
<Pagination
current={tableParams.pagination.current}
pageSize={tableParams.pagination.pageSize}
total={total}
size="small"
showSizeChanger
pageSizeOptions={[10, 20, 50]}
onChange={(page, pageSize) => {
if (page !== taskPage) setTaskPage(page);
if (pageSize !== taskPageSize) setTaskPageSize(pageSize);
setTableParams((prev) => ({
...prev,
pagination: { current: page, pageSize },
}));
}}
/>
</div>
{/* 悬浮右键菜单 — 手动控制 opentrigger=[] 避免 AntD 自动监听干扰 onRow.onContextMenu */}
<Dropdown
open={!!contextMenu}
onOpenChange={(open) => { if (!open) setContextMenu(null); }}
menu={{
items: contextMenu ? buildTaskMenuItems(contextMenu.record) : [],
onClick: () => setContextMenu(null),
}}
trigger={[]}
>
<div
onContextMenu={(e) => { e.preventDefault(); setContextMenu(null); }}
style={{
position: 'fixed',
left: contextMenu?.x ?? -9999,
top: contextMenu?.y ?? -9999,
width: 1,
height: 1,
}}
/>
</Dropdown>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { get, post } from '@/shared/services/http';
import { get, post } from '@/services/request';
export const PAYMENT_METHODS = {
Alipay: 'alipay',

View File

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

View File

@@ -3,7 +3,7 @@
// GET /api/v1/announcements
// ============================================================
import { get } from '@/shared/services/http';
import { get } from '@/services/request';
// ============================================================
// AnnouncementUrlObj — 公告模块路由常量

View File

@@ -1,4 +1,4 @@
import {post, get} from '@/shared/services/http';
import {post, get} from '@/services/request';
// ============================================================
// AuthUrlObj — 认证模块路由常量

View File

@@ -3,7 +3,7 @@
// GET /api/v1/banners
// ============================================================
import {get} from '@/shared/services/http';
import {get} from '@/services/request';
// ---------- 路由常量 ----------

View File

@@ -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 映射。

View File

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

View File

@@ -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';
// ---------- 配置 ----------

View File

@@ -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<Announcement[]>([]);
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 };
}

View File

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

View File

@@ -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';
// ---------- 错误信息映射 ----------

View File

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

View File

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

View File

@@ -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';
// ---------- 序列化 ----------

View File

@@ -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 → 前端标识反向映射

View File

@@ -0,0 +1,37 @@
// ============================================================
// path-join.ts — 渲染进程安全路径拼接
//
// Electron 渲染进程沙箱中不可用 Node path 模块。
// 此工具提供 path.join 等效功能,自动处理平台分隔符差异。
//
// Windows 上 Electron 的 shell.showItemInFolder 等 API 能自动
// 处理正斜杠路径(如 C:/Users/...),但拼接时仍应统一使用
// 平台正确的分隔符以避免边缘情况。
//
// 用法:
// import { joinPaths } from '@/shared/utils/path-join';
// const dir = joinPaths(dataDir, 'repository', userKey, 'outputs');
// ============================================================
/** 当前平台路径分隔符(渲染进程可用 navigator 判断) */
const SEP: string =
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win')
? '\\'
: '/';
/**
* 跨平台路径拼接(等效 Node path.join
*
* - 自动使用当前平台的路径分隔符
* - 去除各段首尾空格
* - 去除重复分隔符
*/
export function joinPaths(...segments: string[]): string {
return segments
.map((s) => s.trim())
.filter((s) => s.length > 0)
.join(SEP)
.replace(/[\\/]{2,}/g, SEP); // 去重连续分隔符
}
export default joinPaths;