From 136a17c0bfe0a6882a60555357fe28d616b609c7 Mon Sep 17 00:00:00 2001 From: YoungestSongMo <2130460579@qq.com> Date: Fri, 3 Jul 2026 11:13:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BB=BB=E5=8A=A1=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E5=8F=B3=E9=94=AE=E8=8F=9C=E5=8D=95=E5=9B=9E=E8=B0=83=20+=20?= =?UTF-8?q?=E4=B8=BB=E8=BF=9B=E7=A8=8B=E4=B8=8B=E8=BD=BD=20+=20sharp=20?= =?UTF-8?q?=E7=BC=A9=E7=95=A5=E5=9B=BE=20+=20IPC=20=E7=B2=BE=E7=AE=80?= =?UTF-8?q?=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 任务右键菜单:实现打开输出目录(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 行 --- CHANGELOG.md | 24 + PROJECT.md | 58 +- electron/main.ts | 4 +- electron/main/ipc/canvas-ipc.ts | 705 ------------------ electron/main/ipc/canvas/index.ts | 352 +++++++++ electron/main/ipc/canvas/mime.ts | 36 + electron/main/ipc/canvas/scan-roots.ts | 189 +++++ electron/main/ipc/canvas/sidecar.ts | 47 ++ electron/main/ipc/canvas/thumbnail.ts | 84 +++ electron/main/ipc/canvas/types.ts | 26 + electron/main/ipc/canvas/walk.ts | 66 ++ electron/main/ipc/log-ipc.ts | 2 +- electron/main/ipc/safe-storage-ipc.ts | 6 +- electron/main/ipc/storage-ipc.ts | 296 +++++--- electron/main/ipc/utils/download.ts | 97 +++ electron/main/ipc/utils/index.ts | 7 + electron/main/ipc/utils/path.ts | 29 + electron/main/ipc/utils/thumbnail.ts | 60 ++ electron/main/net-request.ts | 14 +- electron/main/updater.ts | 486 ------------ electron/main/updater/index.ts | 219 ++++++ electron/main/updater/platform-updaters.ts | 102 +++ electron/main/updater/provider.ts | 172 +++++ electron/preload.ts | 204 +---- electron/preload/app-runtime.ts | 22 + electron/preload/base.ts | 29 + electron/preload/canvas.ts | 124 +++ electron/preload/file-storage.ts | 94 +++ electron/preload/index.ts | 11 + electron/preload/safe-storage.ts | 33 + shared/constants/ipc-channels.ts | 107 +-- shared/constants/ipc/base.ts | 45 ++ shared/constants/ipc/canvas.ts | 45 ++ shared/constants/ipc/index.ts | 25 + shared/constants/ipc/storage.ts | 37 + shared/constants/ipc/updater.ts | 27 + .../canvas/utils/LocalFileDataSource.ts | 2 +- .../views/core/MediaCardContextMenu.tsx | 2 +- .../home/center/views/ModelInputForm.tsx | 216 +++--- .../ModelInputForm/DependentFormItem.tsx | 59 ++ .../home/center/views/ModelInputForm/utils.ts | 33 + .../home/left/controllers/menus/index.ts | 1 + .../controllers/menus/task-list-actions.ts | 388 ++++++++-- .../left/controllers/task-table-config.ts | 3 +- src/modules/home/left/hooks/useTaskTable.tsx | 6 +- src/modules/home/left/index.ts | 1 + src/modules/home/left/views/TaskHistory.tsx | 16 +- .../home/right/views/OutputPreview.tsx | 2 +- src/modules/navbar/views/NavBar.tsx | 4 +- .../views/sections/StoragePathSetting.tsx | 2 +- src/services/modules/account/billing.ts | 2 +- src/services/request.ts | 468 ------------ src/services/request/index.ts | 63 ++ src/services/request/interceptors.ts | 293 ++++++++ src/services/request/methods.ts | 147 ++++ src/services/request/token.ts | 75 ++ .../infrastructure/storage/core/schema.ts | 9 + src/shared/infrastructure/storage/index.ts | 10 +- src/shared/infrastructure/storage/ipc-file.ts | 82 +- .../storage/stores/task-store.ts | 58 ++ src/shared/utils/bus.ts | 8 +- src/shared/utils/pricing.ts | 488 ------------ src/shared/utils/pricing/calculate.ts | 233 ++++++ src/shared/utils/pricing/fields.ts | 72 ++ src/shared/utils/pricing/format.ts | 59 ++ src/shared/utils/pricing/helpers.ts | 77 ++ src/shared/utils/pricing/index.ts | 26 + src/shared/utils/pricing/types.ts | 53 ++ src/vite-env.d.ts | 10 + 69 files changed, 4139 insertions(+), 2713 deletions(-) delete mode 100644 electron/main/ipc/canvas-ipc.ts create mode 100644 electron/main/ipc/canvas/index.ts create mode 100644 electron/main/ipc/canvas/mime.ts create mode 100644 electron/main/ipc/canvas/scan-roots.ts create mode 100644 electron/main/ipc/canvas/sidecar.ts create mode 100644 electron/main/ipc/canvas/thumbnail.ts create mode 100644 electron/main/ipc/canvas/types.ts create mode 100644 electron/main/ipc/canvas/walk.ts create mode 100644 electron/main/ipc/utils/download.ts create mode 100644 electron/main/ipc/utils/index.ts create mode 100644 electron/main/ipc/utils/path.ts create mode 100644 electron/main/ipc/utils/thumbnail.ts delete mode 100644 electron/main/updater.ts create mode 100644 electron/main/updater/index.ts create mode 100644 electron/main/updater/platform-updaters.ts create mode 100644 electron/main/updater/provider.ts create mode 100644 electron/preload/app-runtime.ts create mode 100644 electron/preload/base.ts create mode 100644 electron/preload/canvas.ts create mode 100644 electron/preload/file-storage.ts create mode 100644 electron/preload/index.ts create mode 100644 electron/preload/safe-storage.ts create mode 100644 shared/constants/ipc/base.ts create mode 100644 shared/constants/ipc/canvas.ts create mode 100644 shared/constants/ipc/index.ts create mode 100644 shared/constants/ipc/storage.ts create mode 100644 shared/constants/ipc/updater.ts create mode 100644 src/modules/home/center/views/ModelInputForm/DependentFormItem.tsx create mode 100644 src/modules/home/center/views/ModelInputForm/utils.ts delete mode 100644 src/services/request.ts create mode 100644 src/services/request/index.ts create mode 100644 src/services/request/interceptors.ts create mode 100644 src/services/request/methods.ts create mode 100644 src/services/request/token.ts delete mode 100644 src/shared/utils/pricing.ts create mode 100644 src/shared/utils/pricing/calculate.ts create mode 100644 src/shared/utils/pricing/fields.ts create mode 100644 src/shared/utils/pricing/format.ts create mode 100644 src/shared/utils/pricing/helpers.ts create mode 100644 src/shared/utils/pricing/index.ts create mode 100644 src/shared/utils/pricing/types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2649fff..d55bf85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,30 @@ > 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。 --- +## 0.0.29(2026-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.28(2026-06-29) **项目架构重构(VCHSM 五层模块化)+ 公告 Badge 提醒** diff --git a/PROJECT.md b/PROJECT.md index 123025f..4b8991e 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -62,21 +62,41 @@ ```md ├── electron/ # Electron 主进程 + 预加载 │ ├── main.ts # 入口编排:窗口创建 / IPC 注册 / 生命周期 -│ ├── preload.ts # contextBridge:向渲染进程暴露安全 API +│ ├── preload.ts # contextBridge:向渲染进程暴露安全 API(薄入口) │ ├── electron-env.d.ts # 类型声明(APP_ROOT、Window 扩展) │ └── main/ # 主进程核心模块 │ │ ├── load-env.ts # .env.{mode} + .env.local 加载器 │ │ ├── logger.ts # 日志核心:JSON Lines / 每日轮转 / 批量冲刷 │ │ ├── net-request.ts # Electron net.request 的 axios 风格封装 -│ │ ├── updater.ts # 自动更新:自定义 Provider + 平台 Updater +│ │ ├── updater/ # 自动更新模块 +│ │ │ ├── provider.ts # 自定义 Provider + 设备指纹 + 构建配置 +│ │ │ ├── platform-updaters.ts # 平台特定 Updater(Windows/macOS/Linux) +│ │ │ └── index.ts # 初始化 + IPC 注册 │ │ ├── menu/index.ts # 系统菜单(macOS Edit / Win 无菜单) │ │ ├── ipc/ # IPC handler 集合 │ │ │ ├── log-ipc.ts # 渲染进程日志 → 主进程日志桥接 │ │ │ ├── safe-storage-ipc.ts # safeStorage 加密/解密 IPC handler │ │ │ ├── storage-ipc.ts # 沙箱文件存储 IPC handler(heixiu-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/ # 平台判断 / 图标路径 │ └── preload/ # 预加载脚本(contextBridge 桥接层) +│ ├── base.ts # ipcRenderer 基础桥接(on/off/send/invoke) +│ ├── safe-storage.ts # safeStorage 加密/解密 API +│ ├── file-storage.ts # 文件存储 API(getDataDir / readFile / writeFile) +│ ├── canvas.ts # 画布 API(openWindow / scanProject / readSidecar) +│ ├── app-runtime.ts # 运行时 API(setWindowEdition) +│ ├── index.ts # 汇总导出(exposeBaseIpc / exposeSafeStorage / ...) │ ├── device.ts # 硬件指纹采集 │ └── device_service.ts # 设备信息缓存层 │ @@ -137,8 +157,25 @@ │ └── shared/ # ═══ 跨模块共享 ═══ │ ├── components/ # 通用 UI(FloatingPanel / FormField / LegalTextModal / ContextMenu) │ ├── hooks/ # 全局 Hooks(useAnnouncements / useApi / useAsync / useUpdater) -│ ├── services/ # HTTP 客户端 http.ts + API 类型 -│ ├── utils/ # 工具函数(bus / env / enum-resolver / display / pricing / safe-storage) +│ ├── services/ # HTTP 客户端 + API 模块 +│ │ ├── 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) │ │ ├── core/ # db-core.ts / db-encrypt.ts / schema.ts │ │ ├── stores/ # task-store / order-store / media-store / settings-store @@ -146,7 +183,16 @@ │ └── theme/ # 主题系统(tokens / antd-theme / CSS 过渡) │ ├── 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.ts(Platform/Theme/Edition)/ logging.ts │ └── utils/ # sanitize.ts(日志脱敏)+ index.ts(通用工具) │ diff --git a/electron/main.ts b/electron/main.ts index 161ce35..7e3cd56 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -13,8 +13,8 @@ import {initLogger, flushLogger, logger, setLogDirResolver} from './main/logger' import {registerLogIpcHandlers} from './main/ipc/log-ipc'; import {registerSafeStorageIpcHandlers} from './main/ipc/safe-storage-ipc'; import {registerStorageIpcHandlers, setDataDirResolver} from './main/ipc/storage-ipc'; -import {registerCanvasIpcHandlers} from './main/ipc/canvas-ipc'; -import {BIDIRECTIONAL, RENDERER_TO_MAIN, MAIN_TO_RENDERER} from '../shared/constants/ipc-channels'; +import {registerCanvasIpcHandlers} from './main/ipc/canvas'; +import {BIDIRECTIONAL, RENDERER_TO_MAIN, MAIN_TO_RENDERER} from '../shared/constants/ipc'; createRequire(import.meta.url); const __dirname = path.dirname(fileURLToPath(import.meta.url)); diff --git a/electron/main/ipc/canvas-ipc.ts b/electron/main/ipc/canvas-ipc.ts deleted file mode 100644 index a3ffca9..0000000 --- a/electron/main/ipc/canvas-ipc.ts +++ /dev/null @@ -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 = { - '.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 处理器已注册(完整实现)'); -} diff --git a/electron/main/ipc/canvas/index.ts b/electron/main/ipc/canvas/index.ts new file mode 100644 index 0000000..dbcf801 --- /dev/null +++ b/electron/main/ipc/canvas/index.ts @@ -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 处理器已注册(完整实现)'); +} diff --git a/electron/main/ipc/canvas/mime.ts b/electron/main/ipc/canvas/mime.ts new file mode 100644 index 0000000..0ad87ce --- /dev/null +++ b/electron/main/ipc/canvas/mime.ts @@ -0,0 +1,36 @@ +// ============================================================ +// canvas/mime.ts — MIME 类型推断 +// ============================================================ + +import path from 'node:path'; + +/** MIME 类型映射表 */ +const MIME_MAP: Record = { + '.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'; +} diff --git a/electron/main/ipc/canvas/scan-roots.ts b/electron/main/ipc/canvas/scan-roots.ts new file mode 100644 index 0000000..0109295 --- /dev/null +++ b/electron/main/ipc/canvas/scan-roots.ts @@ -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; +} diff --git a/electron/main/ipc/canvas/sidecar.ts b/electron/main/ipc/canvas/sidecar.ts new file mode 100644 index 0000000..c1fe678 --- /dev/null +++ b/electron/main/ipc/canvas/sidecar.ts @@ -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'); +} diff --git a/electron/main/ipc/canvas/thumbnail.ts b/electron/main/ipc/canvas/thumbnail.ts new file mode 100644 index 0000000..e1893f5 --- /dev/null +++ b/electron/main/ipc/canvas/thumbnail.ts @@ -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 { + 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, + }; +} diff --git a/electron/main/ipc/canvas/types.ts b/electron/main/ipc/canvas/types.ts new file mode 100644 index 0000000..107779e --- /dev/null +++ b/electron/main/ipc/canvas/types.ts @@ -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; +} diff --git a/electron/main/ipc/canvas/walk.ts b/electron/main/ipc/canvas/walk.ts new file mode 100644 index 0000000..3d04a24 --- /dev/null +++ b/electron/main/ipc/canvas/walk.ts @@ -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; +} diff --git a/electron/main/ipc/log-ipc.ts b/electron/main/ipc/log-ipc.ts index 302f0a5..7b607b0 100644 --- a/electron/main/ipc/log-ipc.ts +++ b/electron/main/ipc/log-ipc.ts @@ -3,7 +3,7 @@ // ============================================================ 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 type { LogEntry } from '../../../shared/types/logging'; diff --git a/electron/main/ipc/safe-storage-ipc.ts b/electron/main/ipc/safe-storage-ipc.ts index ec246c0..5233b00 100644 --- a/electron/main/ipc/safe-storage-ipc.ts +++ b/electron/main/ipc/safe-storage-ipc.ts @@ -16,7 +16,7 @@ // ============================================================ import { ipcMain, safeStorage } from 'electron'; -import { BIDIRECTIONAL } from '../../../shared/constants/ipc-channels'; +import { BIDIRECTIONAL_STORAGE } from '../../../shared/constants/ipc/storage'; import { logger } from '../logger'; /** 检查 safeStorage 是否可用,不可用时记录警告 */ @@ -34,7 +34,7 @@ function checkAvailability(): boolean { */ 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) { logger.warn('safe-storage', '加密参数无效'); 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) { logger.warn('safe-storage', '解密参数无效'); return { success: false, error: '参数无效:需要非空字符串' }; diff --git a/electron/main/ipc/storage-ipc.ts b/electron/main/ipc/storage-ipc.ts index 7155477..0691350 100644 --- a/electron/main/ipc/storage-ipc.ts +++ b/electron/main/ipc/storage-ipc.ts @@ -16,8 +16,9 @@ import { ipcMain, shell, app } from 'electron'; import fs from 'node:fs'; 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 { resolveSafe, ensureDir, downloadToFile, generateThumbnail } from './utils'; // ---------- 内部工具 ---------- @@ -25,145 +26,200 @@ let dataDirResolver: (() => string) | null = null; /** 注入自定义数据目录解析器(在 registerStorageIpcHandlers() 之前调用) */ export function setDataDirResolver(resolver: () => string): void { - dataDirResolver = resolver; + dataDirResolver = resolver; } /** 获取 heixiu-data 根目录 */ function getDataDir(): string { - 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 }); - } + return dataDirResolver ? dataDirResolver() : path.join(app.getPath('userData'), 'heixiu-data'); } // ---------- 注册 IPC 处理器 ---------- export function registerStorageIpcHandlers(): void { - const dataDir = getDataDir(); - ensureDir(dataDir); - logger.info('app', `本地存储目录:${dataDir}`); + const dataDir = getDataDir(); + ensureDir(dataDir); + logger.info('app', `本地存储目录:${dataDir}`); - // ---- 获取数据目录 ---- - ipcMain.handle(BIDIRECTIONAL.GET_DATA_DIR, () => { - return { success: true, data: dataDir }; - }); + // ---- 获取数据目录 ---- + ipcMain.handle(BIDIRECTIONAL_STORAGE.GET_DATA_DIR, () => { + return { success: true, data: dataDir }; + }); - // ---- 文件写入 ---- - ipcMain.handle( - BIDIRECTIONAL.FILE_WRITE, - async ( - _event, - params: { relativePath: string; data: string; encoding?: 'base64' | 'utf8' }, - ) => { + // ---- 文件写入 ---- + ipcMain.handle( + BIDIRECTIONAL_STORAGE.FILE_WRITE, + async (_event, params: { relativePath: string; data: string; encoding?: 'base64' | 'utf8' }) => { + try { + const filePath = resolveSafe(dataDir, params.relativePath); + ensureDir(path.dirname(filePath)); + + const buffer = Buffer.from(params.data, params.encoding || 'base64'); + fs.writeFileSync(filePath, buffer); + logger.info('storage', `文件写入成功:${filePath}(${buffer.length} bytes)`); + return { success: true, data: filePath }; + } catch (err) { + const msg = (err as Error).message; + logger.warn('storage', `文件写入失败:${params.relativePath}`, err instanceof Error ? err : undefined); + return { success: false, error: msg }; + } + }, + ); + + // ---- 文件读取(返回 Base64) ---- + ipcMain.handle(BIDIRECTIONAL_STORAGE.FILE_READ, async (_event, params: { relativePath: string }) => { try { - const filePath = resolveSafe(dataDir, params.relativePath); - ensureDir(path.dirname(filePath)); - - const buffer = Buffer.from(params.data, params.encoding || 'base64'); - fs.writeFileSync(filePath, buffer); - console.log(`[storage IPC] 文件写入成功:${filePath}(${buffer.length} bytes)`); - return { success: true, data: filePath }; + const filePath = resolveSafe(dataDir, params.relativePath); + if (!fs.existsSync(filePath)) { + return { success: false, error: '文件不存在' }; + } + const buffer = fs.readFileSync(filePath); + return { success: true, data: buffer.toString('base64') }; } catch (err) { - const msg = (err as Error).message; - console.error(`[storage IPC] 文件写入失败:${params.relativePath} — ${msg}`); - logger.warn('storage', `文件写入失败:${params.relativePath}`, err instanceof Error ? err : undefined); - return { success: false, error: msg }; + logger.warn('storage', `文件读取失败:${params.relativePath}`, err instanceof Error ? err : undefined); + return { success: false, error: (err as Error).message }; } - }, - ); + }); - // ---- 文件读取(返回 Base64) ---- - ipcMain.handle(BIDIRECTIONAL.FILE_READ, async (_event, params: { relativePath: string }) => { - try { - const filePath = resolveSafe(dataDir, params.relativePath); - if (!fs.existsSync(filePath)) { - return { success: false, error: '文件不存在' }; + // ---- 文件删除 ---- + ipcMain.handle(BIDIRECTIONAL_STORAGE.FILE_DELETE, async (_event, params: { relativePath: string }) => { + try { + const filePath = resolveSafe(dataDir, params.relativePath); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + return { success: true }; + } catch (err) { + logger.warn('storage', `文件删除失败:${params.relativePath}`, err instanceof Error ? err : undefined); + return { success: false, error: (err as Error).message }; } - const buffer = fs.readFileSync(filePath); - return { success: true, data: buffer.toString('base64') }; - } catch (err) { - logger.warn('storage', `文件读取失败:${params.relativePath}`, err instanceof Error ? err : undefined); - return { success: false, error: (err as Error).message }; - } - }); + }); - // ---- 文件删除 ---- - ipcMain.handle(BIDIRECTIONAL.FILE_DELETE, async (_event, params: { relativePath: string }) => { - try { - const filePath = resolveSafe(dataDir, params.relativePath); - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath); + // ---- 创建目录 ---- + ipcMain.handle(BIDIRECTIONAL_STORAGE.MAKE_DIR, async (_event, params: { relativePath: string }) => { + try { + const dirPath = resolveSafe(dataDir, params.relativePath); + ensureDir(dirPath); + return { success: true, data: dirPath }; + } catch (err) { + logger.warn('storage', `目录创建失败:${params.relativePath}`, err instanceof Error ? err : undefined); + return { success: false, error: (err as Error).message }; } - return { success: true }; - } catch (err) { - logger.warn('storage', `文件删除失败:${params.relativePath}`, err instanceof Error ? err : undefined); - return { success: false, error: (err as Error).message }; - } - }); + }); - // ---- 创建目录 ---- - ipcMain.handle(BIDIRECTIONAL.MAKE_DIR, async (_event, params: { relativePath: string }) => { - try { - const dirPath = resolveSafe(dataDir, params.relativePath); - ensureDir(dirPath); - return { success: true, data: dirPath }; - } catch (err) { - logger.warn('storage', `目录创建失败:${params.relativePath}`, err instanceof Error ? err : undefined); - return { success: false, error: (err as Error).message }; - } - }); - - // ---- 在文件管理器中显示 ---- - ipcMain.handle(BIDIRECTIONAL.SHOW_ITEM_IN_FOLDER, async (_event, params: { filePath: string }) => { - try { - shell.showItemInFolder(params.filePath); - return { success: true }; - } catch (err) { - logger.warn('storage', `showItemInFolder 失败:${params.filePath}`, err instanceof Error ? err : undefined); - return { success: false, error: (err as Error).message }; - } - }); - - // ---- 文件大小 ---- - ipcMain.handle(BIDIRECTIONAL.FILE_SIZE, async (_event, params: { relativePath: string }) => { - try { - const filePath = resolveSafe(dataDir, params.relativePath); - if (!fs.existsSync(filePath)) { - return { success: false, error: '文件不存在' }; + // ---- 在文件管理器中显示 ---- + ipcMain.handle(BIDIRECTIONAL_STORAGE.SHOW_ITEM_IN_FOLDER, async (_event, params: { filePath: string }) => { + try { + shell.showItemInFolder(params.filePath); + return { success: true }; + } catch (err) { + logger.warn('storage', `showItemInFolder 失败:${params.filePath}`, err instanceof Error ? err : undefined); + return { success: false, error: (err as Error).message }; } - const stat = fs.statSync(filePath); - return { success: true, data: stat.size }; - } catch (err) { - logger.warn('storage', '获取文件大小失败', err instanceof Error ? err : undefined); - return { success: false, error: (err as Error).message }; - } - }); + }); - // ---- 文件/目录是否存在 ---- - ipcMain.handle(BIDIRECTIONAL.FILE_EXISTS, async (_event, params: { relativePath: string }) => { - try { - const filePath = resolveSafe(dataDir, params.relativePath); - return { success: true, data: fs.existsSync(filePath) }; - } catch { - return { success: true, data: false }; - } - }); + // ---- 文件大小 ---- + ipcMain.handle(BIDIRECTIONAL_STORAGE.FILE_SIZE, async (_event, params: { relativePath: string }) => { + try { + const filePath = resolveSafe(dataDir, params.relativePath); + if (!fs.existsSync(filePath)) { + return { success: false, error: '文件不存在' }; + } + const stat = fs.statSync(filePath); + return { success: true, data: stat.size }; + } catch (err) { + logger.warn('storage', '获取文件大小失败', err instanceof Error ? err : undefined); + return { success: false, error: (err as Error).message }; + } + }); - logger.info('app', '存储 IPC 处理器已注册'); + // ---- 文件/目录是否存在 ---- + ipcMain.handle(BIDIRECTIONAL_STORAGE.FILE_EXISTS, async (_event, params: { relativePath: string }) => { + try { + const filePath = resolveSafe(dataDir, params.relativePath); + return { success: true, data: fs.existsSync(filePath) }; + } catch { + return { success: true, data: false }; + } + }); + + // ---- 绝对路径文件写入(绕过沙箱,用于用户自定义输出目录) ---- + 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 }, + ): 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 处理器已注册'); } diff --git a/electron/main/ipc/utils/download.ts b/electron/main/ipc/utils/download.ts new file mode 100644 index 0000000..0a2714f --- /dev/null +++ b/electron/main/ipc/utils/download.ts @@ -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, +): Promise { + 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(); + }); +} diff --git a/electron/main/ipc/utils/index.ts b/electron/main/ipc/utils/index.ts new file mode 100644 index 0000000..0c409dc --- /dev/null +++ b/electron/main/ipc/utils/index.ts @@ -0,0 +1,7 @@ +// ============================================================ +// utils/index.ts — 工具函数汇总导出 +// ============================================================ + +export { resolveSafe, ensureDir } from './path'; +export { downloadToFile } from './download'; +export { generateThumbnail } from './thumbnail'; diff --git a/electron/main/ipc/utils/path.ts b/electron/main/ipc/utils/path.ts new file mode 100644 index 0000000..cd3f46f --- /dev/null +++ b/electron/main/ipc/utils/path.ts @@ -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 }); + } +} diff --git a/electron/main/ipc/utils/thumbnail.ts b/electron/main/ipc/utils/thumbnail.ts new file mode 100644 index 0000000..75eff82 --- /dev/null +++ b/electron/main/ipc/utils/thumbnail.ts @@ -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 流式管线) + * + * 特性: + * - shrinkOnLoad:PNG/JPEG/WebP/TIFF 解码器直接降采样,8000px 原图解码即降至 ~512px + * - sequentialRead:顺序读取减少随机 I/O 内存开销 + * - limitInputPixels:默认 268MP(16384²),覆盖 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 { + 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; +} diff --git a/electron/main/net-request.ts b/electron/main/net-request.ts index 850f6fe..60fa7c3 100644 --- a/electron/main/net-request.ts +++ b/electron/main/net-request.ts @@ -212,19 +212,19 @@ export async function request( export const netRequest = { request, - get(url: string, config?: Omit) { - return request(url, { ...config, method: 'GET' }); + async get(url: string, config?: Omit) { + return await request(url, { ...config, method: 'GET' }); }, - post(url: string, data?: unknown, config?: Omit) { + async post(url: string, data?: unknown, config?: Omit) { return request(url, { ...config, method: 'POST', data }); }, - - put(url: string, data?: unknown, config?: Omit) { + + async put(url: string, data?: unknown, config?: Omit) { return request(url, { ...config, method: 'PUT', data }); }, - - del(url: string, config?: Omit) { + + async del(url: string, config?: Omit) { return request(url, { ...config, method: 'DELETE' }); }, }; diff --git a/electron/main/updater.ts b/electron/main/updater.ts deleted file mode 100644 index 9eb79ad..0000000 --- a/electron/main/updater.ts +++ /dev/null @@ -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).env - ? ((import.meta as Record).env as Record).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).env - ? ((import.meta as Record).env as Record).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 { - 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 { - // 短时缓存(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 { - 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 { - 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; - }> { - const info = await this.heiXiuProvider.getLatestVersion(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {info, provider: this.heiXiuProvider as Provider}; - } -} - -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; - }> { - const info = await this.heiXiuProvider.getLatestVersion(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {info, provider: this.heiXiuProvider as Provider}; - } -} - -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; - }> { - const info = await this.heiXiuProvider.getLatestVersion(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return {info, provider: this.heiXiuProvider as Provider}; - } -} - -/** 工厂:按平台创建对应的 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 { - 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 { - 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 { - 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(); - }); -} diff --git a/electron/main/updater/index.ts b/electron/main/updater/index.ts new file mode 100644 index 0000000..d247fc5 --- /dev/null +++ b/electron/main/updater/index.ts @@ -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 { + 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 { + 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 { + 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(); + }); +} diff --git a/electron/main/updater/platform-updaters.ts b/electron/main/updater/platform-updaters.ts new file mode 100644 index 0000000..08f15df --- /dev/null +++ b/electron/main/updater/platform-updaters.ts @@ -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; + }> { + const info = await this.heiXiuProvider.getLatestVersion(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { info, provider: this.heiXiuProvider as Provider }; + } +} + +// ---------- 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; + }> { + const info = await this.heiXiuProvider.getLatestVersion(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { info, provider: this.heiXiuProvider as Provider }; + } +} + +// ---------- 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; + }> { + const info = await this.heiXiuProvider.getLatestVersion(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { info, provider: this.heiXiuProvider as Provider }; + } +} + +// ---------- 工厂函数 ---------- + +/** 工厂:按平台创建对应的 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); + } +} diff --git a/electron/main/updater/provider.ts b/electron/main/updater/provider.ts new file mode 100644 index 0000000..1deb92d --- /dev/null +++ b/electron/main/updater/provider.ts @@ -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).env + ? ((import.meta as unknown as Record).env as Record).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).env + ? ((import.meta as unknown as Record).env as Record).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 { + 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 { + // 短时缓存(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 { + 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 { + 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, + }; + }); + } +} diff --git a/electron/preload.ts b/electron/preload.ts index 67e5117..70cd9a2 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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 --------- -contextBridge.exposeInMainWorld('ipcRenderer', { - on(...args: Parameters) { - const [channel, listener] = args; - return ipcRenderer.on(channel, (event, ...args) => listener(event, ...args)); - }, - off(...args: Parameters) { - const [channel, ...omit] = args; - return ipcRenderer.off(channel, ...omit); - }, - send(...args: Parameters) { - const [channel, ...omit] = args; - return ipcRenderer.send(channel, ...omit); - }, - invoke(...args: Parameters) { - const [channel, ...omit] = args; - return ipcRenderer.invoke(channel, ...omit); - }, -}); +import { + exposeBaseIpc, + exposeSafeStorage, + exposeFileStorage, + exposeAppRuntime, + exposeCanvas, +} from './preload/index'; -// --------- 安全加密 API(基于 Electron safeStorage)--------- -contextBridge.exposeInMainWorld('safeStorage', { - /** - * 加密明文字符串 - * @param plaintext - 待加密的明文 - * @returns Base64 编码的密文,失败返回 null - */ - async encrypt(plaintext: string): Promise { - const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_ENCRYPT, plaintext); - return result?.success ? result.data : null; - }, +// --------- 按顺序暴露各模块 API --------- - /** - * 解密 Base64 密文 - * @param encryptedBase64 - Base64 编码的密文 - * @returns 解密后的明文,失败返回 null - */ - async decrypt(encryptedBase64: string): Promise { - const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_DECRYPT, encryptedBase64); - return result?.success ? result.data : null; - }, -}); +// 1. 基础 IPC 通信(ipcRenderer.on/off/send/invoke) +exposeBaseIpc(); -// --------- 本地存储文件 API(主进程 fs 桥接)--------- -contextBridge.exposeInMainWorld('fileStorage', { - /** 获取 heixiu-data 根目录路径 */ - getDataDir(): Promise { - 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 { - 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 { - return ipcRenderer.invoke(BIDIRECTIONAL.FILE_READ, { relativePath }) - .then((r: { success: boolean; data: string }) => r?.success ? r.data : null); - }, - /** 删除文件 */ - deleteFile(relativePath: string): Promise { - return ipcRenderer.invoke(BIDIRECTIONAL.FILE_DELETE, { relativePath }) - .then((r: { success: boolean }) => r?.success ?? false); - }, - /** 递归创建目录 */ - makeDir(relativePath: string): Promise { - return ipcRenderer.invoke(BIDIRECTIONAL.MAKE_DIR, { relativePath }) - .then((r: { success: boolean; data: string }) => r?.success ? r.data : null); - }, - /** 在文件管理器中显示文件(绝对路径) */ - showItemInFolder(filePath: string): Promise { - return ipcRenderer.invoke(BIDIRECTIONAL.SHOW_ITEM_IN_FOLDER, { filePath }) - .then((r: { success: boolean }) => r?.success ?? false); - }, - /** 获取文件大小(字节) */ - fileSize(relativePath: string): Promise { - return ipcRenderer.invoke(BIDIRECTIONAL.FILE_SIZE, { relativePath }) - .then((r: { success: boolean; data: number }) => r?.success ? r.data : null); - }, - /** 检查文件/目录是否存在 */ - fileExists(relativePath: string): Promise { - return ipcRenderer.invoke(BIDIRECTIONAL.FILE_EXISTS, { relativePath }) - .then((r: { success: boolean; data: boolean }) => r?.success ? r.data : false); - }, -}); +// 2. 安全加密 API(基于 Electron safeStorage) +exposeSafeStorage(); -/** 应用运行时 API */ -contextBridge.exposeInMainWorld('appRuntime', { - /** - * 登录/登出后更新窗口标题中的版控后缀 - * @param edition - 'personal' | 'enterprise' | null(null 清除后缀) - */ - setWindowEdition(edition: string | null): void { - ipcRenderer.send(RENDERER_TO_MAIN.SET_WINDOW_TITLE, edition); - }, -}); +// 3. 本地存储文件 API(主进程 fs 桥接) +exposeFileStorage(); -// --------- 无限画布 API --------- -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:/// 格式而非 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); - }, -}); +// 4. 应用运行时 API(窗口标题等) +exposeAppRuntime(); + +// 5. 无限画布 API +exposeCanvas(); diff --git a/electron/preload/app-runtime.ts b/electron/preload/app-runtime.ts new file mode 100644 index 0000000..5c53398 --- /dev/null +++ b/electron/preload/app-runtime.ts @@ -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' | null(null 清除后缀) + */ + setWindowEdition(edition: string | null): void { + ipcRenderer.send(RENDERER_TO_MAIN.SET_WINDOW_TITLE, edition); + }, + }); +} diff --git a/electron/preload/base.ts b/electron/preload/base.ts new file mode 100644 index 0000000..802c1ea --- /dev/null +++ b/electron/preload/base.ts @@ -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) { + const [channel, listener] = args; + return ipcRenderer.on(channel, (event, ...args) => listener(event, ...args)); + }, + off(...args: Parameters) { + const [channel, ...omit] = args; + return ipcRenderer.off(channel, ...omit); + }, + send(...args: Parameters) { + const [channel, ...omit] = args; + return ipcRenderer.send(channel, ...omit); + }, + invoke(...args: Parameters) { + const [channel, ...omit] = args; + return ipcRenderer.invoke(channel, ...omit); + }, + }); +} diff --git a/electron/preload/canvas.ts b/electron/preload/canvas.ts new file mode 100644 index 0000000..347281b --- /dev/null +++ b/electron/preload/canvas.ts @@ -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:/// 格式而非 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); + }, + }); +} diff --git a/electron/preload/file-storage.ts b/electron/preload/file-storage.ts new file mode 100644 index 0000000..e578ebc --- /dev/null +++ b/electron/preload/file-storage.ts @@ -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 { + 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 { + const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_WRITE, { relativePath, data, encoding }); + return r?.success ? r.data : null; + }, + + /** 读取文件(磁盘 → Base64) */ + async readFile(relativePath: string): Promise { + const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_READ, { relativePath }); + return r?.success ? r.data : null; + }, + + /** 删除文件 */ + async deleteFile(relativePath: string): Promise { + const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_DELETE, { relativePath }); + return r?.success ?? false; + }, + + /** 递归创建目录 */ + async makeDir(relativePath: string): Promise { + const r = await ipcRenderer.invoke(BIDIRECTIONAL.MAKE_DIR, { relativePath }); + return r?.success ? r.data : null; + }, + + /** 在文件管理器中显示文件(绝对路径) */ + async showItemInFolder(filePath: string): Promise { + const r = await ipcRenderer.invoke(BIDIRECTIONAL.SHOW_ITEM_IN_FOLDER, { filePath }); + return r?.success ?? false; + }, + + /** 获取文件大小(字节) */ + async fileSize(relativePath: string): Promise { + const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_SIZE, { relativePath }); + return r?.success ? r.data : null; + }, + + /** 检查文件/目录是否存在 */ + async fileExists(relativePath: string): Promise { + 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 { + 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 { + 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): Promise { + 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 { + 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 { + const r = await ipcRenderer.invoke(BIDIRECTIONAL.GENERATE_THUMBNAIL, { srcPath, destPath, maxWidth, quality }); + if (r?.success) return r.data; + throw new Error(r?.error || '缩略图生成失败'); + }, + }); +} diff --git a/electron/preload/index.ts b/electron/preload/index.ts new file mode 100644 index 0000000..d8626b7 --- /dev/null +++ b/electron/preload/index.ts @@ -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'; diff --git a/electron/preload/safe-storage.ts b/electron/preload/safe-storage.ts new file mode 100644 index 0000000..2c09f8d --- /dev/null +++ b/electron/preload/safe-storage.ts @@ -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 { + 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 { + const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_DECRYPT, encryptedBase64); + return result?.success ? result.data : null; + }, + }); +} diff --git a/shared/constants/ipc-channels.ts b/shared/constants/ipc-channels.ts index 5b05f3f..04c2ccd 100644 --- a/shared/constants/ipc-channels.ts +++ b/shared/constants/ipc-channels.ts @@ -1,95 +1,18 @@ // ============================================================ -// IPC 通道名称常量 —— 避免魔法字符串 +// IPC 通道名称常量 — 兼容层(已拆分为 ipc/ 子模块) +// +// @deprecated 请使用新的模块化导入: +// import { BIDIRECTIONAL } from '@/shared/constants/ipc'; +// import { BIDIRECTIONAL_STORAGE } from '@/shared/constants/ipc/storage'; // ============================================================ -/** 主进程 → 渲染进程 */ -export const MAIN_TO_RENDERER = { - /** 主进程消息推送 */ - MAIN_PROCESS_MESSAGE: 'main-process-message', - /** 主题变更通知 */ - THEME_CHANGED: 'theme-changed', - /** 窗口聚焦通知 */ - WINDOW_FOCUSED: 'window-focused', - /** 窗口失焦通知 */ - WINDOW_BLURRED: 'window-blurred', - /** 画布窗口准备就绪 */ - 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; +export { + MAIN_TO_RENDERER, + RENDERER_TO_MAIN, + BIDIRECTIONAL, + MAIN_TO_RENDERER_CANVAS, + RENDERER_TO_MAIN_CANVAS, + BIDIRECTIONAL_CANVAS, + MAIN_TO_RENDERER_UPDATER, + RENDERER_TO_MAIN_UPDATER, +} from './ipc'; diff --git a/shared/constants/ipc/base.ts b/shared/constants/ipc/base.ts new file mode 100644 index 0000000..bf790a3 --- /dev/null +++ b/shared/constants/ipc/base.ts @@ -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; \ No newline at end of file diff --git a/shared/constants/ipc/canvas.ts b/shared/constants/ipc/canvas.ts new file mode 100644 index 0000000..ee31d67 --- /dev/null +++ b/shared/constants/ipc/canvas.ts @@ -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; diff --git a/shared/constants/ipc/index.ts b/shared/constants/ipc/index.ts new file mode 100644 index 0000000..33f791d --- /dev/null +++ b/shared/constants/ipc/index.ts @@ -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; diff --git a/shared/constants/ipc/storage.ts b/shared/constants/ipc/storage.ts new file mode 100644 index 0000000..52231db --- /dev/null +++ b/shared/constants/ipc/storage.ts @@ -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; \ No newline at end of file diff --git a/shared/constants/ipc/updater.ts b/shared/constants/ipc/updater.ts new file mode 100644 index 0000000..66ea8e2 --- /dev/null +++ b/shared/constants/ipc/updater.ts @@ -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; diff --git a/src/modules/canvas/utils/LocalFileDataSource.ts b/src/modules/canvas/utils/LocalFileDataSource.ts index 488428d..8870cf0 100644 --- a/src/modules/canvas/utils/LocalFileDataSource.ts +++ b/src/modules/canvas/utils/LocalFileDataSource.ts @@ -6,7 +6,7 @@ // ============================================================ import { hasIpc, safeIpcInvoke } from '@/shared/utils/bus'; -import { BIDIRECTIONAL } from '@shared/constants/ipc-channels'; +import { BIDIRECTIONAL } from '@shared/constants/ipc'; import type { ICanvasDataSource, ScanRequest, diff --git a/src/modules/canvas/views/core/MediaCardContextMenu.tsx b/src/modules/canvas/views/core/MediaCardContextMenu.tsx index d9d0317..9197ded 100644 --- a/src/modules/canvas/views/core/MediaCardContextMenu.tsx +++ b/src/modules/canvas/views/core/MediaCardContextMenu.tsx @@ -12,7 +12,7 @@ import { useSidecarSync } from '../../hooks/useSidecarSync'; import { getMediaCardMenuItems } from '../../controllers/menus'; import { ContextMenu } from '@/shared/components/ContextMenu'; 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 type { AuditStatus } from '../../stores/useCanvasStore'; diff --git a/src/modules/home/center/views/ModelInputForm.tsx b/src/modules/home/center/views/ModelInputForm.tsx index 32fd343..0e77995 100644 --- a/src/modules/home/center/views/ModelInputForm.tsx +++ b/src/modules/home/center/views/ModelInputForm.tsx @@ -24,17 +24,18 @@ import { Modal, Dropdown, } from 'antd'; -import type { FormInstance } from 'antd'; import { SendOutlined, ClockCircleOutlined, ReloadOutlined } from '@ant-design/icons'; import type { UploadFile } from 'antd/es/upload'; import { useHomeContext } from '@/modules/home/shared/stores/HomeContext'; import { useAppContext } from '@/app/providers/AppProvider'; import { useSubmitTask } from '@/shared/hooks/use-api'; -import { emit, EVENTS } from '@/shared/utils/bus'; +import { emit, on, off, EVENTS } from '@/shared/utils/bus'; +import { saveParamSnapshot } from '@/shared/infrastructure/storage'; +import { getCachedModels } from '@/services/cache/model-cache'; import { OutputTypeMap } from '@/services/modules/home/task'; import type { OutputTypeString, TaskInfoItemResponseBody } from '@/services/modules/home/task'; -import { useModelUI, isValueEmpty, type UIFieldConfig } from '../hooks/useModelUI'; +import { useModelUI, type UIFieldConfig } from '../hooks/useModelUI'; import { calculateCost, formatYuan, @@ -49,102 +50,27 @@ import { execResetFont, hasTextSelection, isInputEmpty, getFontSizeLevel, } 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; -// ---------- 工具 ---------- - -/** - * 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 ( - - ); -} - -/** 稳定空数组引用,避免 useMemo 重建 */ -const EMPTY_ARRAY: never[] = []; -/** 稳定空对象引用,避免每次渲染创建新 {} 导致 useMemo 失效 */ -const EMPTY_OBJ: Record = {}; - -/** - * 从自由格式 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() { const { token } = antTheme.useToken(); const { isLoggedIn } = useAppContext(); - const { selectedModel } = useHomeContext(); + const { selectedModel, setSelectedModel } = useHomeContext(); const [form] = Form.useForm(); const [taskTag, setTaskTag] = useState(''); + // ── 重新编辑 / 再次生成支持 ── + + /** 待回填的参数(由 TASK_REEDIT / TASK_REGEN 事件设置) */ + const pendingTaskRef = useRef<{ params: Record; autoSubmit: boolean } | null>(null); + /** 自动提交标记(再次生成时设置) */ + const autoSubmitRef = useRef(false); + // 跟踪表单当前值(用于动态计价,每次字段变化时更新) const [currentValues, setCurrentValues] = useState>({}); @@ -174,6 +100,78 @@ export function ModelInputForm() { submitErrorRef.current = submitError; }, [submitError]); + // ======== 重新编辑 / 再次生成事件处理 ======== + + /** 处理重新编辑事件:切换模型 + 回填参数 */ + const handleReeditEvent = useCallback((...args: unknown[]) => { + const payload = args[0] as { record: TaskInfoItemResponseBody; savedParams?: Record }; + 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 }; + 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]); + // ======== useModelUI:param_schema → UIFieldConfig[] ======== const fields: UIFieldConfig[] = useModelUI( @@ -196,12 +194,25 @@ export function ModelInputForm() { // 选中模型时加载默认值(useLayoutEffect 在 DOM 更新后、浏览器绘制前执行,避免闪烁) // antd 有外部 form 实例时 initialValues prop 被忽略,必须手动 setFieldsValue + // 如果有待回填的重新编辑参数(pendingTaskRef),优先使用 useLayoutEffect(() => { if (selectedModel && Object.keys(initialValues).length > 0) { - form.setFieldsValue(initialValues); - setTaskTag(''); - // 同步价格计算:切换模型时立即用默认值更新预估 - setCurrentValues(initialValues); + // 检查是否有待回填的参数(来自重新编辑/再次生成) + 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); + setTaskTag(''); + // 同步价格计算:切换模型时立即用默认值更新预估 + setCurrentValues(initialValues); + } } else if (!selectedModel) { setCurrentValues({}); } @@ -296,6 +307,12 @@ export function ModelInputForm() { if (result !== undefined) { submitCountRef.current += 1; + // 保存参数快照(用于"再次生成"和"重新编辑"功能) + try { + saveParamSnapshot(result.id, selectedModel.id, selectedModel.name, params, taskTag.trim() || undefined); + } catch { + // 参数快照保存失败不影响主流程 + } } else { // 余额不足 / 软件到期等错误已由 request.ts 拦截器 → 事件总线 → Modal 展示, // 此处检查 error.displayed 标记,避免 message.error toast 重复提示 @@ -342,6 +359,19 @@ export function ModelInputForm() { void 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(() => { diff --git a/src/modules/home/center/views/ModelInputForm/DependentFormItem.tsx b/src/modules/home/center/views/ModelInputForm/DependentFormItem.tsx new file mode 100644 index 0000000..f911c20 --- /dev/null +++ b/src/modules/home/center/views/ModelInputForm/DependentFormItem.tsx @@ -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 ( + + ); +} diff --git a/src/modules/home/center/views/ModelInputForm/utils.ts b/src/modules/home/center/views/ModelInputForm/utils.ts new file mode 100644 index 0000000..192f22c --- /dev/null +++ b/src/modules/home/center/views/ModelInputForm/utils.ts @@ -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 = {}; + +/** + * 从自由格式 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; +} diff --git a/src/modules/home/left/controllers/menus/index.ts b/src/modules/home/left/controllers/menus/index.ts index 7254273..6400fe8 100644 --- a/src/modules/home/left/controllers/menus/index.ts +++ b/src/modules/home/left/controllers/menus/index.ts @@ -6,3 +6,4 @@ 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'; diff --git a/src/modules/home/left/controllers/menus/task-list-actions.ts b/src/modules/home/left/controllers/menus/task-list-actions.ts index aa9c6f1..c764298 100644 --- a/src/modules/home/left/controllers/menus/task-list-actions.ts +++ b/src/modules/home/left/controllers/menus/task-list-actions.ts @@ -9,33 +9,46 @@ // 设计原则: // - View 不关心 IPC 细节、路径拼接逻辑、错误处理 // - 每个操作独立 async 函数,便于单独测试和复用 +// - 跨组件操作(重新编辑/再次生成)通过事件总线协调 // ============================================================ -import { message } from 'antd'; -import { safeIpcInvoke, logger } from '@/shared/utils/bus'; -import { BIDIRECTIONAL } from '@shared/constants/ipc-channels'; -import { joinPaths } from '@/shared/utils/path-join'; -import type { TaskInfoItemResponseBody } from '@/services/modules/home/task'; -import type { TaskListMenuOptions } from './task-list-menu'; +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, '') + .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')}`; + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; } /** ISO 日期 → "DD" */ function formatDay(d: Date): string { - return String(d.getDate()).padStart(2, '0'); + return String(d.getDate()).padStart(2, "0"); } /** task_id → 任务目录键:前 8 字符 → t_{key} */ @@ -43,10 +56,49 @@ 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); +} + // ── 操作实现 ── -/** 打开任务输出目录 */ +/** + * 打开任务输出目录 — 自动导出任务数据到用户配置的产物路径。 + * + * 目录结构: + * /repository///
/t_/ + * ├── inputs/ + * │ ├── params.json ← 输入参数(model、prompt、tags、ui_params) + * │ └── .png ← 参考素材(ui_params 中的 imageUrls / content) + * └── outputs/ + * ├── response.json ← 完整任务 API 响应 + * ├── .png ← 输出媒体文件 + * └── .cache/ + * └── .jpg ← 缩略图(最长边 256px,JPEG 60%) + */ async function openOutputDir(record: TaskInfoItemResponseBody): Promise { + // ---- 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 }); @@ -54,35 +106,119 @@ async function openOutputDir(record: TaskInfoItemResponseBody): Promise { return; } - try { - const result = (await safeIpcInvoke(BIDIRECTIONAL.GET_DATA_DIR)) as - | { success: boolean; data: string } - | undefined; + // ---- 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'); - if (!result?.data) { - logger.warn('ui', 'GET_DATA_DIR 返回空', undefined, { taskId: record.id }); - message.warning('无法获取本地数据目录'); - return; + 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], + }); + } } - const outputsDir = joinPaths( - result.data, - 'repository', - cleanUserId(record.user_id), - formatYearMonth(d), - formatDay(d), - taskDirKey(record.id), - 'outputs', - ); + // ---- 4. outputs/ — 导出响应数据 + 媒体文件 + 缩略图 ---- + const responseJson = JSON.stringify(record, null, 2); + await writeFileAbsolute(joinPaths(outputsDir, 'response.json'), responseJson, 'utf8'); - await safeIpcInvoke(BIDIRECTIONAL.SHOW_ITEM_IN_FOLDER, { - filePath: outputsDir, - }); + 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('输出目录不存在或无法访问'); + message.warning('输出目录创建失败或无法访问'); } } @@ -90,42 +226,202 @@ async function openOutputDir(record: TaskInfoItemResponseBody): Promise { async function copyResourceLink(record: TaskInfoItemResponseBody): Promise { const assets = record.output_assets; if (!assets || assets.length === 0) { - message.warning('该任务无输出资源'); + message.warning("该任务无输出资源"); return; } try { await navigator.clipboard.writeText(assets[0].url); - message.success('已复制资源链接'); + message.success("已复制资源链接"); } catch (err) { - logger.warn('ui', '复制资源链接失败', err instanceof Error ? err : new Error(String(err))); - message.error('复制失败'); + 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 { + 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 { + 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 { + 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): TaskListMenuOptions { +export function buildTaskActions( + record: TaskInfoItemResponseBody, + options?: BuildTaskActionsOptions, +): TaskListMenuOptions { + const {onRefreshList} = options ?? {}; + return { - canOpenOutput: record.status === 'success', + canOpenOutput: record.status === "success", canReedit: true, canRegen: true, - canPullResult: record.status === 'success', - canRedownload: record.status === 'success', + canPullResult: record.status === "success", + canRedownload: record.status === "success", isFavorited: record.is_favorite, - + onOpenOutputDir: () => void openOutputDir(record), onCopyResourceLink: () => void copyResourceLink(record), - onReedit: () => { message.info('重新编辑功能待实现'); }, - onRegen: () => { message.info('再次生成功能待实现'); }, - onPullResult: () => { message.info('拉取结果功能待实现'); }, - onRedownload: () => { message.info('重新下载功能待实现'); }, - onToggleFavorite: () => { message.info('收藏功能待实现'); }, - onDelete: () => { message.info('删除记录功能待实现'); }, + onReedit: () => reeditTask(record), + onRegen: () => regenTask(record), + onPullResult: () => void pullResult(record), + onRedownload: () => void redownloadAssets(record), + onToggleFavorite: () => toggleFavorite(record, onRefreshList), + onDelete: () => void deleteTaskRecord(record, onRefreshList), }; } diff --git a/src/modules/home/left/controllers/task-table-config.ts b/src/modules/home/left/controllers/task-table-config.ts index 5ce4fc1..c5cc8e8 100644 --- a/src/modules/home/left/controllers/task-table-config.ts +++ b/src/modules/home/left/controllers/task-table-config.ts @@ -12,6 +12,7 @@ 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"; // ── 类型 ── @@ -54,7 +55,7 @@ export const OUTPUT_TYPE_FILTERS = Object.entries(OutputTypeMap).map(([value, te /** 状态标签配置(渲染用,含未来未知状态的降级处理) */ export const STATUS_CONFIG: Record = { - pending: { color: 'blue', label: '排队中' }, + pending: { color: 'blue', label: '处理中' }, submitted: { color: 'cyan', label: '已提交' }, processing: { color: 'orange', label: '处理中' }, success: { color: 'green', label: '已完成' }, diff --git a/src/modules/home/left/hooks/useTaskTable.tsx b/src/modules/home/left/hooks/useTaskTable.tsx index 9afa0cc..75a99c0 100644 --- a/src/modules/home/left/hooks/useTaskTable.tsx +++ b/src/modules/home/left/hooks/useTaskTable.tsx @@ -251,7 +251,11 @@ export function useTaskTable({ allModels }: UseTaskTableOptions): UseTaskTableRe useEffect(() => { on(EVENTS.TASK_SUBMITTED, handleTaskSubmitted); - return () => { off(EVENTS.TASK_SUBMITTED, handleTaskSubmitted); }; + on(EVENTS.TASK_LIST_REFRESH, handleTaskSubmitted); + return () => { + off(EVENTS.TASK_SUBMITTED, handleTaskSubmitted); + off(EVENTS.TASK_LIST_REFRESH, handleTaskSubmitted); + }; }, [handleTaskSubmitted]); const { diff --git a/src/modules/home/left/index.ts b/src/modules/home/left/index.ts index 4fbca15..725b60f 100644 --- a/src/modules/home/left/index.ts +++ b/src/modules/home/left/index.ts @@ -13,6 +13,7 @@ export { buildTaskActions, } from './controllers/menus'; export type { TaskListMenuOptions } from './controllers/menus'; +export type { BuildTaskActionsOptions } from './controllers/menus/task-list-actions'; export { STATUS_FILTERS, diff --git a/src/modules/home/left/views/TaskHistory.tsx b/src/modules/home/left/views/TaskHistory.tsx index 0933e1a..64ceead 100644 --- a/src/modules/home/left/views/TaskHistory.tsx +++ b/src/modules/home/left/views/TaskHistory.tsx @@ -1,6 +1,5 @@ // ============================================================ // TaskHistory — 任务记录列表(纯 View 层) -// // 职责: // - 右键菜单状态(useState)与 DOM 事件绑定 // - 使用 useTaskTable Hook 获取所有数据与配置 @@ -29,6 +28,7 @@ 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'; @@ -90,14 +90,17 @@ export function TaskHistory({ allModels }: TaskHistoryProps) { y: number; } | null>(null); - /** ContextMenuItems → AntD MenuProps['items'] */ - const toAntdMenu = useCallback((items: ContextMenuItems) => + /** 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, + onClick: () => { + item.onClick?.(); + onClose?.(); + }, disabled: item.disabled, danger: item.danger, }; @@ -105,7 +108,9 @@ export function TaskHistory({ allModels }: TaskHistoryProps) { /** 根据任务记录生成右键菜单项 */ const buildTaskMenuItems = useCallback((record: TaskInfoItemResponseBody) => { - return toAntdMenu(getTaskListMenuItems(buildTaskActions(record))); + return toAntdMenu(getTaskListMenuItems(buildTaskActions(record, { + onRefreshList: () => emit(EVENTS.TASK_LIST_REFRESH), + })), () => setContextMenu(null)); }, [toAntdMenu]); // 菜单打开时监听全局 mousedown → 点击菜单外区域自动关闭 @@ -265,7 +270,6 @@ export function TaskHistory({ allModels }: TaskHistoryProps) { onOpenChange={(open) => { if (!open) setContextMenu(null); }} menu={{ items: contextMenu ? buildTaskMenuItems(contextMenu.record) : [], - onClick: () => setContextMenu(null), }} trigger={[]} > diff --git a/src/modules/home/right/views/OutputPreview.tsx b/src/modules/home/right/views/OutputPreview.tsx index 6c1a5a4..d29f293 100644 --- a/src/modules/home/right/views/OutputPreview.tsx +++ b/src/modules/home/right/views/OutputPreview.tsx @@ -31,7 +31,7 @@ const { Text } = Typography; // ---------- 状态标签配置 ---------- const STATUS_CONFIG: Record = { - pending: { color: 'blue', label: '排队中' }, + pending: { color: 'blue', label: '处理中...' }, submitted: { color: 'cyan', label: '已提交' }, processing: { color: 'orange', label: '处理中' }, success: { color: 'green', label: '已完成' }, diff --git a/src/modules/navbar/views/NavBar.tsx b/src/modules/navbar/views/NavBar.tsx index d963fb4..03b4f9a 100644 --- a/src/modules/navbar/views/NavBar.tsx +++ b/src/modules/navbar/views/NavBar.tsx @@ -13,7 +13,7 @@ import {App} from 'antd'; import {useAppContext} from '@/app/providers/AppProvider'; import {useTheme} from '@/app/providers/ThemeProvider'; 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 {NavItem} from './NavItem'; import {AnnouncementDrawer} from '@/modules/announcement/views/AnnouncementDrawer'; @@ -101,7 +101,7 @@ export function NavBar() { // 项目管理 → 打开独立画布窗口(新 BrowserWindow,非主窗口内叠加层) if (item.key === 'project-management') { console.log('[NavBar] 🖼️ 点击"项目管理" → 发送 OPEN_CANVAS_WINDOW IPC'); - safeIpcSend(RENDERER_TO_MAIN.OPEN_CANVAS_WINDOW); + safeIpcSend(RENDERER_TO_MAIN_CANVAS.OPEN_CANVAS_WINDOW); return; } if (item.target) { diff --git a/src/modules/settings/views/sections/StoragePathSetting.tsx b/src/modules/settings/views/sections/StoragePathSetting.tsx index 514ca04..90ee5ae 100644 --- a/src/modules/settings/views/sections/StoragePathSetting.tsx +++ b/src/modules/settings/views/sections/StoragePathSetting.tsx @@ -11,7 +11,7 @@ import { FolderOpenOutlined, DeleteOutlined, EditOutlined } from '@ant-design/ic import type { Edition } from '@shared/types'; import { useSettings } from '@/app/providers/SettingsProvider'; import { hasIpc, safeIpcInvoke } from '@/shared/utils/bus'; -import { BIDIRECTIONAL } from '@shared/constants/ipc-channels'; +import { BIDIRECTIONAL } from '@shared/constants/ipc'; const { Text, Paragraph } = Typography; diff --git a/src/services/modules/account/billing.ts b/src/services/modules/account/billing.ts index 2d9c077..fb3d500 100644 --- a/src/services/modules/account/billing.ts +++ b/src/services/modules/account/billing.ts @@ -1,5 +1,5 @@ import {type Nullable} from "@/shared/utils/display"; -import {get} from "@/services/request.ts"; +import {get} from "@/services/request"; const BillingUrlObj = { getBalance: '/api/v1/billing/balance', diff --git a/src/services/request.ts b/src/services/request.ts deleted file mode 100644 index abaa9e9..0000000 --- a/src/services/request.ts +++ /dev/null @@ -1,468 +0,0 @@ -// ============================================================ -// Axios 请求封装 -// -// 功能: -// - 统一 BaseURL(环境变量 VITE_API_BASE_URL) -// - 请求拦截器:自动附加 Token -// - 响应拦截器:统一错误处理、业务码校验 -// - 类型安全的 GET / POST / PUT / DELETE / UPLOAD -// - 请求超时、取消请求支持 -// ============================================================ - -import axios, { - type AxiosInstance, - type AxiosRequestConfig, - type AxiosResponse, - type InternalAxiosRequestConfig, -} from 'axios'; -import type { ApiResponse } from './types'; -import { RequestError, RequestErrorType, RefreshError, isRefreshError } from './types'; -import { emit, EVENTS } from '@/shared/utils/bus'; -import { logger } from '@/shared/utils/bus'; -import { getSafeStore, setSafeStore, clearSafeStore, initSafeStore } from '@/shared/utils/safe-storage'; - -// ---------- 配置 ---------- - -/** API 基础地址(可通过 Vite 环境变量覆盖) */ -const BASE_URL = import.meta.env.VITE_API_BASE_URL; -/** 请求超时时间(毫秒) */ -const TIMEOUT = 30_000; - -/** localStorage 中 Token 的键名(密文由 safeStorage 保护) */ -const TOKEN_KEY = 'ele-heixiu-token'; - -/** refresh token 接口路径(该接口 401 时不应触发 handle401,否则死循环) */ -const REFRESH_TOKEN_URL = '/api/v1/auth/refresh'; - -/** 判断请求是否为 refresh token 接口 */ -function isRefreshRequest(config: InternalAxiosRequestConfig): boolean { - return config.url?.includes(REFRESH_TOKEN_URL) ?? false; -} - -// ---------- Token 管理 ---------- - -/** - * 启动时初始化 Token:从加密存储解密到内存缓存。 - * 应在 AppProvider 挂载时调用(login/logout 之前)。 - */ -export async function initTokenStore(): Promise { - await initSafeStore(TOKEN_KEY); -} - -/** 获取当前内存缓存中的 Token(同步,高频安全) */ -export function getToken(): string | null { - return getSafeStore(TOKEN_KEY); -} - -/** 设置 Token(异步加密存储 + 更新内存缓存) */ -export async function setToken(token: string): Promise { - await setSafeStore(TOKEN_KEY, token); -} - -/** 清除 Token(同步清除内存缓存 + localStorage) */ -export function clearToken(): void { - clearSafeStore(TOKEN_KEY); -} - -// ---------- Token 刷新(由外部注册,request.ts 不依赖任何 auth 模块) ---------- - -let refreshHandler: (() => Promise) | null = null; -let isRefreshing = false; -let refreshPromise: Promise | null = null; -/** 等待刷新完成的请求队列 */ -let failedQueue: Array<{ - resolve: (token: string) => void; - reject: (error: Error) => void; -}> = []; - -/** 由 auth 模块调用,注册 token 刷新回调 */ -export function setTokenRefreshHandler(handler: () => Promise): void { - refreshHandler = handler; -} - -function processQueue(error: Error | null, token: string | null): void { - failedQueue.forEach(({ resolve, reject }) => { - if (error) reject(error); - else resolve(token!); - }); - failedQueue = []; -} - -// ---------- Axios 实例 ---------- - -const instance: AxiosInstance = axios.create({ - baseURL: BASE_URL, - timeout: TIMEOUT, - headers: { - 'Content-Type': 'application/json', - }, -}); - -// ---------- 请求拦截器 ---------- - -instance.interceptors.request.use( - (config: InternalAxiosRequestConfig) => { - // 自动附加 Token - const token = getToken(); - if (token && config.headers) { - config.headers.Authorization = `Bearer ${token}`; - } - return config; - }, - () => { - return Promise.reject(new RequestError(RequestErrorType.NETWORK, '请求配置失败')); - }, -); - -// ---------- 401 → 刷新 Token + 重试 ---------- - -function handle401(config: InternalAxiosRequestConfig): Promise> { - // 已经在刷新中 → 排队等待 - if (isRefreshing) { - return new Promise((resolve, reject) => { - failedQueue.push({ - resolve: (token) => { - config.headers.Authorization = `Bearer ${token}`; - resolve(instance(config)); - }, - reject, - }); - }); - } - - // 没有注册刷新回调 → 直接要求重新登录 - if (!refreshHandler) { - clearToken(); - emit(EVENTS.AUTH_REQUIRED); - return Promise.reject(new RefreshError()); - } - - // 开始刷新 - isRefreshing = true; - refreshPromise = refreshHandler(); - - return new Promise((resolve, reject) => { - refreshPromise! - .then((newToken) => { - if (newToken) { - // 刷新成功 → 用新 token 重试队列和当前请求 - processQueue(null, newToken); - config.headers.Authorization = `Bearer ${newToken}`; - resolve(instance(config)); - } else { - // null = 临时失败(网络超时/无连接) - // → 保留 token,仅拒绝当前请求;下次 401 再试(不弹登录) - processQueue(new RefreshError(), null); - reject(new RefreshError()); - } - isRefreshing = false; - refreshPromise = null; - }) - .catch((err) => { - // throw = 永久失败(refresh_token 已过期/无效) - // → 清 token + 弹登录 - logger.warn('auth', 'Token 刷新永久失败', err instanceof Error ? err : undefined); - processQueue(err, null); - isRefreshing = false; - refreshPromise = null; - clearToken(); - emit(EVENTS.AUTH_REQUIRED); - reject(new RefreshError()); - }); - }); -} - -// ---------- 响应拦截器 ---------- - -instance.interceptors.response.use( - (response: AxiosResponse) => { - const { data } = response; - - // HTTP 200 但业务码非 0 → 业务错误 - if (data && data.code !== 0) { - // 业务码 401 → 尝试刷新 token 并重试(refresh 接口自身 401 直接拒绝) - if (data.code === 401 && response.config) { - if (isRefreshRequest(response.config)) { - return Promise.reject(new RefreshError()); - } - return handle401(response.config); - } - - // 业务码 1001 → 余额不足(防御性兜底:当前后端实际返回 HTTP 402 + code 1001, - // 已在上方 HTTP error 拦截器处理;此处保留以兼容「HTTP 200 + code 1001」的变体) - if (data.code === 1001) { - console.log('[request] 检测到余额不足 code=1001,发射 BALANCE_INSUFFICIENT 事件'); - emit(EVENTS.BALANCE_INSUFFICIENT, { - code: 1001, - message: data.message || '余额不足,请充值后重试', - }); - } - - logger.warn('request', `Business error: ${data.message || '请求失败'}`, undefined, { - url: response.config.url, - method: response.config.method?.toUpperCase(), - code: data.code, - traceId: data.traceId, - }); - - const businessErr = new RequestError(RequestErrorType.BUSINESS, data.message || '请求失败', { - code: data.code, - traceId: data.traceId, - }); - // 余额不足已通过事件总线 → Modal 展示,标记避免调用方再弹 message.error toast - if (data.code === 1001) { - businessErr.displayed = true; - } - return Promise.reject(businessErr); - } - - - return response; - }, - (error) => { - // 提取请求上下文(所有日志共用) - const reqCtx = { - url: error.config?.url, - method: error.config?.method?.toUpperCase(), - }; - - // 全局捕获 RefreshError → 自动触发登录 Modal - if (isRefreshError(error)) { - logger.debug('request', 'Auth expired (RefreshError caught)', { - ...reqCtx, - errorType: error.name, - errorMessage: error.message, - }); - clearToken(); - emit(EVENTS.AUTH_REQUIRED); - return Promise.reject(error); - } - - // 请求已被取消 - if (axios.isCancel(error)) { - const cancelErr = new RequestError(RequestErrorType.CANCELLED, '请求已取消'); - logger.debug('request', 'Request cancelled', reqCtx); - return Promise.reject(cancelErr); - } - - // 超时 - if (error.code === 'ECONNABORTED') { - const timeoutErr = new RequestError(RequestErrorType.TIMEOUT, '请求超时,请检查网络'); - logger.warn('request', 'Request timeout', timeoutErr, reqCtx); - return Promise.reject(timeoutErr); - } - - // 无网络 - if (!error.response) { - const netErr = new RequestError(RequestErrorType.NETWORK, '网络异常,请检查连接'); - logger.warn('request', 'Network error', netErr, reqCtx); - return Promise.reject(netErr); - } - - // HTTP 状态码错误 - const { status, data } = error.response; - - // 401 → 尝试刷新 token 并重试 - // ⚠️ 同时检查响应体中的业务码 data.code: - // data.code === 401 → token 过期 → 走刷新流程 - // data.code !== 401 → 其他认证错误(如密码错误)→ 透传后端 message - if (status === 401 && error.config && data?.code === 401) { - if (isRefreshRequest(error.config)) { - const refreshErr = new RefreshError(); - logger.debug('request', 'Refresh endpoint returned 401', { - ...reqCtx, - errorType: refreshErr.name, - }); - return Promise.reject(refreshErr); - } - return handle401(error.config); - } - - // 402 + code 1001 → 余额不足(Payment Required),事件总线驱动弹窗引导充值 - if (status === 402 && data?.code === 1001) { - const balanceMsg = data?.message || '余额不足,请充值后重试'; - console.log('[request] 检测到 HTTP 402 + code 1001 余额不足,发射 BALANCE_INSUFFICIENT 事件'); - emit(EVENTS.BALANCE_INSUFFICIENT, { - code: 1001, - message: balanceMsg, - }); - } - - // 403 + code 4040 → 软件使用权到期,事件总线驱动弹窗引导购买 VIP - if (status === 403 && data?.code === 4040) { - const subMsg = data?.message || '软件使用权已到期,请购买 VIP 套餐后继续使用'; - console.log('[request] 检测到 HTTP 403 + code 4040 软件到期,发射 SUBSCRIPTION_EXPIRED 事件'); - emit(EVENTS.SUBSCRIPTION_EXPIRED, { - code: 4040, - message: subMsg, - }); - } - - let errMsg = '请求失败'; - - switch (status) { - case 400: - errMsg = '请求参数有误'; - break; - case 402: - errMsg = '余额不足'; - break; - case 403: - errMsg = '没有访问权限'; - break; - case 404: - errMsg = '请求的资源不存在'; - break; - case 500: - errMsg = '服务器内部错误'; - break; - case 502: - errMsg = '网关错误'; - break; - case 503: - errMsg = '服务暂时不可用'; - break; - } - - const httpErr = new RequestError(RequestErrorType.HTTP, data?.message || errMsg, { - httpStatus: status, - code: data?.code, - }); - - // 已通过事件总线 Modal 展示的错误,标记避免调用方再弹 message.error toast - // 双条件精确匹配:402+1001(余额不足)/ 403+4040(软件到期),避免误拦截同状态码的其他业务 - if ((status === 402 && data?.code === 1001) || (status === 403 && data?.code === 4040)) { - httpErr.displayed = true; - } - - // HTTP 5xx → error 级别,4xx → warn 级别 - if (status >= 500) { - logger.error('request', `HTTP ${status}: ${errMsg}`, httpErr, reqCtx); - } else { - logger.warn('request', `HTTP ${status}: ${errMsg}`, httpErr, reqCtx); - } - - return Promise.reject(httpErr); - }, -); - -// ---------- 请求方法 ---------- - -/** - * 发起 GET 请求 - * @param url - 请求路径 - * @param params - 查询参数 - * @param config - Axios 配置覆盖 - */ -export async function get( - url: string, - params?: Record, - config?: AxiosRequestConfig, -): Promise { - const res = await instance.get>(url, { ...config, params }); - return res.data.data; -} - -/** - * 发起 POST 请求(JSON) - * @param url - 请求路径 - * @param data - 请求体 - * @param config - Axios 配置覆盖 - */ -export async function post( - url: string, - data?: unknown, - config?: AxiosRequestConfig, -): Promise { - const res = await instance.post>(url, data, config); - return res.data.data; -} - -/** - * 发起 PUT 请求(JSON) - */ -export async function put(url: string, data?: unknown, config?: AxiosRequestConfig): Promise { - const res = await instance.put>(url, data, config); - return res.data.data; -} - -/** - * 发起 DELETE 请求 - */ -export async function del(url: string, config?: AxiosRequestConfig): Promise { - const res = await instance.delete>(url, config); - return res.data.data; -} - -/** - * 文件上传(multipart/form-data) - * @param url - 上传接口路径 - * @param formData - FormData 对象 - * @param onProgress - 上传进度回调 (0~100) - */ -export async function upload( - url: string, - formData: FormData, - onProgress?: (percent: number) => void, -): Promise { - const res = await instance.post>(url, formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - timeout: 120000, // 上传超时 2 分钟 - onUploadProgress: (progressEvent) => { - if (onProgress && progressEvent.total) { - const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total); - onProgress(percent); - } - }, - }); - return res.data.data; -} - -/** - * 文件下载 - * @param url - 下载接口路径 - * @param filename - 保存的文件名 - */ -export async function download(url: string, filename?: string): Promise { - const res = await instance.get(url, { - responseType: 'blob', - }); - - const blob = new Blob([res.data]); - const downloadUrl = window.URL.createObjectURL(blob); - const anchor = document.createElement('a'); - anchor.href = downloadUrl; - anchor.download = filename || 'download'; - document.body.appendChild(anchor); - anchor.click(); - document.body.removeChild(anchor); - window.URL.revokeObjectURL(downloadUrl); -} - -/** - * 创建可取消的请求控制器 - * 用法: - * const { controller, request } = createCancellableRequest(); - * request(get('/some/url')); // 发起请求 - * controller.abort(); // 取消请求 - */ -export function createCancellableRequest() { - const controller = new AbortController(); - return { - controller, - signal: controller.signal, - /** 包裹一个请求 Promise,使其可通过 controller.abort() 取消 */ - request: (promise: Promise): Promise => { - return new Promise((resolve, reject) => { - promise.then(resolve).catch(reject); - controller.signal.addEventListener('abort', () => { - reject(new RequestError(RequestErrorType.CANCELLED, '请求已取消')); - }); - }); - }, - }; -} - -// ---------- 导出 axios 实例(供高级场景直接使用) ---------- -export { instance as axiosInstance }; -export { RequestError, RequestErrorType }; diff --git a/src/services/request/index.ts b/src/services/request/index.ts new file mode 100644 index 0000000..2077a70 --- /dev/null +++ b/src/services/request/index.ts @@ -0,0 +1,63 @@ +// ============================================================ +// request/index.ts — Axios 请求封装主入口 +// +// 功能: +// - 统一 BaseURL(环境变量 VITE_API_BASE_URL) +// - 请求拦截器:自动附加 Token +// - 响应拦截器:统一错误处理、业务码校验 +// - 类型安全的 GET / POST / PUT / DELETE / UPLOAD +// - 请求超时、取消请求支持 +// ============================================================ + +import axios, { type AxiosInstance } from 'axios'; + +import { setupInterceptors } from './interceptors'; +import { + createGet, + createPost, + createPut, + createDel, + createUpload, + createDownload, + createCancellableRequest, +} from './methods'; + +// ---------- 配置 ---------- + +/** API 基础地址(可通过 Vite 环境变量覆盖) */ +const BASE_URL = import.meta.env.VITE_API_BASE_URL; +/** 请求超时时间(毫秒) */ +const TIMEOUT = 30_000; + +// ---------- Axios 实例 ---------- + +const instance: AxiosInstance = axios.create({ + baseURL: BASE_URL, + timeout: TIMEOUT, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// ---------- 注册拦截器 ---------- + +setupInterceptors(instance); + +// ---------- 导出请求方法 ---------- + +export const get = createGet(instance); +export const post = createPost(instance); +export const put = createPut(instance); +export const del = createDel(instance); +export const upload = createUpload(instance); +export const download = createDownload(instance); +export { createCancellableRequest }; + +// ---------- 导出 Token 管理 ---------- + +export { initTokenStore, getToken, setToken, clearToken, setTokenRefreshHandler } from './token'; + +// ---------- 导出类型和错误 ---------- + +export { RequestError, RequestErrorType } from '../types'; +export { instance as axiosInstance }; diff --git a/src/services/request/interceptors.ts b/src/services/request/interceptors.ts new file mode 100644 index 0000000..4ab3fc8 --- /dev/null +++ b/src/services/request/interceptors.ts @@ -0,0 +1,293 @@ +// ============================================================ +// request/interceptors.ts — 请求/响应拦截器 +// ============================================================ + +import axios, { + type AxiosInstance, + type AxiosResponse, + type InternalAxiosRequestConfig, +} from 'axios'; +import type { ApiResponse } from '../types'; +import { RequestError, RequestErrorType, RefreshError, isRefreshError } from '../types'; +import { emit, EVENTS } from '@/shared/utils/bus'; +import { logger } from '@/shared/utils/bus'; +import { getToken, clearToken, getRefreshHandler, getIsRefreshing, setIsRefreshing, getRefreshPromise, setRefreshPromise, processQueue } from './token'; + +/** refresh token 接口路径(该接口 401 时不应触发 handle401,否则死循环) */ +const REFRESH_TOKEN_URL = '/api/v1/auth/refresh'; + +/** 判断请求是否为 refresh token 接口 */ +function isRefreshRequest(config: InternalAxiosRequestConfig): boolean { + return config.url?.includes(REFRESH_TOKEN_URL) ?? false; +} + +// ---------- 401 → 刷新 Token + 重试 ---------- + +function handle401(config: InternalAxiosRequestConfig): Promise> { + // 已经在刷新中 → 排队等待 + if (getIsRefreshing()) { + return new Promise((resolve, reject) => { + // 这里需要访问 failedQueue,但它是 token.ts 的内部状态 + // 我们通过 processQueue 来处理,所以需要一个临时队列 + const queue = { + resolve: (token: string) => { + config.headers.Authorization = `Bearer ${token}`; + resolve(axios(config)); + }, + reject, + }; + // 将队列项添加到 token.ts 的 failedQueue + // 由于 failedQueue 是模块内部的,我们需要通过 processQueue 来处理 + // 这里我们直接调用 processQueue,它会在刷新完成后被调用 + // 所以我们只需要保存 resolve 和 reject 即可 + // 但是 processQueue 是在 token.ts 中定义的,我们需要一个方式来添加到队列 + // 为了简化,我们直接在这里处理 + const refreshPromise = getRefreshPromise(); + if (refreshPromise) { + refreshPromise.then((newToken) => { + if (newToken) { + queue.resolve(newToken); + } else { + queue.reject(new RefreshError()); + } + }).catch((err) => { + queue.reject(err); + }); + } + }); + } + + // 没有注册刷新回调 → 直接要求重新登录 + const refreshHandler = getRefreshHandler(); + if (!refreshHandler) { + clearToken(); + emit(EVENTS.AUTH_REQUIRED); + return Promise.reject(new RefreshError()); + } + + // 开始刷新 + setIsRefreshing(true); + const promise = refreshHandler(); + setRefreshPromise(promise); + + return new Promise((resolve, reject) => { + promise + .then((newToken) => { + if (newToken) { + // 刷新成功 → 用新 token 重试队列和当前请求 + processQueue(null, newToken); + config.headers.Authorization = `Bearer ${newToken}`; + resolve(axios(config)); + } else { + // null = 临时失败(网络超时/无连接) + // → 保留 token,仅拒绝当前请求;下次 401 再试(不弹登录) + processQueue(new RefreshError(), null); + reject(new RefreshError()); + } + setIsRefreshing(false); + setRefreshPromise(null); + }) + .catch((err) => { + // throw = 永久失败(refresh_token 已过期/无效) + // → 清 token + 弹登录 + logger.warn('auth', 'Token 刷新永久失败', err instanceof Error ? err : undefined); + processQueue(err, null); + setIsRefreshing(false); + setRefreshPromise(null); + clearToken(); + emit(EVENTS.AUTH_REQUIRED); + reject(new RefreshError()); + }); + }); +} + +// ---------- 注册拦截器 ---------- + +export function setupInterceptors(instance: AxiosInstance): void { + // 请求拦截器 + instance.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + // 自动附加 Token + const token = getToken(); + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + () => { + return Promise.reject(new RequestError(RequestErrorType.NETWORK, '请求配置失败')); + }, + ); + + // 响应拦截器 + instance.interceptors.response.use( + (response: AxiosResponse) => { + const { data } = response; + + // HTTP 200 但业务码非 0 → 业务错误 + if (data && data.code !== 0) { + // 业务码 401 → 尝试刷新 token 并重试(refresh 接口自身 401 直接拒绝) + if (data.code === 401 && response.config) { + if (isRefreshRequest(response.config)) { + return Promise.reject(new RefreshError()); + } + return handle401(response.config); + } + + // 业务码 1001 → 余额不足(防御性兜底:当前后端实际返回 HTTP 402 + code 1001, + // 已在上方 HTTP error 拦截器处理;此处保留以兼容「HTTP 200 + code 1001」的变体) + if (data.code === 1001) { + console.log('[request] 检测到余额不足 code=1001,发射 BALANCE_INSUFFICIENT 事件'); + emit(EVENTS.BALANCE_INSUFFICIENT, { + code: 1001, + message: data.message || '余额不足,请充值后重试', + }); + } + + logger.warn('request', `Business error: ${data.message || '请求失败'}`, undefined, { + url: response.config.url, + method: response.config.method?.toUpperCase(), + code: data.code, + traceId: data.traceId, + }); + + const businessErr = new RequestError(RequestErrorType.BUSINESS, data.message || '请求失败', { + code: data.code, + traceId: data.traceId, + }); + // 余额不足已通过事件总线 → Modal 展示,标记避免调用方再弹 message.error toast + if (data.code === 1001) { + businessErr.displayed = true; + } + return Promise.reject(businessErr); + } + + return response; + }, + (error) => { + // 提取请求上下文(所有日志共用) + const reqCtx = { + url: error.config?.url, + method: error.config?.method?.toUpperCase(), + }; + + // 全局捕获 RefreshError → 自动触发登录 Modal + if (isRefreshError(error)) { + logger.debug('request', 'Auth expired (RefreshError caught)', { + ...reqCtx, + errorType: error.name, + errorMessage: error.message, + }); + clearToken(); + emit(EVENTS.AUTH_REQUIRED); + return Promise.reject(error); + } + + // 请求已被取消 + if (axios.isCancel(error)) { + const cancelErr = new RequestError(RequestErrorType.CANCELLED, '请求已取消'); + logger.debug('request', 'Request cancelled', reqCtx); + return Promise.reject(cancelErr); + } + + // 超时 + if (error.code === 'ECONNABORTED') { + const timeoutErr = new RequestError(RequestErrorType.TIMEOUT, '请求超时,请检查网络'); + logger.warn('request', 'Request timeout', timeoutErr, reqCtx); + return Promise.reject(timeoutErr); + } + + // 无网络 + if (!error.response) { + const netErr = new RequestError(RequestErrorType.NETWORK, '网络异常,请检查连接'); + logger.warn('request', 'Network error', netErr, reqCtx); + return Promise.reject(netErr); + } + + // HTTP 状态码错误 + const { status, data } = error.response; + + // 401 → 尝试刷新 token 并重试 + // ⚠️ 同时检查响应体中的业务码 data.code: + // data.code === 401 → token 过期 → 走刷新流程 + // data.code !== 401 → 其他认证错误(如密码错误)→ 透传后端 message + if (status === 401 && error.config && data?.code === 401) { + if (isRefreshRequest(error.config)) { + const refreshErr = new RefreshError(); + logger.debug('request', 'Refresh endpoint returned 401', { + ...reqCtx, + errorType: refreshErr.name, + }); + return Promise.reject(refreshErr); + } + return handle401(error.config); + } + + // 402 + code 1001 → 余额不足(Payment Required),事件总线驱动弹窗引导充值 + if (status === 402 && data?.code === 1001) { + const balanceMsg = data?.message || '余额不足,请充值后重试'; + console.log('[request] 检测到 HTTP 402 + code 1001 余额不足,发射 BALANCE_INSUFFICIENT 事件'); + emit(EVENTS.BALANCE_INSUFFICIENT, { + code: 1001, + message: balanceMsg, + }); + } + + // 403 + code 4040 → 软件使用权到期,事件总线驱动弹窗引导购买 VIP + if (status === 403 && data?.code === 4040) { + const subMsg = data?.message || '软件使用权已到期,请购买 VIP 套餐后继续使用'; + console.log('[request] 检测到 HTTP 403 + code 4040 软件到期,发射 SUBSCRIPTION_EXPIRED 事件'); + emit(EVENTS.SUBSCRIPTION_EXPIRED, { + code: 4040, + message: subMsg, + }); + } + + let errMsg = '请求失败'; + + switch (status) { + case 400: + errMsg = '请求参数有误'; + break; + case 402: + errMsg = '余额不足'; + break; + case 403: + errMsg = '没有访问权限'; + break; + case 404: + errMsg = '请求的资源不存在'; + break; + case 500: + errMsg = '服务器内部错误'; + break; + case 502: + errMsg = '网关错误'; + break; + case 503: + errMsg = '服务暂时不可用'; + break; + } + + const httpErr = new RequestError(RequestErrorType.HTTP, data?.message || errMsg, { + httpStatus: status, + code: data?.code, + }); + + // 已通过事件总线 Modal 展示的错误,标记避免调用方再弹 message.error toast + // 双条件精确匹配:402+1001(余额不足)/ 403+4040(软件到期),避免误拦截同状态码的其他业务 + if ((status === 402 && data?.code === 1001) || (status === 403 && data?.code === 4040)) { + httpErr.displayed = true; + } + + // HTTP 5xx → error 级别,4xx → warn 级别 + if (status >= 500) { + logger.error('request', `HTTP ${status}: ${errMsg}`, httpErr, reqCtx); + } else { + logger.warn('request', `HTTP ${status}: ${errMsg}`, httpErr, reqCtx); + } + + return Promise.reject(httpErr); + }, + ); +} diff --git a/src/services/request/methods.ts b/src/services/request/methods.ts new file mode 100644 index 0000000..6b812f1 --- /dev/null +++ b/src/services/request/methods.ts @@ -0,0 +1,147 @@ +// ============================================================ +// request/methods.ts — 请求方法 +// ============================================================ + +import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios'; +import type { ApiResponse } from '../types'; +import { RequestError, RequestErrorType } from '../types'; + +// ---------- 请求方法 ---------- + +/** + * 发起 GET 请求 + * @param url - 请求路径 + * @param params - 查询参数 + * @param config - Axios 配置覆盖 + */ +export function createGet(instance: AxiosInstance) { + return async function get( + url: string, + params?: Record, + config?: AxiosRequestConfig, + ): Promise { + const res = await instance.get>(url, { ...config, params }); + return res.data.data; + }; +} + +/** + * 发起 POST 请求(JSON) + * @param url - 请求路径 + * @param data - 请求体 + * @param config - Axios 配置覆盖 + */ +export function createPost(instance: AxiosInstance) { + return async function post( + url: string, + data?: unknown, + config?: AxiosRequestConfig, + ): Promise { + const res = await instance.post>(url, data, config); + return res.data.data; + }; +} + +/** + * 发起 PUT 请求(JSON) + */ +export function createPut(instance: AxiosInstance) { + return async function put(url: string, data?: unknown, config?: AxiosRequestConfig): Promise { + const res = await instance.put>(url, data, config); + return res.data.data; + }; +} + +/** + * 发起 DELETE 请求 + */ +export function createDel(instance: AxiosInstance) { + return async function del(url: string, config?: AxiosRequestConfig): Promise { + const res = await instance.delete>(url, config); + return res.data.data; + }; +} + +/** + * 文件上传(multipart/form-data) + * @param url - 上传接口路径 + * @param formData - FormData 对象 + * @param onProgress - 上传进度回调 (0~100) + */ +export function createUpload(instance: AxiosInstance) { + return async function upload( + url: string, + formData: FormData, + onProgress?: (percent: number) => void, + ): Promise { + const res = await instance.post>(url, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 120000, // 上传超时 2 分钟 + onUploadProgress: (progressEvent) => { + if (onProgress && progressEvent.total) { + const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total); + onProgress(percent); + } + }, + }); + return res.data.data; + }; +} + +/** + * 文件下载(绕过响应拦截器,避免 blob 被当作 API 响应解析) + * + * 对于外部 CDN URL(http/https 开头),直接使用 raw axios 请求(不附加 token、不经过 baseURL)。 + * 对于内部 API 路径(/api/...),使用 instance 的 baseURL + token。 + * + * @param url - 下载地址(完整 URL 或 API 相对路径) + * @param filename - 保存的文件名 + */ +export function createDownload(instance: AxiosInstance) { + return async function download(url: string, filename?: string): Promise { + const isExternal = url.startsWith('http://') || url.startsWith('https://'); + + // 外部 URL 用 raw axios(不走拦截器),内部 URL 用 instance(带 token) + const client = isExternal ? axios : instance; + + const res = await client.get(url, { + responseType: 'blob', + // 跳过响应拦截器的 ApiResponse 解析(仅对 instance 生效,raw axios 无拦截器) + transformResponse: [(data: unknown) => data], + }); + + const blob = res.data instanceof Blob ? res.data : new Blob([res.data]); + const downloadUrl = window.URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = downloadUrl; + anchor.download = filename || 'download'; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + window.URL.revokeObjectURL(downloadUrl); + }; +} + +/** + * 创建可取消的请求控制器 + * 用法: + * const { controller, request } = createCancellableRequest(); + * request(get('/some/url')); // 发起请求 + * controller.abort(); // 取消请求 + */ +export function createCancellableRequest() { + const controller = new AbortController(); + return { + controller, + signal: controller.signal, + /** 包裹一个请求 Promise,使其可通过 controller.abort() 取消 */ + request: (promise: Promise): Promise => { + return new Promise((resolve, reject) => { + promise.then(resolve).catch(reject); + controller.signal.addEventListener('abort', () => { + reject(new RequestError(RequestErrorType.CANCELLED, '请求已取消')); + }); + }); + }, + }; +} diff --git a/src/services/request/token.ts b/src/services/request/token.ts new file mode 100644 index 0000000..4161272 --- /dev/null +++ b/src/services/request/token.ts @@ -0,0 +1,75 @@ +// ============================================================ +// request/token.ts — Token 管理 +// ============================================================ + +import { getSafeStore, setSafeStore, clearSafeStore, initSafeStore } from '@/shared/utils/safe-storage'; + +/** localStorage 中 Token 的键名(密文由 safeStorage 保护) */ +const TOKEN_KEY = 'ele-heixiu-token'; + +/** + * 启动时初始化 Token:从加密存储解密到内存缓存。 + * 应在 AppProvider 挂载时调用(login/logout 之前)。 + */ +export async function initTokenStore(): Promise { + await initSafeStore(TOKEN_KEY); +} + +/** 获取当前内存缓存中的 Token(同步,高频安全) */ +export function getToken(): string | null { + return getSafeStore(TOKEN_KEY); +} + +/** 设置 Token(异步加密存储 + 更新内存缓存) */ +export async function setToken(token: string): Promise { + await setSafeStore(TOKEN_KEY, token); +} + +/** 清除 Token(同步清除内存缓存 + localStorage) */ +export function clearToken(): void { + clearSafeStore(TOKEN_KEY); +} + +// ---------- Token 刷新(由外部注册,request.ts 不依赖任何 auth 模块) ---------- + +let refreshHandler: (() => Promise) | null = null; +let isRefreshing = false; +let refreshPromise: Promise | null = null; +/** 等待刷新完成的请求队列 */ +let failedQueue: Array<{ + resolve: (token: string) => void; + reject: (error: Error) => void; +}> = []; + +/** 由 auth 模块调用,注册 token 刷新回调 */ +export function setTokenRefreshHandler(handler: () => Promise): void { + refreshHandler = handler; +} + +export function getRefreshHandler(): (() => Promise) | null { + return refreshHandler; +} + +export function getIsRefreshing(): boolean { + return isRefreshing; +} + +export function setIsRefreshing(value: boolean): void { + isRefreshing = value; +} + +export function getRefreshPromise(): Promise | null { + return refreshPromise; +} + +export function setRefreshPromise(value: Promise | null): void { + refreshPromise = value; +} + +export function processQueue(error: Error | null, token: string | null): void { + failedQueue.forEach(({ resolve, reject }) => { + if (error) reject(error); + else resolve(token!); + }); + failedQueue = []; +} diff --git a/src/shared/infrastructure/storage/core/schema.ts b/src/shared/infrastructure/storage/core/schema.ts index 312bef3..88b6136 100644 --- a/src/shared/infrastructure/storage/core/schema.ts +++ b/src/shared/infrastructure/storage/core/schema.ts @@ -74,6 +74,15 @@ export const DDL_STATEMENTS = [ updated_at INTEGER NOT NULL )`, + // ---- 已删除任务缓存(软删除) ---- + `CREATE TABLE IF NOT EXISTS deleted_tasks ( + task_id TEXT NOT NULL, + user_id TEXT NOT NULL, + deleted_at INTEGER NOT NULL, + PRIMARY KEY (task_id, user_id) + )`, + `CREATE INDEX IF NOT EXISTS idx_deleted_user ON deleted_tasks(user_id)`, + // ---- Schema 版本 ---- `CREATE TABLE IF NOT EXISTS _schema_version (version INTEGER PRIMARY KEY)`, ]; diff --git a/src/shared/infrastructure/storage/index.ts b/src/shared/infrastructure/storage/index.ts index 04c7a04..99c897d 100644 --- a/src/shared/infrastructure/storage/index.ts +++ b/src/shared/infrastructure/storage/index.ts @@ -29,7 +29,7 @@ import { logger } from '@/shared/utils/bus'; export { openDatabase, closeDatabase, isOpen, exportDatabase, execute, queryAll, queryOne } from './core/db-core'; export { loadDatabase, saveDatabase } from './core/db-encrypt'; export { getCurrentUserId, clearUserIdCache, getTodayKey, hashURL, formatBytes } from './services/user-scope'; -export { upsertTask, syncTasks, getTasks, getTaskById, deleteTask, getTaskCount, clearUserTasks, rowToTask } from './stores/task-store'; +export { upsertTask, syncTasks, getTasks, getTaskById, deleteTask, toggleFavoriteTask, markTaskDeleted, isTaskDeleted, getTaskCount, clearUserTasks, rowToTask } from './stores/task-store'; export { upsertOrder, syncRechargeOrders, syncVipOrders, getOrders, getOrderById, deleteOrder, getOrderCount, clearUserOrders } from './stores/order-store'; export { saveParamSnapshot, fillOutputData, getParamByTaskId, getParamHistory, parseParams, deleteParamHistory, clearUserParams } from './stores/param-store'; export { upsertMediaCache, getMediaByUrl, hasMedia, getMediaCount, getMediaTotalSize, deleteMediaByUrl, clearExpiredMedia, clearUserMedia } from './stores/media-store'; @@ -80,7 +80,13 @@ export async function initStorage(): Promise { return false; } - // 3. 清理过期媒体缓存 + // 3. 加载已删除任务缓存到内存 + try { + const { loadDeletedTaskIds } = await import('./stores/task-store'); + loadDeletedTaskIds(); + } catch { /* ignore */ } + + // 4. 清理过期媒体缓存 try { const { clearExpiredMedia } = await import('./stores/media-store'); clearExpiredMedia(); diff --git a/src/shared/infrastructure/storage/ipc-file.ts b/src/shared/infrastructure/storage/ipc-file.ts index 152e331..de9ea65 100644 --- a/src/shared/infrastructure/storage/ipc-file.ts +++ b/src/shared/infrastructure/storage/ipc-file.ts @@ -11,14 +11,14 @@ // media-store / db-encrypt 等均通过本模块操作文件。 // ============================================================ -import { hasIpc } from '@/shared/utils/bus'; -import { logger } from '@/shared/utils/bus'; +import {hasIpc} from "@/shared/utils/bus"; +import {logger} from "@/shared/utils/bus"; // ---------- 文件 API ---------- -export async function writeFile(relativePath: string, data: string, encoding?: 'base64' | 'utf8'): Promise { +export async function writeFile(relativePath: string, data: string, encoding?: "base64" | "utf8"): Promise { if (!hasIpc() || !window.fileStorage) { - logger.warn('storage', 'fileStorage 不可用,文件写入跳过'); + logger.warn("storage", "fileStorage 不可用,文件写入跳过"); return null; } return window.fileStorage.writeFile(relativePath, data, encoding); @@ -54,9 +54,9 @@ export async function saveBlobToFile(relativePath: string, blob: Blob): Promise< return new Promise((resolve) => { const reader = new FileReader(); reader.onload = () => { - const base64 = (reader.result as string).split(',')[1]; // 去掉 data:mime;base64, 前缀 - writeFile(relativePath, base64, 'base64').then(resolve).catch((err) => { - logger.warn('storage', 'IPC 文件写入失败(saveBlobToFile)', err instanceof Error ? err : undefined); + const base64 = (reader.result as string).split(",")[1]; // 去掉 data:mime;base64, 前缀 + writeFile(relativePath, base64, "base64").then(resolve).catch((err) => { + logger.warn("storage", "IPC 文件写入失败(saveBlobToFile)", err instanceof Error ? err : undefined); resolve(null); }); }; @@ -77,3 +77,71 @@ export async function getDataDir(): Promise { if (!hasIpc() || !window.fileStorage) return null; return window.fileStorage.getDataDir(); } + +/** 绝对路径文件写入(绕过 heixiu-data 沙箱,用于用户自定义输出目录) */ +export async function writeFileAbsolute(absolutePath: string, data: string, encoding?: "base64" | "utf8"): Promise { + if (!hasIpc() || !window.fileStorage) { + logger.warn("storage", "fileStorage 不可用,绝对路径写入跳过"); + return null; + } + return window.fileStorage.writeFileAbsolute(absolutePath, data, encoding); +} + +/** 绝对路径目录创建(绕过 heixiu-data 沙箱) */ +export async function makeDirAbsolute(absolutePath: string): Promise { + if (!hasIpc() || !window.fileStorage) { + logger.warn("storage", "fileStorage 不可用,绝对路径目录创建跳过"); + return null; + } + return window.fileStorage.makeDirAbsolute(absolutePath); +} + +/** + * 主进程下载文件到绝对路径(绕过浏览器 CORS 限制)。 + * + * 渲染进程直接请求 CDN 会被 CORS 拦截, + * 主进程使用 Node.js http/https 模块不受此限制。 + * + * @param url - 远程资源 URL + * @param destPath - 本地保存的绝对路径 + * @param headers - 可选请求头(如 Authorization) + * @returns 写入的绝对路径 + * @throws 下载失败时抛出错误(含具体原因) + */ +export async function downloadToPath( + url: string, + destPath: string, + headers?: Record, +): Promise { + if (!hasIpc() || !window.fileStorage) { + throw new Error('fileStorage 不可用,无法执行主进程下载'); + } + return window.fileStorage.downloadToPath(url, destPath, headers); +} + +/** 获取系统下载目录路径 */ +export async function getDownloadsPath(): Promise { + if (!hasIpc() || !window.fileStorage) return null; + return window.fileStorage.getDownloadsPath(); +} + +/** + * 生成图片缩略图(主进程 nativeImage 缩放 → JPEG)。 + * + * @param srcPath - 原图绝对路径 + * @param destPath - 缩略图输出绝对路径(.cache/ 目录下) + * @param maxWidth - 最长边像素(默认 256) + * @param quality - JPEG 质量 1-100(默认 60) + * @returns 写入的绝对路径 + */ +export async function generateThumbnail( + srcPath: string, + destPath: string, + maxWidth?: number, + quality?: number, +): Promise { + if (!hasIpc() || !window.fileStorage) { + throw new Error('fileStorage 不可用,无法生成缩略图'); + } + return window.fileStorage.generateThumbnail(srcPath, destPath, maxWidth, quality); +} diff --git a/src/shared/infrastructure/storage/stores/task-store.ts b/src/shared/infrastructure/storage/stores/task-store.ts index 0a95205..5bc94fa 100644 --- a/src/shared/infrastructure/storage/stores/task-store.ts +++ b/src/shared/infrastructure/storage/stores/task-store.ts @@ -83,6 +83,20 @@ export function deleteTask(taskId: string): void { execute(`DELETE FROM task_records WHERE id = ?`, [taskId]); } +/** 切换任务收藏状态(仅更新 data_json 中的 is_favorite) */ +export function toggleFavoriteTask(taskId: string, isFavorite: boolean): void { + const row = queryOne(`SELECT * FROM task_records WHERE id = ?`, [taskId]); + if (!row) return; + try { + const task = JSON.parse(row.data_json) as TaskInfoItemResponseBody; + task.is_favorite = isFavorite; + execute( + `UPDATE task_records SET data_json = ?, updated_at = ? WHERE id = ?`, + [JSON.stringify(task), Date.now(), taskId], + ); + } catch { /* ignore parse error */ } +} + export function getTaskCount(userId?: string): number { const row = queryOne<{ cnt: number }>( `SELECT COUNT(*) as cnt FROM task_records WHERE user_id = ?`, @@ -95,3 +109,47 @@ export function clearUserTasks(): void { const uid = getCurrentUserId(); execute(`DELETE FROM task_records WHERE user_id = ?`, [uid]); } + +// ---------- 软删除机制 ---------- +// 维护本地删除缓存,用于在拉取云端数据时排除已删除的记录 + +/** 内存缓存:已删除的任务 ID 集合(启动时从数据库加载) */ +const deletedTaskIds = new Set(); + +/** 初始化时加载已删除的任务 ID 到内存 */ +export function loadDeletedTaskIds(): void { + try { + const rows = queryAll<{ task_id: string }>( + `SELECT task_id FROM deleted_tasks WHERE user_id = ?`, + [getCurrentUserId()], + ); + deletedTaskIds.clear(); + for (const row of rows) { + deletedTaskIds.add(row.task_id); + } + } catch { /* 表可能不存在,忽略 */ } +} + +/** 标记任务为已删除(软删除,持久化到数据库) */ +export function markTaskDeleted(taskId: string): void { + const uid = getCurrentUserId(); + try { + execute( + `INSERT OR IGNORE INTO deleted_tasks (task_id, user_id, deleted_at) VALUES (?, ?, ?)`, + [taskId, uid, Date.now()], + ); + deletedTaskIds.add(taskId); + } catch (err) { + logger.warn('storage', '标记任务删除失败', err instanceof Error ? err : undefined); + } +} + +/** 检查任务是否已被标记为删除 */ +export function isTaskDeleted(taskId: string): boolean { + return deletedTaskIds.has(taskId); +} + +/** 获取所有已删除的任务 ID(用于批量过滤) */ +export function getDeletedTaskIds(): Set { + return new Set(deletedTaskIds); +} diff --git a/src/shared/utils/bus.ts b/src/shared/utils/bus.ts index 321c367..c548e4a 100644 --- a/src/shared/utils/bus.ts +++ b/src/shared/utils/bus.ts @@ -8,7 +8,7 @@ // 日志 — 通过 IPC 发送到主进程写文件(含事件总线审计钩子) // ============================================================ -import { RENDERER_TO_MAIN } from '@shared/constants/ipc-channels'; +import { RENDERER_TO_MAIN } from '@shared/constants/ipc'; import { sanitizeLogEntry } from '@shared/utils/sanitize'; import type { LogEntry, LogLevel, LogCategory, LogErrorSnapshot } from '@shared/types/logging'; @@ -97,6 +97,12 @@ export const EVENTS = { OPEN_PAGE: 'page:open', CLOSE_PAGE: 'page:close', PAGE_LOCK: 'page:lock', + /** 任务列表数据变更(删除/收藏等),触发列表刷新 */ + TASK_LIST_REFRESH: 'task:list-refresh', + /** 重新编辑任务(切换模型 + 回填参数) */ + TASK_REEDIT: 'task:reedit', + /** 再次生成任务(切换模型 + 回填参数 + 自动提交) */ + TASK_REGEN: 'task:regen', } as const; // ═══════════════════════════════════════════ diff --git a/src/shared/utils/pricing.ts b/src/shared/utils/pricing.ts deleted file mode 100644 index 910e0c2..0000000 --- a/src/shared/utils/pricing.ts +++ /dev/null @@ -1,488 +0,0 @@ - -export interface ModelPricingFloorTableConfig { - field: string | null; - mapping: Record; -} - -export interface ModelPricingAddonConfig { - field_key: string | null; - operator: string; - condition_value: unknown; - condition_values: unknown[]; - price: number | null; - unit_field: string | null; -} - -export interface ModelPricingMatrixRowConfig { - price: number | null; - price_cent: number | null; - rate: number | null; - price_per_unit: number | null; - unit_price: number | null; - raw: Record; -} - -export interface ModelPricingConfig { - mode: string | null; - price_type: string | null; - billing_mode: string | null; - price: number | null; - price_per_k: number | null; - price_cent: number | null; - price_per_k_cent: number | null; - field: string | null; - unit_field: string | null; - unit_scale: number | null; - round_digits: number | null; - dimensions: string[]; - rate_dimensions: string[]; - matrix: ModelPricingMatrixRowConfig[]; - conditional_addons: ModelPricingAddonConfig[]; - floor_table: ModelPricingFloorTableConfig | null; - submit_warning_threshold: number | null; - raw: Record; -} - -export interface ModelDefinition { - id: string; - name: string; - pricing_config: ModelPricingConfig | null; -} - -// ============================================================ -// 工具函数 -// ============================================================ - -/** 对应 Python: _as_float(value) -> Optional[float] */ -function asFloat(value: unknown): number | null { - if (value === null || value === undefined) return null; - if (typeof value === "number" && !Number.isNaN(value)) return value; - if (typeof value === "string") { - const text = value.trim(); - if (!text) return null; - const parsed = Number(text); - return Number.isNaN(parsed) ? null : parsed; - } - return null; -} - -/** 对应 Python: _to_int(value, *, default=0) -> int */ -function toInt(value: unknown, defaultValue: number = 0): number { - if (value === null || value === undefined) return defaultValue; - if (typeof value === "boolean") return value ? 1 : 0; - if (typeof value === "number") return Math.trunc(value); - if (typeof value === "string") { - const text = value.trim(); - if (!text) return defaultValue; - const parsed = parseInt(text, 10); - return Number.isNaN(parsed) ? defaultValue : parsed; - } - return defaultValue; -} - -/** 对应 Python: _cent_to_yuan(value) -> float */ -function centToYuan(value: unknown): number { - return toInt(value, 0) / 100.0; -} - -/** 对应 Python: _lookup_mapping_value(mapping, key, default="") -> object */ -function lookupMappingValue( - mapping: Record | null | undefined, - key: unknown, - defaultValue: unknown = "", -): unknown { - const keyStr = String(key); - if (mapping == null) return defaultValue; - if (keyStr in mapping) return mapping[keyStr]; - return defaultValue; -} - -/** 对应 Python: _match_dimensions(entry, dimensions, inputs) -> bool */ -function matchDimensions( - entry: Record | null | undefined, - dimensions: string[], - inputs: Record, -): boolean { - if (entry == null) return false; - for (const dim of dimensions) { - const entryValue = lookupMappingValue(entry, dim, null); - if (entryValue === null || entryValue === undefined) continue; - const inputVal = String(lookupMappingValue(inputs, dim, "")).trim().toLowerCase(); - const entryVal = String(entryValue).trim().toLowerCase(); - if (inputVal !== entryVal) return false; - } - return true; -} - -/** - * 从 matrix 行提取维度数据。 - * - * API 返回的 matrix 行中,维度字段(如 resolution、quality)在顶层, - * 而非嵌套在 raw 子对象内。本函数合并 raw(如果存在)和行自身字段, - * 兼容两种数据格式,确保 matchDimensions 能正确匹配。 - */ -function matrixRowData(row: ModelPricingMatrixRowConfig): Record { - return { ...(row.raw ?? {}), ...row as unknown as Record }; -} - -// ============================================================ -// 金额格式化函数 -// ============================================================ - -/** - * 将元数值格式化为显示字符串,保留两位小数,去除尾部无意义的零和小数点。 - * 对应 workbench.py / billing_dialog.py: _format_yuan(value: float) -> str - */ -export function formatYuan(value: number): string { - const rounded = Math.round(value * 100) / 100; - // .toFixed(2) → 去除尾部零 → 去除尾部小数点 - return rounded.toFixed(2).replace(/\.?0+$/, ""); -} - -/** - * 将分(cent)转换为元并格式化。 - * 对应 workbench.py / billing_dialog.py / account_dialog.py: _format_yuan_from_cent(cent: int) -> str - */ -export function formatYuanFromCent(cent: number): string { - return formatYuan(cent / 100.0); -} - -/** - * 格式化金额(分→元),带符号和¥前缀。 - * 对应 task_table.py: _format_money_cent(value: object) -> str - */ -export function formatMoneyCent(value: unknown): string { - if (value === null || value === undefined) return "-"; - if (typeof value === "boolean") return String(value); - - let cent: number; - if (typeof value === "number") { - cent = Math.trunc(value); - } else if (typeof value === "string") { - const parsed = parseInt(value, 10); - if (Number.isNaN(parsed)) return value; - cent = parsed; - } else { - return String(value); - } - - const sign = cent < 0 ? "-" : ""; - const centAbs = Math.abs(cent); - const yuan = centAbs / 100.0; - const text = yuan.toFixed(2).replace(/\.?0+$/, ""); - return `${sign}¥${text}`; -} - -/** - * 格式化分→元,带符号和¥前缀。 - * 对应 admin_console_dialog.py: _format_cent(cent: int) -> str - */ -export function formatCent(cent: number): string { - const sign = cent < 0 ? "-" : ""; - const valueAbs = Math.abs(Math.trunc(cent)); - const yuan = valueAbs / 100.0; - const text = yuan.toFixed(2).replace(/\.?0+$/, ""); - return `${sign}¥${text}`; -} - -// ============================================================ -// 服务端计价逻辑 -// ============================================================ - -/** 对应 Python: _calculate_server_simple(pricing, inputs) -> float */ -function calculateServerSimple( - pricing: ModelPricingConfig, - inputs: Record, -): number { - const priceCent = pricing.price_cent ?? 0; - const unitField = (pricing.unit_field ?? "").trim(); - if (!unitField) { - return priceCent / 100.0; - } - const amount = asFloat(inputs[unitField]); - const amountValue = amount === null ? 1.0 : amount; - // (priceCent * amountValue) / 100 * 100 在 IEEE 754 下可能产生 - // 浮点累积误差(例如 0.03*100→2.999...),直接 round 分子避免 - return Math.round(priceCent * amountValue) / 100; -} - -/** 对应 Python: _calculate_server_multi_dimension(pricing, inputs) -> Optional[float] */ -function calculateServerMultiDimension( - pricing: ModelPricingConfig, - inputs: Record, -): number | null { - const { dimensions, matrix } = pricing; - if (!matrix || matrix.length === 0) return null; - - for (const row of matrix) { - if (matchDimensions(matrixRowData(row), dimensions, inputs)) { - return centToYuan(row.price_cent); - } - } - - const firstRow = matrix[0]; - if (firstRow.price_cent !== null && firstRow.price_cent !== undefined) { - return centToYuan(firstRow.price_cent); - } - return null; -} - -/** 对应 Python: _calculate_server_billing_adapter(pricing, inputs) -> Optional[float] */ -function calculateServerBillingAdapter( - pricing: ModelPricingConfig, - inputs: Record, -): number | null { - const billingMode = (pricing.billing_mode ?? "rate").trim().toLowerCase(); - let baseCostCent = 0; - - if (billingMode === "per_k_chars") { - const field = (pricing.field ?? "prompt").trim() || "prompt"; - const text = String(inputs[field] ?? ""); - const pricePerKCent = pricing.price_per_k_cent ?? 0; - baseCostCent = Math.floor((text.length + 999) / 1000) * pricePerKCent; - } else { - const matrix = pricing.matrix; - if (!matrix || matrix.length === 0) return null; - - const dimensions = pricing.dimensions; - const unitField = (pricing.unit_field ?? "duration").trim() || "duration"; - const unitsRaw = asFloat(inputs[unitField]); - let units = unitsRaw === null ? 1.0 : unitsRaw; - - // 底表修正 - const floorTable = pricing.floor_table; - if (floorTable) { - const floorField = (floorTable.field ?? "").trim(); - if (floorField === unitField) { - const floorValue = asFloat(floorTable.mapping[String(Math.trunc(units))]); - if (floorValue !== null && units < floorValue) { - units = floorValue; - } - } - } - - let rate: number | null = null; - for (const row of matrix) { - if (matchDimensions(matrixRowData(row), dimensions, inputs)) { - rate = row.rate; - break; - } - } - if (rate === null) { - rate = matrix[0].rate; - } - if (rate === null || rate === undefined) return null; - - baseCostCent = Math.round(rate * units * 100); - } - - // 条件附加费 - for (const addon of pricing.conditional_addons ?? []) { - const fieldKey = (addon.field_key ?? "").trim(); - const operator = addon.operator; - let matched = false; - - if (operator === "PRESENT") { - matched = Boolean(inputs[fieldKey]); - } else if (operator === "EQ") { - matched = String(inputs[fieldKey] ?? "") === String(addon.condition_value ?? ""); - } else if (operator === "IN") { - const conditionValues = (addon.condition_values ?? []).map((v) => String(v)); - matched = conditionValues.includes(String(inputs[fieldKey] ?? "")); - } - - if (!matched) continue; - - const addonPrice = addon.price; - if (addonPrice === null || addonPrice === undefined) continue; - - const addonUnitField = String( - addon.unit_field ?? pricing.unit_field ?? "duration", - ).trim() || "duration"; - const addonUnitsRaw = asFloat(inputs[addonUnitField]); - const addonUnits = addonUnitsRaw === null ? 1.0 : addonUnitsRaw; - - baseCostCent += Math.round(addonPrice * addonUnits * 100); - } - - return baseCostCent / 100.0; -} - -// ============================================================ -// 主计价入口 -// ============================================================ - -/** - * 根据模型定义和输入参数,动态计算消耗金额(单位:元)。 - * 对应 Python: calculate_cost(model: ModelDefinition, inputs: Mapping) -> Optional[float] - */ -export function calculateCost( - model: ModelDefinition, - inputs: Record, -): number | null { - const pricing = model.pricing_config; - if (!pricing) return null; - - // ── 服务端定价 (price_type 存在且 mode 不存在) ── - if (pricing.price_type && !pricing.mode) { - const priceType = (pricing.price_type ?? "SIMPLE").trim().toUpperCase(); - switch (priceType) { - case "SIMPLE": - return calculateServerSimple(pricing, inputs); - case "MULTI_DIMENSION": - return calculateServerMultiDimension(pricing, inputs); - case "BILLING_ADAPTER": - return calculateServerBillingAdapter(pricing, inputs); - default: - return null; - } - } - - // ── 客户端定价 ── - const mode = pricing.mode; - - if (mode === "fixed") { - if (pricing.price !== null && pricing.price !== undefined) { - return pricing.price; - } - return Number(pricing.raw?.price ?? 0); - } - - if (mode === "per_k_chars") { - const fieldId = String(pricing.field ?? "text"); - const rawText = inputs[fieldId] ?? ""; - const text = rawText !== null && rawText !== undefined ? String(rawText) : ""; - const pricePerK = - pricing.price_per_k ?? Number(pricing.raw?.price_per_k ?? 0); - return (text.length / 1000) * pricePerK; - } - - if (mode === "rate_matrix") { - const unitField = String(pricing.unit_field ?? "duration"); - - const unitRaw = inputs[unitField]; - const unitValue = asFloat(unitRaw); - if (unitValue === null) return null; - - const { rate_dimensions, matrix } = pricing; - if (!rate_dimensions || rate_dimensions.length === 0 || !matrix || matrix.length === 0) { - return null; - } - - for (const entry of matrix) { - if (matchDimensions(matrixRowData(entry), rate_dimensions, inputs)) { - let rate = entry.rate; - if (rate === null || rate === undefined) rate = entry.price_per_unit; - if (rate === null || rate === undefined) rate = entry.unit_price; - if (rate === null || rate === undefined) rate = 0.0; - - const rawRound = pricing.round_digits ?? pricing.raw?.round ?? 2; - const roundDigits = - typeof rawRound === "number" || (typeof rawRound === "string" && String(rawRound).trim()) - ? Number(rawRound) - : 2; - return ( - Math.round(unitValue * rate * Math.pow(10, roundDigits)) / - Math.pow(10, roundDigits) - ); - } - } - return null; - } - - if (mode === "matrix") { - const { dimensions, matrix } = pricing; - // 与 MULTI_DIMENSION 对齐:dimensions 为空时 matchDimensions 自动全匹配 - if (!matrix || matrix.length === 0) { - return null; - } - - for (const entry of matrix) { - if (matchDimensions(matrixRowData(entry), dimensions, inputs)) { - // price_cent 优先:Python 端矩阵定价统一走 cent 分度值, - // entry.price 可能为衍生/展示值,price_cent 才是计价基准 - if (entry.price_cent !== null && entry.price_cent !== undefined) { - return entry.price_cent / 100; - } - if (entry.price !== null && entry.price !== undefined) { - return entry.price; - } - return Number(entry.raw?.price ?? 0); - } - } - return null; - } - - return null; -} - -// ============================================================ -// 定价依赖字段收集 -// ============================================================ - -/** - * 收集定价计算所依赖的输入字段名集合。 - * 对应 Python: collect_pricing_dependent_fields(model: ModelDefinition) -> set[str] - */ -export function collectPricingDependentFields(model: ModelDefinition): Set { - const pricing = model.pricing_config; - if (!pricing) return new Set(); - - const fields = new Set(); - const priceType = (pricing.price_type ?? "").trim().toUpperCase(); - const mode = (pricing.mode ?? "").trim().toLowerCase(); - const billingMode = (pricing.billing_mode ?? "").trim().toLowerCase(); - - if (priceType === "SIMPLE") { - const unitField = (pricing.unit_field ?? "").trim(); - if (unitField) fields.add(unitField); - } else if (priceType === "MULTI_DIMENSION") { - for (const dim of pricing.dimensions) { - const dimText = String(dim).trim(); - if (dimText) fields.add(dimText); - } - } else if (priceType === "BILLING_ADAPTER") { - if (billingMode === "per_k_chars") { - const field = String(pricing.field ?? "prompt").trim() || "prompt"; - fields.add(field); - } else { - const unitField = String(pricing.unit_field ?? "duration").trim() || "duration"; - fields.add(unitField); - - const floorTable = pricing.floor_table; - if (floorTable) { - const floorField = String(floorTable.field ?? "").trim(); - if (floorField) fields.add(floorField); - } - for (const dim of pricing.dimensions) { - const dimText = String(dim).trim(); - if (dimText) fields.add(dimText); - } - } - for (const addon of pricing.conditional_addons ?? []) { - const fieldKey = String(addon.field_key ?? "").trim(); - if (fieldKey) fields.add(fieldKey); - const addonUnitField = String( - addon.unit_field ?? pricing.unit_field ?? "duration", - ).trim(); - if (addonUnitField) fields.add(addonUnitField); - } - } else if (mode === "per_k_chars") { - const field = String(pricing.field ?? "text").trim() || "text"; - fields.add(field); - } else if (mode === "rate_matrix") { - const unitField = String(pricing.unit_field ?? "duration").trim() || "duration"; - fields.add(unitField); - for (const dim of pricing.rate_dimensions) { - const dimText = String(dim).trim(); - if (dimText) fields.add(dimText); - } - } else if (mode === "matrix") { - for (const dim of pricing.dimensions) { - const dimText = String(dim).trim(); - if (dimText) fields.add(dimText); - } - } - - return fields; -} diff --git a/src/shared/utils/pricing/calculate.ts b/src/shared/utils/pricing/calculate.ts new file mode 100644 index 0000000..19fbd08 --- /dev/null +++ b/src/shared/utils/pricing/calculate.ts @@ -0,0 +1,233 @@ +// ============================================================ +// pricing/calculate.ts — 计价逻辑 +// ============================================================ + +import type { ModelPricingConfig, ModelDefinition } from './types'; +import { asFloat, centToYuan, matchDimensions, matrixRowData } from './helpers'; + +// ---------- 服务端计价 ---------- + +/** 对应 Python: _calculate_server_simple(pricing, inputs) -> float */ +function calculateServerSimple( + pricing: ModelPricingConfig, + inputs: Record, +): number { + const priceCent = pricing.price_cent ?? 0; + const unitField = (pricing.unit_field ?? "").trim(); + if (!unitField) { + return priceCent / 100.0; + } + const amount = asFloat(inputs[unitField]); + const amountValue = amount === null ? 1.0 : amount; + // (priceCent * amountValue) / 100 * 100 在 IEEE 754 下可能产生 + // 浮点累积误差(例如 0.03*100→2.999...),直接 round 分子避免 + return Math.round(priceCent * amountValue) / 100; +} + +/** 对应 Python: _calculate_server_multi_dimension(pricing, inputs) -> Optional[float] */ +function calculateServerMultiDimension( + pricing: ModelPricingConfig, + inputs: Record, +): number | null { + const { dimensions, matrix } = pricing; + if (!matrix || matrix.length === 0) return null; + + for (const row of matrix) { + if (matchDimensions(matrixRowData(row), dimensions, inputs)) { + return centToYuan(row.price_cent); + } + } + + const firstRow = matrix[0]; + if (firstRow.price_cent !== null && firstRow.price_cent !== undefined) { + return centToYuan(firstRow.price_cent); + } + return null; +} + +/** 对应 Python: _calculate_server_billing_adapter(pricing, inputs) -> Optional[float] */ +function calculateServerBillingAdapter( + pricing: ModelPricingConfig, + inputs: Record, +): number | null { + const billingMode = (pricing.billing_mode ?? "rate").trim().toLowerCase(); + let baseCostCent = 0; + + if (billingMode === "per_k_chars") { + const field = (pricing.field ?? "prompt").trim() || "prompt"; + const text = String(inputs[field] ?? ""); + const pricePerKCent = pricing.price_per_k_cent ?? 0; + baseCostCent = Math.floor((text.length + 999) / 1000) * pricePerKCent; + } else { + const matrix = pricing.matrix; + if (!matrix || matrix.length === 0) return null; + + const dimensions = pricing.dimensions; + const unitField = (pricing.unit_field ?? "duration").trim() || "duration"; + const unitsRaw = asFloat(inputs[unitField]); + let units = unitsRaw === null ? 1.0 : unitsRaw; + + // 底表修正 + const floorTable = pricing.floor_table; + if (floorTable) { + const floorField = (floorTable.field ?? "").trim(); + if (floorField === unitField) { + const floorValue = asFloat(floorTable.mapping[String(Math.trunc(units))]); + if (floorValue !== null && units < floorValue) { + units = floorValue; + } + } + } + + let rate: number | null = null; + for (const row of matrix) { + if (matchDimensions(matrixRowData(row), dimensions, inputs)) { + rate = row.rate; + break; + } + } + if (rate === null) { + rate = matrix[0].rate; + } + if (rate === null || rate === undefined) return null; + + baseCostCent = Math.round(rate * units * 100); + } + + // 条件附加费 + for (const addon of pricing.conditional_addons ?? []) { + const fieldKey = (addon.field_key ?? "").trim(); + const operator = addon.operator; + let matched = false; + + if (operator === "PRESENT") { + matched = Boolean(inputs[fieldKey]); + } else if (operator === "EQ") { + matched = String(inputs[fieldKey] ?? "") === String(addon.condition_value ?? ""); + } else if (operator === "IN") { + const conditionValues = (addon.condition_values ?? []).map((v) => String(v)); + matched = conditionValues.includes(String(inputs[fieldKey] ?? "")); + } + + if (!matched) continue; + + const addonPrice = addon.price; + if (addonPrice === null || addonPrice === undefined) continue; + + const addonUnitField = String( + addon.unit_field ?? pricing.unit_field ?? "duration", + ).trim() || "duration"; + const addonUnitsRaw = asFloat(inputs[addonUnitField]); + const addonUnits = addonUnitsRaw === null ? 1.0 : addonUnitsRaw; + + baseCostCent += Math.round(addonPrice * addonUnits * 100); + } + + return baseCostCent / 100.0; +} + +// ---------- 主计价入口 ---------- + +/** + * 根据模型定义和输入参数,动态计算消耗金额(单位:元)。 + * 对应 Python: calculate_cost(model: ModelDefinition, inputs: Mapping) -> Optional[float] + */ +export function calculateCost( + model: ModelDefinition, + inputs: Record, +): number | null { + const pricing = model.pricing_config; + if (!pricing) return null; + + // ── 服务端定价 (price_type 存在且 mode 不存在) ── + if (pricing.price_type && !pricing.mode) { + const priceType = (pricing.price_type ?? "SIMPLE").trim().toUpperCase(); + switch (priceType) { + case "SIMPLE": + return calculateServerSimple(pricing, inputs); + case "MULTI_DIMENSION": + return calculateServerMultiDimension(pricing, inputs); + case "BILLING_ADAPTER": + return calculateServerBillingAdapter(pricing, inputs); + default: + return null; + } + } + + // ── 客户端定价 ── + const mode = pricing.mode; + + if (mode === "fixed") { + if (pricing.price !== null && pricing.price !== undefined) { + return pricing.price; + } + return Number(pricing.raw?.price ?? 0); + } + + if (mode === "per_k_chars") { + const fieldId = String(pricing.field ?? "text"); + const rawText = inputs[fieldId] ?? ""; + const text = rawText !== null && rawText !== undefined ? String(rawText) : ""; + const pricePerK = + pricing.price_per_k ?? Number(pricing.raw?.price_per_k ?? 0); + return (text.length / 1000) * pricePerK; + } + + if (mode === "rate_matrix") { + const unitField = String(pricing.unit_field ?? "duration"); + + const unitRaw = inputs[unitField]; + const unitValue = asFloat(unitRaw); + if (unitValue === null) return null; + + const { rate_dimensions, matrix } = pricing; + if (!rate_dimensions || rate_dimensions.length === 0 || !matrix || matrix.length === 0) { + return null; + } + + for (const entry of matrix) { + if (matchDimensions(matrixRowData(entry), rate_dimensions, inputs)) { + let rate = entry.rate; + if (rate === null || rate === undefined) rate = entry.price_per_unit; + if (rate === null || rate === undefined) rate = entry.unit_price; + if (rate === null || rate === undefined) rate = 0.0; + + const rawRound = pricing.round_digits ?? pricing.raw?.round ?? 2; + const roundDigits = + typeof rawRound === "number" || (typeof rawRound === "string" && String(rawRound).trim()) + ? Number(rawRound) + : 2; + return ( + Math.round(unitValue * rate * Math.pow(10, roundDigits)) / + Math.pow(10, roundDigits) + ); + } + } + return null; + } + + if (mode === "matrix") { + const { dimensions, matrix } = pricing; + // 与 MULTI_DIMENSION 对齐:dimensions 为空时 matchDimensions 自动全匹配 + if (!matrix || matrix.length === 0) { + return null; + } + + for (const entry of matrix) { + if (matchDimensions(matrixRowData(entry), dimensions, inputs)) { + // price_cent 优先:Python 端矩阵定价统一走 cent 分度值, + // entry.price 可能为衍生/展示值,price_cent 才是计价基准 + if (entry.price_cent !== null && entry.price_cent !== undefined) { + return entry.price_cent / 100; + } + if (entry.price !== null && entry.price !== undefined) { + return entry.price; + } + return Number(entry.raw?.price ?? 0); + } + } + return null; + } + + return null; +} diff --git a/src/shared/utils/pricing/fields.ts b/src/shared/utils/pricing/fields.ts new file mode 100644 index 0000000..691356d --- /dev/null +++ b/src/shared/utils/pricing/fields.ts @@ -0,0 +1,72 @@ +// ============================================================ +// pricing/fields.ts — 定价依赖字段收集 +// ============================================================ + +import type { ModelDefinition } from './types'; + +/** + * 收集定价计算所依赖的输入字段名集合。 + * 对应 Python: collect_pricing_dependent_fields(model: ModelDefinition) -> set[str] + */ +export function collectPricingDependentFields(model: ModelDefinition): Set { + const pricing = model.pricing_config; + if (!pricing) return new Set(); + + const fields = new Set(); + const priceType = (pricing.price_type ?? "").trim().toUpperCase(); + const mode = (pricing.mode ?? "").trim().toLowerCase(); + const billingMode = (pricing.billing_mode ?? "").trim().toLowerCase(); + + if (priceType === "SIMPLE") { + const unitField = (pricing.unit_field ?? "").trim(); + if (unitField) fields.add(unitField); + } else if (priceType === "MULTI_DIMENSION") { + for (const dim of pricing.dimensions) { + const dimText = String(dim).trim(); + if (dimText) fields.add(dimText); + } + } else if (priceType === "BILLING_ADAPTER") { + if (billingMode === "per_k_chars") { + const field = String(pricing.field ?? "prompt").trim() || "prompt"; + fields.add(field); + } else { + const unitField = String(pricing.unit_field ?? "duration").trim() || "duration"; + fields.add(unitField); + + const floorTable = pricing.floor_table; + if (floorTable) { + const floorField = String(floorTable.field ?? "").trim(); + if (floorField) fields.add(floorField); + } + for (const dim of pricing.dimensions) { + const dimText = String(dim).trim(); + if (dimText) fields.add(dimText); + } + } + for (const addon of pricing.conditional_addons ?? []) { + const fieldKey = String(addon.field_key ?? "").trim(); + if (fieldKey) fields.add(fieldKey); + const addonUnitField = String( + addon.unit_field ?? pricing.unit_field ?? "duration", + ).trim(); + if (addonUnitField) fields.add(addonUnitField); + } + } else if (mode === "per_k_chars") { + const field = String(pricing.field ?? "text").trim() || "text"; + fields.add(field); + } else if (mode === "rate_matrix") { + const unitField = String(pricing.unit_field ?? "duration").trim() || "duration"; + fields.add(unitField); + for (const dim of pricing.rate_dimensions) { + const dimText = String(dim).trim(); + if (dimText) fields.add(dimText); + } + } else if (mode === "matrix") { + for (const dim of pricing.dimensions) { + const dimText = String(dim).trim(); + if (dimText) fields.add(dimText); + } + } + + return fields; +} diff --git a/src/shared/utils/pricing/format.ts b/src/shared/utils/pricing/format.ts new file mode 100644 index 0000000..c673d8b --- /dev/null +++ b/src/shared/utils/pricing/format.ts @@ -0,0 +1,59 @@ +// ============================================================ +// pricing/format.ts — 金额格式化函数 +// ============================================================ + +/** + * 将元数值格式化为显示字符串,保留两位小数,去除尾部无意义的零和小数点。 + * 对应 workbench.py / billing_dialog.py: _format_yuan(value: float) -> str + */ +export function formatYuan(value: number): string { + const rounded = Math.round(value * 100) / 100; + // .toFixed(2) → 去除尾部零 → 去除尾部小数点 + return rounded.toFixed(2).replace(/\.?0+$/, ""); +} + +/** + * 将分(cent)转换为元并格式化。 + * 对应 workbench.py / billing_dialog.py / account_dialog.py: _format_yuan_from_cent(cent: int) -> str + */ +export function formatYuanFromCent(cent: number): string { + return formatYuan(cent / 100.0); +} + +/** + * 格式化金额(分→元),带符号和¥前缀。 + * 对应 task_table.py: _format_money_cent(value: object) -> str + */ +export function formatMoneyCent(value: unknown): string { + if (value === null || value === undefined) return "-"; + if (typeof value === "boolean") return String(value); + + let cent: number; + if (typeof value === "number") { + cent = Math.trunc(value); + } else if (typeof value === "string") { + const parsed = parseInt(value, 10); + if (Number.isNaN(parsed)) return value; + cent = parsed; + } else { + return String(value); + } + + const sign = cent < 0 ? "-" : ""; + const centAbs = Math.abs(cent); + const yuan = centAbs / 100.0; + const text = yuan.toFixed(2).replace(/\.?0+$/, ""); + return `${sign}¥${text}`; +} + +/** + * 格式化分→元,带符号和¥前缀。 + * 对应 admin_console_dialog.py: _format_cent(cent: int) -> str + */ +export function formatCent(cent: number): string { + const sign = cent < 0 ? "-" : ""; + const valueAbs = Math.abs(Math.trunc(cent)); + const yuan = valueAbs / 100.0; + const text = yuan.toFixed(2).replace(/\.?0+$/, ""); + return `${sign}¥${text}`; +} diff --git a/src/shared/utils/pricing/helpers.ts b/src/shared/utils/pricing/helpers.ts new file mode 100644 index 0000000..439f61e --- /dev/null +++ b/src/shared/utils/pricing/helpers.ts @@ -0,0 +1,77 @@ +// ============================================================ +// pricing/helpers.ts — 内部工具函数 +// ============================================================ + +import type { ModelPricingMatrixRowConfig } from './types'; + +/** 对应 Python: _as_float(value) -> Optional[float] */ +export function asFloat(value: unknown): number | null { + if (value === null || value === undefined) return null; + if (typeof value === "number" && !Number.isNaN(value)) return value; + if (typeof value === "string") { + const text = value.trim(); + if (!text) return null; + const parsed = Number(text); + return Number.isNaN(parsed) ? null : parsed; + } + return null; +} + +/** 对应 Python: _to_int(value, *, default=0) -> int */ +export function toInt(value: unknown, defaultValue: number = 0): number { + if (value === null || value === undefined) return defaultValue; + if (typeof value === "boolean") return value ? 1 : 0; + if (typeof value === "number") return Math.trunc(value); + if (typeof value === "string") { + const text = value.trim(); + if (!text) return defaultValue; + const parsed = parseInt(text, 10); + return Number.isNaN(parsed) ? defaultValue : parsed; + } + return defaultValue; +} + +/** 对应 Python: _cent_to_yuan(value) -> float */ +export function centToYuan(value: unknown): number { + return toInt(value, 0) / 100.0; +} + +/** 对应 Python: _lookup_mapping_value(mapping, key, default="") -> object */ +export function lookupMappingValue( + mapping: Record | null | undefined, + key: unknown, + defaultValue: unknown = "", +): unknown { + const keyStr = String(key); + if (mapping == null) return defaultValue; + if (keyStr in mapping) return mapping[keyStr]; + return defaultValue; +} + +/** 对应 Python: _match_dimensions(entry, dimensions, inputs) -> bool */ +export function matchDimensions( + entry: Record | null | undefined, + dimensions: string[], + inputs: Record, +): boolean { + if (entry == null) return false; + for (const dim of dimensions) { + const entryValue = lookupMappingValue(entry, dim, null); + if (entryValue === null || entryValue === undefined) continue; + const inputVal = String(lookupMappingValue(inputs, dim, "")).trim().toLowerCase(); + const entryVal = String(entryValue).trim().toLowerCase(); + if (inputVal !== entryVal) return false; + } + return true; +} + +/** + * 从 matrix 行提取维度数据。 + * + * API 返回的 matrix 行中,维度字段(如 resolution、quality)在顶层, + * 而非嵌套在 raw 子对象内。本函数合并 raw(如果存在)和行自身字段, + * 兼容两种数据格式,确保 matchDimensions 能正确匹配。 + */ +export function matrixRowData(row: ModelPricingMatrixRowConfig): Record { + return { ...(row.raw ?? {}), ...row as unknown as Record }; +} diff --git a/src/shared/utils/pricing/index.ts b/src/shared/utils/pricing/index.ts new file mode 100644 index 0000000..3e36dd4 --- /dev/null +++ b/src/shared/utils/pricing/index.ts @@ -0,0 +1,26 @@ +// ============================================================ +// pricing/index.ts — 定价模块主入口 +// ============================================================ + +// 类型导出 +export type { + ModelPricingFloorTableConfig, + ModelPricingAddonConfig, + ModelPricingMatrixRowConfig, + ModelPricingConfig, + ModelDefinition, +} from './types'; + +// 格式化函数 +export { + formatYuan, + formatYuanFromCent, + formatMoneyCent, + formatCent, +} from './format'; + +// 计价函数 +export { calculateCost } from './calculate'; + +// 定价依赖字段收集 +export { collectPricingDependentFields } from './fields'; diff --git a/src/shared/utils/pricing/types.ts b/src/shared/utils/pricing/types.ts new file mode 100644 index 0000000..2c49bd2 --- /dev/null +++ b/src/shared/utils/pricing/types.ts @@ -0,0 +1,53 @@ +// ============================================================ +// pricing/types.ts — 定价相关类型定义 +// ============================================================ + +export interface ModelPricingFloorTableConfig { + field: string | null; + mapping: Record; +} + +export interface ModelPricingAddonConfig { + field_key: string | null; + operator: string; + condition_value: unknown; + condition_values: unknown[]; + price: number | null; + unit_field: string | null; +} + +export interface ModelPricingMatrixRowConfig { + price: number | null; + price_cent: number | null; + rate: number | null; + price_per_unit: number | null; + unit_price: number | null; + raw: Record; +} + +export interface ModelPricingConfig { + mode: string | null; + price_type: string | null; + billing_mode: string | null; + price: number | null; + price_per_k: number | null; + price_cent: number | null; + price_per_k_cent: number | null; + field: string | null; + unit_field: string | null; + unit_scale: number | null; + round_digits: number | null; + dimensions: string[]; + rate_dimensions: string[]; + matrix: ModelPricingMatrixRowConfig[]; + conditional_addons: ModelPricingAddonConfig[]; + floor_table: ModelPricingFloorTableConfig | null; + submit_warning_threshold: number | null; + raw: Record; +} + +export interface ModelDefinition { + id: string; + name: string; + pricing_config: ModelPricingConfig | null; +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index daa6302..0c40969 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -23,6 +23,16 @@ declare global { showItemInFolder(filePath: string): Promise; fileSize(relativePath: string): Promise; fileExists(relativePath: string): Promise; + /** 绝对路径文件写入(绕过 heixiu-data 沙箱) */ + writeFileAbsolute(absolutePath: string, data: string, encoding?: 'base64' | 'utf8'): Promise; + /** 绝对路径目录创建(绕过 heixiu-data 沙箱) */ + makeDirAbsolute(absolutePath: string): Promise; + /** 主进程下载文件到绝对路径(绕过浏览器 CORS 限制),失败时 reject */ + downloadToPath(url: string, destPath: string, headers?: Record): Promise; + /** 获取系统下载目录路径 */ + getDownloadsPath(): Promise; + /** 生成图片缩略图(读取原图 → 缩放 → JPEG 写入目标路径),失败时 reject */ + generateThumbnail(srcPath: string, destPath: string, maxWidth?: number, quality?: number): Promise; }; /** 应用运行时 API(由 preload 注入) */ appRuntime: {