Compare commits

...

2 Commits

Author SHA1 Message Date
136a17c0bf feat: 任务列表右键菜单回调 + 主进程下载 + sharp 缩略图 + IPC 精简重构
- 任务右键菜单:实现打开输出目录(inputs/outputs/.cache 结构)、
    复制资源链接、重新编辑、再次生成、拉取结果、重新下载、收藏、删除记录
  - 主进程下载:通过 Electron net 模块绕过 CORS,支持 CDN 媒体文件下载
  - sharp 缩略图:替换 nativeImage,支持 4K/8K+ 大尺寸素材的流式处理
  - IPC 精简:40+ 冗余通道 → 15 个分组通道,移除未使用的 preload 桥接方法
  - 任务存储:新增收藏字段、schema 迁移、task-store 持久化
  - 清理废弃模块:canvas-ipc / updater / request / pricing

refactor: IPC/Preload/Services 模块化拆分 — 单一职责 + 向后兼容

  主要变更:
  - shared/constants/ipc-channels.ts → ipc/ 模块 (base/storage/canvas/updater)
  - electron/preload.ts → preload/ 模块 (base/safe-storage/file-storage/canvas)
  - electron/main/ipc/canvas-ipc.ts → canvas/ 模块 (types/walk/sidecar/mime/thumbnail)
  - electron/main/ipc/storage-ipc.ts → 提取 utils/ (path/download/thumbnail)
  - electron/main/updater.ts → updater/ 模块 (provider/platform-updaters)
  - src/services/request.ts → request/ 模块 (token/interceptors/methods)
  - src/shared/utils/pricing.ts → pricing/ 模块 (types/helpers/format/calculate/fields)
  - src/modules/home/center/views/ModelInputForm.tsx → 提取 utils + DependentFormItem

  架构优化:
  - 所有拆分均保持向后兼容(旧导入路径仍可用)
  - TypeScript 编译通过 ✓
  - 每个模块单一职责,平均 ~130 行
2026-07-03 11:13:36 +08:00
904edecd90 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>
2026-06-30 13:39:38 +08:00
130 changed files with 5558 additions and 3688 deletions

View File

@@ -3,6 +3,30 @@
> 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。 > 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。
--- ---
## 0.0.292026-06-29
**IPC/Preload/Services 模块化拆分 — 单一职责 + 向后兼容**
### 模块化拆分
- **shared/constants/ipc**`ipc-channels.ts` (105行) → `ipc/` 模块 (base/storage/canvas/updater)
- **electron/preload**`preload.ts` (229行) → `preload/` 模块 (base/safe-storage/file-storage/canvas/app-runtime)
- **electron/main/ipc**
- `canvas-ipc.ts` (705行) → `canvas/` 模块 (types/walk/sidecar/mime/thumbnail/scan-roots)
- `storage-ipc.ts` (357行) → 提取 `utils/` (path/download/thumbnail)
- **electron/main/updater**`updater.ts` (479行) → `updater/` 模块 (provider/platform-updaters)
- **src/services/request**`request.ts` (479行) → `request/` 模块 (token/interceptors/methods)
- **src/shared/utils/pricing**`pricing.ts` (488行) → `pricing/` 模块 (types/helpers/format/calculate/fields)
- **ModelInputForm.tsx**:提取 `utils.ts` + `DependentFormItem.tsx` 子组件
### 架构优化
- 所有拆分均保持向后兼容(旧导入路径仍可用)
- 每个模块单一职责,平均 ~130 行
- TypeScript 编译零错误
---
## 0.0.282026-06-29 ## 0.0.282026-06-29
**项目架构重构VCHSM 五层模块化)+ 公告 Badge 提醒** **项目架构重构VCHSM 五层模块化)+ 公告 Badge 提醒**

View File

@@ -62,21 +62,41 @@
```md ```md
├── electron/ # Electron 主进程 + 预加载 ├── electron/ # Electron 主进程 + 预加载
│ ├── main.ts # 入口编排:窗口创建 / IPC 注册 / 生命周期 │ ├── main.ts # 入口编排:窗口创建 / IPC 注册 / 生命周期
│ ├── preload.ts # contextBridge向渲染进程暴露安全 API │ ├── preload.ts # contextBridge向渲染进程暴露安全 API(薄入口)
│ ├── electron-env.d.ts # 类型声明APP_ROOT、Window 扩展) │ ├── electron-env.d.ts # 类型声明APP_ROOT、Window 扩展)
│ └── main/ # 主进程核心模块 │ └── main/ # 主进程核心模块
│ │ ├── load-env.ts # .env.{mode} + .env.local 加载器 │ │ ├── load-env.ts # .env.{mode} + .env.local 加载器
│ │ ├── logger.ts # 日志核心JSON Lines / 每日轮转 / 批量冲刷 │ │ ├── logger.ts # 日志核心JSON Lines / 每日轮转 / 批量冲刷
│ │ ├── net-request.ts # Electron net.request 的 axios 风格封装 │ │ ├── net-request.ts # Electron net.request 的 axios 风格封装
│ │ ├── updater.ts # 自动更新:自定义 Provider + 平台 Updater │ │ ├── updater/ # 自动更新模块
│ │ │ ├── provider.ts # 自定义 Provider + 设备指纹 + 构建配置
│ │ │ ├── platform-updaters.ts # 平台特定 UpdaterWindows/macOS/Linux
│ │ │ └── index.ts # 初始化 + IPC 注册
│ │ ├── menu/index.ts # 系统菜单macOS Edit / Win 无菜单) │ │ ├── menu/index.ts # 系统菜单macOS Edit / Win 无菜单)
│ │ ├── ipc/ # IPC handler 集合 │ │ ├── ipc/ # IPC handler 集合
│ │ │ ├── log-ipc.ts # 渲染进程日志 → 主进程日志桥接 │ │ │ ├── log-ipc.ts # 渲染进程日志 → 主进程日志桥接
│ │ │ ├── safe-storage-ipc.ts # safeStorage 加密/解密 IPC handler │ │ │ ├── safe-storage-ipc.ts # safeStorage 加密/解密 IPC handler
│ │ │ ├── storage-ipc.ts # 沙箱文件存储 IPC handlerheixiu-data/ │ │ │ ├── storage-ipc.ts # 沙箱文件存储 IPC handlerheixiu-data/
│ │ │ ── canvas-ipc.ts # 画布文件操作 IPC handler │ │ │ ── utils/ # 存储通用工具
│ │ │ │ ├── path.ts # 路径安全解析resolveSafe / ensureDir
│ │ │ │ ├── download.ts # Electron net 文件下载(绕过 CORS
│ │ │ │ └── thumbnail.ts # sharp 缩略图生成(内存优化)
│ │ │ └── canvas/ # 画布文件操作模块
│ │ │ ├── types.ts # 类型定义StageType / ScanEntry / ThumbnailResult
│ │ │ ├── walk.ts # 递归目录遍历
│ │ │ ├── sidecar.ts # Sidecar JSON 元数据读写
│ │ │ ├── mime.ts # MIME 类型推断
│ │ │ ├── thumbnail.ts # 画布缩略图生成(缓存优先)
│ │ │ ├── scan-roots.ts # 扫描根目录计算 + 阶段过滤
│ │ │ └── index.ts # IPC handler 注册
│ │ └── utils/ # 平台判断 / 图标路径 │ │ └── utils/ # 平台判断 / 图标路径
│ └── preload/ # 预加载脚本contextBridge 桥接层) │ └── preload/ # 预加载脚本contextBridge 桥接层)
│ ├── base.ts # ipcRenderer 基础桥接on/off/send/invoke
│ ├── safe-storage.ts # safeStorage 加密/解密 API
│ ├── file-storage.ts # 文件存储 APIgetDataDir / readFile / writeFile
│ ├── canvas.ts # 画布 APIopenWindow / scanProject / readSidecar
│ ├── app-runtime.ts # 运行时 APIsetWindowEdition
│ ├── index.ts # 汇总导出exposeBaseIpc / exposeSafeStorage / ...
│ ├── device.ts # 硬件指纹采集 │ ├── device.ts # 硬件指纹采集
│ └── device_service.ts # 设备信息缓存层 │ └── device_service.ts # 设备信息缓存层
@@ -137,8 +157,25 @@
│ └── shared/ # ═══ 跨模块共享 ═══ │ └── shared/ # ═══ 跨模块共享 ═══
│ ├── components/ # 通用 UIFloatingPanel / FormField / LegalTextModal / ContextMenu │ ├── components/ # 通用 UIFloatingPanel / FormField / LegalTextModal / ContextMenu
│ ├── hooks/ # 全局 HooksuseAnnouncements / useApi / useAsync / useUpdater │ ├── hooks/ # 全局 HooksuseAnnouncements / useApi / useAsync / useUpdater
│ ├── services/ # HTTP 客户端 http.ts + API 类型 │ ├── services/ # HTTP 客户端 + API 模块
│ ├── utils/ # 工具函数bus / env / enum-resolver / display / pricing / safe-storage ├── request/ # Axios 请求封装
│ │ │ ├── token.ts # Token 管理(加密存储 + 内存缓存)
│ │ │ ├── interceptors.ts # 请求/响应拦截器401 刷新 + 错误处理)
│ │ │ ├── methods.ts # 请求方法get/post/put/del/upload/download
│ │ │ └── index.ts # 主入口 + 导出
│ │ └── modules/ # API 模块auth / home / account / announcement
│ ├── utils/ # 工具函数
│ │ ├── bus.ts # 事件总线emit / on / off / EVENTS
│ │ ├── pricing/ # 定价计算模块
│ │ │ ├── types.ts # 类型定义ModelPricingConfig / ModelDefinition
│ │ │ ├── helpers.ts # 内部工具asFloat / matchDimensions
│ │ │ ├── format.ts # 金额格式化formatYuan / formatCent
│ │ │ ├── calculate.ts # 计价逻辑6 种定价模式)
│ │ │ ├── fields.ts # 依赖字段收集
│ │ │ └── index.ts # 主入口 + 导出
│ │ ├── enum-resolver.ts # 枚举值解析
│ │ ├── display.ts # 显示工具
│ │ └── safe-storage.ts # 安全存储工具
│ ├── infrastructure/storage/ # 本地加密数据库sql.js + safeStorage │ ├── infrastructure/storage/ # 本地加密数据库sql.js + safeStorage
│ │ ├── core/ # db-core.ts / db-encrypt.ts / schema.ts │ │ ├── core/ # db-core.ts / db-encrypt.ts / schema.ts
│ │ ├── stores/ # task-store / order-store / media-store / settings-store │ │ ├── stores/ # task-store / order-store / media-store / settings-store
@@ -146,7 +183,16 @@
│ └── theme/ # 主题系统tokens / antd-theme / CSS 过渡) │ └── theme/ # 主题系统tokens / antd-theme / CSS 过渡)
├── shared/ # 主进程 & 渲染进程共享(根级 @shared 别名) ├── shared/ # 主进程 & 渲染进程共享(根级 @shared 别名)
│ ├── constants/ # app.ts / ipc-channels.ts / version.ts / keyboard.ts │ ├── constants/ # 应用常量
│ │ ├── app.ts # 应用名称 / 窗口标题
│ │ ├── version.ts # 版本号
│ │ ├── keyboard.ts # 快捷键映射
│ │ └── ipc/ # IPC 通道常量(模块化)
│ │ ├── base.ts # 基础通道(窗口/日志/主题/配置)
│ │ ├── storage.ts # 存储通道(文件/加密/下载/缩略图)
│ │ ├── canvas.ts # 画布通道(扫描/sidecar/拖拽)
│ │ ├── updater.ts # 更新通道(检查/下载/安装)
│ │ └── index.ts # 汇总导出 + 兼容性 BIDIRECTIONAL
│ ├── types/ # index.tsPlatform/Theme/Edition/ logging.ts │ ├── types/ # index.tsPlatform/Theme/Edition/ logging.ts
│ └── utils/ # sanitize.ts日志脱敏+ index.ts通用工具 │ └── utils/ # sanitize.ts日志脱敏+ index.ts通用工具

View File

@@ -13,8 +13,8 @@ import {initLogger, flushLogger, logger, setLogDirResolver} from './main/logger'
import {registerLogIpcHandlers} from './main/ipc/log-ipc'; import {registerLogIpcHandlers} from './main/ipc/log-ipc';
import {registerSafeStorageIpcHandlers} from './main/ipc/safe-storage-ipc'; import {registerSafeStorageIpcHandlers} from './main/ipc/safe-storage-ipc';
import {registerStorageIpcHandlers, setDataDirResolver} from './main/ipc/storage-ipc'; import {registerStorageIpcHandlers, setDataDirResolver} from './main/ipc/storage-ipc';
import {registerCanvasIpcHandlers} from './main/ipc/canvas-ipc'; import {registerCanvasIpcHandlers} from './main/ipc/canvas';
import {BIDIRECTIONAL, RENDERER_TO_MAIN, MAIN_TO_RENDERER} from '../shared/constants/ipc-channels'; import {BIDIRECTIONAL, RENDERER_TO_MAIN, MAIN_TO_RENDERER} from '../shared/constants/ipc';
createRequire(import.meta.url); createRequire(import.meta.url);
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));

View File

@@ -1,705 +0,0 @@
// ============================================================
// canvas-ipc.ts — 无限画布 IPC 处理器(主进程)
//
// 职责:
// - 目录扫描fs.readdirSync + 递归遍历)
// - sidecar JSON 读写
// - 缩略图生成sharp 可选依赖)
// - 文件复制(导入操作)
// ============================================================
import { ipcMain } from 'electron';
import fs from 'node:fs';
import path from 'node:path';
import { BIDIRECTIONAL } from '../../../shared/constants/ipc-channels';
import { logger } from '../logger';
import sharp from "sharp";
// ---------- 类型 ----------
type StageType = 'Storyboard' | 'Keyframe' | 'Video' | 'Audio' | 'LipSync' | 'Assets';
type AssetTypeFilter = '全部' | '角色' | '道具' | '场景';
// ---------- 扫描算法 ----------
/** 递归遍历目录,收集所有匹配扩展名的文件 */
function walkDirectory(
root: string,
extensions: string[],
recursive: boolean,
): Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }> {
const results: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }> = [];
const extSet = new Set(extensions.map((e) => e.toLowerCase()));
function walk(dir: string) {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return; // 权限不足等,跳过该目录
}
for (const entry of entries) {
// 跳过隐藏文件和文件夹
if (entry.name.startsWith('.')) continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (recursive) walk(fullPath);
} else if (entry.isFile()) {
const ext = path.extname(entry.name).slice(1).toLowerCase();
if (extSet.has(ext)) {
try {
const stat = fs.statSync(fullPath);
results.push({
path: fullPath,
fileName: entry.name,
ext,
fileSize: stat.size,
modifiedAt: stat.mtime.toISOString(),
});
} catch {
// 文件不可访问,跳过
}
}
}
}
}
walk(root);
return results;
}
// ---------- Sidecar 操作 ----------
/** 生成 sidecar JSON 文件路径:同目录、同名、.json 扩展名 */
function sidecarPathForMedia(filePath: string): string {
const dir = path.dirname(filePath);
const baseName = path.basename(filePath, path.extname(filePath));
return path.join(dir, `${baseName}.json`);
}
/** 读取旁加载元数据,文件不存在返回 null */
function readSidecarFile(filePath: string): object | null {
const sidecarPath = sidecarPathForMedia(filePath);
if (!fs.existsSync(sidecarPath)) return null;
try {
const content = fs.readFileSync(sidecarPath, 'utf-8');
return JSON.parse(content);
} catch {
return null;
}
}
/** 写入旁加载元数据(自动创建目录) */
function writeSidecarFile(filePath: string, metadata: object): void {
const sidecarPath = sidecarPathForMedia(filePath);
const dir = path.dirname(sidecarPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(sidecarPath, JSON.stringify(metadata, null, 2), 'utf-8');
}
// ---------- MIME 类型推断 ----------
function getMimeType(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
const mimeMap: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.bmp': 'image/bmp',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mkv': 'video/x-matroska',
'.mov': 'video/quicktime',
'.avi': 'video/x-msvideo',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
'.flac': 'audio/flac',
'.aac': 'audio/aac',
'.m4a': 'audio/mp4',
};
return mimeMap[ext] || 'application/octet-stream';
}
// ---------- 缩略图生成 ----------
/**
* 尝试使用 sharp 库生成缩略图。
*
* 优先级:
* 1. .cache/{filename}.webp — 预生成 WEBP 缩略图(最快)
* 2. .cache/{filename} — 预生成 PNG 缩略图
* 3. sharp — 实时缩放(可选依赖)
* 4. 原始文件字节 — 浏览器端缩放回退
*/
async function generateThumbnailImpl(
filePath: string,
size: number,
): Promise<{ data: string; mimeType: string; width: number; height: number }> {
const dir = path.dirname(filePath);
const baseName = path.basename(filePath);
const cacheDir = path.join(dir, '.cache');
// ── 优先级 1.cache/{filename}.webp ──
const cachedWebp = path.join(cacheDir, `${baseName}.webp`);
if (fs.existsSync(cachedWebp)) {
const buffer = fs.readFileSync(cachedWebp);
return {
data: buffer.toString('base64'),
mimeType: 'image/webp',
width: 0,
height: 0,
};
}
// ── 优先级 2.cache/{filename}PNG 副本) ──
const cachedPng = path.join(cacheDir, baseName);
if (fs.existsSync(cachedPng)) {
const buffer = fs.readFileSync(cachedPng);
return {
data: buffer.toString('base64'),
mimeType: 'image/png',
width: 0,
height: 0,
};
}
if (sharp) {
const image = sharp(filePath);
const metadata = await image.metadata();
const resized = await image
.resize(size, size, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 85 })
.toBuffer();
return {
data: resized.toString('base64'),
mimeType: 'image/jpeg',
width: metadata.width || 0,
height: metadata.height || 0,
};
}
// ── 优先级 4回退原始文件 ──
const buffer = fs.readFileSync(filePath);
const mimeType = getMimeType(filePath);
return {
data: buffer.toString('base64'),
mimeType,
width: 0,
height: 0,
};
}
// ---------- 扫描根目录计算(移自主进程,因渲染进程沙箱无 fs 权限) ----------
/** 阶段 → 允许的扩展名 */
function getStageExtensions(stage: StageType): string[] {
switch (stage) {
case 'Storyboard':
case 'Keyframe':
return ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif'];
case 'Video':
return ['mp4', 'webm', 'mov', 'avi', 'mkv'];
case 'Audio':
case 'LipSync':
return ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'];
case 'Assets':
return ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'mp4', 'webm'];
}
}
/** 镜头名匹配查询 */
function shotNameMatchesQuery(shotName: string, query: string): boolean {
if (!query) return true;
const upperShot = shotName.toUpperCase();
const upperQuery = query.toUpperCase();
if (upperShot.includes(upperQuery)) return true;
if (/^\d+$/.test(query)) {
const digits = upperShot.replace(/\D/g, '');
if (digits.includes(query)) return true;
}
return false;
}
/** 计算扫描根目录列表 */
function buildScanRoots(params: {
projectPath: string;
stage: StageType;
assetTypeFilter: AssetTypeFilter;
shotQuery: string;
}): string[] {
const { projectPath, stage, assetTypeFilter, shotQuery } = params;
// ── 诊断:检查 projectPath 本身是否存在 ──
const projectExists = fs.existsSync(projectPath);
logger.info('canvas', 'buildScanRoots 诊断 — projectPath', {
projectPath,
exists: projectExists,
});
if (!projectExists) {
logger.warn('canvas', '⚠️ 项目路径不可访问', undefined, { projectPath });
// 尝试列出父目录内容以便排查
const parent = path.dirname(projectPath);
try {
const siblings = fs.readdirSync(parent).filter((n) => !n.startsWith('.'));
logger.info('canvas', `父目录 "${parent}" 内容(前 20 项)`, {
count: siblings.length,
items: siblings.slice(0, 20),
});
} catch {
logger.warn('canvas', `无法读取父目录 "${parent}"`);
}
return [];
}
// ── 诊断:列出项目根目录内容 ──
let projectEntries: string[] = [];
try {
projectEntries = fs.readdirSync(projectPath).filter((n) => !n.startsWith('.'));
} catch {
/* ignore */
}
logger.info('canvas', 'buildScanRoots 诊断 — 项目根目录内容(前 30 项)', {
count: projectEntries.length,
items: projectEntries.slice(0, 30),
});
if (stage === 'Assets') {
const assetsDir = path.join(projectPath, 'assets');
logger.info('canvas', 'buildScanRoots — Assets 模式', {
assetsDir,
exists: fs.existsSync(assetsDir),
assetTypeFilter,
});
switch (assetTypeFilter) {
case '角色': return [path.join(assetsDir, 'chr')];
case '道具': return [path.join(assetsDir, 'prp')];
case '场景': return [path.join(assetsDir, 'set')];
default: return [assetsDir];
}
}
const shotsDir = path.join(projectPath, 'shots');
const shotsExists = fs.existsSync(shotsDir);
logger.info('canvas', 'buildScanRoots 诊断 — shotsDir', {
shotsDir,
exists: shotsExists,
});
if (!shotsExists) {
// 列出可能存在的子目录帮助用户对比
const dirsInRoot = projectEntries.filter((name) => {
try {
return fs.statSync(path.join(projectPath, name)).isDirectory();
} catch {
return false;
}
});
logger.warn('canvas', '⚠️ shots 目录不存在,项目根下的子目录:', undefined, {
subdirs: dirsInRoot,
});
return [];
}
const roots: string[] = [];
let episodes: string[];
try {
episodes = fs.readdirSync(shotsDir).filter((name) => {
const full = path.join(shotsDir, name);
return !name.startsWith('.') && fs.statSync(full).isDirectory();
});
} catch (err) {
logger.warn('canvas', '无法读取 shots 目录', err instanceof Error ? err : undefined, {
error: (err as Error).message,
});
return [];
}
logger.info('canvas', `buildScanRoots — 发现 ${episodes.length} 个剧集目录`, {
episodes,
});
for (const ep of episodes) {
const epDir = path.join(shotsDir, ep);
let shotNames: string[];
try {
shotNames = fs.readdirSync(epDir).filter((name) => {
const full = path.join(epDir, name);
return !name.startsWith('.') && fs.statSync(full).isDirectory();
});
} catch (err) {
logger.warn(
'canvas',
`无法读取剧集目录 "${ep}"`,
err instanceof Error ? err : undefined,
{ error: (err as Error).message },
);
continue;
}
logger.info('canvas', ` 📂 ${ep}${shotNames.length} 个镜头`, {
shots: shotNames.slice(0, 20),
});
for (const shot of shotNames) {
if (shotQuery && !shotNameMatchesQuery(shot, shotQuery)) continue;
const stageDir = path.join(epDir, shot, stage);
const stageExists = fs.existsSync(stageDir);
if (stageExists) {
roots.push(stageDir);
}
// 仅在前几个镜头时记录阶段目录存在性
if (roots.length <= 3 && !stageExists) {
logger.info('canvas', ` 阶段目录不存在: ${stageDir}`);
}
}
}
logger.info('canvas', `buildScanRoots 最终结果:${roots.length} 个扫描根`, {
roots: roots.slice(0, 10),
stage,
});
return roots;
}
// ---------- 注册 ----------
export function registerCanvasIpcHandlers(): void {
// ---- 扫描项目(主进程内部 buildScanRoots + walkDirectory ----
ipcMain.handle(
BIDIRECTIONAL.CANVAS_SCAN_PROJECT,
async (
_event,
params: {
projectPath: string;
stage: StageType;
assetTypeFilter: AssetTypeFilter;
shotQuery: string;
},
) => {
try {
logger.info('canvas', '收到 CANVAS_SCAN_PROJECT', params);
const roots = buildScanRoots(params);
const extensions = getStageExtensions(params.stage);
logger.info('canvas', `buildScanRoots 结果:${roots.length} 个扫描根`, {
roots: roots.length <= 10 ? roots : roots.slice(0, 10).concat(`... 共 ${roots.length}`),
extensions,
stage: params.stage,
});
if (roots.length === 0) {
logger.warn('canvas', '扫描根目录为空', undefined, {
projectPath: params.projectPath,
stage: params.stage,
shotQuery: params.shotQuery || '(无)',
});
return { success: true, data: [] };
}
const allResults: Array<{
path: string;
fileName: string;
ext: string;
fileSize: number;
modifiedAt: string;
}> = [];
for (const root of roots) {
const results = walkDirectory(root, extensions, true);
if (results.length > 0) {
logger.info('canvas', ` 📁 ${root}${results.length} 个文件`);
}
allResults.push(...results);
}
logger.info(
'canvas',
`扫描项目完成:${allResults.length} 个文件(${roots.length} 个扫描根目录,阶段:${params.stage}`,
);
return { success: true, data: allResults };
} catch (err) {
const msg = (err as Error).message;
logger.warn('canvas', '项目扫描失败', err instanceof Error ? err : undefined);
return { success: false, error: msg };
}
},
);
// ---- 目录扫描(保留低级 API外部提供扫描根 ----
// ---- 目录扫描 ----
ipcMain.handle(
BIDIRECTIONAL.CANVAS_SCAN_DIRECTORY,
async (
_event,
params: { roots: string[]; extensions: string[]; recursive: boolean },
) => {
try {
const allResults: Array<{
path: string;
fileName: string;
ext: string;
fileSize: number;
modifiedAt: string;
}> = [];
for (const root of params.roots) {
if (!fs.existsSync(root)) {
logger.warn('canvas', `扫描根目录不存在,跳过:${root}`);
continue;
}
const results = walkDirectory(root, params.extensions, params.recursive);
allResults.push(...results);
}
logger.info(
'canvas',
`扫描完成:${allResults.length} 个文件(${params.roots.length} 个根目录)`,
);
return { success: true, data: allResults };
} catch (err) {
const msg = (err as Error).message;
logger.warn('canvas', '目录扫描失败', err instanceof Error ? err : undefined);
return { success: false, error: msg };
}
},
);
// ---- 读取 sidecar ----
ipcMain.handle(
BIDIRECTIONAL.CANVAS_READ_SIDECAR,
async (_event, params: { filePath: string }) => {
try {
const data = readSidecarFile(params.filePath);
return { success: true, data };
} catch (err) {
logger.warn('canvas', '读取 sidecar 失败', err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
},
);
// ---- 写入 sidecar ----
ipcMain.handle(
BIDIRECTIONAL.CANVAS_WRITE_SIDECAR,
async (_event, params: { filePath: string; metadata: object }) => {
try {
writeSidecarFile(params.filePath, params.metadata);
return { success: true };
} catch (err) {
logger.warn('canvas', '写入 sidecar 失败', err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
},
);
// ---- 生成缩略图 ----
ipcMain.handle(
BIDIRECTIONAL.CANVAS_GENERATE_THUMBNAIL,
async (_event, params: { filePath: string; size: number }) => {
try {
const result = await generateThumbnailImpl(params.filePath, params.size);
return { success: true, data: result };
} catch (err) {
logger.warn('canvas', '生成缩略图失败', err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
},
);
// ---- 复制文件 ----
ipcMain.handle(
BIDIRECTIONAL.CANVAS_COPY_FILE,
async (_event, params: { src: string; destDir: string; fileName: string }) => {
try {
if (!fs.existsSync(params.destDir)) {
fs.mkdirSync(params.destDir, { recursive: true });
}
const dest = path.join(params.destDir, params.fileName);
fs.copyFileSync(params.src, dest);
return { success: true, data: dest };
} catch (err) {
logger.warn('canvas', '复制文件失败', err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
},
);
// ---- 获取图片尺寸(使用 sharp 或 image-size ----
ipcMain.handle(
BIDIRECTIONAL.CANVAS_GET_IMAGE_SIZE,
async (_event, params: { filePath: string }) => {
try {
if (sharp) {
const metadata = await sharp(params.filePath).metadata();
return {
success: true,
data: {
width: metadata.width || 0,
height: metadata.height || 0,
},
};
}
return { success: false, error: 'sharp 未安装,无法获取图片尺寸' };
} catch (err) {
logger.warn('canvas', '获取图片尺寸失败', err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
},
);
// ---- 列出子文件夹(非递归,排除隐藏) ----
ipcMain.handle(
BIDIRECTIONAL.CANVAS_LIST_SUBDIRS,
async (_event, params: { parentPath: string }) => {
try {
if (!fs.existsSync(params.parentPath)) {
logger.info('canvas', `listSubdirs: 路径不存在,返回空数组 — ${params.parentPath}`);
return { success: true, data: [] };
}
const entries = fs.readdirSync(params.parentPath, { withFileTypes: true });
const dirs = entries
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
.map((e) => e.name);
logger.info('canvas', `listSubdirs: ${dirs.length} 个子文件夹 — ${params.parentPath}`);
return { success: true, data: dirs };
} catch (err) {
logger.warn('canvas', 'listSubdirs 失败', err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
},
);
// ---- 扫描阶段文件 ----
ipcMain.handle(
BIDIRECTIONAL.CANVAS_SCAN_STAGE_FILES,
async (
_event,
params: {
projectPath: string;
episode: string;
shot: string;
stage: string;
},
) => {
try {
const stageDir = path.join(
params.projectPath,
'shots',
params.episode,
params.shot,
params.stage,
);
logger.info('canvas', `scanStageFiles: ${stageDir}`);
if (!fs.existsSync(stageDir)) {
logger.info('canvas', `scanStageFiles: 阶段目录不存在 — ${stageDir}`);
return { success: true, data: [] };
}
const extensions = getStageExtensions(params.stage as StageType);
const results = walkDirectory(stageDir, extensions, true);
logger.info('canvas', `scanStageFiles: ${results.length} 个文件 — ${stageDir}`);
return { success: true, data: results };
} catch (err) {
logger.warn('canvas', 'scanStageFiles 失败', err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
},
);
// ---- 扫描资产文件(深度递归) ----
ipcMain.handle(
BIDIRECTIONAL.CANVAS_SCAN_ASSET_FILES,
async (
_event,
params: { projectPath: string; assetTypeFilter: string },
) => {
try {
let assetsDir: string;
switch (params.assetTypeFilter) {
case '角色':
assetsDir = path.join(params.projectPath, 'assets', 'chr');
break;
case '道具':
assetsDir = path.join(params.projectPath, 'assets', 'prp');
break;
case '场景':
assetsDir = path.join(params.projectPath, 'assets', 'set');
break;
default:
assetsDir = path.join(params.projectPath, 'assets');
}
logger.info('canvas', `scanAssetFiles: ${assetsDir} (filter=${params.assetTypeFilter})`);
if (!fs.existsSync(assetsDir)) {
logger.info('canvas', `scanAssetFiles: 资产目录不存在 — ${assetsDir}`);
return { success: true, data: [] };
}
// 资产深度递归:图片 + 视频
const extensions = ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'mp4', 'webm'];
const results = walkDirectory(assetsDir, extensions, true);
logger.info('canvas', `scanAssetFiles: ${results.length} 个文件 — ${assetsDir}`);
return { success: true, data: results };
} catch (err) {
logger.warn('canvas', 'scanAssetFiles 失败', err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
},
);
// ---- 创建目录 ----
ipcMain.handle(
BIDIRECTIONAL.CANVAS_CREATE_DIRECTORY,
async (_event, params: { parentPath: string; dirName: string }) => {
try {
// 安全校验:拒绝包含危险字符的名称
const dangerous = /[/\\:*?"<>|]/;
if (!params.dirName || dangerous.test(params.dirName)) {
return {
success: false,
error: `无效的目录名: "${params.dirName}"`,
};
}
const fullPath = path.join(params.parentPath, params.dirName);
if (fs.existsSync(fullPath)) {
return { success: false, error: `目录已存在: ${fullPath}` };
}
fs.mkdirSync(fullPath);
logger.info('canvas', `createDirectory: ${fullPath}`);
return { success: true, data: fullPath };
} catch (err) {
logger.warn('canvas', 'createDirectory 失败', err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
},
);
// ---- 启动原生文件拖拽(拖放到 Photoshop/Explorer 等外部应用) ----
ipcMain.handle(
BIDIRECTIONAL.CANVAS_START_DRAG,
async (_event, params: { filePath: string; fileName: string }) => {
try {
if (!fs.existsSync(params.filePath)) {
logger.warn('canvas', `startDrag: 文件不存在 — ${params.filePath}`);
return { success: false, error: '文件不存在' };
}
_event.sender.startDrag({
file: params.filePath,
icon: params.filePath,
});
logger.info('canvas', `startDrag: 已启动 — ${params.fileName}`);
return { success: true };
} catch (err) {
const msg = (err as Error).message;
logger.warn('canvas', 'startDrag 失败', err instanceof Error ? err : undefined);
return { success: false, error: msg };
}
},
);
logger.info('canvas', '画布 IPC 处理器已注册(完整实现)');
}

View File

@@ -0,0 +1,352 @@
// ============================================================
// canvas/index.ts — 画布 IPC 处理器注册
//
// 职责:
// - 注册所有画布相关的 IPC 处理器
// - 组合使用各工具模块实现业务逻辑
// ============================================================
import { ipcMain } from 'electron';
import fs from 'node:fs';
import path from 'node:path';
import sharp from 'sharp';
import { BIDIRECTIONAL_CANVAS } from '../../../../shared/constants/ipc/canvas';
import { logger } from '../../logger';
import { walkDirectory } from './walk';
import { readSidecarFile, writeSidecarFile } from './sidecar';
import { generateCanvasThumbnail } from './thumbnail';
import { buildScanRoots, getStageExtensions } from './scan-roots';
import type { StageType, AssetTypeFilter } from './types';
/** 注册画布 IPC 处理器 */
export function registerCanvasIpcHandlers(): void {
// ---- 扫描项目(主进程内部 buildScanRoots + walkDirectory ----
ipcMain.handle(
BIDIRECTIONAL_CANVAS.CANVAS_SCAN_PROJECT,
async (
_event,
params: {
projectPath: string;
stage: StageType;
assetTypeFilter: AssetTypeFilter;
shotQuery: string;
},
) => {
try {
logger.info('canvas', '收到 CANVAS_SCAN_PROJECT', params);
const roots = buildScanRoots(params);
const extensions = getStageExtensions(params.stage);
logger.info('canvas', `buildScanRoots 结果:${roots.length} 个扫描根`, {
roots: roots.length <= 10 ? roots : roots.slice(0, 10).concat(`... 共 ${roots.length}`),
extensions,
stage: params.stage,
});
if (roots.length === 0) {
logger.warn('canvas', '扫描根目录为空', undefined, {
projectPath: params.projectPath,
stage: params.stage,
shotQuery: params.shotQuery || '(无)',
});
return { success: true, data: [] };
}
const allResults: Array<{
path: string;
fileName: string;
ext: string;
fileSize: number;
modifiedAt: string;
}> = [];
for (const root of roots) {
const results = walkDirectory(root, extensions, true);
if (results.length > 0) {
logger.info('canvas', ` 📁 ${root}${results.length} 个文件`);
}
allResults.push(...results);
}
logger.info(
'canvas',
`扫描项目完成:${allResults.length} 个文件(${roots.length} 个扫描根目录,阶段:${params.stage}`,
);
return { success: true, data: allResults };
} catch (err) {
const msg = (err as Error).message;
logger.warn('canvas', '项目扫描失败', err instanceof Error ? err : undefined);
return { success: false, error: msg };
}
},
);
// ---- 目录扫描(保留低级 API外部提供扫描根 ----
ipcMain.handle(
BIDIRECTIONAL_CANVAS.CANVAS_SCAN_DIRECTORY,
async (
_event,
params: { roots: string[]; extensions: string[]; recursive: boolean },
) => {
try {
const allResults: Array<{
path: string;
fileName: string;
ext: string;
fileSize: number;
modifiedAt: string;
}> = [];
for (const root of params.roots) {
if (!fs.existsSync(root)) {
logger.warn('canvas', `扫描根目录不存在,跳过:${root}`);
continue;
}
const results = walkDirectory(root, params.extensions, params.recursive);
allResults.push(...results);
}
logger.info(
'canvas',
`扫描完成:${allResults.length} 个文件(${params.roots.length} 个根目录)`,
);
return { success: true, data: allResults };
} catch (err) {
const msg = (err as Error).message;
logger.warn('canvas', '目录扫描失败', err instanceof Error ? err : undefined);
return { success: false, error: msg };
}
},
);
// ---- 读取 sidecar ----
ipcMain.handle(
BIDIRECTIONAL_CANVAS.CANVAS_READ_SIDECAR,
async (_event, params: { filePath: string }) => {
try {
const data = readSidecarFile(params.filePath);
return { success: true, data };
} catch (err) {
logger.warn('canvas', '读取 sidecar 失败', err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
},
);
// ---- 写入 sidecar ----
ipcMain.handle(
BIDIRECTIONAL_CANVAS.CANVAS_WRITE_SIDECAR,
async (_event, params: { filePath: string; metadata: object }) => {
try {
writeSidecarFile(params.filePath, params.metadata);
return { success: true };
} catch (err) {
logger.warn('canvas', '写入 sidecar 失败', err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
},
);
// ---- 生成缩略图 ----
ipcMain.handle(
BIDIRECTIONAL_CANVAS.CANVAS_GENERATE_THUMBNAIL,
async (_event, params: { filePath: string; size: number }) => {
try {
const result = await generateCanvasThumbnail(params.filePath, params.size);
return { success: true, data: result };
} catch (err) {
logger.warn('canvas', '生成缩略图失败', err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
},
);
// ---- 复制文件 ----
ipcMain.handle(
BIDIRECTIONAL_CANVAS.CANVAS_COPY_FILE,
async (_event, params: { src: string; destDir: string; fileName: string }) => {
try {
if (!fs.existsSync(params.destDir)) {
fs.mkdirSync(params.destDir, { recursive: true });
}
const dest = path.join(params.destDir, params.fileName);
fs.copyFileSync(params.src, dest);
return { success: true, data: dest };
} catch (err) {
logger.warn('canvas', '复制文件失败', err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
},
);
// ---- 获取图片尺寸(使用 sharp ----
ipcMain.handle(
BIDIRECTIONAL_CANVAS.CANVAS_GET_IMAGE_SIZE,
async (_event, params: { filePath: string }) => {
try {
if (sharp) {
const metadata = await sharp(params.filePath).metadata();
return {
success: true,
data: {
width: metadata.width || 0,
height: metadata.height || 0,
},
};
}
return { success: false, error: 'sharp 未安装,无法获取图片尺寸' };
} catch (err) {
logger.warn('canvas', '获取图片尺寸失败', err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
},
);
// ---- 列出子文件夹(非递归,排除隐藏) ----
ipcMain.handle(
BIDIRECTIONAL_CANVAS.CANVAS_LIST_SUBDIRS,
async (_event, params: { parentPath: string }) => {
try {
if (!fs.existsSync(params.parentPath)) {
logger.info('canvas', `listSubdirs: 路径不存在,返回空数组 — ${params.parentPath}`);
return { success: true, data: [] };
}
const entries = fs.readdirSync(params.parentPath, { withFileTypes: true });
const dirs = entries
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
.map((e) => e.name);
logger.info('canvas', `listSubdirs: ${dirs.length} 个子文件夹 — ${params.parentPath}`);
return { success: true, data: dirs };
} catch (err) {
logger.warn('canvas', 'listSubdirs 失败', err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
},
);
// ---- 扫描阶段文件 ----
ipcMain.handle(
BIDIRECTIONAL_CANVAS.CANVAS_SCAN_STAGE_FILES,
async (
_event,
params: {
projectPath: string;
episode: string;
shot: string;
stage: string;
},
) => {
try {
const stageDir = path.join(
params.projectPath,
'shots',
params.episode,
params.shot,
params.stage,
);
logger.info('canvas', `scanStageFiles: ${stageDir}`);
if (!fs.existsSync(stageDir)) {
logger.info('canvas', `scanStageFiles: 阶段目录不存在 — ${stageDir}`);
return { success: true, data: [] };
}
const extensions = getStageExtensions(params.stage as StageType);
const results = walkDirectory(stageDir, extensions, true);
logger.info('canvas', `scanStageFiles: ${results.length} 个文件 — ${stageDir}`);
return { success: true, data: results };
} catch (err) {
logger.warn('canvas', 'scanStageFiles 失败', err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
},
);
// ---- 扫描资产文件(深度递归) ----
ipcMain.handle(
BIDIRECTIONAL_CANVAS.CANVAS_SCAN_ASSET_FILES,
async (
_event,
params: { projectPath: string; assetTypeFilter: string },
) => {
try {
let assetsDir: string;
switch (params.assetTypeFilter) {
case '角色':
assetsDir = path.join(params.projectPath, 'assets', 'chr');
break;
case '道具':
assetsDir = path.join(params.projectPath, 'assets', 'prp');
break;
case '场景':
assetsDir = path.join(params.projectPath, 'assets', 'set');
break;
default:
assetsDir = path.join(params.projectPath, 'assets');
}
logger.info('canvas', `scanAssetFiles: ${assetsDir} (filter=${params.assetTypeFilter})`);
if (!fs.existsSync(assetsDir)) {
logger.info('canvas', `scanAssetFiles: 资产目录不存在 — ${assetsDir}`);
return { success: true, data: [] };
}
// 资产深度递归:图片 + 视频
const extensions = ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'mp4', 'webm'];
const results = walkDirectory(assetsDir, extensions, true);
logger.info('canvas', `scanAssetFiles: ${results.length} 个文件 — ${assetsDir}`);
return { success: true, data: results };
} catch (err) {
logger.warn('canvas', 'scanAssetFiles 失败', err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
},
);
// ---- 创建目录 ----
ipcMain.handle(
BIDIRECTIONAL_CANVAS.CANVAS_CREATE_DIRECTORY,
async (_event, params: { parentPath: string; dirName: string }) => {
try {
// 安全校验:拒绝包含危险字符的名称
const dangerous = /[/\\:*?"<>|]/;
if (!params.dirName || dangerous.test(params.dirName)) {
return {
success: false,
error: `无效的目录名: "${params.dirName}"`,
};
}
const fullPath = path.join(params.parentPath, params.dirName);
if (fs.existsSync(fullPath)) {
return { success: false, error: `目录已存在: ${fullPath}` };
}
fs.mkdirSync(fullPath);
logger.info('canvas', `createDirectory: ${fullPath}`);
return { success: true, data: fullPath };
} catch (err) {
logger.warn('canvas', 'createDirectory 失败', err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
},
);
// ---- 启动原生文件拖拽(拖放到 Photoshop/Explorer 等外部应用) ----
ipcMain.handle(
BIDIRECTIONAL_CANVAS.CANVAS_START_DRAG,
async (_event, params: { filePath: string; fileName: string }) => {
try {
if (!fs.existsSync(params.filePath)) {
logger.warn('canvas', `startDrag: 文件不存在 — ${params.filePath}`);
return { success: false, error: '文件不存在' };
}
_event.sender.startDrag({
file: params.filePath,
icon: params.filePath,
});
logger.info('canvas', `startDrag: 已启动 — ${params.fileName}`);
return { success: true };
} catch (err) {
const msg = (err as Error).message;
logger.warn('canvas', 'startDrag 失败', err instanceof Error ? err : undefined);
return { success: false, error: msg };
}
},
);
logger.info('canvas', '画布 IPC 处理器已注册(完整实现)');
}

View File

@@ -0,0 +1,36 @@
// ============================================================
// canvas/mime.ts — MIME 类型推断
// ============================================================
import path from 'node:path';
/** MIME 类型映射表 */
const MIME_MAP: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.bmp': 'image/bmp',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mkv': 'video/x-matroska',
'.mov': 'video/quicktime',
'.avi': 'video/x-msvideo',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
'.flac': 'audio/flac',
'.aac': 'audio/aac',
'.m4a': 'audio/mp4',
};
/**
* 根据文件扩展名推断 MIME 类型。
* @param filePath - 文件路径
* @returns MIME 类型字符串,未知类型返回 'application/octet-stream'
*/
export function getMimeType(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
return MIME_MAP[ext] || 'application/octet-stream';
}

View File

@@ -0,0 +1,189 @@
// ============================================================
// canvas/scan-roots.ts — 扫描根目录计算
//
// 职责:
// - 根据项目路径、阶段、资产类型计算扫描根目录列表
// - 支持 shots 和 assets 两种目录结构
// ============================================================
import fs from 'node:fs';
import path from 'node:path';
import { logger } from '../../logger';
import type { StageType, AssetTypeFilter } from './types';
/** 阶段 → 允许的扩展名 */
export function getStageExtensions(stage: StageType): string[] {
switch (stage) {
case 'Storyboard':
case 'Keyframe':
return ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif'];
case 'Video':
return ['mp4', 'webm', 'mov', 'avi', 'mkv'];
case 'Audio':
case 'LipSync':
return ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'];
case 'Assets':
return ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'mp4', 'webm'];
}
}
/** 镜头名匹配查询 */
export function shotNameMatchesQuery(shotName: string, query: string): boolean {
if (!query) return true;
const upperShot = shotName.toUpperCase();
const upperQuery = query.toUpperCase();
if (upperShot.includes(upperQuery)) return true;
if (/^\d+$/.test(query)) {
const digits = upperShot.replace(/\D/g, '');
if (digits.includes(query)) return true;
}
return false;
}
/**
* 计算扫描根目录列表。
*
* @param params - 扫描参数
* @returns 扫描根目录路径列表
*/
export function buildScanRoots(params: {
projectPath: string;
stage: StageType;
assetTypeFilter: AssetTypeFilter;
shotQuery: string;
}): string[] {
const { projectPath, stage, assetTypeFilter, shotQuery } = params;
// ── 诊断:检查 projectPath 本身是否存在 ──
const projectExists = fs.existsSync(projectPath);
logger.info('canvas', 'buildScanRoots 诊断 — projectPath', {
projectPath,
exists: projectExists,
});
if (!projectExists) {
logger.warn('canvas', '⚠️ 项目路径不可访问', undefined, { projectPath });
// 尝试列出父目录内容以便排查
const parent = path.dirname(projectPath);
try {
const siblings = fs.readdirSync(parent).filter((n) => !n.startsWith('.'));
logger.info('canvas', `父目录 "${parent}" 内容(前 20 项)`, {
count: siblings.length,
items: siblings.slice(0, 20),
});
} catch {
logger.warn('canvas', `无法读取父目录 "${parent}"`);
}
return [];
}
// ── 诊断:列出项目根目录内容 ──
let projectEntries: string[] = [];
try {
projectEntries = fs.readdirSync(projectPath).filter((n) => !n.startsWith('.'));
} catch {
/* ignore */
}
logger.info('canvas', 'buildScanRoots 诊断 — 项目根目录内容(前 30 项)', {
count: projectEntries.length,
items: projectEntries.slice(0, 30),
});
if (stage === 'Assets') {
const assetsDir = path.join(projectPath, 'assets');
logger.info('canvas', 'buildScanRoots — Assets 模式', {
assetsDir,
exists: fs.existsSync(assetsDir),
assetTypeFilter,
});
switch (assetTypeFilter) {
case '角色': return [path.join(assetsDir, 'chr')];
case '道具': return [path.join(assetsDir, 'prp')];
case '场景': return [path.join(assetsDir, 'set')];
default: return [assetsDir];
}
}
const shotsDir = path.join(projectPath, 'shots');
const shotsExists = fs.existsSync(shotsDir);
logger.info('canvas', 'buildScanRoots 诊断 — shotsDir', {
shotsDir,
exists: shotsExists,
});
if (!shotsExists) {
// 列出可能存在的子目录帮助用户对比
const dirsInRoot = projectEntries.filter((name) => {
try {
return fs.statSync(path.join(projectPath, name)).isDirectory();
} catch {
return false;
}
});
logger.warn('canvas', '⚠️ shots 目录不存在,项目根下的子目录:', undefined, {
subdirs: dirsInRoot,
});
return [];
}
const roots: string[] = [];
let episodes: string[];
try {
episodes = fs.readdirSync(shotsDir).filter((name) => {
const full = path.join(shotsDir, name);
return !name.startsWith('.') && fs.statSync(full).isDirectory();
});
} catch (err) {
logger.warn('canvas', '无法读取 shots 目录', err instanceof Error ? err : undefined, {
error: (err as Error).message,
});
return [];
}
logger.info('canvas', `buildScanRoots — 发现 ${episodes.length} 个剧集目录`, {
episodes,
});
for (const ep of episodes) {
const epDir = path.join(shotsDir, ep);
let shotNames: string[];
try {
shotNames = fs.readdirSync(epDir).filter((name) => {
const full = path.join(epDir, name);
return !name.startsWith('.') && fs.statSync(full).isDirectory();
});
} catch (err) {
logger.warn(
'canvas',
`无法读取剧集目录 "${ep}"`,
err instanceof Error ? err : undefined,
{ error: (err as Error).message },
);
continue;
}
logger.info('canvas', ` 📂 ${ep}${shotNames.length} 个镜头`, {
shots: shotNames.slice(0, 20),
});
for (const shot of shotNames) {
if (shotQuery && !shotNameMatchesQuery(shot, shotQuery)) continue;
const stageDir = path.join(epDir, shot, stage);
const stageExists = fs.existsSync(stageDir);
if (stageExists) {
roots.push(stageDir);
}
// 仅在前几个镜头时记录阶段目录存在性
if (roots.length <= 3 && !stageExists) {
logger.info('canvas', ` 阶段目录不存在: ${stageDir}`);
}
}
}
logger.info('canvas', `buildScanRoots 最终结果:${roots.length} 个扫描根`, {
roots: roots.slice(0, 10),
stage,
});
return roots;
}

View File

@@ -0,0 +1,47 @@
// ============================================================
// canvas/sidecar.ts — Sidecar 元数据操作
//
// 职责:
// - 读取/写入旁加载 JSON 元数据文件
// - sidecar 文件与媒体文件同目录、同名、.json 扩展名
// ============================================================
import fs from 'node:fs';
import path from 'node:path';
/** 生成 sidecar JSON 文件路径:同目录、同名、.json 扩展名 */
export function sidecarPathForMedia(filePath: string): string {
const dir = path.dirname(filePath);
const baseName = path.basename(filePath, path.extname(filePath));
return path.join(dir, `${baseName}.json`);
}
/**
* 读取旁加载元数据。
* @param filePath - 媒体文件路径
* @returns 元数据对象,文件不存在返回 null
*/
export function readSidecarFile(filePath: string): object | null {
const sidecarPath = sidecarPathForMedia(filePath);
if (!fs.existsSync(sidecarPath)) return null;
try {
const content = fs.readFileSync(sidecarPath, 'utf-8');
return JSON.parse(content);
} catch {
return null;
}
}
/**
* 写入旁加载元数据(自动创建目录)。
* @param filePath - 媒体文件路径
* @param metadata - 元数据对象
*/
export function writeSidecarFile(filePath: string, metadata: object): void {
const sidecarPath = sidecarPathForMedia(filePath);
const dir = path.dirname(sidecarPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(sidecarPath, JSON.stringify(metadata, null, 2), 'utf-8');
}

View File

@@ -0,0 +1,84 @@
// ============================================================
// canvas/thumbnail.ts — 画布缩略图生成
//
// 职责:
// - 生成画布专用缩略图(返回 base64 数据)
// - 支持缓存优先策略(.cache 目录)
//
// 优先级:
// 1. .cache/{filename}.webp — 预生成 WEBP 缩略图(最快)
// 2. .cache/{filename} — 预生成 PNG 缩略图
// 3. sharp — 实时缩放(可选依赖)
// 4. 原始文件字节 — 浏览器端缩放回退
// ============================================================
import fs from 'node:fs';
import path from 'node:path';
import sharp from 'sharp';
import { getMimeType } from './mime';
import type { ThumbnailResult } from './types';
/**
* 生成画布缩略图。
*
* @param filePath - 源文件路径
* @param size - 缩略图尺寸
* @returns 缩略图数据base64
*/
export async function generateCanvasThumbnail(
filePath: string,
size: number,
): Promise<ThumbnailResult> {
const dir = path.dirname(filePath);
const baseName = path.basename(filePath);
const cacheDir = path.join(dir, '.cache');
// ── 优先级 1.cache/{filename}.webp ──
const cachedWebp = path.join(cacheDir, `${baseName}.webp`);
if (fs.existsSync(cachedWebp)) {
const buffer = fs.readFileSync(cachedWebp);
return {
data: buffer.toString('base64'),
mimeType: 'image/webp',
width: 0,
height: 0,
};
}
// ── 优先级 2.cache/{filename}PNG 副本) ──
const cachedPng = path.join(cacheDir, baseName);
if (fs.existsSync(cachedPng)) {
const buffer = fs.readFileSync(cachedPng);
return {
data: buffer.toString('base64'),
mimeType: 'image/png',
width: 0,
height: 0,
};
}
if (sharp) {
const image = sharp(filePath);
const metadata = await image.metadata();
const resized = await image
.resize(size, size, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 85 })
.toBuffer();
return {
data: resized.toString('base64'),
mimeType: 'image/jpeg',
width: metadata.width || 0,
height: metadata.height || 0,
};
}
// ── 优先级 4回退原始文件 ──
const buffer = fs.readFileSync(filePath);
const mimeType = getMimeType(filePath);
return {
data: buffer.toString('base64'),
mimeType,
width: 0,
height: 0,
};
}

View File

@@ -0,0 +1,26 @@
// ============================================================
// canvas/types.ts — 画布相关类型定义
// ============================================================
/** 阶段类型 */
export type StageType = 'Storyboard' | 'Keyframe' | 'Video' | 'Audio' | 'LipSync' | 'Assets';
/** 资产类型过滤器 */
export type AssetTypeFilter = '全部' | '角色' | '道具' | '场景';
/** 扫描结果条目 */
export interface ScanEntry {
path: string;
fileName: string;
ext: string;
fileSize: number;
modifiedAt: string;
}
/** 缩略图结果 */
export interface ThumbnailResult {
data: string;
mimeType: string;
width: number;
height: number;
}

View File

@@ -0,0 +1,66 @@
// ============================================================
// canvas/walk.ts — 目录遍历算法
//
// 职责:
// - 递归遍历目录,收集所有匹配扩展名的文件
// - 跳过隐藏文件和文件夹
// ============================================================
import fs from 'node:fs';
import path from 'node:path';
import type { ScanEntry } from './types';
/**
* 递归遍历目录,收集所有匹配扩展名的文件。
*
* @param root - 根目录路径
* @param extensions - 允许的文件扩展名列表(不含点号)
* @param recursive - 是否递归遍历子目录
* @returns 文件列表
*/
export function walkDirectory(
root: string,
extensions: string[],
recursive: boolean,
): ScanEntry[] {
const results: ScanEntry[] = [];
const extSet = new Set(extensions.map((e) => e.toLowerCase()));
function walk(dir: string) {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return; // 权限不足等,跳过该目录
}
for (const entry of entries) {
// 跳过隐藏文件和文件夹
if (entry.name.startsWith('.')) continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (recursive) walk(fullPath);
} else if (entry.isFile()) {
const ext = path.extname(entry.name).slice(1).toLowerCase();
if (extSet.has(ext)) {
try {
const stat = fs.statSync(fullPath);
results.push({
path: fullPath,
fileName: entry.name,
ext,
fileSize: stat.size,
modifiedAt: stat.mtime.toISOString(),
});
} catch {
// 文件不可访问,跳过
}
}
}
}
}
walk(root);
return results;
}

View File

@@ -3,7 +3,7 @@
// ============================================================ // ============================================================
import { ipcMain } from 'electron'; import { ipcMain } from 'electron';
import { RENDERER_TO_MAIN } from '../../../shared/constants/ipc-channels'; import { RENDERER_TO_MAIN } from '../../../shared/constants/ipc/base';
import { writeRendererLog } from '../logger'; import { writeRendererLog } from '../logger';
import type { LogEntry } from '../../../shared/types/logging'; import type { LogEntry } from '../../../shared/types/logging';

View File

@@ -16,7 +16,7 @@
// ============================================================ // ============================================================
import { ipcMain, safeStorage } from 'electron'; import { ipcMain, safeStorage } from 'electron';
import { BIDIRECTIONAL } from '../../../shared/constants/ipc-channels'; import { BIDIRECTIONAL_STORAGE } from '../../../shared/constants/ipc/storage';
import { logger } from '../logger'; import { logger } from '../logger';
/** 检查 safeStorage 是否可用,不可用时记录警告 */ /** 检查 safeStorage 是否可用,不可用时记录警告 */
@@ -34,7 +34,7 @@ function checkAvailability(): boolean {
*/ */
export function registerSafeStorageIpcHandlers(): void { export function registerSafeStorageIpcHandlers(): void {
// ---------- 加密 ---------- // ---------- 加密 ----------
ipcMain.handle(BIDIRECTIONAL.SAFESTORAGE_ENCRYPT, (_event, plaintext: string) => { ipcMain.handle(BIDIRECTIONAL_STORAGE.SAFESTORAGE_ENCRYPT, (_event, plaintext: string) => {
if (typeof plaintext !== 'string' || plaintext.length === 0) { if (typeof plaintext !== 'string' || plaintext.length === 0) {
logger.warn('safe-storage', '加密参数无效'); logger.warn('safe-storage', '加密参数无效');
return { success: false, error: '参数无效:需要非空字符串' }; return { success: false, error: '参数无效:需要非空字符串' };
@@ -56,7 +56,7 @@ export function registerSafeStorageIpcHandlers(): void {
}); });
// ---------- 解密 ---------- // ---------- 解密 ----------
ipcMain.handle(BIDIRECTIONAL.SAFESTORAGE_DECRYPT, (_event, encryptedBase64: string) => { ipcMain.handle(BIDIRECTIONAL_STORAGE.SAFESTORAGE_DECRYPT, (_event, encryptedBase64: string) => {
if (typeof encryptedBase64 !== 'string' || encryptedBase64.length === 0) { if (typeof encryptedBase64 !== 'string' || encryptedBase64.length === 0) {
logger.warn('safe-storage', '解密参数无效'); logger.warn('safe-storage', '解密参数无效');
return { success: false, error: '参数无效:需要非空字符串' }; return { success: false, error: '参数无效:需要非空字符串' };

View File

@@ -16,8 +16,9 @@
import { ipcMain, shell, app } from 'electron'; import { ipcMain, shell, app } from 'electron';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { BIDIRECTIONAL } from '../../../shared/constants/ipc-channels'; import { BIDIRECTIONAL_STORAGE } from '../../../shared/constants/ipc/storage';
import { logger } from '../logger'; import { logger } from '../logger';
import { resolveSafe, ensureDir, downloadToFile, generateThumbnail } from './utils';
// ---------- 内部工具 ---------- // ---------- 内部工具 ----------
@@ -33,25 +34,6 @@ function getDataDir(): string {
return dataDirResolver ? dataDirResolver() : path.join(app.getPath('userData'), 'heixiu-data'); return dataDirResolver ? dataDirResolver() : path.join(app.getPath('userData'), 'heixiu-data');
} }
/**
* 将相对路径解析为绝对路径,并校验是否在 dataDir 内。
* 防止路径穿越攻击。
*/
function resolveSafe(dataDir: string, relativePath: string): string {
const resolved = path.resolve(dataDir, relativePath);
if (!resolved.startsWith(dataDir + path.sep) && resolved !== dataDir) {
throw new Error(`路径越界:${relativePath}`);
}
return resolved;
}
/** 确保目录存在(递归创建) */
function ensureDir(dirPath: string): void {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
// ---------- 注册 IPC 处理器 ---------- // ---------- 注册 IPC 处理器 ----------
export function registerStorageIpcHandlers(): void { export function registerStorageIpcHandlers(): void {
@@ -60,28 +42,24 @@ export function registerStorageIpcHandlers(): void {
logger.info('app', `本地存储目录:${dataDir}`); logger.info('app', `本地存储目录:${dataDir}`);
// ---- 获取数据目录 ---- // ---- 获取数据目录 ----
ipcMain.handle(BIDIRECTIONAL.GET_DATA_DIR, () => { ipcMain.handle(BIDIRECTIONAL_STORAGE.GET_DATA_DIR, () => {
return { success: true, data: dataDir }; return { success: true, data: dataDir };
}); });
// ---- 文件写入 ---- // ---- 文件写入 ----
ipcMain.handle( ipcMain.handle(
BIDIRECTIONAL.FILE_WRITE, BIDIRECTIONAL_STORAGE.FILE_WRITE,
async ( async (_event, params: { relativePath: string; data: string; encoding?: 'base64' | 'utf8' }) => {
_event,
params: { relativePath: string; data: string; encoding?: 'base64' | 'utf8' },
) => {
try { try {
const filePath = resolveSafe(dataDir, params.relativePath); const filePath = resolveSafe(dataDir, params.relativePath);
ensureDir(path.dirname(filePath)); ensureDir(path.dirname(filePath));
const buffer = Buffer.from(params.data, params.encoding || 'base64'); const buffer = Buffer.from(params.data, params.encoding || 'base64');
fs.writeFileSync(filePath, buffer); fs.writeFileSync(filePath, buffer);
console.log(`[storage IPC] 文件写入成功:${filePath}${buffer.length} bytes`); logger.info('storage', `文件写入成功:${filePath}${buffer.length} bytes`);
return { success: true, data: filePath }; return { success: true, data: filePath };
} catch (err) { } catch (err) {
const msg = (err as Error).message; const msg = (err as Error).message;
console.error(`[storage IPC] 文件写入失败:${params.relativePath}${msg}`);
logger.warn('storage', `文件写入失败:${params.relativePath}`, err instanceof Error ? err : undefined); logger.warn('storage', `文件写入失败:${params.relativePath}`, err instanceof Error ? err : undefined);
return { success: false, error: msg }; return { success: false, error: msg };
} }
@@ -89,7 +67,7 @@ export function registerStorageIpcHandlers(): void {
); );
// ---- 文件读取(返回 Base64 ---- // ---- 文件读取(返回 Base64 ----
ipcMain.handle(BIDIRECTIONAL.FILE_READ, async (_event, params: { relativePath: string }) => { ipcMain.handle(BIDIRECTIONAL_STORAGE.FILE_READ, async (_event, params: { relativePath: string }) => {
try { try {
const filePath = resolveSafe(dataDir, params.relativePath); const filePath = resolveSafe(dataDir, params.relativePath);
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
@@ -104,7 +82,7 @@ export function registerStorageIpcHandlers(): void {
}); });
// ---- 文件删除 ---- // ---- 文件删除 ----
ipcMain.handle(BIDIRECTIONAL.FILE_DELETE, async (_event, params: { relativePath: string }) => { ipcMain.handle(BIDIRECTIONAL_STORAGE.FILE_DELETE, async (_event, params: { relativePath: string }) => {
try { try {
const filePath = resolveSafe(dataDir, params.relativePath); const filePath = resolveSafe(dataDir, params.relativePath);
if (fs.existsSync(filePath)) { if (fs.existsSync(filePath)) {
@@ -118,7 +96,7 @@ export function registerStorageIpcHandlers(): void {
}); });
// ---- 创建目录 ---- // ---- 创建目录 ----
ipcMain.handle(BIDIRECTIONAL.MAKE_DIR, async (_event, params: { relativePath: string }) => { ipcMain.handle(BIDIRECTIONAL_STORAGE.MAKE_DIR, async (_event, params: { relativePath: string }) => {
try { try {
const dirPath = resolveSafe(dataDir, params.relativePath); const dirPath = resolveSafe(dataDir, params.relativePath);
ensureDir(dirPath); ensureDir(dirPath);
@@ -130,7 +108,7 @@ export function registerStorageIpcHandlers(): void {
}); });
// ---- 在文件管理器中显示 ---- // ---- 在文件管理器中显示 ----
ipcMain.handle(BIDIRECTIONAL.SHOW_ITEM_IN_FOLDER, async (_event, params: { filePath: string }) => { ipcMain.handle(BIDIRECTIONAL_STORAGE.SHOW_ITEM_IN_FOLDER, async (_event, params: { filePath: string }) => {
try { try {
shell.showItemInFolder(params.filePath); shell.showItemInFolder(params.filePath);
return { success: true }; return { success: true };
@@ -141,7 +119,7 @@ export function registerStorageIpcHandlers(): void {
}); });
// ---- 文件大小 ---- // ---- 文件大小 ----
ipcMain.handle(BIDIRECTIONAL.FILE_SIZE, async (_event, params: { relativePath: string }) => { ipcMain.handle(BIDIRECTIONAL_STORAGE.FILE_SIZE, async (_event, params: { relativePath: string }) => {
try { try {
const filePath = resolveSafe(dataDir, params.relativePath); const filePath = resolveSafe(dataDir, params.relativePath);
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
@@ -156,7 +134,7 @@ export function registerStorageIpcHandlers(): void {
}); });
// ---- 文件/目录是否存在 ---- // ---- 文件/目录是否存在 ----
ipcMain.handle(BIDIRECTIONAL.FILE_EXISTS, async (_event, params: { relativePath: string }) => { ipcMain.handle(BIDIRECTIONAL_STORAGE.FILE_EXISTS, async (_event, params: { relativePath: string }) => {
try { try {
const filePath = resolveSafe(dataDir, params.relativePath); const filePath = resolveSafe(dataDir, params.relativePath);
return { success: true, data: fs.existsSync(filePath) }; return { success: true, data: fs.existsSync(filePath) };
@@ -165,5 +143,83 @@ export function registerStorageIpcHandlers(): void {
} }
}); });
// ---- 绝对路径文件写入(绕过沙箱,用于用户自定义输出目录) ----
ipcMain.handle(
BIDIRECTIONAL_STORAGE.FILE_WRITE_ABSOLUTE,
async (_event, params: { absolutePath: string; data: string; encoding?: 'base64' | 'utf8' }) => {
try {
ensureDir(path.dirname(params.absolutePath));
const buffer = Buffer.from(params.data, params.encoding || 'base64');
fs.writeFileSync(params.absolutePath, buffer);
logger.info('storage', `绝对路径写入成功:${params.absolutePath}${buffer.length} bytes`);
return { success: true, data: params.absolutePath };
} catch (err) {
const msg = (err as Error).message;
logger.warn('storage', `绝对路径写入失败:${params.absolutePath}`, err instanceof Error ? err : undefined);
return { success: false, error: msg };
}
},
);
// ---- 绝对路径目录创建(绕过沙箱) ----
ipcMain.handle(BIDIRECTIONAL_STORAGE.MAKE_DIR_ABSOLUTE, async (_event, params: { absolutePath: string }) => {
try {
ensureDir(params.absolutePath);
return { success: true, data: params.absolutePath };
} catch (err) {
logger.warn('storage', `绝对路径目录创建失败:${params.absolutePath}`, err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
});
// ---- 获取系统下载目录 ----
ipcMain.handle(BIDIRECTIONAL_STORAGE.GET_DOWNLOADS_PATH, () => {
try {
return { success: true, data: app.getPath('downloads') };
} catch {
return { success: false, error: '无法获取下载目录' };
}
});
// ---- 主进程下载文件到绝对路径(绕过浏览器 CORS ----
ipcMain.handle(
BIDIRECTIONAL_STORAGE.DOWNLOAD_TO_PATH,
async (
_event,
params: { url: string; destPath: string; headers?: Record<string, string> },
): Promise<{ success: boolean; data?: string; error?: string }> => {
const { url, destPath, headers } = params;
try {
await downloadToFile(url, destPath, headers);
logger.info('storage', `主进程下载成功:${destPath}`);
return { success: true, data: destPath };
} catch (err) {
const msg = (err as Error).message || '下载失败';
logger.warn('storage', `主进程下载失败:${url}`, err instanceof Error ? err : undefined);
return { success: false, error: msg };
}
},
);
// ---- 生成图片缩略图sharp 流式管线) ----
ipcMain.handle(
BIDIRECTIONAL_STORAGE.GENERATE_THUMBNAIL,
async (
_event,
params: { srcPath: string; destPath: string; maxWidth?: number; quality?: number },
): Promise<{ success: boolean; data?: string; error?: string }> => {
const { srcPath, destPath, maxWidth = 256, quality = 60 } = params;
try {
await generateThumbnail(srcPath, destPath, maxWidth, quality);
logger.info('storage', `缩略图生成成功:${destPath}`);
return { success: true, data: destPath };
} catch (err) {
const msg = (err as Error).message || '缩略图生成失败';
logger.warn('storage', `缩略图生成失败:${srcPath}`, err instanceof Error ? err : undefined);
return { success: false, error: msg };
}
},
);
logger.info('app', '存储 IPC 处理器已注册'); logger.info('app', '存储 IPC 处理器已注册');
} }

View File

@@ -0,0 +1,97 @@
// ============================================================
// download.ts — 文件下载工具函数
//
// 职责:
// - 通过 Electron net 模块下载文件到磁盘
// - 支持重定向、超时、自定义请求头
//
// 设计:
// - net 基于 Chromium 网络栈,自动携带浏览器标准头,不受 CORS 限制
// - 不能用 netRequest它 chunk.toString() 会损坏二进制数据)
// - 不能用 Node.js https缺少浏览器头导致 CDN 返回 400
// ============================================================
import { net } from 'electron';
import fs from 'node:fs';
import path from 'node:path';
import { ensureDir } from './path';
/**
* 通过 Electron net 模块下载文件到磁盘。
*
* @param url - 下载地址
* @param destPath - 目标文件路径
* @param headers - 可选的自定义请求头
*/
export function downloadToFile(
url: string,
destPath: string,
headers?: Record<string, string>,
): Promise<void> {
return new Promise((resolve, reject) => {
const req = net.request({ method: 'GET', url });
// 自定义请求头
if (headers) {
for (const [key, value] of Object.entries(headers)) {
req.setHeader(key, value);
}
}
// 超时 120s
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
req.abort();
reject(new Error('下载超时120s'));
}, 120_000);
req.on('response', (response) => {
clearTimeout(timer);
const status = response.statusCode;
// 3xx 重定向
if (status >= 300 && status < 400) {
const location = response.headers['location']?.[0];
if (location && location !== url) {
downloadToFile(location, destPath, headers).then(resolve).catch(reject);
} else {
reject(new Error('重定向循环或缺少 Location 头'));
}
response.on('data', () => {}); // 消费响应体
return;
}
if (status >= 400) {
reject(new Error(`HTTP ${status}`));
response.on('data', () => {});
return;
}
// 收集二进制 chunks → 写入文件
const chunks: Buffer[] = [];
response.on('data', (chunk: Buffer) => chunks.push(chunk));
response.on('end', () => {
try {
ensureDir(path.dirname(destPath));
const buffer = Buffer.concat(chunks);
fs.writeFileSync(destPath, buffer);
resolve();
} catch (err) {
reject(err);
}
});
response.on('error', (err) => {
reject(new Error(`响应读取失败: ${err.message}`));
});
});
req.on('error', (err) => {
clearTimeout(timer);
if (timedOut) return;
reject(new Error(`网络请求失败: ${err.message}`));
});
req.end();
});
}

View File

@@ -0,0 +1,7 @@
// ============================================================
// utils/index.ts — 工具函数汇总导出
// ============================================================
export { resolveSafe, ensureDir } from './path';
export { downloadToFile } from './download';
export { generateThumbnail } from './thumbnail';

View File

@@ -0,0 +1,29 @@
// ============================================================
// path.ts — 路径安全工具函数
//
// 职责:
// - 路径穿越防护
// - 目录创建
// ============================================================
import fs from 'node:fs';
import path from 'node:path';
/**
* 将相对路径解析为绝对路径,并校验是否在 dataDir 内。
* 防止路径穿越攻击(../ 穿越)。
*/
export function resolveSafe(dataDir: string, relativePath: string): string {
const resolved = path.resolve(dataDir, relativePath);
if (!resolved.startsWith(dataDir + path.sep) && resolved !== dataDir) {
throw new Error(`路径越界:${relativePath}`);
}
return resolved;
}
/** 确保目录存在(递归创建) */
export function ensureDir(dirPath: string): void {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}

View File

@@ -0,0 +1,60 @@
// ============================================================
// thumbnail.ts — 缩略图生成工具函数
//
// 职责:
// - 使用 sharp 库生成图片缩略图
// - 内存优化配置
//
// 依赖:
// - sharp (libvips) - 可选依赖,用于高效图片处理
// ============================================================
import fs from 'node:fs';
import path from 'node:path';
import sharp from 'sharp';
import { ensureDir } from './path';
// ---------- sharp/libvips 内存优化 ----------
// 单块内存上限 128MB默认 512MB降低以适配 Electron 单进程内存压力)
sharp.cache({ memory: 128, items: 100 });
// libvips 并发数 1减少大图并发处理时的内存峰值
sharp.concurrency(1);
/**
* 生成图片缩略图sharp 流式管线)
*
* 特性:
* - shrinkOnLoadPNG/JPEG/WebP/TIFF 解码器直接降采样8000px 原图解码即降至 ~512px
* - sequentialRead顺序读取减少随机 I/O 内存开销
* - limitInputPixels默认 268MP16384²覆盖 8K+ 素材
*
* @param srcPath - 源文件路径
* @param destPath - 目标文件路径
* @param maxWidth - 最大宽度(默认 256
* @param quality - JPEG 质量(默认 60
* @returns 目标文件路径
*/
export async function generateThumbnail(
srcPath: string,
destPath: string,
maxWidth: number = 256,
quality: number = 60,
): Promise<string> {
if (!fs.existsSync(srcPath)) {
throw new Error('源文件不存在');
}
ensureDir(path.dirname(destPath));
await sharp(srcPath, {
animated: false, // 只取第一帧GIF/APNG 等多帧格式)
sequentialRead: true,
limitInputPixels: 268402689,
})
.resize({ width: maxWidth, withoutEnlargement: true, fit: 'inside' })
.jpeg({ quality, mozjpeg: true }) // mozjpeg 压缩率更优
.toFile(destPath);
return destPath;
}

View File

@@ -212,19 +212,19 @@ export async function request<T = unknown>(
export const netRequest = { export const netRequest = {
request, request,
get<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) { async get<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) {
return request<T>(url, { ...config, method: 'GET' }); return await request<T>(url, { ...config, method: 'GET' });
}, },
post<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) { async post<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) {
return request<T>(url, { ...config, method: 'POST', data }); return request<T>(url, { ...config, method: 'POST', data });
}, },
put<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) { async put<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) {
return request<T>(url, { ...config, method: 'PUT', data }); return request<T>(url, { ...config, method: 'PUT', data });
}, },
del<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) { async del<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) {
return request<T>(url, { ...config, method: 'DELETE' }); return request<T>(url, { ...config, method: 'DELETE' });
}, },
}; };

View File

@@ -1,486 +0,0 @@
// 自动更新 — HeiXiuProvider + electron-updater 增量下载
// 流程图:调用自有 API → 获取版本信息 → electron-updater 差分下载 → SHA512 校验 → 安装
import {app, BrowserWindow, ipcMain} from 'electron';
import {createHash} from 'node:crypto';
import {URL} from 'node:url';
import {
NsisUpdater,
MacUpdater,
AppImageUpdater,
Provider,
} from 'electron-updater';
import type {ProviderRuntimeOptions} from 'electron-updater/out/providers/Provider';
import type {UpdateInfo, UpdateFileInfo} from 'builder-util-runtime';
import type {ResolvedUpdateFileInfo} from 'electron-updater/out/types';
import type {UpdateCheckResult} from '../../shared/types';
import {APP_VERSION} from '../../shared/constants/version';
import {logger} from './logger';
import {netRequest} from './net-request';
// ---------- 构建时注入的渠道信息 ----------
/** 构建渠道(由 build.mjs 通过 VITE_BUILD_CHANNEL 注入Vite 编译时内联) */
const BUILD_CHANNEL: string =
(typeof import.meta !== 'undefined' && (import.meta as Record<string, unknown>).env
? ((import.meta as Record<string, unknown>).env as Record<string, string>).VITE_BUILD_CHANNEL
: undefined)
|| process.env.VITE_BUILD_CHANNEL
|| 'stable';
/** 构建版本类型(由 build.mjs 通过 VITE_BUILD_EDITION 注入) */
const BUILD_EDITION: string =
(typeof import.meta !== 'undefined' && (import.meta as Record<string, unknown>).env
? ((import.meta as Record<string, unknown>).env as Record<string, string>).VITE_BUILD_EDITION
: undefined)
|| process.env.VITE_BUILD_EDITION
|| 'personal';
// ---------- 设备指纹(稳定的机器标识,用于灰度分组)----------
let _deviceFingerprint: string | null = null;
/** 获取设备指纹(基于机器特定信息的 SHA-256 前 16 位) */
function getDeviceFingerprint(): string {
if (_deviceFingerprint) return _deviceFingerprint;
try {
const parts = [
process.platform,
process.arch,
app.getPath('home'),
];
_deviceFingerprint = createHash('sha256')
.update(parts.join('|'))
.digest('hex')
.slice(0, 16);
} catch {
_deviceFingerprint = 'unknown-device';
}
return _deviceFingerprint;
}
// ---------- IPC 通道(对渲染进程暴露,与旧版兼容)----------
export const UPDATE_CHANNELS = {
UPDATE_AVAILABLE: 'update-available',
UPDATE_PROGRESS: 'update-progress',
UPDATE_DOWNLOADED: 'update-downloaded',
UPDATE_ERROR: 'update-error',
UPDATE_NOT_AVAILABLE: 'update-not-available',
INSTALL_UPDATE: 'install-update',
CHECK_FOR_UPDATE: 'check-for-update',
DOWNLOAD_UPDATE: 'download-update',
} as const;
// ---------- 配置 ----------
function getApiBaseUrl(): string {
const url = (process.env.UPDATE_API_URL || 'https://www.heixiu.com').trim();
console.log(`[updater] UPDATE_API_URL=${process.env.UPDATE_API_URL} → 使用: ${url}`);
return url;
}
// ============================================================
// 自定义 Provider
// ============================================================
class HeiXiuProvider extends Provider<UpdateInfo> {
private apiBaseUrl: string;
/** 短时缓存:避免 checkForUpdates 预检 + electron-updater 内部调用导致两次 API 请求 */
private _cachedResult: { data: UpdateInfo; ts: number } | null = null;
constructor(runtimeOptions: ProviderRuntimeOptions) {
super(runtimeOptions);
this.apiBaseUrl = getApiBaseUrl();
}
/**
* 调用自有版本检查 API返回 electron-updater 格式的 UpdateInfo。
* blockMapSize > 0 时 electron-updater 自动启用差分下载。
* 无更新时返回当前版本号APP_VERSION
*/
async getLatestVersion(): Promise<UpdateInfo> {
// 短时缓存500ms预检和 electron-updater 内部调用共享同一结果
if (this._cachedResult && Date.now() - this._cachedResult.ts < 500) {
return this._cachedResult.data;
}
const data = await this._doGetLatestVersion();
this._cachedResult = { data, ts: Date.now() };
return data;
}
private async _doGetLatestVersion(): Promise<UpdateInfo> {
const {data} = await netRequest.get<{ code: number; data?: UpdateCheckResult } & UpdateCheckResult>(
'/api/v1/update/check',
{
baseURL: this.apiBaseUrl,
params: {
platform: process.platform,
version: APP_VERSION,
channel: BUILD_CHANNEL,
edition: BUILD_EDITION,
deviceFingerprint: getDeviceFingerprint(),
},
},
);
// 兼容两种 API 响应格式
const result: UpdateCheckResult =
(data as { data?: UpdateCheckResult }).data ?? (data as UpdateCheckResult);
// 无更新 → 返回当前版本号
if (!result.hasUpdate) {
return {
version: APP_VERSION,
files: [],
path: '',
sha512: '',
releaseDate: new Date().toISOString(),
};
}
const file: UpdateFileInfo = {
url: result.downloadUrl,
sha512: result.sha512,
size: result.fileSize,
};
// 后端 API 返回 blockMapSize 时启用增量更新
if (result.blockMapSize) {
file.blockMapSize = result.blockMapSize;
}
logger.info('updater', 'Provider 获取到新版本', {
latestVersion: result.latestVersion,
hasBlockmap: file.blockMapSize != null,
});
return {
version: result.latestVersion,
files: [file],
path: result.downloadUrl,
sha512: result.sha512,
releaseDate: result.releaseDate,
releaseNotes: result.changelog,
};
}
/**
* 将 UpdateInfo 中的相对路径解析为完整的下载 URL。
* electron-updater 在下载前调用此方法。
*/
resolveFiles(updateInfo: UpdateInfo): Array<ResolvedUpdateFileInfo> {
return updateInfo.files.map((f) => {
// 使用 downloadUrl 作为 base URL 来解析文件路径
const baseUrl = new URL(updateInfo.path);
const fileUrl = new URL(f.url, baseUrl.origin);
return {
url: fileUrl,
info: f,
};
});
}
}
// ============================================================
// 平台特定 Updater注入自定义 Provider
// ============================================================
/**
* 覆盖 getUpdateInfoAndProvider() 以注入 HeiXiuProvider。
* 其他行为NSIS 安装、签名校验等)完全由父类处理。
*/
class HeiXiuNsisUpdater extends NsisUpdater {
private heiXiuProvider: HeiXiuProvider;
constructor(heiXiuProvider: HeiXiuProvider) {
// 传 undefined 跳过内置 Provider 创建,由我们自己注入
super(undefined);
this.heiXiuProvider = heiXiuProvider;
this.autoDownload = false; // 由我们手动控制流程,保持 IPC 兼容
this.autoInstallOnAppQuit = false;
this.forceDevUpdateConfig = true; // 绕过 electron-updater 的 dev 模式检查
}
/** @internal — 注入自定义 Provider */
protected override async getUpdateInfoAndProvider(): Promise<{
info: UpdateInfo;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provider: Provider<any>;
}> {
const info = await this.heiXiuProvider.getLatestVersion();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return {info, provider: this.heiXiuProvider as Provider<any>};
}
}
class HeiXiuMacUpdater extends MacUpdater {
private heiXiuProvider: HeiXiuProvider;
constructor(heiXiuProvider: HeiXiuProvider) {
super(undefined);
this.heiXiuProvider = heiXiuProvider;
this.autoDownload = false;
this.autoInstallOnAppQuit = false;
}
protected override async getUpdateInfoAndProvider(): Promise<{
info: UpdateInfo;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provider: Provider<any>;
}> {
const info = await this.heiXiuProvider.getLatestVersion();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return {info, provider: this.heiXiuProvider as Provider<any>};
}
}
class HeiXiuAppImageUpdater extends AppImageUpdater {
private heiXiuProvider: HeiXiuProvider;
constructor(heiXiuProvider: HeiXiuProvider) {
super(null);
this.heiXiuProvider = heiXiuProvider;
this.autoDownload = false;
this.autoInstallOnAppQuit = false;
}
protected override async getUpdateInfoAndProvider(): Promise<{
info: UpdateInfo;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provider: Provider<any>;
}> {
const info = await this.heiXiuProvider.getLatestVersion();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return {info, provider: this.heiXiuProvider as Provider<any>};
}
}
/** 工厂:按平台创建对应的 Updater 实例 */
function createPlatformUpdater(provider: HeiXiuProvider) {
switch (process.platform) {
case 'win32':
return new HeiXiuNsisUpdater(provider);
case 'darwin':
return new HeiXiuMacUpdater(provider);
default:
return new HeiXiuAppImageUpdater(provider);
}
}
// ---------- 状态 ----------
let updateWindow: BrowserWindow | null = null;
let provider: HeiXiuProvider | null = null;
let platformUpdater: NsisUpdater | MacUpdater | AppImageUpdater | null = null;
// ============================================================
// 初始化
// ============================================================
export function initUpdater(mainWindow: BrowserWindow): void {
updateWindow = mainWindow;
console.log('[updater] initUpdater 被调用');
console.log(`[updater] API URL: ${getApiBaseUrl()}`);
console.log(`[updater] 渠道: ${BUILD_CHANNEL}`);
console.log(`[updater] 版本类型: ${BUILD_EDITION}`);
console.log(`[updater] 当前版本: ${APP_VERSION}`);
console.log(`[updater] 设备指纹: ${getDeviceFingerprint()}`);
// if (import.meta.env.DEV) {
// logger.info('updater', '开发模式,跳过自动检查更新', { apiUrl: getApiBaseUrl() });
// return;
// }
// 创建自定义 Provider
provider = new HeiXiuProvider({
isUseMultipleRangeRequest: true,
platform: process.platform as ProviderRuntimeOptions['platform'],
executor: null as unknown as ProviderRuntimeOptions['executor'],
});
// 创建平台 Updater
platformUpdater = createPlatformUpdater(provider);
// 将 electron-updater 事件桥接到现有 IPC 通道
platformUpdater.on('checking-for-update', () => {
logger.debug('updater', '正在检查更新');
});
platformUpdater.on('update-available', (info: UpdateInfo) => {
// releaseNotes 可能是 string | ReleaseNoteInfo[] | null统一转为 string
let notes = '';
if (typeof info.releaseNotes === 'string') {
notes = info.releaseNotes;
} else if (Array.isArray(info.releaseNotes)) {
notes = info.releaseNotes.map((n) => n.note).filter(Boolean).join('\n');
}
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_AVAILABLE, {
version: info.version,
releaseDate: info.releaseDate,
releaseNotes: notes,
forceUpdate: false,
});
logger.info('updater', '发现新版本', {
version: info.version,
releaseNotes: notes ? `${notes.slice(0, 50)}...` : '(空)',
});
});
platformUpdater.on('update-not-available', (info: UpdateInfo) => {
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_NOT_AVAILABLE);
logger.info('updater', '当前已是最新版本', {currentVersion: info.version});
});
platformUpdater.on('download-progress', (progress: {
percent: number;
bytesPerSecond: number;
transferred: number;
total: number
}) => {
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_PROGRESS, {
percent: Math.round(progress.percent),
bytesPerSecond: progress.bytesPerSecond,
transferred: progress.transferred,
total: progress.total,
});
});
platformUpdater.on('update-downloaded', () => {
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_DOWNLOADED);
logger.info('updater', '更新已下载完成');
});
platformUpdater.on('error', (error: Error) => {
logger.error('updater', '更新流程出错', error);
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
message: error.message || '更新失败',
});
});
// 启动 5 秒后静默检查
setTimeout(() => {
checkForUpdates();
}, 5000);
}
// ============================================================
// 操作方法
// ============================================================
let updateChecked = false;
/** 静默检查更新(自动下载) */
export async function checkForUpdates(): Promise<void> {
if (updateChecked) {
logger.debug('updater', '已检查过更新,跳过');
return;
}
if (!platformUpdater || !provider) return;
try {
updateChecked = true;
// ① 先自行检查有无更新(不经过 electron-updater
const info = await provider.getLatestVersion();
if (info.version === APP_VERSION) {
// 无更新 → 直接通知 UI不触发 electron-updater 的任何事件
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_NOT_AVAILABLE);
logger.debug('updater', '当前已是最新版本', {version: APP_VERSION});
return;
}
// ② 有新版本 → 交给 electron-updater 触发 'update-available' 事件(不自动下载)
await platformUpdater.checkForUpdates();
} catch (error) {
updateChecked = false;
logger.error('updater', '更新流程失败', error as Error, {apiUrl: getApiBaseUrl()});
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
message: (error as Error).message || '更新检查失败',
});
}
}
/** 手动检查更新(用户点击触发) */
export async function checkForUpdatesManual(): Promise<void> {
if (!platformUpdater || !provider) return;
try {
updateChecked = false;
// ① 先自行检查有无更新
const info = await provider.getLatestVersion();
if (info.version === APP_VERSION) {
// 无更新 → 直接通知 UI
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_NOT_AVAILABLE);
logger.debug('updater', '手动检查:当前已是最新版本', {version: APP_VERSION});
return;
}
// ② 有新版本 → 交给 electron-updater 触发 'update-available' 事件(不自动下载)
await platformUpdater.checkForUpdates();
} catch (error) {
logger.error('updater', '手动更新失败', error as Error);
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
message: (error as Error).message || '检查更新失败',
});
}
}
/** 用户确认后开始下载更新 */
export async function downloadUpdate(): Promise<void> {
if (!platformUpdater) {
logger.warn('updater', 'downloadUpdate: Updater 未初始化');
return;
}
try {
await platformUpdater.downloadUpdate();
} catch (error) {
logger.error('updater', '下载更新失败', error as Error);
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
message: (error as Error).message || '下载更新失败',
});
}
}
/** 安装已下载的更新并退出应用 */
export function installUpdateNow(): void {
if (!platformUpdater) {
logger.warn('updater', 'Updater 未初始化');
return;
}
logger.info('updater', '开始安装更新并退出应用');
setImmediate(() => {
platformUpdater!.quitAndInstall(false, true);
});
}
// ============================================================
// IPC 注册
// ============================================================
export function registerUpdateIpcHandlers(): void {
ipcMain.handle(UPDATE_CHANNELS.CHECK_FOR_UPDATE, async () => {
await checkForUpdatesManual();
});
ipcMain.handle(UPDATE_CHANNELS.DOWNLOAD_UPDATE, async () => {
await downloadUpdate();
});
ipcMain.on(UPDATE_CHANNELS.INSTALL_UPDATE, () => {
installUpdateNow();
});
}

View File

@@ -0,0 +1,219 @@
// ============================================================
// updater/index.ts — 自动更新主入口
//
// 流程图:调用自有 API → 获取版本信息 → electron-updater 差分下载 → SHA512 校验 → 安装
// ============================================================
import { BrowserWindow, ipcMain } from 'electron';
import type { NsisUpdater, MacUpdater, AppImageUpdater } from 'electron-updater';
import type { UpdateInfo } from 'builder-util-runtime';
import { APP_VERSION } from '../../../shared/constants/version';
import { MAIN_TO_RENDERER_UPDATER, RENDERER_TO_MAIN_UPDATER } from '../../../shared/constants/ipc/updater';
import { logger } from '../logger';
import { HeiXiuProvider, getApiBaseUrl, getDeviceFingerprint, BUILD_CHANNEL, BUILD_EDITION } from './provider';
import { createPlatformUpdater } from './platform-updaters';
// ---------- 状态 ----------
let updateWindow: BrowserWindow | null = null;
let provider: HeiXiuProvider | null = null;
let platformUpdater: NsisUpdater | MacUpdater | AppImageUpdater | null = null;
// ============================================================
// 初始化
// ============================================================
export function initUpdater(mainWindow: BrowserWindow): void {
updateWindow = mainWindow;
console.log('[updater] initUpdater 被调用');
console.log(`[updater] API URL: ${getApiBaseUrl()}`);
console.log(`[updater] 渠道: ${BUILD_CHANNEL}`);
console.log(`[updater] 版本类型: ${BUILD_EDITION}`);
console.log(`[updater] 当前版本: ${APP_VERSION}`);
console.log(`[updater] 设备指纹: ${getDeviceFingerprint()}`);
// 创建自定义 Provider
provider = new HeiXiuProvider({
isUseMultipleRangeRequest: true,
platform: process.platform as 'win32' | 'darwin' | 'linux',
executor: null as never,
});
// 创建平台 Updater
platformUpdater = createPlatformUpdater(provider);
// 将 electron-updater 事件桥接到现有 IPC 通道
platformUpdater.on('checking-for-update', () => {
logger.debug('updater', '正在检查更新');
});
platformUpdater.on('update-available', (info: UpdateInfo) => {
// releaseNotes 可能是 string | ReleaseNoteInfo[] | null统一转为 string
let notes = '';
if (typeof info.releaseNotes === 'string') {
notes = info.releaseNotes;
} else if (Array.isArray(info.releaseNotes)) {
notes = info.releaseNotes.map((n) => n.note).filter(Boolean).join('\n');
}
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_AVAILABLE, {
version: info.version,
releaseDate: info.releaseDate,
releaseNotes: notes,
forceUpdate: false,
});
logger.info('updater', '发现新版本', {
version: info.version,
releaseNotes: notes ? `${notes.slice(0, 50)}...` : '(空)',
});
});
platformUpdater.on('update-not-available', (info: UpdateInfo) => {
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_NOT_AVAILABLE);
logger.info('updater', '当前已是最新版本', { currentVersion: info.version });
});
platformUpdater.on('download-progress', (progress: {
percent: number;
bytesPerSecond: number;
transferred: number;
total: number;
}) => {
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_PROGRESS, {
percent: Math.round(progress.percent),
bytesPerSecond: progress.bytesPerSecond,
transferred: progress.transferred,
total: progress.total,
});
});
platformUpdater.on('update-downloaded', () => {
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_DOWNLOADED);
logger.info('updater', '更新已下载完成');
});
platformUpdater.on('error', (error: Error) => {
logger.error('updater', '更新流程出错', error);
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_ERROR, {
message: error.message || '更新失败',
});
});
// 启动 5 秒后静默检查
setTimeout(() => {
checkForUpdates();
}, 5000);
}
// ============================================================
// 操作方法
// ============================================================
let updateChecked = false;
/** 静默检查更新(自动下载) */
export async function checkForUpdates(): Promise<void> {
if (updateChecked) {
logger.debug('updater', '已检查过更新,跳过');
return;
}
if (!platformUpdater || !provider) return;
try {
updateChecked = true;
// ① 先自行检查有无更新(不经过 electron-updater
const info = await provider.getLatestVersion();
if (info.version === APP_VERSION) {
// 无更新 → 直接通知 UI不触发 electron-updater 的任何事件
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_NOT_AVAILABLE);
logger.debug('updater', '当前已是最新版本', { version: APP_VERSION });
return;
}
// ② 有新版本 → 交给 electron-updater 触发 'update-available' 事件(不自动下载)
await platformUpdater.checkForUpdates();
} catch (error) {
updateChecked = false;
logger.error('updater', '更新流程失败', error as Error, { apiUrl: getApiBaseUrl() });
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_ERROR, {
message: (error as Error).message || '更新检查失败',
});
}
}
/** 手动检查更新(用户点击触发) */
export async function checkForUpdatesManual(): Promise<void> {
if (!platformUpdater || !provider) return;
try {
updateChecked = false;
// ① 先自行检查有无更新
const info = await provider.getLatestVersion();
if (info.version === APP_VERSION) {
// 无更新 → 直接通知 UI
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_NOT_AVAILABLE);
logger.debug('updater', '手动检查:当前已是最新版本', { version: APP_VERSION });
return;
}
// ② 有新版本 → 交给 electron-updater 触发 'update-available' 事件(不自动下载)
await platformUpdater.checkForUpdates();
} catch (error) {
logger.error('updater', '手动更新失败', error as Error);
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_ERROR, {
message: (error as Error).message || '检查更新失败',
});
}
}
/** 用户确认后开始下载更新 */
export async function downloadUpdate(): Promise<void> {
if (!platformUpdater) {
logger.warn('updater', 'downloadUpdate: Updater 未初始化');
return;
}
try {
await platformUpdater.downloadUpdate();
} catch (error) {
logger.error('updater', '下载更新失败', error as Error);
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_ERROR, {
message: (error as Error).message || '下载更新失败',
});
}
}
/** 安装已下载的更新并退出应用 */
export function installUpdateNow(): void {
if (!platformUpdater) {
logger.warn('updater', 'Updater 未初始化');
return;
}
logger.info('updater', '开始安装更新并退出应用');
setImmediate(() => {
platformUpdater!.quitAndInstall(false, true);
});
}
// ============================================================
// IPC 注册
// ============================================================
export function registerUpdateIpcHandlers(): void {
ipcMain.handle(RENDERER_TO_MAIN_UPDATER.CHECK_FOR_UPDATE, async () => {
await checkForUpdatesManual();
});
ipcMain.handle(RENDERER_TO_MAIN_UPDATER.DOWNLOAD_UPDATE, async () => {
await downloadUpdate();
});
ipcMain.on(RENDERER_TO_MAIN_UPDATER.INSTALL_UPDATE, () => {
installUpdateNow();
});
}

View File

@@ -0,0 +1,102 @@
// ============================================================
// platform-updaters.ts — 平台特定 Updater
//
// 覆盖 getUpdateInfoAndProvider() 以注入 HeiXiuProvider。
// 其他行为NSIS 安装、签名校验等)完全由父类处理。
// ============================================================
import {
NsisUpdater,
MacUpdater,
AppImageUpdater,
Provider,
} from 'electron-updater';
import type { UpdateInfo } from 'builder-util-runtime';
import type { HeiXiuProvider } from './provider';
// ---------- Windows NSIS ----------
export class HeiXiuNsisUpdater extends NsisUpdater {
private heiXiuProvider: HeiXiuProvider;
constructor(heiXiuProvider: HeiXiuProvider) {
// 传 undefined 跳过内置 Provider 创建,由我们自己注入
super(undefined);
this.heiXiuProvider = heiXiuProvider;
this.autoDownload = false; // 由我们手动控制流程,保持 IPC 兼容
this.autoInstallOnAppQuit = false;
this.forceDevUpdateConfig = true; // 绕过 electron-updater 的 dev 模式检查
}
/** @internal — 注入自定义 Provider */
protected override async getUpdateInfoAndProvider(): Promise<{
info: UpdateInfo;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provider: Provider<any>;
}> {
const info = await this.heiXiuProvider.getLatestVersion();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { info, provider: this.heiXiuProvider as Provider<any> };
}
}
// ---------- macOS ----------
export class HeiXiuMacUpdater extends MacUpdater {
private heiXiuProvider: HeiXiuProvider;
constructor(heiXiuProvider: HeiXiuProvider) {
super(undefined);
this.heiXiuProvider = heiXiuProvider;
this.autoDownload = false;
this.autoInstallOnAppQuit = false;
}
protected override async getUpdateInfoAndProvider(): Promise<{
info: UpdateInfo;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provider: Provider<any>;
}> {
const info = await this.heiXiuProvider.getLatestVersion();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { info, provider: this.heiXiuProvider as Provider<any> };
}
}
// ---------- Linux AppImage ----------
export class HeiXiuAppImageUpdater extends AppImageUpdater {
private heiXiuProvider: HeiXiuProvider;
constructor(heiXiuProvider: HeiXiuProvider) {
super(null);
this.heiXiuProvider = heiXiuProvider;
this.autoDownload = false;
this.autoInstallOnAppQuit = false;
}
protected override async getUpdateInfoAndProvider(): Promise<{
info: UpdateInfo;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provider: Provider<any>;
}> {
const info = await this.heiXiuProvider.getLatestVersion();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { info, provider: this.heiXiuProvider as Provider<any> };
}
}
// ---------- 工厂函数 ----------
/** 工厂:按平台创建对应的 Updater 实例 */
export function createPlatformUpdater(provider: HeiXiuProvider) {
switch (process.platform) {
case 'win32':
return new HeiXiuNsisUpdater(provider);
case 'darwin':
return new HeiXiuMacUpdater(provider);
default:
return new HeiXiuAppImageUpdater(provider);
}
}

View File

@@ -0,0 +1,172 @@
// ============================================================
// provider.ts — 自定义更新 Provider
//
// 封装与自有版本检查 API 的交互逻辑,
// 返回 electron-updater 格式的 UpdateInfo。
// ============================================================
import { app } from 'electron';
import { createHash } from 'node:crypto';
import { URL } from 'node:url';
import { Provider } from 'electron-updater';
import type { ProviderRuntimeOptions } from 'electron-updater/out/providers/Provider';
import type { UpdateInfo, UpdateFileInfo } from 'builder-util-runtime';
import type { ResolvedUpdateFileInfo } from 'electron-updater/out/types';
import type { UpdateCheckResult } from '../../../shared/types';
import { APP_VERSION } from '../../../shared/constants/version';
import { logger } from '../logger';
import { netRequest } from '../net-request';
// ---------- 构建时注入的渠道信息 ----------
/** 构建渠道(由 build.mjs 通过 VITE_BUILD_CHANNEL 注入Vite 编译时内联) */
export const BUILD_CHANNEL: string =
(typeof import.meta !== 'undefined' && (import.meta as unknown as Record<string, unknown>).env
? ((import.meta as unknown as Record<string, unknown>).env as Record<string, string>).VITE_BUILD_CHANNEL
: undefined)
|| process.env.VITE_BUILD_CHANNEL
|| 'stable';
/** 构建版本类型(由 build.mjs 通过 VITE_BUILD_EDITION 注入) */
export const BUILD_EDITION: string =
(typeof import.meta !== 'undefined' && (import.meta as unknown as Record<string, unknown>).env
? ((import.meta as unknown as Record<string, unknown>).env as Record<string, string>).VITE_BUILD_EDITION
: undefined)
|| process.env.VITE_BUILD_EDITION
|| 'personal';
// ---------- 设备指纹(稳定的机器标识,用于灰度分组)----------
let _deviceFingerprint: string | null = null;
/** 获取设备指纹(基于机器特定信息的 SHA-256 前 16 位) */
export function getDeviceFingerprint(): string {
if (_deviceFingerprint) return _deviceFingerprint;
try {
const parts = [
process.platform,
process.arch,
app.getPath('home'),
];
_deviceFingerprint = createHash('sha256')
.update(parts.join('|'))
.digest('hex')
.slice(0, 16);
} catch {
_deviceFingerprint = 'unknown-device';
}
return _deviceFingerprint;
}
// ---------- 配置 ----------
export function getApiBaseUrl(): string {
const url = (process.env.UPDATE_API_URL || 'https://www.heixiu.com').trim();
console.log(`[updater] UPDATE_API_URL=${process.env.UPDATE_API_URL} → 使用: ${url}`);
return url;
}
// ============================================================
// HeiXiuProvider
// ============================================================
export class HeiXiuProvider extends Provider<UpdateInfo> {
private apiBaseUrl: string;
/** 短时缓存:避免 checkForUpdates 预检 + electron-updater 内部调用导致两次 API 请求 */
private _cachedResult: { data: UpdateInfo; ts: number } | null = null;
constructor(runtimeOptions: ProviderRuntimeOptions) {
super(runtimeOptions);
this.apiBaseUrl = getApiBaseUrl();
}
/**
* 调用自有版本检查 API返回 electron-updater 格式的 UpdateInfo。
* blockMapSize > 0 时 electron-updater 自动启用差分下载。
* 无更新时返回当前版本号APP_VERSION
*/
async getLatestVersion(): Promise<UpdateInfo> {
// 短时缓存500ms预检和 electron-updater 内部调用共享同一结果
if (this._cachedResult && Date.now() - this._cachedResult.ts < 500) {
return this._cachedResult.data;
}
const data = await this._doGetLatestVersion();
this._cachedResult = { data, ts: Date.now() };
return data;
}
private async _doGetLatestVersion(): Promise<UpdateInfo> {
const { data } = await netRequest.get<{ code: number; data?: UpdateCheckResult } & UpdateCheckResult>(
'/api/v1/update/check',
{
baseURL: this.apiBaseUrl,
params: {
platform: process.platform,
version: APP_VERSION,
channel: BUILD_CHANNEL,
edition: BUILD_EDITION,
deviceFingerprint: getDeviceFingerprint(),
},
},
);
// 兼容两种 API 响应格式
const result: UpdateCheckResult =
(data as { data?: UpdateCheckResult }).data ?? (data as UpdateCheckResult);
// 无更新 → 返回当前版本号
if (!result.hasUpdate) {
return {
version: APP_VERSION,
files: [],
path: '',
sha512: '',
releaseDate: new Date().toISOString(),
};
}
const file: UpdateFileInfo = {
url: result.downloadUrl,
sha512: result.sha512,
size: result.fileSize,
};
// 后端 API 返回 blockMapSize 时启用增量更新
if (result.blockMapSize) {
file.blockMapSize = result.blockMapSize;
}
logger.info('updater', 'Provider 获取到新版本', {
latestVersion: result.latestVersion,
hasBlockmap: file.blockMapSize != null,
});
return {
version: result.latestVersion,
files: [file],
path: result.downloadUrl,
sha512: result.sha512,
releaseDate: result.releaseDate,
releaseNotes: result.changelog,
};
}
/**
* 将 UpdateInfo 中的相对路径解析为完整的下载 URL。
* electron-updater 在下载前调用此方法。
*/
resolveFiles(updateInfo: UpdateInfo): Array<ResolvedUpdateFileInfo> {
return updateInfo.files.map((f) => {
// 使用 downloadUrl 作为 base URL 来解析文件路径
const baseUrl = new URL(updateInfo.path);
const fileUrl = new URL(f.url, baseUrl.origin);
return {
url: fileUrl,
info: f,
};
});
}
}

View File

@@ -1,177 +1,39 @@
import { ipcRenderer, contextBridge } from 'electron'; // ============================================================
import { BIDIRECTIONAL, RENDERER_TO_MAIN } from '../shared/constants/ipc-channels'; // preload.ts — Electron 预加载脚本(入口)
//
// 职责:
// - 调用各功能模块的 expose 函数,将安全 API 暴露给渲染进程
// - 所有 API 通过 contextBridge 桥接,确保进程隔离安全
//
// 模块拆分:
// - base.ts → ipcRenderer 基础通信
// - safe-storage.ts → 加密/解密 API
// - file-storage.ts → 文件读写 API
// - app-runtime.ts → 运行时 API窗口标题等
// - canvas.ts → 无限画布 API
// ============================================================
// --------- Expose some API to the Renderer process --------- import {
contextBridge.exposeInMainWorld('ipcRenderer', { exposeBaseIpc,
on(...args: Parameters<typeof ipcRenderer.on>) { exposeSafeStorage,
const [channel, listener] = args; exposeFileStorage,
return ipcRenderer.on(channel, (event, ...args) => listener(event, ...args)); exposeAppRuntime,
}, exposeCanvas,
off(...args: Parameters<typeof ipcRenderer.off>) { } from './preload/index';
const [channel, ...omit] = args;
return ipcRenderer.off(channel, ...omit);
},
send(...args: Parameters<typeof ipcRenderer.send>) {
const [channel, ...omit] = args;
return ipcRenderer.send(channel, ...omit);
},
invoke(...args: Parameters<typeof ipcRenderer.invoke>) {
const [channel, ...omit] = args;
return ipcRenderer.invoke(channel, ...omit);
},
});
// --------- 安全加密 API基于 Electron safeStorage--------- // --------- 按顺序暴露各模块 API ---------
contextBridge.exposeInMainWorld('safeStorage', {
/**
* 加密明文字符串
* @param plaintext - 待加密的明文
* @returns Base64 编码的密文,失败返回 null
*/
async encrypt(plaintext: string): Promise<string | null> {
const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_ENCRYPT, plaintext);
return result?.success ? result.data : null;
},
/** // 1. 基础 IPC 通信ipcRenderer.on/off/send/invoke
* 解密 Base64 密文 exposeBaseIpc();
* @param encryptedBase64 - Base64 编码的密文
* @returns 解密后的明文,失败返回 null
*/
async decrypt(encryptedBase64: string): Promise<string | null> {
const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_DECRYPT, encryptedBase64);
return result?.success ? result.data : null;
},
});
// --------- 本地存储文件 API主进程 fs 桥接)--------- // 2. 安全加密 API基于 Electron safeStorage
contextBridge.exposeInMainWorld('fileStorage', { exposeSafeStorage();
/** 获取 heixiu-data 根目录路径 */
getDataDir(): Promise<string | null> {
return ipcRenderer.invoke(BIDIRECTIONAL.GET_DATA_DIR)
.then((r: { success: boolean; data: string }) => r?.success ? r.data : null);
},
/** 写入文件Base64 → 磁盘),返回写入的绝对路径 */
writeFile(relativePath: string, data: string, encoding?: 'base64' | 'utf8'): Promise<string | null> {
return ipcRenderer.invoke(BIDIRECTIONAL.FILE_WRITE, { relativePath, data, encoding })
.then((r: { success: boolean; data: string }) => r?.success ? r.data : null);
},
/** 读取文件(磁盘 → Base64 */
readFile(relativePath: string): Promise<string | null> {
return ipcRenderer.invoke(BIDIRECTIONAL.FILE_READ, { relativePath })
.then((r: { success: boolean; data: string }) => r?.success ? r.data : null);
},
/** 删除文件 */
deleteFile(relativePath: string): Promise<boolean> {
return ipcRenderer.invoke(BIDIRECTIONAL.FILE_DELETE, { relativePath })
.then((r: { success: boolean }) => r?.success ?? false);
},
/** 递归创建目录 */
makeDir(relativePath: string): Promise<string | null> {
return ipcRenderer.invoke(BIDIRECTIONAL.MAKE_DIR, { relativePath })
.then((r: { success: boolean; data: string }) => r?.success ? r.data : null);
},
/** 在文件管理器中显示文件(绝对路径) */
showItemInFolder(filePath: string): Promise<boolean> {
return ipcRenderer.invoke(BIDIRECTIONAL.SHOW_ITEM_IN_FOLDER, { filePath })
.then((r: { success: boolean }) => r?.success ?? false);
},
/** 获取文件大小(字节) */
fileSize(relativePath: string): Promise<number | null> {
return ipcRenderer.invoke(BIDIRECTIONAL.FILE_SIZE, { relativePath })
.then((r: { success: boolean; data: number }) => r?.success ? r.data : null);
},
/** 检查文件/目录是否存在 */
fileExists(relativePath: string): Promise<boolean> {
return ipcRenderer.invoke(BIDIRECTIONAL.FILE_EXISTS, { relativePath })
.then((r: { success: boolean; data: boolean }) => r?.success ? r.data : false);
},
});
/** 应用运行时 API */ // 3. 本地存储文件 API主进程 fs 桥接)
contextBridge.exposeInMainWorld('appRuntime', { exposeFileStorage();
/**
* 登录/登出后更新窗口标题中的版控后缀
* @param edition - 'personal' | 'enterprise' | nullnull 清除后缀)
*/
setWindowEdition(edition: string | null): void {
ipcRenderer.send(RENDERER_TO_MAIN.SET_WINDOW_TITLE, edition);
},
});
// --------- 无限画布 API --------- // 4. 应用运行时 API窗口标题等
contextBridge.exposeInMainWorld('canvas', { exposeAppRuntime();
/** 打开画布窗口(主进程创建新 BrowserWindow */
openWindow(projectPath?: string): void { // 5. 无限画布 API
ipcRenderer.send(RENDERER_TO_MAIN.OPEN_CANVAS_WINDOW, { projectPath }); exposeCanvas();
},
/** 扫描目录,返回匹配的文件列表 */
scanDirectory(params: {
roots: string[];
extensions: string[];
recursive: boolean;
}): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_DIRECTORY, params);
},
/** 扫描项目(主进程内部完成 buildScanRoots + walkDirectory推荐使用 */
scanProject(params: {
projectPath: string;
stage: string;
assetTypeFilter: string;
shotQuery: string;
}): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_PROJECT, params);
},
/** 读取旁加载 JSON 元数据 */
readSidecar(params: { filePath: string }): Promise<{ success: boolean; data: object | null }> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_READ_SIDECAR, params);
},
/** 写入旁加载 JSON 元数据 */
writeSidecar(params: { filePath: string; metadata: object }): Promise<{ success: boolean; error?: string }> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_WRITE_SIDECAR, params);
},
/** 生成缩略图JPEG base64 */
generateThumbnail(params: { filePath: string; size: number }): Promise<{
success: boolean;
data: { data: string; mimeType: string; width: number; height: number };
error?: string;
}> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_GENERATE_THUMBNAIL, params);
},
/** 复制文件到目标目录(导入操作) */
copyFile(params: { src: string; destDir: string; fileName: string }): Promise<{ success: boolean; data: string; error?: string }> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_COPY_FILE, params);
},
/** 列出目录下的子文件夹(非递归,排除隐藏) */
listSubdirs(params: { parentPath: string }): Promise<{ success: boolean; data: string[]; error?: string }> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_LIST_SUBDIRS, params);
},
/** 扫描阶段文件(单个 episode/shot/stage 组合) */
scanStageFiles(params: { projectPath: string; episode: string; shot: string; stage: string }): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_STAGE_FILES, params);
},
/** 扫描资产文件(深度递归扫描 assets/ 目录) */
scanAssetFiles(params: { projectPath: string; assetTypeFilter: string }): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_ASSET_FILES, params);
},
/** 创建目录 */
createDirectory(params: { parentPath: string; dirName: string }): Promise<{ success: boolean; data: string; error?: string }> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_CREATE_DIRECTORY, params);
},
/** 将本地文件路径转换为可通过 local-media:// 协议加载的 URL */
getMediaUrl(filePath: string): string {
const normalized = filePath.replace(/\\/g, '/');
// UNC 路径(//server/share/…)开头 // 会被 URL 解析器视为 authority
// 必须用 local-media://<host>/<path> 格式而非 local-media:/// 前缀
if (normalized.startsWith('//')) {
// normalized = "//192.168.110.250/share/path"
return 'local-media://' + normalized.slice(2);
}
// 本地路径C:/path → local-media:///C:/path
return 'local-media:///' + normalized;
},
/** 启动原生文件拖拽(从缩略图拖放媒体文件到外部应用) */
startDrag(params: { filePath: string; fileName: string }): Promise<{ success: boolean; error?: string }> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_START_DRAG, params);
},
});

View File

@@ -0,0 +1,22 @@
// ============================================================
// preload/app-runtime.ts — 应用运行时 API
//
// 提供窗口标题、版本控制等运行时接口
// 渲染进程通过 window.appRuntime 调用
// ============================================================
import { ipcRenderer, contextBridge } from 'electron';
import { RENDERER_TO_MAIN } from '../../shared/constants/ipc';
/** 应用运行时 API */
export function exposeAppRuntime(): void {
contextBridge.exposeInMainWorld('appRuntime', {
/**
* 登录/登出后更新窗口标题中的版控后缀
* @param edition - 'personal' | 'enterprise' | nullnull 清除后缀)
*/
setWindowEdition(edition: string | null): void {
ipcRenderer.send(RENDERER_TO_MAIN.SET_WINDOW_TITLE, edition);
},
});
}

29
electron/preload/base.ts Normal file
View File

@@ -0,0 +1,29 @@
// ============================================================
// preload/base.ts — 基础 ipcRenderer 桥接
//
// 提供安全的 IPC 通信接口,暴露给渲染进程
// ============================================================
import { ipcRenderer, contextBridge } from 'electron';
/** 基础 IPC 通信 API */
export function exposeBaseIpc(): void {
contextBridge.exposeInMainWorld('ipcRenderer', {
on(...args: Parameters<typeof ipcRenderer.on>) {
const [channel, listener] = args;
return ipcRenderer.on(channel, (event, ...args) => listener(event, ...args));
},
off(...args: Parameters<typeof ipcRenderer.off>) {
const [channel, ...omit] = args;
return ipcRenderer.off(channel, ...omit);
},
send(...args: Parameters<typeof ipcRenderer.send>) {
const [channel, ...omit] = args;
return ipcRenderer.send(channel, ...omit);
},
invoke(...args: Parameters<typeof ipcRenderer.invoke>) {
const [channel, ...omit] = args;
return ipcRenderer.invoke(channel, ...omit);
},
});
}

124
electron/preload/canvas.ts Normal file
View File

@@ -0,0 +1,124 @@
// ============================================================
// preload/canvas.ts — 无限画布 API
//
// 提供画布窗口、文件扫描、缩略图、拖拽等接口
// 渲染进程通过 window.canvas 调用
// ============================================================
import { ipcRenderer, contextBridge } from 'electron';
import { BIDIRECTIONAL, RENDERER_TO_MAIN } from '../../shared/constants/ipc';
/** 无限画布 API */
export function exposeCanvas(): void {
contextBridge.exposeInMainWorld('canvas', {
/** 打开画布窗口(主进程创建新 BrowserWindow */
openWindow(projectPath?: string): void {
ipcRenderer.send(RENDERER_TO_MAIN.OPEN_CANVAS_WINDOW, { projectPath });
},
/** 扫描目录,返回匹配的文件列表 */
scanDirectory(params: {
roots: string[];
extensions: string[];
recursive: boolean;
}): Promise<{
success: boolean;
data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>;
error?: string;
}> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_DIRECTORY, params);
},
/** 扫描项目(主进程内部完成 buildScanRoots + walkDirectory推荐使用 */
scanProject(params: {
projectPath: string;
stage: string;
assetTypeFilter: string;
shotQuery: string;
}): Promise<{
success: boolean;
data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>;
error?: string;
}> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_PROJECT, params);
},
/** 读取旁加载 JSON 元数据 */
readSidecar(params: { filePath: string }): Promise<{ success: boolean; data: object | null }> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_READ_SIDECAR, params);
},
/** 写入旁加载 JSON 元数据 */
writeSidecar(params: { filePath: string; metadata: object }): Promise<{ success: boolean; error?: string }> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_WRITE_SIDECAR, params);
},
/** 生成缩略图JPEG base64 */
generateThumbnail(params: { filePath: string; size: number }): Promise<{
success: boolean;
data: { data: string; mimeType: string; width: number; height: number };
error?: string;
}> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_GENERATE_THUMBNAIL, params);
},
/** 复制文件到目标目录(导入操作) */
copyFile(params: { src: string; destDir: string; fileName: string }): Promise<{
success: boolean;
data: string;
error?: string;
}> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_COPY_FILE, params);
},
/** 列出目录下的子文件夹(非递归,排除隐藏) */
listSubdirs(params: { parentPath: string }): Promise<{ success: boolean; data: string[]; error?: string }> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_LIST_SUBDIRS, params);
},
/** 扫描阶段文件(单个 episode/shot/stage 组合) */
scanStageFiles(params: { projectPath: string; episode: string; shot: string; stage: string }): Promise<{
success: boolean;
data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>;
error?: string;
}> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_STAGE_FILES, params);
},
/** 扫描资产文件(深度递归扫描 assets/ 目录) */
scanAssetFiles(params: { projectPath: string; assetTypeFilter: string }): Promise<{
success: boolean;
data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>;
error?: string;
}> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_ASSET_FILES, params);
},
/** 创建目录 */
createDirectory(params: { parentPath: string; dirName: string }): Promise<{
success: boolean;
data: string;
error?: string;
}> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_CREATE_DIRECTORY, params);
},
/** 将本地文件路径转换为可通过 local-media:// 协议加载的 URL */
getMediaUrl(filePath: string): string {
const normalized = filePath.replace(/\\/g, '/');
// UNC 路径(//server/share/…)开头 // 会被 URL 解析器视为 authority
// 必须用 local-media://<host>/<path> 格式而非 local-media:/// 前缀
if (normalized.startsWith('//')) {
// normalized = "//192.168.110.250/share/path"
return 'local-media://' + normalized.slice(2);
}
// 本地路径C:/path → local-media:///C:/path
return 'local-media:///' + normalized;
},
/** 启动原生文件拖拽(从缩略图拖放媒体文件到外部应用) */
startDrag(params: { filePath: string; fileName: string }): Promise<{ success: boolean; error?: string }> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_START_DRAG, params);
},
});
}

View File

@@ -0,0 +1,94 @@
// ============================================================
// preload/file-storage.ts — 本地存储文件 API主进程 fs 桥接)
//
// 提供文件读写、目录管理、下载等接口
// 渲染进程通过 window.fileStorage 调用
// ============================================================
import { ipcRenderer, contextBridge } from 'electron';
import { BIDIRECTIONAL } from '../../shared/constants/ipc';
/** 本地存储文件 API */
export function exposeFileStorage(): void {
contextBridge.exposeInMainWorld('fileStorage', {
/** 获取 heixiu-data 根目录路径 */
async getDataDir(): Promise<string | null> {
const r = await ipcRenderer.invoke(BIDIRECTIONAL.GET_DATA_DIR);
return r?.success ? r.data : null;
},
/** 写入文件Base64 → 磁盘),返回写入的绝对路径 */
async writeFile(relativePath: string, data: string, encoding?: 'base64' | 'utf8'): Promise<string | null> {
const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_WRITE, { relativePath, data, encoding });
return r?.success ? r.data : null;
},
/** 读取文件(磁盘 → Base64 */
async readFile(relativePath: string): Promise<string | null> {
const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_READ, { relativePath });
return r?.success ? r.data : null;
},
/** 删除文件 */
async deleteFile(relativePath: string): Promise<boolean> {
const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_DELETE, { relativePath });
return r?.success ?? false;
},
/** 递归创建目录 */
async makeDir(relativePath: string): Promise<string | null> {
const r = await ipcRenderer.invoke(BIDIRECTIONAL.MAKE_DIR, { relativePath });
return r?.success ? r.data : null;
},
/** 在文件管理器中显示文件(绝对路径) */
async showItemInFolder(filePath: string): Promise<boolean> {
const r = await ipcRenderer.invoke(BIDIRECTIONAL.SHOW_ITEM_IN_FOLDER, { filePath });
return r?.success ?? false;
},
/** 获取文件大小(字节) */
async fileSize(relativePath: string): Promise<number | null> {
const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_SIZE, { relativePath });
return r?.success ? r.data : null;
},
/** 检查文件/目录是否存在 */
async fileExists(relativePath: string): Promise<boolean> {
const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_EXISTS, { relativePath });
return r?.success ? r.data : false;
},
/** 绝对路径文件写入(绕过 heixiu-data 沙箱) */
async writeFileAbsolute(absolutePath: string, data: string, encoding?: 'base64' | 'utf8'): Promise<string | null> {
const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_WRITE_ABSOLUTE, { absolutePath, data, encoding });
return r?.success ? r.data : null;
},
/** 绝对路径目录创建(绕过 heixiu-data 沙箱) */
async makeDirAbsolute(absolutePath: string): Promise<string | null> {
const r = await ipcRenderer.invoke(BIDIRECTIONAL.MAKE_DIR_ABSOLUTE, { absolutePath });
return r?.success ? r.data : null;
},
/** 主进程下载文件到绝对路径(绕过浏览器 CORS 限制),失败时 reject 并携带错误信息 */
async downloadToPath(url: string, destPath: string, headers?: Record<string, string>): Promise<string> {
const r = await ipcRenderer.invoke(BIDIRECTIONAL.DOWNLOAD_TO_PATH, { url, destPath, headers });
if (r?.success) return r.data;
throw new Error(r?.error || '主进程下载失败');
},
/** 获取系统下载目录路径 */
async getDownloadsPath(): Promise<string | null> {
const r = await ipcRenderer.invoke(BIDIRECTIONAL.GET_DOWNLOADS_PATH);
return r?.success ? r.data : null;
},
/** 生成图片缩略图(读取原图 → 缩放 → JPEG 写入目标路径) */
async generateThumbnail(srcPath: string, destPath: string, maxWidth?: number, quality?: number): Promise<string> {
const r = await ipcRenderer.invoke(BIDIRECTIONAL.GENERATE_THUMBNAIL, { srcPath, destPath, maxWidth, quality });
if (r?.success) return r.data;
throw new Error(r?.error || '缩略图生成失败');
},
});
}

11
electron/preload/index.ts Normal file
View File

@@ -0,0 +1,11 @@
// ============================================================
// preload/index.ts — 汇总导出所有 preload 模块
//
// 在主 preload.ts 中调用各模块的 expose 函数
// ============================================================
export { exposeBaseIpc } from './base';
export { exposeSafeStorage } from './safe-storage';
export { exposeFileStorage } from './file-storage';
export { exposeAppRuntime } from './app-runtime';
export { exposeCanvas } from './canvas';

View File

@@ -0,0 +1,33 @@
// ============================================================
// preload/safe-storage.ts — 安全加密 API基于 Electron safeStorage
//
// 提供加密/解密接口,渲染进程通过 window.safeStorage 调用
// ============================================================
import { ipcRenderer, contextBridge } from 'electron';
import { BIDIRECTIONAL } from '../../shared/constants/ipc';
/** 安全加密 API */
export function exposeSafeStorage(): void {
contextBridge.exposeInMainWorld('safeStorage', {
/**
* 加密明文字符串
* @param plaintext - 待加密的明文
* @returns Base64 编码的密文,失败返回 null
*/
async encrypt(plaintext: string): Promise<string | null> {
const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_ENCRYPT, plaintext);
return result?.success ? result.data : null;
},
/**
* 解密 Base64 密文
* @param encryptedBase64 - Base64 编码的密文
* @returns 解密后的明文,失败返回 null
*/
async decrypt(encryptedBase64: string): Promise<string | null> {
const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_DECRYPT, encryptedBase64);
return result?.success ? result.data : null;
},
});
}

View File

@@ -1,95 +1,18 @@
// ============================================================ // ============================================================
// IPC 通道名称常量 —— 避免魔法字符串 // IPC 通道名称常量 — 兼容层(已拆分为 ipc/ 子模块)
//
// @deprecated 请使用新的模块化导入:
// import { BIDIRECTIONAL } from '@/shared/constants/ipc';
// import { BIDIRECTIONAL_STORAGE } from '@/shared/constants/ipc/storage';
// ============================================================ // ============================================================
/** 主进程 → 渲染进程 */ export {
export const MAIN_TO_RENDERER = { MAIN_TO_RENDERER,
/** 主进程消息推送 */ RENDERER_TO_MAIN,
MAIN_PROCESS_MESSAGE: 'main-process-message', BIDIRECTIONAL,
/** 主题变更通知 */ MAIN_TO_RENDERER_CANVAS,
THEME_CHANGED: 'theme-changed', RENDERER_TO_MAIN_CANVAS,
/** 窗口聚焦通知 */ BIDIRECTIONAL_CANVAS,
WINDOW_FOCUSED: 'window-focused', MAIN_TO_RENDERER_UPDATER,
/** 窗口失焦通知 */ RENDERER_TO_MAIN_UPDATER,
WINDOW_BLURRED: 'window-blurred', } from './ipc';
/** 画布窗口准备就绪 */
CANVAS_READY: 'canvas:ready',
/** 画布扫描进度 */
CANVAS_SCAN_PROGRESS: 'canvas:scan-progress',
} as const;
/** 渲染进程 → 主进程 */
export const RENDERER_TO_MAIN = {
/** 打开子窗口 */
OPEN_SUB_WINDOW: 'open-sub-window',
/** 关闭子窗口 */
CLOSE_SUB_WINDOW: 'close-sub-window',
/** 设置窗口标题 */
SET_WINDOW_TITLE: 'set-window-title',
/** 获取平台信息 */
GET_PLATFORM_INFO: 'get-platform-info',
/** 最小化窗口 */
MINIMIZE_WINDOW: 'minimize-window',
/** 最大化/还原窗口 */
TOGGLE_MAXIMIZE: 'toggle-maximize',
/** 关闭窗口 */
CLOSE_WINDOW: 'close-window',
/** 渲染进程日志上报 */
LOG_MESSAGE: 'log-message',
/** 画布:打开无限画布窗口 */
OPEN_CANVAS_WINDOW: 'canvas:open-window',
} as const;
/** 双向通信通道 */
export const BIDIRECTIONAL = {
/** 读取应用配置 */
READ_CONFIG: 'read-config',
/** 写入应用配置 */
WRITE_CONFIG: 'write-config',
/** 文件对话框 */
FILE_DIALOG: 'file-dialog',
/** safeStorage 加密(明文 → Base64 密文) */
SAFESTORAGE_ENCRYPT: 'safe-storage:encrypt',
/** safeStorage 解密Base64 密文 → 明文) */
SAFESTORAGE_DECRYPT: 'safe-storage:decrypt',
/** 文件写入Blob Buffer → 磁盘) */
FILE_WRITE: 'storage:file-write',
/** 文件读取(磁盘 → Base64 */
FILE_READ: 'storage:file-read',
/** 文件删除 */
FILE_DELETE: 'storage:file-delete',
/** 创建目录(递归) */
MAKE_DIR: 'storage:make-dir',
/** 在文件管理器中显示文件 */
SHOW_ITEM_IN_FOLDER: 'storage:show-item-in-folder',
/** 获取应用数据目录路径 */
GET_DATA_DIR: 'storage:get-data-dir',
/** 获取文件大小 */
FILE_SIZE: 'storage:file-size',
/** 文件/目录是否存在 */
FILE_EXISTS: 'storage:file-exists',
/** 画布:扫描目录 */
CANVAS_SCAN_DIRECTORY: 'canvas:scan-directory',
/** 画布:扫描项目(接收 projectPath主进程内部完成 buildScanRoots + walkDirectory */
CANVAS_SCAN_PROJECT: 'canvas:scan-project',
/** 画布:读取 sidecar 元数据 */
CANVAS_READ_SIDECAR: 'canvas:read-sidecar',
/** 画布:写入 sidecar 元数据 */
CANVAS_WRITE_SIDECAR: 'canvas:write-sidecar',
/** 画布:生成缩略图 */
CANVAS_GENERATE_THUMBNAIL: 'canvas:generate-thumbnail',
/** 画布:复制文件(导入) */
CANVAS_COPY_FILE: 'canvas:copy-file',
/** 画布:获取原生图片尺寸 */
CANVAS_GET_IMAGE_SIZE: 'canvas:get-image-size',
/** 画布:列出目录下的子文件夹(非递归,排除隐藏) */
CANVAS_LIST_SUBDIRS: 'canvas:list-subdirs',
/** 画布:扫描阶段文件(单个 episode/shot/stage 组合) */
CANVAS_SCAN_STAGE_FILES: 'canvas:scan-stage-files',
/** 画布:扫描资产文件(深度递归扫描 assets/ 目录) */
CANVAS_SCAN_ASSET_FILES: 'canvas:scan-asset-files',
/** 画布:创建目录 */
CANVAS_CREATE_DIRECTORY: 'canvas:create-directory',
/** 画布:启动原生文件拖拽(拖放媒体文件到外部应用) */
CANVAS_START_DRAG: 'canvas:start-drag',
} as const;

View File

@@ -0,0 +1,45 @@
// ============================================================
// IPC 通道常量 — 基础通道(窗口、日志、主题等)
// ============================================================
/** 主进程 → 渲染进程 */
export const MAIN_TO_RENDERER = {
/** 主进程消息推送 */
MAIN_PROCESS_MESSAGE: 'main-process-message',
/** 主题变更通知 */
THEME_CHANGED: 'theme-changed',
/** 窗口聚焦通知 */
WINDOW_FOCUSED: 'window-focused',
/** 窗口失焦通知 */
WINDOW_BLURRED: 'window-blurred',
} as const;
/** 渲染进程 → 主进程 */
export const RENDERER_TO_MAIN = {
/** 打开子窗口 */
OPEN_SUB_WINDOW: 'open-sub-window',
/** 关闭子窗口 */
CLOSE_SUB_WINDOW: 'close-sub-window',
/** 设置窗口标题 */
SET_WINDOW_TITLE: 'set-window-title',
/** 获取平台信息 */
GET_PLATFORM_INFO: 'get-platform-info',
/** 最小化窗口 */
MINIMIZE_WINDOW: 'minimize-window',
/** 最大化/还原窗口 */
TOGGLE_MAXIMIZE: 'toggle-maximize',
/** 关闭窗口 */
CLOSE_WINDOW: 'close-window',
/** 渲染进程日志上报 */
LOG_MESSAGE: 'log-message',
} as const;
/** 双向通信通道 — 应用配置 */
export const BIDIRECTIONAL_CONFIG = {
/** 读取应用配置 */
READ_CONFIG: 'read-config',
/** 写入应用配置 */
WRITE_CONFIG: 'write-config',
/** 文件对话框 */
FILE_DIALOG: 'file-dialog',
} as const;

View File

@@ -0,0 +1,45 @@
// ============================================================
// IPC 通道常量 — 无限画布相关通道
// ============================================================
/** 主进程 → 渲染进程 — 画布 */
export const MAIN_TO_RENDERER_CANVAS = {
/** 画布窗口准备就绪 */
CANVAS_READY: 'canvas:ready',
/** 画布扫描进度 */
CANVAS_SCAN_PROGRESS: 'canvas:scan-progress',
} as const;
/** 渲染进程 → 主进程 — 画布 */
export const RENDERER_TO_MAIN_CANVAS = {
/** 画布:打开无限画布窗口 */
OPEN_CANVAS_WINDOW: 'canvas:open-window',
} as const;
/** 双向通信通道 — 画布 */
export const BIDIRECTIONAL_CANVAS = {
/** 画布:扫描目录 */
CANVAS_SCAN_DIRECTORY: 'canvas:scan-directory',
/** 画布:扫描项目(接收 projectPath主进程内部完成 buildScanRoots + walkDirectory */
CANVAS_SCAN_PROJECT: 'canvas:scan-project',
/** 画布:读取 sidecar 元数据 */
CANVAS_READ_SIDECAR: 'canvas:read-sidecar',
/** 画布:写入 sidecar 元数据 */
CANVAS_WRITE_SIDECAR: 'canvas:write-sidecar',
/** 画布:生成缩略图 */
CANVAS_GENERATE_THUMBNAIL: 'canvas:generate-thumbnail',
/** 画布:复制文件(导入) */
CANVAS_COPY_FILE: 'canvas:copy-file',
/** 画布:获取原生图片尺寸 */
CANVAS_GET_IMAGE_SIZE: 'canvas:get-image-size',
/** 画布:列出目录下的子文件夹(非递归,排除隐藏) */
CANVAS_LIST_SUBDIRS: 'canvas:list-subdirs',
/** 画布:扫描阶段文件(单个 episode/shot/stage 组合) */
CANVAS_SCAN_STAGE_FILES: 'canvas:scan-stage-files',
/** 画布:扫描资产文件(深度递归扫描 assets/ 目录) */
CANVAS_SCAN_ASSET_FILES: 'canvas:scan-asset-files',
/** 画布:创建目录 */
CANVAS_CREATE_DIRECTORY: 'canvas:create-directory',
/** 画布:启动原生文件拖拽(拖放媒体文件到外部应用) */
CANVAS_START_DRAG: 'canvas:start-drag',
} as const;

View File

@@ -0,0 +1,25 @@
// ============================================================
// IPC 通道常量 — 汇总导出
//
// 使用方式:
// import { MAIN_TO_RENDERER, BIDIRECTIONAL } from '@/shared/constants/ipc';
// import { BIDIRECTIONAL_STORAGE } from '@/shared/constants/ipc/storage';
// ============================================================
export { MAIN_TO_RENDERER, RENDERER_TO_MAIN, BIDIRECTIONAL_CONFIG } from './base';
export { BIDIRECTIONAL_STORAGE } from './storage';
export { MAIN_TO_RENDERER_CANVAS, RENDERER_TO_MAIN_CANVAS, BIDIRECTIONAL_CANVAS } from './canvas';
export { MAIN_TO_RENDERER_UPDATER, RENDERER_TO_MAIN_UPDATER } from './updater';
// ---------- 兼容性汇总(向后兼容) ----------
import { BIDIRECTIONAL_CONFIG } from './base';
import { BIDIRECTIONAL_STORAGE } from './storage';
import { BIDIRECTIONAL_CANVAS } from './canvas';
/** @deprecated 请使用按功能域拆分的导出 */
export const BIDIRECTIONAL = {
...BIDIRECTIONAL_CONFIG,
...BIDIRECTIONAL_STORAGE,
...BIDIRECTIONAL_CANVAS,
} as const;

View File

@@ -0,0 +1,37 @@
// ============================================================
// IPC 通道常量 — 存储相关通道
// ============================================================
/** 双向通信通道 — 文件存储 */
export const BIDIRECTIONAL_STORAGE = {
/** safeStorage 加密(明文 → Base64 密文) */
SAFESTORAGE_ENCRYPT: 'safe-storage:encrypt',
/** safeStorage 解密Base64 密文 → 明文) */
SAFESTORAGE_DECRYPT: 'safe-storage:decrypt',
/** 文件写入Blob Buffer → 磁盘) */
FILE_WRITE: 'storage:file-write',
/** 文件读取(磁盘 → Base64 */
FILE_READ: 'storage:file-read',
/** 文件删除 */
FILE_DELETE: 'storage:file-delete',
/** 创建目录(递归) */
MAKE_DIR: 'storage:make-dir',
/** 在文件管理器中显示文件 */
SHOW_ITEM_IN_FOLDER: 'storage:show-item-in-folder',
/** 获取应用数据目录路径 */
GET_DATA_DIR: 'storage:get-data-dir',
/** 获取文件大小 */
FILE_SIZE: 'storage:file-size',
/** 文件/目录是否存在 */
FILE_EXISTS: 'storage:file-exists',
/** 绝对路径文件写入(绕过 heixiu-data 沙箱,用于用户自定义输出目录) */
FILE_WRITE_ABSOLUTE: 'storage:file-write-absolute',
/** 绝对路径目录创建(绕过 heixiu-data 沙箱) */
MAKE_DIR_ABSOLUTE: 'storage:make-dir-absolute',
/** 主进程下载文件到绝对路径(绕过浏览器 CORS 限制) */
DOWNLOAD_TO_PATH: 'storage:download-to-path',
/** 获取系统下载目录路径 */
GET_DOWNLOADS_PATH: 'storage:get-downloads-path',
/** 生成图片缩略图(读取原图 → 缩放 → 写入目标路径) */
GENERATE_THUMBNAIL: 'storage:generate-thumbnail',
} as const;

View File

@@ -0,0 +1,27 @@
// ============================================================
// IPC 通道常量 — 自动更新相关通道
// ============================================================
/** 主进程 → 渲染进程 — 更新 */
export const MAIN_TO_RENDERER_UPDATER = {
/** 发现新版本 */
UPDATE_AVAILABLE: 'update-available',
/** 下载进度 */
UPDATE_PROGRESS: 'update-progress',
/** 更新已下载完成 */
UPDATE_DOWNLOADED: 'update-downloaded',
/** 更新出错 */
UPDATE_ERROR: 'update-error',
/** 当前已是最新版本 */
UPDATE_NOT_AVAILABLE: 'update-not-available',
} as const;
/** 渲染进程 → 主进程 — 更新 */
export const RENDERER_TO_MAIN_UPDATER = {
/** 安装更新 */
INSTALL_UPDATE: 'install-update',
/** 检查更新 */
CHECK_FOR_UPDATE: 'check-for-update',
/** 下载更新 */
DOWNLOAD_UPDATE: 'download-update',
} as const;

View File

@@ -7,7 +7,7 @@ import { SettingsPage } from '@/modules/settings';
import { on, off, emit, EVENTS } from '@/shared/utils/bus'; import { on, off, emit, EVENTS } from '@/shared/utils/bus';
import type { BusinessErrorEvent } from '@/shared/utils/bus'; import type { BusinessErrorEvent } from '@/shared/utils/bus';
import { Layout } from '@/app/layout/Layout'; import { Layout } from '@/app/layout/Layout';
import { HomePage } from '@/modules/home/views/HomePage'; import { HomePage } from '@/modules/home/shared/views/HomePage';
import { PageDispatcher } from '@/app/layout/PageDispatcher'; import { PageDispatcher } from '@/app/layout/PageDispatcher';
/** /**

View File

@@ -6,7 +6,7 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { theme as antTheme } from 'antd'; import { theme as antTheme } from 'antd';
import { NavBar } from '@/modules/navbar'; import { NavBar } from '@/modules/navbar';
import { BannerCarousel } from '@/modules/home/views/right/BannerCarousel'; import { BannerCarousel } from '@/modules/home/shared/views/BannerCarousel';
export function Layout({ children }: { children: ReactNode }) { export function Layout({ children }: { children: ReactNode }) {
const { token } = antTheme.useToken(); const { token } = antTheme.useToken();

View File

@@ -12,11 +12,11 @@ import type { Platform, Edition } from '@shared/types';
import { getPlatform, isDev } from '@/shared/utils/env'; import { getPlatform, isDev } from '@/shared/utils/env';
import { safeIpcOn, safeIpcOff } from '@/shared/utils/bus'; import { safeIpcOn, safeIpcOff } from '@/shared/utils/bus';
import { AuthAPI, type LoginResponseBody } from '@/modules/auth/controllers/auth-api'; import { AuthAPI, type LoginResponseBody } from '@/services/modules/auth/auth';
import { setToken, clearToken, initTokenStore } from '@/shared/services/http'; import { setToken, clearToken, initTokenStore } from '@/services/request';
import { emit, EVENTS } from '@/shared/utils/bus'; import { emit, EVENTS } from '@/shared/utils/bus';
import { logger } from '@/shared/utils/bus'; import { logger } from '@/shared/utils/bus';
import { getAutoLoginFlag, clearAutoLoginFlag } from '@/modules/auth/controllers/auth-persistence'; import { getAutoLoginFlag, clearAutoLoginFlag } from '@/services/auth-persistence';
import { import {
initAuthRefresh, initAuthRefresh,
getStoredRefreshToken, getStoredRefreshToken,
@@ -25,8 +25,8 @@ import {
initRefreshTokenStore, initRefreshTokenStore,
scheduleProactiveRefresh, scheduleProactiveRefresh,
clearProactiveRefresh, clearProactiveRefresh,
} from '@/modules/auth/controllers/token-manager'; } from '@/services/auth-token';
import {initModelCache} from '@/shared/services/cache/model-cache'; import {initModelCache} from '@/services/cache/model-cache';
import {initStorage} from '@/shared/infrastructure/storage'; import {initStorage} from '@/shared/infrastructure/storage';
// ---------- Context 类型 ---------- // ---------- Context 类型 ----------

View File

@@ -16,7 +16,7 @@ import {
ApiOutlined, ApiOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import {useBillingBalance, useBillingLedger} from '@/shared/hooks/use-api'; import {useBillingBalance, useBillingLedger} from '@/shared/hooks/use-api';
import type {EntryTypeValue, LedgerItemResponseBody, LedgerReqeustParam} from '@/modules/account/controllers/billing-api'; import type {EntryTypeValue, LedgerItemResponseBody, LedgerReqeustParam} from '@/services/modules/account/billing';
import {EnumResolver} from '@/shared/utils/enum-resolver'; import {EnumResolver} from '@/shared/utils/enum-resolver';
import {centToYuan, formatDate} from '@/shared/utils/display'; import {centToYuan, formatDate} from '@/shared/utils/display';

View File

@@ -18,7 +18,7 @@ import {
RightOutlined, RightOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import {EnumResolver} from '@/shared/utils/enum-resolver'; import {EnumResolver} from '@/shared/utils/enum-resolver';
import type { VipLevelsItem } from '@/modules/account/controllers/vip-api'; import type { VipLevelsItem } from '@/services/modules/account/vip-api';
import { useAccountInfo } from '@/shared/hooks/use-api'; import { useAccountInfo } from '@/shared/hooks/use-api';
import { centToYuan, formatDate } from '@/shared/utils/display'; import { centToYuan, formatDate } from '@/shared/utils/display';

View File

@@ -21,16 +21,16 @@ import {
type PaymentStatus, type PaymentStatus,
type OrderListResponseBody, type OrderListResponseBody,
type OrderInfoResponseBody, type OrderInfoResponseBody,
} from '@/modules/account/controllers/payment-api'; } from '@/services/modules/account/payments';
import { import {
VipAPI, VipAPI,
VipOrderStatus, VipOrderStatus,
type VipOrderStatusValue, type VipOrderStatusValue,
type VipOrderRead, type VipOrderRead,
} from '@/modules/account/controllers/vip-api'; } from '@/services/modules/account/vip-api';
import { centToYuan, formatDate } from '@/shared/utils/display'; import { centToYuan, formatDate } from '@/shared/utils/display';
import { EnumResolver } from '@/shared/utils/enum-resolver'; import { EnumResolver } from '@/shared/utils/enum-resolver';
import { RequestError, RequestErrorType } from '@/shared/services/http'; import { RequestError, RequestErrorType } from '@/services/request';
const { Text } = Typography; const { Text } = Typography;

View File

@@ -39,7 +39,7 @@ import {
PAYMENT_STATUS, PAYMENT_STATUS,
getPaymentStatusText, getPaymentStatusText,
type PaymentStatus, type PaymentStatus,
} from '@/modules/account/controllers/payment-api'; } from '@/services/modules/account/payments';
import { emit, EVENTS } from '@/shared/utils/bus'; import { emit, EVENTS } from '@/shared/utils/bus';
const { Text } = Typography; const { Text } = Typography;

View File

@@ -34,11 +34,11 @@ import {
VipOrderStatus, VipOrderStatus,
type VipLevelsItem, type VipLevelsItem,
type VipOrderStatusValue, type VipOrderStatusValue,
} from '@/modules/account/controllers/vip-api'; } from '@/services/modules/account/vip-api';
import {centToYuan} from '@/shared/utils/display'; import {centToYuan} from '@/shared/utils/display';
import {EnumResolver} from '@/shared/utils/enum-resolver'; import {EnumResolver} from '@/shared/utils/enum-resolver';
import {emit, EVENTS} from '@/shared/utils/bus'; import {emit, EVENTS} from '@/shared/utils/bus';
import {RequestError, RequestErrorType} from '@/shared/services/http'; import {RequestError, RequestErrorType} from '@/services/request';
const {Text} = Typography; const {Text} = Typography;

View File

@@ -12,7 +12,7 @@ import { useEffect, useState, useCallback } from 'react';
import { logger } from '@/shared/utils/bus'; import { logger } from '@/shared/utils/bus';
import { Spin, Empty } from 'antd'; import { Spin, Empty } from 'antd';
import { WechatOutlined } from '@ant-design/icons'; import { WechatOutlined } from '@ant-design/icons';
import { fetchBanners, type Banner } from '@/modules/home/controllers/banner-api'; import { fetchBanners, type Banner } from '@/services/modules/home/banner';
export function WechatWorkPage() { export function WechatWorkPage() {
const [banner, setBanner] = useState<Banner | null>(null); const [banner, setBanner] = useState<Banner | null>(null);

View File

@@ -1,5 +1,5 @@
// announcement 模块 — 公告 // announcement 模块 — 公告
export { AnnouncementDrawer } from './views/AnnouncementDrawer'; export { AnnouncementDrawer } from './views/AnnouncementDrawer';
export { fetchAnnouncements } from './controllers/announcement-api'; export { fetchAnnouncements } from '@/services/modules/announcement/announcement';
export type { Announcement, AnnouncementType, AnnouncementTypeValue, AnnouncementListResponse } from './controllers/announcement-api'; export type { Announcement, AnnouncementType, AnnouncementTypeValue, AnnouncementListResponse } from '@/services/modules/announcement/announcement';
export { AnnouncementTypeEnum, AnnouncementTypeLabelMap, AnnouncementTypeColorMap } from './controllers/announcement-api'; export { AnnouncementTypeEnum, AnnouncementTypeLabelMap, AnnouncementTypeColorMap } from '@/services/modules/announcement/announcement';

View File

@@ -10,7 +10,7 @@ import {useState} from 'react';
import {Drawer, Card, Typography, Empty, Tag, Spin, Modal, Tooltip} from 'antd'; import {Drawer, Card, Typography, Empty, Tag, Spin, Modal, Tooltip} from 'antd';
import {NotificationOutlined} from '@ant-design/icons'; import {NotificationOutlined} from '@ant-design/icons';
import type { Announcement, AnnouncementTypeValue } from '@/modules/announcement/controllers/announcement-api'; import type { Announcement, AnnouncementTypeValue } from '@/services/modules/announcement/announcement';
import { EnumResolver } from '@/shared/utils/enum-resolver'; import { EnumResolver } from '@/shared/utils/enum-resolver';
const {Text, Paragraph} = Typography; const {Text, Paragraph} = Typography;

View File

@@ -19,9 +19,9 @@ import {
getSavedUsername, getSavedUsername,
getSavedRemember, getSavedRemember,
getSavedAutoLogin, getSavedAutoLogin,
} from '@/modules/auth/controllers/auth-persistence'; } from '@/services/auth-persistence';
export { getAutoLoginFlag, clearAutoLoginFlag, clearSavedAuth } from '@/modules/auth/controllers/auth-persistence'; export { getAutoLoginFlag, clearAutoLoginFlag, clearSavedAuth } from '@/services/auth-persistence';
// ---------- Hook ---------- // ---------- Hook ----------

View File

@@ -1,6 +1,6 @@
// auth 模块 — 认证(登录/注册/密码/Token // auth 模块 — 认证(登录/注册/密码/Token
export { LoginPage } from './views/AuthPage'; export { LoginPage } from './views/AuthPage';
export { AuthAPI } from './controllers/auth-api'; export { AuthAPI } from '@/services/modules/auth/auth';
export type { LoginResponseBody } from './controllers/auth-api'; export type { LoginResponseBody } from '@/services/modules/auth/auth';
export { initAuthRefresh } from './controllers/token-manager'; export { initAuthRefresh } from '@/services/auth-token';
export { getAutoLoginFlag, clearAutoLoginFlag } from './controllers/auth-persistence'; export { getAutoLoginFlag, clearAutoLoginFlag } from '@/services/auth-persistence';

View File

@@ -17,7 +17,7 @@ import { LoginForm } from './components/LoginForm';
import { RegisterForm } from './components/RegisterForm'; import { RegisterForm } from './components/RegisterForm';
import { ForgotPasswordModal } from './components/ForgotPasswordModal'; import { ForgotPasswordModal } from './components/ForgotPasswordModal';
import { useAuthState } from '../hooks/use-auth-state'; import { useAuthState } from '../hooks/use-auth-state';
import { AuthAPI, type LoginResponseBody } from '../controllers/auth-api'; import { AuthAPI, type LoginResponseBody } from '@/services/modules/auth/auth';
import { getDeviceId } from '@/shared/utils/env'; import { getDeviceId } from '@/shared/utils/env';
import type { LoginFormValues, RegisterFormValues } from '../model/types'; import type { LoginFormValues, RegisterFormValues } from '../model/types';

View File

@@ -14,7 +14,7 @@ import { Modal, Input, Button, Typography, App } from 'antd';
import { LockOutlined, KeyOutlined, SafetyOutlined } from '@ant-design/icons'; import { LockOutlined, KeyOutlined, SafetyOutlined } from '@ant-design/icons';
import { useTheme } from '@/app/providers/ThemeProvider'; import { useTheme } from '@/app/providers/ThemeProvider';
import { AuthAPI } from '@/modules/auth/controllers/auth-api'; import { AuthAPI } from '@/services/modules/auth/auth';
import { validatePassword, validateConfirmPassword, runFieldChecks } from '../../settings/validators'; import { validatePassword, validateConfirmPassword, runFieldChecks } from '../../settings/validators';
const { Text, Title } = Typography; const { Text, Title } = Typography;

View File

@@ -20,7 +20,7 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useTheme } from '@/app/providers/ThemeProvider'; import { useTheme } from '@/app/providers/ThemeProvider';
import { AuthAPI } from '@/modules/auth/controllers/auth-api'; import { AuthAPI } from '@/services/modules/auth/auth';
import { import {
validatePhone, validatePhone,
validateSmsCode, validateSmsCode,

View File

@@ -6,7 +6,7 @@
// ============================================================ // ============================================================
import { hasIpc, safeIpcInvoke } from '@/shared/utils/bus'; import { hasIpc, safeIpcInvoke } from '@/shared/utils/bus';
import { BIDIRECTIONAL } from '@shared/constants/ipc-channels'; import { BIDIRECTIONAL } from '@shared/constants/ipc';
import type { import type {
ICanvasDataSource, ICanvasDataSource,
ScanRequest, ScanRequest,

View File

@@ -12,7 +12,7 @@ import { useSidecarSync } from '../../hooks/useSidecarSync';
import { getMediaCardMenuItems } from '../../controllers/menus'; import { getMediaCardMenuItems } from '../../controllers/menus';
import { ContextMenu } from '@/shared/components/ContextMenu'; import { ContextMenu } from '@/shared/components/ContextMenu';
import { safeIpcInvoke } from '@/shared/utils/bus'; import { safeIpcInvoke } from '@/shared/utils/bus';
import { BIDIRECTIONAL } from '@shared/constants/ipc-channels'; import { BIDIRECTIONAL } from '@shared/constants/ipc';
import { ITEM_W, ITEM_H } from '../../utils/constants'; import { ITEM_W, ITEM_H } from '../../utils/constants';
import type { AuditStatus } from '../../stores/useCanvasStore'; import type { AuditStatus } from '../../stores/useCanvasStore';

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 { getTextInputMenuItems } from './text-input-menu';
export type { TextInputMenuOptions } from './text-input-menu'; export type { TextInputMenuOptions } from './text-input-menu';
export { getMediaRefImageMenu, getMediaRefBlankMenu } from './media-ref-menu'; export { getMediaRefImageMenu, getMediaRefBlankMenu } from './media-ref-menu';
export type { MediaRefMenuOptions } from './media-ref-menu'; export type { MediaRefMenuOptions } from './media-ref-menu';
export { getPreviewImageMenu, getPreviewEmptyMenu } from './preview-area-menu';
export type { PreviewAreaMenuOptions } from './preview-area-menu';

View File

@@ -14,9 +14,9 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { ComponentType } from 'react'; import type { ComponentType } from 'react';
import type { ModelsListResponseBody } from '@/modules/home/controllers/model-api'; import type { ModelsListResponseBody } from '@/services/modules/home/models';
import { WIDGET_MAP, FALLBACK_WIDGET } from '../views/left/controls'; import { WIDGET_MAP, FALLBACK_WIDGET } from '../views/widgets';
import type { WidgetProps } from '../views/left/controls'; import type { WidgetProps } from '../views/widgets';
// ---------- 输出类型 ---------- // ---------- 输出类型 ----------

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 { theme as antTheme } from 'antd';
import { ModelInputForm } from '../right/ModelInputForm'; import { ModelInputForm } from './ModelInputForm';
export function CenterPanel() { export function CenterPanel() {
const { token } = antTheme.useToken(); const { token } = antTheme.useToken();

View File

@@ -24,127 +24,53 @@ import {
Modal, Modal,
Dropdown, Dropdown,
} from 'antd'; } from 'antd';
import type { FormInstance } from 'antd';
import { SendOutlined, ClockCircleOutlined, ReloadOutlined } from '@ant-design/icons'; import { SendOutlined, ClockCircleOutlined, ReloadOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload'; import type { UploadFile } from 'antd/es/upload';
import { useHomeContext } from '@/modules/home/stores/HomeContext'; import { useHomeContext } from '@/modules/home/shared/stores/HomeContext';
import { useAppContext } from '@/app/providers/AppProvider'; import { useAppContext } from '@/app/providers/AppProvider';
import { useSubmitTask } from '@/shared/hooks/use-api'; import { useSubmitTask } from '@/shared/hooks/use-api';
import { emit, EVENTS } from '@/shared/utils/bus'; import { emit, on, off, EVENTS } from '@/shared/utils/bus';
import { OutputTypeMap } from '@/modules/home/controllers/task-api'; import { saveParamSnapshot } from '@/shared/infrastructure/storage';
import type { OutputTypeString, TaskInfoItemResponseBody } from '@/modules/home/controllers/task-api'; import { getCachedModels } from '@/services/cache/model-cache';
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, type UIFieldConfig } from '../hooks/useModelUI';
import { import {
calculateCost, calculateCost,
formatYuan, formatYuan,
type ModelDefinition, type ModelDefinition,
type ModelPricingConfig, type ModelPricingConfig,
} from '@/shared/utils/pricing'; } from '@/shared/utils/pricing';
import { getTextInputMenuItems } from '../../controllers/menus'; import { getTextInputMenuItems } from '../controllers/menus';
import type { ContextMenuItems } from '@/shared/components/ContextMenu'; import type { ContextMenuItems } from '@/shared/components/ContextMenu';
import { import {
execUndo, execRedo, execCut, execCopy, execPaste, execUndo, execRedo, execCut, execCopy, execPaste,
execClear, execSelectAll, execIncreaseFont, execDecreaseFont, execClear, execSelectAll, execIncreaseFont, execDecreaseFont,
execResetFont, hasTextSelection, isInputEmpty, getFontSizeLevel, execResetFont, hasTextSelection, isInputEmpty, getFontSizeLevel,
} from '../../controllers/menus/text-input-ops'; } from '../controllers/menus/text-input-ops';
import { handleFileWidgetValue, EMPTY_ARRAY, EMPTY_OBJ, safeNum } from './ModelInputForm/utils';
import { DependentFormItem } from './ModelInputForm/DependentFormItem';
const { Text, Title } = Typography; const { Text, Title } = Typography;
// ---------- 工具 ----------
/**
* Form.Item getValueFromEvent onChange
* 1. Upload DOM { fileList: UploadFile[] } fileList
* 2. enable_switch undefined / [] / [{...}]
*/
function handleFileWidgetValue(e: unknown): unknown {
if (e && typeof e === 'object' && 'fileList' in e) {
return (e as { fileList: unknown }).fileList;
}
return e;
}
// ---------- 依赖字段包装组件 ----------
/**
* DependentFormItem
*
* Form.useWatch dependsOn
* disabled: true widgetConfig
*
* DependentFormItem dependsOn
* key React hooks
*/
function DependentFormItem({
field,
form,
value,
onChange,
disabled: formItemDisabled,
}: {
field: UIFieldConfig;
form: FormInstance;
/** Form.Item 通过 React.cloneElement 注入,必须透传给 widget */
value?: unknown;
onChange?: (value: unknown) => void;
disabled?: boolean;
}) {
// Form.useWatch 必须在组件顶层无条件调用(不能放 .map() 或条件分支中)。
// dependsOn 长度由 param_schema 约束(当前 API 最多 2 个),
// 预留 3 个固定槽位覆盖未来扩展;无依赖时 watch 空串 → 返回 undefined。
const deps = field.dependsOn || [];
const d0 = Form.useWatch(deps[0] || '', form);
const d1 = Form.useWatch(deps[1] || '', form);
const d2 = Form.useWatch(deps[2] || '', form);
const depValues = [d0, d1, d2].slice(0, deps.length);
const isDepDisabled =
deps.some((_, i) => isValueEmpty(depValues[i]));
const effectiveDisabled = formItemDisabled || isDepDisabled;
const effectiveConfig = effectiveDisabled
? { ...field.widgetConfig, disabled: true }
: field.widgetConfig;
const Component = field.component;
return (
<Component
value={value}
onChange={onChange}
disabled={effectiveDisabled}
config={effectiveConfig}
/>
);
}
/** 稳定空数组引用,避免 useMemo 重建 */
const EMPTY_ARRAY: never[] = [];
/** 稳定空对象引用,避免每次渲染创建新 {} 导致 useMemo 失效 */
const EMPTY_OBJ: Record<string, unknown> = {};
/**
* JSON int / float / string
*/
function safeNum(v: unknown): number | null {
if (v === null || v === undefined) return null;
if (typeof v === 'number' && !Number.isNaN(v)) return v;
if (typeof v === 'string') {
const n = Number(v.trim());
return Number.isNaN(n) ? null : n;
}
return null;
}
// ---------- 主组件 ---------- // ---------- 主组件 ----------
export function ModelInputForm() { export function ModelInputForm() {
const { token } = antTheme.useToken(); const { token } = antTheme.useToken();
const { isLoggedIn } = useAppContext(); const { isLoggedIn } = useAppContext();
const { selectedModel } = useHomeContext(); const { selectedModel, setSelectedModel } = useHomeContext();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [taskTag, setTaskTag] = useState(''); const [taskTag, setTaskTag] = useState('');
// ── 重新编辑 / 再次生成支持 ──
/** 待回填的参数(由 TASK_REEDIT / TASK_REGEN 事件设置) */
const pendingTaskRef = useRef<{ params: Record<string, unknown>; autoSubmit: boolean } | null>(null);
/** 自动提交标记(再次生成时设置) */
const autoSubmitRef = useRef(false);
// 跟踪表单当前值(用于动态计价,每次字段变化时更新) // 跟踪表单当前值(用于动态计价,每次字段变化时更新)
const [currentValues, setCurrentValues] = useState<Record<string, unknown>>({}); const [currentValues, setCurrentValues] = useState<Record<string, unknown>>({});
@@ -174,6 +100,78 @@ export function ModelInputForm() {
submitErrorRef.current = submitError; submitErrorRef.current = submitError;
}, [submitError]); }, [submitError]);
// ======== 重新编辑 / 再次生成事件处理 ========
/** 处理重新编辑事件:切换模型 + 回填参数 */
const handleReeditEvent = useCallback((...args: unknown[]) => {
const payload = args[0] as { record: TaskInfoItemResponseBody; savedParams?: Record<string, unknown> };
const { record, savedParams } = payload;
// 从缓存中查找模型
const models = getCachedModels();
const model = models?.find((m) => m.id === record.model_id);
if (!model) {
message.warning('未找到对应模型,可能模型列表未加载');
return;
}
// 同模型 → 直接回填表单
if (selectedModel?.id === model.id && savedParams && Object.keys(savedParams).length > 0) {
form.setFieldsValue(savedParams);
setCurrentValues(savedParams);
setTaskTag('');
message.info('已回填历史参数,请修改后提交');
return;
}
// 不同模型 → 切换模型 + 存储待回填参数
if (savedParams && Object.keys(savedParams).length > 0) {
pendingTaskRef.current = { params: savedParams, autoSubmit: false };
}
setSelectedModel(model);
}, [selectedModel, form, setSelectedModel]);
/** 处理再次生成事件:切换模型 + 回填参数 + 自动提交 */
const handleRegenEvent = useCallback((...args: unknown[]) => {
const payload = args[0] as { record: TaskInfoItemResponseBody; savedParams?: Record<string, unknown> };
const { record, savedParams } = payload;
const models = getCachedModels();
const model = models?.find((m) => m.id === record.model_id);
if (!model) {
message.warning('未找到对应模型,可能模型列表未加载');
return;
}
if (!savedParams || Object.keys(savedParams).length === 0) {
message.warning('未找到原始参数,请手动重新提交');
return;
}
// 同模型 → 直接回填 + 自动提交
if (selectedModel?.id === model.id) {
form.setFieldsValue(savedParams);
setCurrentValues(savedParams);
setTaskTag('');
autoSubmitRef.current = true;
return;
}
// 不同模型 → 切换模型 + 存储待回填参数 + 标记自动提交
pendingTaskRef.current = { params: savedParams, autoSubmit: true };
setSelectedModel(model);
}, [selectedModel, form, setSelectedModel]);
// 注册事件监听
useEffect(() => {
on(EVENTS.TASK_REEDIT, handleReeditEvent);
on(EVENTS.TASK_REGEN, handleRegenEvent);
return () => {
off(EVENTS.TASK_REEDIT, handleReeditEvent);
off(EVENTS.TASK_REGEN, handleRegenEvent);
};
}, [handleReeditEvent, handleRegenEvent]);
// ======== useModelUIparam_schema → UIFieldConfig[] ======== // ======== useModelUIparam_schema → UIFieldConfig[] ========
const fields: UIFieldConfig[] = useModelUI( const fields: UIFieldConfig[] = useModelUI(
@@ -196,12 +194,25 @@ export function ModelInputForm() {
// 选中模型时加载默认值useLayoutEffect 在 DOM 更新后、浏览器绘制前执行,避免闪烁) // 选中模型时加载默认值useLayoutEffect 在 DOM 更新后、浏览器绘制前执行,避免闪烁)
// antd 有外部 form 实例时 initialValues prop 被忽略,必须手动 setFieldsValue // antd 有外部 form 实例时 initialValues prop 被忽略,必须手动 setFieldsValue
// 如果有待回填的重新编辑参数pendingTaskRef优先使用
useLayoutEffect(() => { useLayoutEffect(() => {
if (selectedModel && Object.keys(initialValues).length > 0) { if (selectedModel && Object.keys(initialValues).length > 0) {
// 检查是否有待回填的参数(来自重新编辑/再次生成)
const pending = pendingTaskRef.current;
if (pending) {
pendingTaskRef.current = null;
form.setFieldsValue(pending.params);
setCurrentValues(pending.params);
setTaskTag('');
if (pending.autoSubmit) {
autoSubmitRef.current = true;
}
} else {
form.setFieldsValue(initialValues); form.setFieldsValue(initialValues);
setTaskTag(''); setTaskTag('');
// 同步价格计算:切换模型时立即用默认值更新预估 // 同步价格计算:切换模型时立即用默认值更新预估
setCurrentValues(initialValues); setCurrentValues(initialValues);
}
} else if (!selectedModel) { } else if (!selectedModel) {
setCurrentValues({}); setCurrentValues({});
} }
@@ -296,6 +307,12 @@ export function ModelInputForm() {
if (result !== undefined) { if (result !== undefined) {
submitCountRef.current += 1; submitCountRef.current += 1;
// 保存参数快照(用于"再次生成"和"重新编辑"功能)
try {
saveParamSnapshot(result.id, selectedModel.id, selectedModel.name, params, taskTag.trim() || undefined);
} catch {
// 参数快照保存失败不影响主流程
}
} else { } else {
// 余额不足 / 软件到期等错误已由 request.ts 拦截器 → 事件总线 → Modal 展示, // 余额不足 / 软件到期等错误已由 request.ts 拦截器 → 事件总线 → Modal 展示,
// 此处检查 error.displayed 标记,避免 message.error toast 重复提示 // 此处检查 error.displayed 标记,避免 message.error toast 重复提示
@@ -342,6 +359,19 @@ export function ModelInputForm() {
void doSubmit(); void doSubmit();
}, [isLoggedIn, selectedModel, doSubmit]); }, [isLoggedIn, selectedModel, doSubmit]);
// 自动提交检测(再次生成时,回填参数后自动触发提交)
useEffect(() => {
if (!autoSubmitRef.current || !selectedModel || submitting || !isLoggedIn) return;
autoSubmitRef.current = false;
// 延迟确保表单值已设置
const timer = setTimeout(() => {
// 跳过防抖和二次确认,直接提交
lastClickTimeRef.current = 0;
void doSubmit();
}, 300);
return () => clearTimeout(timer);
});
// ======== 动态预估消费金额(必须在所有提前返回之前) ======== // ======== 动态预估消费金额(必须在所有提前返回之前) ========
const estimatedCostDisplay: string = useMemo(() => { const estimatedCostDisplay: string = useMemo(() => {

View File

@@ -0,0 +1,59 @@
// ============================================================
// ModelInputForm/DependentFormItem.tsx — 依赖字段包装组件
// ============================================================
import { Form } from 'antd';
import type { FormInstance } from 'antd';
import { isValueEmpty, type UIFieldConfig } from '../../hooks/useModelUI';
/**
* DependentFormItem — 为单个字段处理条件依赖禁用。
*
* 通过 Form.useWatch 监听 dependsOn 列表中的字段值,
* 任一依赖为空时自动禁用本字段(注入 disabled: true 到 widgetConfig
*
* 每个 DependentFormItem 实例的 dependsOn 长度固定,
* 切换模型时通过外层 key 变化自动重新挂载,满足 React hooks 规则。
*/
export function DependentFormItem({
field,
form,
value,
onChange,
disabled: formItemDisabled,
}: {
field: UIFieldConfig;
form: FormInstance;
/** Form.Item 通过 React.cloneElement 注入,必须透传给 widget */
value?: unknown;
onChange?: (value: unknown) => void;
disabled?: boolean;
}) {
// Form.useWatch 必须在组件顶层无条件调用(不能放 .map() 或条件分支中)。
// dependsOn 长度由 param_schema 约束(当前 API 最多 2 个),
// 预留 3 个固定槽位覆盖未来扩展;无依赖时 watch 空串 → 返回 undefined。
const deps = field.dependsOn || [];
const d0 = Form.useWatch(deps[0] || '', form);
const d1 = Form.useWatch(deps[1] || '', form);
const d2 = Form.useWatch(deps[2] || '', form);
const depValues = [d0, d1, d2].slice(0, deps.length);
const isDepDisabled =
deps.some((_, i) => isValueEmpty(depValues[i]));
const effectiveDisabled = formItemDisabled || isDepDisabled;
const effectiveConfig = effectiveDisabled
? { ...field.widgetConfig, disabled: true }
: field.widgetConfig;
const Component = field.component;
return (
<Component
value={value}
onChange={onChange}
disabled={effectiveDisabled}
config={effectiveConfig}
/>
);
}

View File

@@ -0,0 +1,33 @@
// ============================================================
// ModelInputForm/utils.ts — 工具函数
// ============================================================
/**
* Form.Item 的 getValueFromEvent兼容两种 onChange 参数形态:
* 1. Upload 组件的 DOM 事件:{ fileList: UploadFile[] } → 提取 fileList
* 2. enable_switch 的裸值undefined / [] / [{...}] → 直接透传
*/
export function handleFileWidgetValue(e: unknown): unknown {
if (e && typeof e === 'object' && 'fileList' in e) {
return (e as { fileList: unknown }).fileList;
}
return e;
}
/** 稳定空数组引用,避免 useMemo 重建 */
export const EMPTY_ARRAY: never[] = [];
/** 稳定空对象引用,避免每次渲染创建新 {} 导致 useMemo 失效 */
export const EMPTY_OBJ: Record<string, unknown> = {};
/**
* 从自由格式 JSON 中安全读取数字(兼容 int / float / string
*/
export function safeNum(v: unknown): number | null {
if (v === null || v === undefined) return null;
if (typeof v === 'number' && !Number.isNaN(v)) return v;
if (typeof v === 'string') {
const n = Number(v.trim());
return Number.isNaN(n) ? null : n;
}
return null;
}

View File

@@ -21,7 +21,7 @@ import type { UploadFile, RcFile } from 'antd/es/upload';
import type { WidgetProps } from './types'; import type { WidgetProps } from './types';
import { useFileUpload } from '@/shared/hooks/use-api'; import { useFileUpload } from '@/shared/hooks/use-api';
import { ContextMenu } from '@/shared/components/ContextMenu'; import { ContextMenu } from '@/shared/components/ContextMenu';
import { getMediaRefImageMenu, getMediaRefBlankMenu } from '../../../controllers/menus'; import { getMediaRefImageMenu, getMediaRefBlankMenu } from '../../controllers/menus';
import type { ContextMenuItems } from '@/shared/components/ContextMenu'; import type { ContextMenuItems } from '@/shared/components/ContextMenu';
const { TextArea } = Input; const { TextArea } = Input;

14
src/modules/home/index.ts Normal file
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,9 @@
// ============================================================
// menus — 左栏右键菜单定义统一导出
// ============================================================
export { getTaskListMenuItems } from './task-list-menu';
export type { TaskListMenuOptions } from './task-list-menu';
export { buildTaskActions } from './task-list-actions';
export type { BuildTaskActionsOptions } from './task-list-actions';

View File

@@ -0,0 +1,427 @@
// ============================================================
// task-list-actions.ts — 任务列表右键菜单操作实现
//
// 职责:
// - 接收 TaskInfoItemResponseBody返回 TaskListMenuOptions
// - 所有回调在此文件中实现IPC 调用、日志记录、用户提示)
// - View 层只需调用 buildTaskActions(record) 即可获得完整菜单配置
//
// 设计原则:
// - View 不关心 IPC 细节、路径拼接逻辑、错误处理
// - 每个操作独立 async 函数,便于单独测试和复用
// - 跨组件操作(重新编辑/再次生成)通过事件总线协调
// ============================================================
import {message, Modal} from "antd";
import {safeIpcInvoke, logger, emit, EVENTS} from "@/shared/utils/bus";
import {BIDIRECTIONAL} from "@shared/constants/ipc";
import {joinPaths} from "@/shared/utils/path-join";
import {getToken} from "@/services/request";
import {TaskAPI} from "@/services/modules/home/task";
import {
deleteTask,
toggleFavoriteTask,
upsertTask,
getParamByTaskId,
parseParams,
getOutputPath,
} from "@/shared/infrastructure/storage";
import {makeDirAbsolute, writeFileAbsolute, downloadToPath, getDownloadsPath, generateThumbnail} from "@/shared/infrastructure/storage/ipc-file";
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)}`;
}
/** 从 ui_params 中提取参考素材 URL 列表 */
function extractReferenceUrls(record: TaskInfoItemResponseBody): string[] {
const urls: string[] = [];
if (record.output_type === 'image' && record.ui_params.imageUrls) {
urls.push(...record.ui_params.imageUrls);
} else if (record.output_type === 'video' && record.ui_params.content) {
for (const item of record.ui_params.content) {
if (item.type === 'image_url' && 'image_url' in item) {
urls.push(item.image_url.url);
} else if (item.type === 'video_url' && 'video_url' in item) {
urls.push(item.video_url.url);
} else if (item.type === 'audio_url' && 'audio_url' in item) {
urls.push(item.audio_url.url);
}
}
}
return urls.filter(Boolean);
}
// ── 操作实现 ──
/**
* 打开任务输出目录 — 自动导出任务数据到用户配置的产物路径。
*
* 目录结构:
* <outputPath>/repository/<userId>/<YYYY-MM>/<DD>/t_<taskId前8位>/
* ├── inputs/
* │ ├── params.json ← 输入参数model、prompt、tags、ui_params
* │ └── <ref_image>.png ← 参考素材ui_params 中的 imageUrls / content
* └── outputs/
* ├── response.json ← 完整任务 API 响应
* ├── <output_1>.png ← 输出媒体文件
* └── .cache/
* └── <output_1>.jpg ← 缩略图(最长边 256pxJPEG 60%
*/
async function openOutputDir(record: TaskInfoItemResponseBody): Promise<void> {
// ---- 1. 校验 outputPath ----
const outputPath = getOutputPath();
if (!outputPath) {
message.warning('请先在设置中配置"产物输出路径"');
return;
}
const d = new Date(record.created_at);
if (isNaN(d.getTime())) {
logger.warn('ui', '任务创建时间无效', undefined, { taskId: record.id });
message.warning('任务创建时间无效,无法定位输出目录');
return;
}
// ---- 2. 构建目录结构 ----
const taskDir = joinPaths(
outputPath,
'repository',
cleanUserId(record.user_id),
formatYearMonth(d),
formatDay(d),
taskDirKey(record.id),
);
const inputsDir = joinPaths(taskDir, 'inputs');
const outputsDir = joinPaths(taskDir, 'outputs');
const cacheDir = joinPaths(outputsDir, '.cache');
try {
// 创建三级目录
await makeDirAbsolute(inputsDir);
await makeDirAbsolute(outputsDir);
await makeDirAbsolute(cacheDir);
const token = getToken();
const authHeaders = token ? { Authorization: `Bearer ${token}` } : undefined;
// ---- 3. inputs/ — 导出参数 + 参考素材 ----
const paramEntry = getParamByTaskId(record.id);
const savedParams = paramEntry ? parseParams(paramEntry) : undefined;
const paramsJson = JSON.stringify(
{
task_id: record.id,
model_id: record.model_id,
model_name: record.model_name,
provider_name: record.provider_name,
prompt: record.prompt ?? null,
tags: record.tags,
ui_params: record.ui_params,
saved_params: savedParams ?? null,
},
null,
2,
);
await writeFileAbsolute(joinPaths(inputsDir, 'params.json'), paramsJson, 'utf8');
// 下载参考素材到 inputs/
const refUrls = extractReferenceUrls(record);
for (let i = 0; i < refUrls.length; i++) {
try {
const urlPath = new URL(refUrls[i]).pathname;
const fileName = urlPath.split('/').pop() || `ref_${i + 1}`;
await downloadToPath(refUrls[i], joinPaths(inputsDir, fileName), authHeaders);
} catch (err) {
logger.warn('ui', '参考素材下载失败', err instanceof Error ? err : undefined, {
taskId: record.id,
url: refUrls[i],
});
}
}
// ---- 4. outputs/ — 导出响应数据 + 媒体文件 + 缩略图 ----
const responseJson = JSON.stringify(record, null, 2);
await writeFileAbsolute(joinPaths(outputsDir, 'response.json'), responseJson, 'utf8');
const assets = record.output_assets;
if (assets && assets.length > 0) {
let downloadCount = 0;
const errors: string[] = [];
for (const asset of assets) {
try {
const urlPath = new URL(asset.url).pathname;
const fileName = urlPath.split('/').pop() || `output_${downloadCount + 1}`;
const filePath = joinPaths(outputsDir, fileName);
// 下载输出媒体
await downloadToPath(asset.url, filePath, authHeaders);
downloadCount++;
// 生成缩略图到 .cache/(仅图片)
const ext = fileName.split('.').pop()?.toLowerCase();
if (ext && ['png', 'jpg', 'jpeg', 'webp', 'bmp'].includes(ext)) {
const thumbName = fileName.replace(/\.[^.]+$/, '.jpg');
try {
await generateThumbnail(filePath, joinPaths(cacheDir, thumbName));
} catch {
// 缩略图生成失败不阻塞主流程
logger.warn('ui', '缩略图生成失败', undefined, { taskId: record.id, file: fileName });
}
}
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
errors.push(reason);
logger.warn('ui', '输出媒体下载失败', err instanceof Error ? err : new Error(String(err)), {
taskId: record.id,
url: asset.url,
});
}
}
if (errors.length > 0 && downloadCount > 0) {
message.warning(`已导出 ${downloadCount} 个文件,${errors.length} 个下载失败`);
} else if (errors.length > 0) {
message.error(`媒体下载失败:${errors[0]}`);
return;
}
}
// ---- 5. 打开任务根目录 ----
await safeIpcInvoke(BIDIRECTIONAL.SHOW_ITEM_IN_FOLDER, { filePath: taskDir });
message.success('已导出任务数据到本地目录');
logger.info('ui', '导出任务数据成功', { taskId: record.id, path: taskDir });
} 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("复制失败");
}
}
/** 重新编辑:通过事件总线通知 ModelInputForm 切换模型并回填参数 */
function reeditTask(record: TaskInfoItemResponseBody): void {
// 尝试从本地参数历史中恢复原始输入参数
const paramEntry = getParamByTaskId(record.id);
const savedParams = paramEntry ? parseParams(paramEntry) : undefined;
emit(EVENTS.TASK_REEDIT, {record, savedParams});
logger.info("ui", "触发重新编辑", {taskId: record.id, modelId: record.model_id});
}
/** 再次生成:切换模型 + 回填参数 + 自动提交 */
function regenTask(record: TaskInfoItemResponseBody): void {
const paramEntry = getParamByTaskId(record.id);
const savedParams = paramEntry ? parseParams(paramEntry) : undefined;
if (!savedParams) {
// 无参数快照 → 退化为重新编辑(用户手动提交)
message.info("未找到原始参数,请手动修改后重新提交");
emit(EVENTS.TASK_REEDIT, {record, savedParams: undefined});
logger.info("ui", "再次生成退化为重新编辑(无参数快照)", {taskId: record.id});
return;
}
emit(EVENTS.TASK_REGEN, {record, savedParams});
logger.info("ui", "触发再次生成", {taskId: record.id, modelId: record.model_id});
}
/** 拉取结果:重新从 API 获取任务详情并更新本地存储 */
async function pullResult(record: TaskInfoItemResponseBody): Promise<void> {
try {
const latest = await TaskAPI.getTaskInfo(record.id);
upsertTask(latest);
emit(EVENTS.TASK_LIST_REFRESH);
message.success("已拉取最新结果");
logger.info("ui", "拉取结果成功", {taskId: record.id, status: latest.status});
} catch (err) {
logger.warn("ui", "拉取结果失败", err instanceof Error ? err : new Error(String(err)), {
taskId: record.id,
});
message.error("拉取结果失败,请稍后重试");
}
}
/** 重新下载:通过主进程下载任务的所有输出资源到系统下载目录 */
async function redownloadAssets(record: TaskInfoItemResponseBody): Promise<void> {
const assets = record.output_assets;
if (!assets || assets.length === 0) {
message.warning("该任务无输出资源");
return;
}
// 获取系统下载目录
const downloadsDir = await getDownloadsPath();
if (!downloadsDir) {
message.error("无法获取系统下载目录");
return;
}
let successCount = 0;
const errors: string[] = [];
const token = getToken();
const authHeaders = token ? {Authorization: `Bearer ${token}`} : undefined;
for (const asset of assets) {
try {
const urlPath = new URL(asset.url).pathname;
const fileName = urlPath.split("/").pop() || `task_${record.id}_output`;
const destPath = joinPaths(downloadsDir, fileName);
// 通过主进程下载(绕过浏览器 CORS
await downloadToPath(asset.url, destPath, authHeaders);
successCount++;
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
errors.push(reason);
logger.warn("ui", "重新下载失败", err instanceof Error ? err : new Error(String(err)), {
taskId: record.id,
url: asset.url,
});
}
}
if (errors.length === 0 && successCount > 0) {
message.success(`已下载 ${successCount} 个文件到下载目录`);
} else if (successCount > 0) {
message.warning(`${successCount} 个成功,${errors.length} 个失败:${errors[0]}`);
} else {
const detail = errors[0] || "未知错误";
message.error(`下载失败:${detail}`);
}
}
/** 删除记录:仅删除本地存储 */
async function deleteTaskRecord(
record: TaskInfoItemResponseBody,
onRefreshList?: () => void,
): Promise<void> {
return new Promise((resolve) => {
Modal.confirm({
title: "确认删除",
content: `确定要删除任务 ${record.id.slice(0, 8)}... 的记录吗?此操作仅删除本地记录。`,
okText: "删除",
okType: "danger",
cancelText: "取消",
onOk: () => {
try {
deleteTask(record.id);
onRefreshList?.();
message.success("已删除任务记录");
logger.info("ui", "删除任务记录", {taskId: record.id});
} catch (err) {
logger.warn("ui", "删除任务记录失败", err instanceof Error ? err : new Error(String(err)), {
taskId: record.id,
});
message.error("删除失败");
}
resolve();
},
onCancel: () => resolve(),
});
});
}
/** 切换收藏状态 */
function toggleFavorite(
record: TaskInfoItemResponseBody,
onRefreshList?: () => void,
): void {
const newState = !record.is_favorite;
try {
toggleFavoriteTask(record.id, newState);
onRefreshList?.();
message.success(newState ? "已收藏" : "已取消收藏");
logger.info("ui", "切换收藏状态", {taskId: record.id, isFavorite: newState});
} catch (err) {
logger.warn("ui", "切换收藏状态失败", err instanceof Error ? err : new Error(String(err)), {
taskId: record.id,
});
message.error("操作失败");
}
}
// ── 公开入口 ──
/**
* buildTaskActions 的额外配置项。
*
* 由 View 层传入,用于协调跨组件操作(如刷新列表)。
*/
export interface BuildTaskActionsOptions {
/** 删除/收藏后触发任务列表刷新 */
onRefreshList?: () => void;
}
/**
* 根据任务记录构建完整的右键菜单操作配置。
*
* View 层调用此函数获取 TaskListMenuOptions传入 getTaskListMenuItems()
* 即可得到渲染就绪的 ContextMenuItems。
*
* @param record 任务记录
* @param options 可选配置(列表刷新回调等)
*/
export function buildTaskActions(
record: TaskInfoItemResponseBody,
options?: BuildTaskActionsOptions,
): TaskListMenuOptions {
const {onRefreshList} = options ?? {};
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: () => reeditTask(record),
onRegen: () => regenTask(record),
onPullResult: () => void pullResult(record),
onRedownload: () => void redownloadAssets(record),
onToggleFavorite: () => toggleFavorite(record, onRefreshList),
onDelete: () => void deleteTaskRecord(record, onRefreshList),
};
}

View File

@@ -0,0 +1,138 @@
// ============================================================
// 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';
import React from "react";
// ── 类型 ──
/** 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,645 @@
// ============================================================
// 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);
on(EVENTS.TASK_LIST_REFRESH, handleTaskSubmitted);
return () => {
off(EVENTS.TASK_SUBMITTED, handleTaskSubmitted);
off(EVENTS.TASK_LIST_REFRESH, 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,40 @@
// ============================================================
// 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 type { BuildTaskActionsOptions } from './controllers/menus/task-list-actions';
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 { useState, useRef, useEffect, useCallback } from 'react';
import { theme as antTheme } from 'antd'; import { theme as antTheme } from 'antd';
import { ModelSelector } from '../right/ModelSelector'; import { ModelSelector } from './ModelSelector';
import { TaskHistory } from '../right/TaskHistory'; import { TaskHistory } from './TaskHistory';
import { useModelList } from '@/shared/hooks/use-api'; import { useModelList } from '@/shared/hooks/use-api';
// ---------- 常量 ---------- // ---------- 常量 ----------

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 { Tabs, Spin, Empty, Tag, Typography, Button, Result, message, theme as antTheme } from 'antd';
import { AppstoreOutlined } from '@ant-design/icons'; import { AppstoreOutlined } from '@ant-design/icons';
import { useHomeContext } from '@/modules/home/stores/HomeContext'; import { useHomeContext } from '@/modules/home/shared/stores/HomeContext';
import { MODEL_CATEGORIES, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/modules/home/controllers/model-api'; import { MODEL_CATEGORIES, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/home/models';
import { EnumResolver } from '@/shared/utils/enum-resolver'; import { EnumResolver } from '@/shared/utils/enum-resolver';
const { Text } = Typography; const { Text } = Typography;

View File

@@ -0,0 +1,289 @@
// ============================================================
// 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 { emit, EVENTS } from '@/shared/utils/bus';
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'](包装 onClick 以在回调后关闭菜单) */
const toAntdMenu = useCallback((items: ContextMenuItems, onClose?: () => void) =>
items.map((item) => {
if (item === 'divider') return { type: 'divider' as const };
return {
key: item.key,
label: item.label,
onClick: () => {
item.onClick?.();
onClose?.();
},
disabled: item.disabled,
danger: item.danger,
};
}), []);
/** 根据任务记录生成右键菜单项 */
const buildTaskMenuItems = useCallback((record: TaskInfoItemResponseBody) => {
return toAntdMenu(getTaskListMenuItems(buildTaskActions(record, {
onRefreshList: () => emit(EVENTS.TASK_LIST_REFRESH),
})), () => setContextMenu(null));
}, [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) : [],
}}
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,18 +20,18 @@ import {
CloseCircleOutlined, CloseCircleOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useHomeContext } from '@/modules/home/stores/HomeContext'; import { useHomeContext } from '@/modules/home/shared/stores/HomeContext';
import { useTaskDetail } from '@/shared/hooks/use-api'; import { useTaskDetail } from '@/shared/hooks/use-api';
import { POLLING_STATUSES, type TaskStatusString } from '@/modules/home/controllers/task-api'; import { POLLING_STATUSES, type TaskStatusString } from '@/services/modules/home/task';
import { ContextMenu } from '@/shared/components/ContextMenu'; import { ContextMenu } from '@/shared/components/ContextMenu';
import { getPreviewImageMenu, getPreviewEmptyMenu } from '../../controllers/menus'; import { getPreviewImageMenu, getPreviewEmptyMenu } from '../controllers/menus';
const { Text } = Typography; const { Text } = Typography;
// ---------- 状态标签配置 ---------- // ---------- 状态标签配置 ----------
const STATUS_CONFIG: Record<TaskStatusString, { color: string; label: string }> = { const STATUS_CONFIG: Record<TaskStatusString, { color: string; label: string }> = {
pending: { color: 'blue', label: '排队中' }, pending: { color: 'blue', label: '处理中...' },
submitted: { color: 'cyan', label: '已提交' }, submitted: { color: 'cyan', label: '已提交' },
processing: { color: 'orange', label: '处理中' }, processing: { color: 'orange', label: '处理中' },
success: { color: 'green', label: '已完成' }, success: { color: 'green', label: '已完成' },

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 { createContext, useContext } from 'react';
import type { ModelsListResponseBody } from '@/modules/home/controllers/model-api'; import type { ModelsListResponseBody } from '@/services/modules/home/models';
import type { TaskInfoItemResponseBody } from '@/modules/home/controllers/task-api'; import type { TaskInfoItemResponseBody } from '@/services/modules/home/task';
// ---------- Context 类型 ---------- // ---------- Context 类型 ----------

View File

@@ -14,7 +14,7 @@ import { CloseOutlined } from '@ant-design/icons';
import type { CarouselRef } from 'antd/es/carousel'; import type { CarouselRef } from 'antd/es/carousel';
import { useBannerList } from '@/shared/hooks/use-api'; import { useBannerList } from '@/shared/hooks/use-api';
import type { Banner } from '@/modules/home/controllers/banner-api'; import type { Banner } from '@/services/modules/home/banner';
// ---------- 组件 Props ---------- // ---------- 组件 Props ----------

View File

@@ -7,11 +7,11 @@ import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import { theme as antTheme, Empty } from 'antd'; import { theme as antTheme, Empty } from 'antd';
import { useAppContext } from '@/app/providers/AppProvider'; import { useAppContext } from '@/app/providers/AppProvider';
import type { ModelsListResponseBody } from '@/modules/home/controllers/model-api'; import type { ModelsListResponseBody } from '@/services/modules/home/models';
import type { TaskInfoItemResponseBody } from '@/modules/home/controllers/task-api'; import type { TaskInfoItemResponseBody } from '@/services/modules/home/task';
import { LeftPanel } from './left/LeftPanel'; import { LeftPanel } from '../../left/views/LeftPanel';
import { CenterPanel } from './center/CenterPanel'; import { CenterPanel } from '../../center/views/CenterPanel';
import { RightPanel } from './right/RightPanel'; import { RightPanel } from '../../right/views/RightPanel';
import { Typography } from 'antd'; import { Typography } from 'antd';

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

@@ -13,7 +13,7 @@ import {App} from 'antd';
import {useAppContext} from '@/app/providers/AppProvider'; import {useAppContext} from '@/app/providers/AppProvider';
import {useTheme} from '@/app/providers/ThemeProvider'; import {useTheme} from '@/app/providers/ThemeProvider';
import {emit, EVENTS, safeIpcSend} from '@/shared/utils/bus'; import {emit, EVENTS, safeIpcSend} from '@/shared/utils/bus';
import {RENDERER_TO_MAIN} from '@shared/constants/ipc-channels'; import {RENDERER_TO_MAIN_CANVAS} from '@shared/constants/ipc';
import {useAnnouncements} from '@/shared/hooks/use-announcements'; import {useAnnouncements} from '@/shared/hooks/use-announcements';
import {NavItem} from './NavItem'; import {NavItem} from './NavItem';
import {AnnouncementDrawer} from '@/modules/announcement/views/AnnouncementDrawer'; import {AnnouncementDrawer} from '@/modules/announcement/views/AnnouncementDrawer';
@@ -30,7 +30,7 @@ export function NavBar() {
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
// 公告轮询 + 未读缓存 // 公告轮询 + 未读缓存
const { announcements, unreadCount, loading: announcementsLoading, markAsRead } = useAnnouncements(); const { announcements, unreadCount, loading: announcementsLoading, markAsRead, refetch: refetchAnnouncements } = useAnnouncements();
// 根据版本选择导航项 // 根据版本选择导航项
const {leftItems, rightItems} = useMemo(() => { const {leftItems, rightItems} = useMemo(() => {
@@ -76,6 +76,7 @@ export function NavBar() {
case 'drawer': case 'drawer':
if (item.key === 'announcements') { if (item.key === 'announcements') {
setDrawerOpen(true); setDrawerOpen(true);
refetchAnnouncements();
markAsRead(); markAsRead();
} }
break; break;
@@ -100,7 +101,7 @@ export function NavBar() {
// 项目管理 → 打开独立画布窗口(新 BrowserWindow非主窗口内叠加层 // 项目管理 → 打开独立画布窗口(新 BrowserWindow非主窗口内叠加层
if (item.key === 'project-management') { if (item.key === 'project-management') {
console.log('[NavBar] 🖼️ 点击"项目管理" → 发送 OPEN_CANVAS_WINDOW IPC'); console.log('[NavBar] 🖼️ 点击"项目管理" → 发送 OPEN_CANVAS_WINDOW IPC');
safeIpcSend(RENDERER_TO_MAIN.OPEN_CANVAS_WINDOW); safeIpcSend(RENDERER_TO_MAIN_CANVAS.OPEN_CANVAS_WINDOW);
return; return;
} }
if (item.target) { if (item.target) {
@@ -113,7 +114,7 @@ export function NavBar() {
break; break;
} }
}, },
[isLoggedIn, logout, message, markAsRead], [isLoggedIn, logout, message, markAsRead, refetchAnnouncements],
); );
const macPaddingLeft = platform === 'darwin' ? 80 : 12; const macPaddingLeft = platform === 'darwin' ? 80 : 12;

View File

@@ -11,7 +11,7 @@ import { FolderOpenOutlined, DeleteOutlined, EditOutlined } from '@ant-design/ic
import type { Edition } from '@shared/types'; import type { Edition } from '@shared/types';
import { useSettings } from '@/app/providers/SettingsProvider'; import { useSettings } from '@/app/providers/SettingsProvider';
import { hasIpc, safeIpcInvoke } from '@/shared/utils/bus'; import { hasIpc, safeIpcInvoke } from '@/shared/utils/bus';
import { BIDIRECTIONAL } from '@shared/constants/ipc-channels'; import { BIDIRECTIONAL } from '@shared/constants/ipc';
const { Text, Paragraph } = Typography; const { Text, Paragraph } = Typography;

View File

@@ -16,9 +16,9 @@
// - 内存缓存供同步读取setTokenRefreshHandler 回调内使用) // - 内存缓存供同步读取setTokenRefreshHandler 回调内使用)
// ============================================================ // ============================================================
import { setToken, setTokenRefreshHandler, RequestError, RequestErrorType } from '@/shared/services/http'; import { setToken, setTokenRefreshHandler, RequestError, RequestErrorType } from '@/services/request';
import { AuthAPI, type RefreshTokenResponseBody } from './auth-api'; import { AuthAPI, type RefreshTokenResponseBody } from './modules/auth/auth';
import { isRefreshError } from '@/shared/services/types'; import { isRefreshError } from '@/services/types';
import { import {
getSafeStore, getSafeStore,
setSafeStore, setSafeStore,

View File

@@ -9,7 +9,7 @@
// ============================================================ // ============================================================
import {initSafeStore, getSafeStore, setSafeStore} from '@/shared/utils/safe-storage'; import {initSafeStore, getSafeStore, setSafeStore} from '@/shared/utils/safe-storage';
import type {ModelsListResponseBody} from '@/modules/home/controllers/model-api'; import type {ModelsListResponseBody} from '@/services/modules/home/models';
/** 缓存键名(与 safe-storage 加密存储一致) */ /** 缓存键名(与 safe-storage 加密存储一致) */
const MODEL_CACHE_KEY = 'ele-heixiu-model-cache'; const MODEL_CACHE_KEY = 'ele-heixiu-model-cache';

View File

@@ -1,5 +1,5 @@
import {type Nullable} from "@/shared/utils/display"; import {type Nullable} from "@/shared/utils/display";
import {get} from "@/shared/services/http.ts"; import {get} from "@/services/request";
const BillingUrlObj = { const BillingUrlObj = {
getBalance: '/api/v1/billing/balance', 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 = { export const PAYMENT_METHODS = {
Alipay: 'alipay', Alipay: 'alipay',

View File

@@ -1,5 +1,5 @@
import {post, get} from '@/shared/services/http'; import {post, get} from '@/services/request';
import {type UserInfoBody} from "@/modules/auth/controllers/auth-api"; import {type UserInfoBody} from "@/services/modules/auth/auth";
import {type Nullable} from "@/shared/utils/display"; import {type Nullable} from "@/shared/utils/display";

View File

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

Some files were not shown because too many files have changed in this diff Show More