feat: 任务列表右键菜单回调 + 主进程下载 + sharp 缩略图 + IPC 精简重构
- 任务右键菜单:实现打开输出目录(inputs/outputs/.cache 结构)、
复制资源链接、重新编辑、再次生成、拉取结果、重新下载、收藏、删除记录
- 主进程下载:通过 Electron net 模块绕过 CORS,支持 CDN 媒体文件下载
- sharp 缩略图:替换 nativeImage,支持 4K/8K+ 大尺寸素材的流式处理
- IPC 精简:40+ 冗余通道 → 15 个分组通道,移除未使用的 preload 桥接方法
- 任务存储:新增收藏字段、schema 迁移、task-store 持久化
- 清理废弃模块:canvas-ipc / updater / request / pricing
refactor: IPC/Preload/Services 模块化拆分 — 单一职责 + 向后兼容
主要变更:
- shared/constants/ipc-channels.ts → ipc/ 模块 (base/storage/canvas/updater)
- electron/preload.ts → preload/ 模块 (base/safe-storage/file-storage/canvas)
- electron/main/ipc/canvas-ipc.ts → canvas/ 模块 (types/walk/sidecar/mime/thumbnail)
- electron/main/ipc/storage-ipc.ts → 提取 utils/ (path/download/thumbnail)
- electron/main/updater.ts → updater/ 模块 (provider/platform-updaters)
- src/services/request.ts → request/ 模块 (token/interceptors/methods)
- src/shared/utils/pricing.ts → pricing/ 模块 (types/helpers/format/calculate/fields)
- src/modules/home/center/views/ModelInputForm.tsx → 提取 utils + DependentFormItem
架构优化:
- 所有拆分均保持向后兼容(旧导入路径仍可用)
- TypeScript 编译通过 ✓
- 每个模块单一职责,平均 ~130 行
This commit is contained in:
24
CHANGELOG.md
24
CHANGELOG.md
@@ -3,6 +3,30 @@
|
|||||||
> 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。
|
> 每次发版前修改此文件,`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)
|
## 0.0.28(2026-06-29)
|
||||||
|
|
||||||
**项目架构重构(VCHSM 五层模块化)+ 公告 Badge 提醒**
|
**项目架构重构(VCHSM 五层模块化)+ 公告 Badge 提醒**
|
||||||
|
|||||||
58
PROJECT.md
58
PROJECT.md
@@ -62,21 +62,41 @@
|
|||||||
```md
|
```md
|
||||||
├── electron/ # Electron 主进程 + 预加载
|
├── electron/ # Electron 主进程 + 预加载
|
||||||
│ ├── main.ts # 入口编排:窗口创建 / IPC 注册 / 生命周期
|
│ ├── main.ts # 入口编排:窗口创建 / IPC 注册 / 生命周期
|
||||||
│ ├── preload.ts # contextBridge:向渲染进程暴露安全 API
|
│ ├── preload.ts # contextBridge:向渲染进程暴露安全 API(薄入口)
|
||||||
│ ├── electron-env.d.ts # 类型声明(APP_ROOT、Window 扩展)
|
│ ├── electron-env.d.ts # 类型声明(APP_ROOT、Window 扩展)
|
||||||
│ └── main/ # 主进程核心模块
|
│ └── main/ # 主进程核心模块
|
||||||
│ │ ├── load-env.ts # .env.{mode} + .env.local 加载器
|
│ │ ├── load-env.ts # .env.{mode} + .env.local 加载器
|
||||||
│ │ ├── logger.ts # 日志核心:JSON Lines / 每日轮转 / 批量冲刷
|
│ │ ├── logger.ts # 日志核心:JSON Lines / 每日轮转 / 批量冲刷
|
||||||
│ │ ├── net-request.ts # Electron net.request 的 axios 风格封装
|
│ │ ├── net-request.ts # Electron net.request 的 axios 风格封装
|
||||||
│ │ ├── updater.ts # 自动更新:自定义 Provider + 平台 Updater
|
│ │ ├── updater/ # 自动更新模块
|
||||||
|
│ │ │ ├── provider.ts # 自定义 Provider + 设备指纹 + 构建配置
|
||||||
|
│ │ │ ├── platform-updaters.ts # 平台特定 Updater(Windows/macOS/Linux)
|
||||||
|
│ │ │ └── index.ts # 初始化 + IPC 注册
|
||||||
│ │ ├── menu/index.ts # 系统菜单(macOS Edit / Win 无菜单)
|
│ │ ├── menu/index.ts # 系统菜单(macOS Edit / Win 无菜单)
|
||||||
│ │ ├── ipc/ # IPC handler 集合
|
│ │ ├── ipc/ # IPC handler 集合
|
||||||
│ │ │ ├── log-ipc.ts # 渲染进程日志 → 主进程日志桥接
|
│ │ │ ├── log-ipc.ts # 渲染进程日志 → 主进程日志桥接
|
||||||
│ │ │ ├── safe-storage-ipc.ts # safeStorage 加密/解密 IPC handler
|
│ │ │ ├── safe-storage-ipc.ts # safeStorage 加密/解密 IPC handler
|
||||||
│ │ │ ├── storage-ipc.ts # 沙箱文件存储 IPC handler(heixiu-data/)
|
│ │ │ ├── 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/ # 平台判断 / 图标路径
|
│ │ └── utils/ # 平台判断 / 图标路径
|
||||||
│ └── preload/ # 预加载脚本(contextBridge 桥接层)
|
│ └── 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.ts # 硬件指纹采集
|
||||||
│ └── device_service.ts # 设备信息缓存层
|
│ └── device_service.ts # 设备信息缓存层
|
||||||
│
|
│
|
||||||
@@ -137,8 +157,25 @@
|
|||||||
│ └── shared/ # ═══ 跨模块共享 ═══
|
│ └── shared/ # ═══ 跨模块共享 ═══
|
||||||
│ ├── components/ # 通用 UI(FloatingPanel / FormField / LegalTextModal / ContextMenu)
|
│ ├── components/ # 通用 UI(FloatingPanel / FormField / LegalTextModal / ContextMenu)
|
||||||
│ ├── hooks/ # 全局 Hooks(useAnnouncements / useApi / useAsync / useUpdater)
|
│ ├── hooks/ # 全局 Hooks(useAnnouncements / useApi / useAsync / useUpdater)
|
||||||
│ ├── services/ # HTTP 客户端 http.ts + API 类型
|
│ ├── services/ # HTTP 客户端 + API 模块
|
||||||
│ ├── utils/ # 工具函数(bus / env / enum-resolver / display / pricing / safe-storage)
|
│ │ ├── request/ # Axios 请求封装
|
||||||
|
│ │ │ ├── token.ts # Token 管理(加密存储 + 内存缓存)
|
||||||
|
│ │ │ ├── interceptors.ts # 请求/响应拦截器(401 刷新 + 错误处理)
|
||||||
|
│ │ │ ├── methods.ts # 请求方法(get/post/put/del/upload/download)
|
||||||
|
│ │ │ └── index.ts # 主入口 + 导出
|
||||||
|
│ │ └── modules/ # API 模块(auth / home / account / announcement)
|
||||||
|
│ ├── utils/ # 工具函数
|
||||||
|
│ │ ├── bus.ts # 事件总线(emit / on / off / EVENTS)
|
||||||
|
│ │ ├── pricing/ # 定价计算模块
|
||||||
|
│ │ │ ├── types.ts # 类型定义(ModelPricingConfig / ModelDefinition)
|
||||||
|
│ │ │ ├── helpers.ts # 内部工具(asFloat / matchDimensions)
|
||||||
|
│ │ │ ├── format.ts # 金额格式化(formatYuan / formatCent)
|
||||||
|
│ │ │ ├── calculate.ts # 计价逻辑(6 种定价模式)
|
||||||
|
│ │ │ ├── fields.ts # 依赖字段收集
|
||||||
|
│ │ │ └── index.ts # 主入口 + 导出
|
||||||
|
│ │ ├── enum-resolver.ts # 枚举值解析
|
||||||
|
│ │ ├── display.ts # 显示工具
|
||||||
|
│ │ └── safe-storage.ts # 安全存储工具
|
||||||
│ ├── infrastructure/storage/ # 本地加密数据库(sql.js + safeStorage)
|
│ ├── infrastructure/storage/ # 本地加密数据库(sql.js + safeStorage)
|
||||||
│ │ ├── core/ # db-core.ts / db-encrypt.ts / schema.ts
|
│ │ ├── core/ # db-core.ts / db-encrypt.ts / schema.ts
|
||||||
│ │ ├── stores/ # task-store / order-store / media-store / settings-store
|
│ │ ├── stores/ # task-store / order-store / media-store / settings-store
|
||||||
@@ -146,7 +183,16 @@
|
|||||||
│ └── theme/ # 主题系统(tokens / antd-theme / CSS 过渡)
|
│ └── theme/ # 主题系统(tokens / antd-theme / CSS 过渡)
|
||||||
│
|
│
|
||||||
├── shared/ # 主进程 & 渲染进程共享(根级 @shared 别名)
|
├── shared/ # 主进程 & 渲染进程共享(根级 @shared 别名)
|
||||||
│ ├── constants/ # app.ts / ipc-channels.ts / version.ts / keyboard.ts
|
│ ├── constants/ # 应用常量
|
||||||
|
│ │ ├── app.ts # 应用名称 / 窗口标题
|
||||||
|
│ │ ├── version.ts # 版本号
|
||||||
|
│ │ ├── keyboard.ts # 快捷键映射
|
||||||
|
│ │ └── ipc/ # IPC 通道常量(模块化)
|
||||||
|
│ │ ├── base.ts # 基础通道(窗口/日志/主题/配置)
|
||||||
|
│ │ ├── storage.ts # 存储通道(文件/加密/下载/缩略图)
|
||||||
|
│ │ ├── canvas.ts # 画布通道(扫描/sidecar/拖拽)
|
||||||
|
│ │ ├── updater.ts # 更新通道(检查/下载/安装)
|
||||||
|
│ │ └── index.ts # 汇总导出 + 兼容性 BIDIRECTIONAL
|
||||||
│ ├── types/ # index.ts(Platform/Theme/Edition)/ logging.ts
|
│ ├── types/ # index.ts(Platform/Theme/Edition)/ logging.ts
|
||||||
│ └── utils/ # sanitize.ts(日志脱敏)+ index.ts(通用工具)
|
│ └── utils/ # sanitize.ts(日志脱敏)+ index.ts(通用工具)
|
||||||
│
|
│
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import {initLogger, flushLogger, logger, setLogDirResolver} from './main/logger'
|
|||||||
import {registerLogIpcHandlers} from './main/ipc/log-ipc';
|
import {registerLogIpcHandlers} from './main/ipc/log-ipc';
|
||||||
import {registerSafeStorageIpcHandlers} from './main/ipc/safe-storage-ipc';
|
import {registerSafeStorageIpcHandlers} from './main/ipc/safe-storage-ipc';
|
||||||
import {registerStorageIpcHandlers, setDataDirResolver} from './main/ipc/storage-ipc';
|
import {registerStorageIpcHandlers, setDataDirResolver} from './main/ipc/storage-ipc';
|
||||||
import {registerCanvasIpcHandlers} from './main/ipc/canvas-ipc';
|
import {registerCanvasIpcHandlers} from './main/ipc/canvas';
|
||||||
import {BIDIRECTIONAL, RENDERER_TO_MAIN, MAIN_TO_RENDERER} from '../shared/constants/ipc-channels';
|
import {BIDIRECTIONAL, RENDERER_TO_MAIN, MAIN_TO_RENDERER} from '../shared/constants/ipc';
|
||||||
|
|
||||||
createRequire(import.meta.url);
|
createRequire(import.meta.url);
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|||||||
@@ -1,705 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// canvas-ipc.ts — 无限画布 IPC 处理器(主进程)
|
|
||||||
//
|
|
||||||
// 职责:
|
|
||||||
// - 目录扫描(fs.readdirSync + 递归遍历)
|
|
||||||
// - sidecar JSON 读写
|
|
||||||
// - 缩略图生成(sharp 可选依赖)
|
|
||||||
// - 文件复制(导入操作)
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { ipcMain } from 'electron';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { BIDIRECTIONAL } from '../../../shared/constants/ipc-channels';
|
|
||||||
import { logger } from '../logger';
|
|
||||||
import sharp from "sharp";
|
|
||||||
// ---------- 类型 ----------
|
|
||||||
|
|
||||||
type StageType = 'Storyboard' | 'Keyframe' | 'Video' | 'Audio' | 'LipSync' | 'Assets';
|
|
||||||
type AssetTypeFilter = '全部' | '角色' | '道具' | '场景';
|
|
||||||
|
|
||||||
// ---------- 扫描算法 ----------
|
|
||||||
|
|
||||||
/** 递归遍历目录,收集所有匹配扩展名的文件 */
|
|
||||||
function walkDirectory(
|
|
||||||
root: string,
|
|
||||||
extensions: string[],
|
|
||||||
recursive: boolean,
|
|
||||||
): Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }> {
|
|
||||||
const results: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }> = [];
|
|
||||||
const extSet = new Set(extensions.map((e) => e.toLowerCase()));
|
|
||||||
|
|
||||||
function walk(dir: string) {
|
|
||||||
let entries: fs.Dirent[];
|
|
||||||
try {
|
|
||||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
||||||
} catch {
|
|
||||||
return; // 权限不足等,跳过该目录
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
// 跳过隐藏文件和文件夹
|
|
||||||
if (entry.name.startsWith('.')) continue;
|
|
||||||
|
|
||||||
const fullPath = path.join(dir, entry.name);
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
if (recursive) walk(fullPath);
|
|
||||||
} else if (entry.isFile()) {
|
|
||||||
const ext = path.extname(entry.name).slice(1).toLowerCase();
|
|
||||||
if (extSet.has(ext)) {
|
|
||||||
try {
|
|
||||||
const stat = fs.statSync(fullPath);
|
|
||||||
results.push({
|
|
||||||
path: fullPath,
|
|
||||||
fileName: entry.name,
|
|
||||||
ext,
|
|
||||||
fileSize: stat.size,
|
|
||||||
modifiedAt: stat.mtime.toISOString(),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// 文件不可访问,跳过
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
walk(root);
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Sidecar 操作 ----------
|
|
||||||
|
|
||||||
/** 生成 sidecar JSON 文件路径:同目录、同名、.json 扩展名 */
|
|
||||||
function sidecarPathForMedia(filePath: string): string {
|
|
||||||
const dir = path.dirname(filePath);
|
|
||||||
const baseName = path.basename(filePath, path.extname(filePath));
|
|
||||||
return path.join(dir, `${baseName}.json`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 读取旁加载元数据,文件不存在返回 null */
|
|
||||||
function readSidecarFile(filePath: string): object | null {
|
|
||||||
const sidecarPath = sidecarPathForMedia(filePath);
|
|
||||||
if (!fs.existsSync(sidecarPath)) return null;
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(sidecarPath, 'utf-8');
|
|
||||||
return JSON.parse(content);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 写入旁加载元数据(自动创建目录) */
|
|
||||||
function writeSidecarFile(filePath: string, metadata: object): void {
|
|
||||||
const sidecarPath = sidecarPathForMedia(filePath);
|
|
||||||
const dir = path.dirname(sidecarPath);
|
|
||||||
if (!fs.existsSync(dir)) {
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
fs.writeFileSync(sidecarPath, JSON.stringify(metadata, null, 2), 'utf-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- MIME 类型推断 ----------
|
|
||||||
|
|
||||||
function getMimeType(filePath: string): string {
|
|
||||||
const ext = path.extname(filePath).toLowerCase();
|
|
||||||
const mimeMap: Record<string, string> = {
|
|
||||||
'.jpg': 'image/jpeg',
|
|
||||||
'.jpeg': 'image/jpeg',
|
|
||||||
'.png': 'image/png',
|
|
||||||
'.webp': 'image/webp',
|
|
||||||
'.gif': 'image/gif',
|
|
||||||
'.bmp': 'image/bmp',
|
|
||||||
'.mp4': 'video/mp4',
|
|
||||||
'.webm': 'video/webm',
|
|
||||||
'.mkv': 'video/x-matroska',
|
|
||||||
'.mov': 'video/quicktime',
|
|
||||||
'.avi': 'video/x-msvideo',
|
|
||||||
'.mp3': 'audio/mpeg',
|
|
||||||
'.wav': 'audio/wav',
|
|
||||||
'.ogg': 'audio/ogg',
|
|
||||||
'.flac': 'audio/flac',
|
|
||||||
'.aac': 'audio/aac',
|
|
||||||
'.m4a': 'audio/mp4',
|
|
||||||
};
|
|
||||||
return mimeMap[ext] || 'application/octet-stream';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 缩略图生成 ----------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 尝试使用 sharp 库生成缩略图。
|
|
||||||
*
|
|
||||||
* 优先级:
|
|
||||||
* 1. .cache/{filename}.webp — 预生成 WEBP 缩略图(最快)
|
|
||||||
* 2. .cache/{filename} — 预生成 PNG 缩略图
|
|
||||||
* 3. sharp — 实时缩放(可选依赖)
|
|
||||||
* 4. 原始文件字节 — 浏览器端缩放回退
|
|
||||||
*/
|
|
||||||
async function generateThumbnailImpl(
|
|
||||||
filePath: string,
|
|
||||||
size: number,
|
|
||||||
): Promise<{ data: string; mimeType: string; width: number; height: number }> {
|
|
||||||
const dir = path.dirname(filePath);
|
|
||||||
const baseName = path.basename(filePath);
|
|
||||||
const cacheDir = path.join(dir, '.cache');
|
|
||||||
|
|
||||||
// ── 优先级 1:.cache/{filename}.webp ──
|
|
||||||
const cachedWebp = path.join(cacheDir, `${baseName}.webp`);
|
|
||||||
if (fs.existsSync(cachedWebp)) {
|
|
||||||
const buffer = fs.readFileSync(cachedWebp);
|
|
||||||
return {
|
|
||||||
data: buffer.toString('base64'),
|
|
||||||
mimeType: 'image/webp',
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 优先级 2:.cache/{filename}(PNG 副本) ──
|
|
||||||
const cachedPng = path.join(cacheDir, baseName);
|
|
||||||
if (fs.existsSync(cachedPng)) {
|
|
||||||
const buffer = fs.readFileSync(cachedPng);
|
|
||||||
return {
|
|
||||||
data: buffer.toString('base64'),
|
|
||||||
mimeType: 'image/png',
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sharp) {
|
|
||||||
const image = sharp(filePath);
|
|
||||||
const metadata = await image.metadata();
|
|
||||||
const resized = await image
|
|
||||||
.resize(size, size, { fit: 'inside', withoutEnlargement: true })
|
|
||||||
.jpeg({ quality: 85 })
|
|
||||||
.toBuffer();
|
|
||||||
return {
|
|
||||||
data: resized.toString('base64'),
|
|
||||||
mimeType: 'image/jpeg',
|
|
||||||
width: metadata.width || 0,
|
|
||||||
height: metadata.height || 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 优先级 4:回退原始文件 ──
|
|
||||||
const buffer = fs.readFileSync(filePath);
|
|
||||||
const mimeType = getMimeType(filePath);
|
|
||||||
return {
|
|
||||||
data: buffer.toString('base64'),
|
|
||||||
mimeType,
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 扫描根目录计算(移自主进程,因渲染进程沙箱无 fs 权限) ----------
|
|
||||||
|
|
||||||
/** 阶段 → 允许的扩展名 */
|
|
||||||
function getStageExtensions(stage: StageType): string[] {
|
|
||||||
switch (stage) {
|
|
||||||
case 'Storyboard':
|
|
||||||
case 'Keyframe':
|
|
||||||
return ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif'];
|
|
||||||
case 'Video':
|
|
||||||
return ['mp4', 'webm', 'mov', 'avi', 'mkv'];
|
|
||||||
case 'Audio':
|
|
||||||
case 'LipSync':
|
|
||||||
return ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'];
|
|
||||||
case 'Assets':
|
|
||||||
return ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'mp4', 'webm'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 镜头名匹配查询 */
|
|
||||||
function shotNameMatchesQuery(shotName: string, query: string): boolean {
|
|
||||||
if (!query) return true;
|
|
||||||
const upperShot = shotName.toUpperCase();
|
|
||||||
const upperQuery = query.toUpperCase();
|
|
||||||
if (upperShot.includes(upperQuery)) return true;
|
|
||||||
if (/^\d+$/.test(query)) {
|
|
||||||
const digits = upperShot.replace(/\D/g, '');
|
|
||||||
if (digits.includes(query)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 计算扫描根目录列表 */
|
|
||||||
function buildScanRoots(params: {
|
|
||||||
projectPath: string;
|
|
||||||
stage: StageType;
|
|
||||||
assetTypeFilter: AssetTypeFilter;
|
|
||||||
shotQuery: string;
|
|
||||||
}): string[] {
|
|
||||||
const { projectPath, stage, assetTypeFilter, shotQuery } = params;
|
|
||||||
|
|
||||||
// ── 诊断:检查 projectPath 本身是否存在 ──
|
|
||||||
const projectExists = fs.existsSync(projectPath);
|
|
||||||
logger.info('canvas', 'buildScanRoots 诊断 — projectPath', {
|
|
||||||
projectPath,
|
|
||||||
exists: projectExists,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!projectExists) {
|
|
||||||
logger.warn('canvas', '⚠️ 项目路径不可访问', undefined, { projectPath });
|
|
||||||
// 尝试列出父目录内容以便排查
|
|
||||||
const parent = path.dirname(projectPath);
|
|
||||||
try {
|
|
||||||
const siblings = fs.readdirSync(parent).filter((n) => !n.startsWith('.'));
|
|
||||||
logger.info('canvas', `父目录 "${parent}" 内容(前 20 项)`, {
|
|
||||||
count: siblings.length,
|
|
||||||
items: siblings.slice(0, 20),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
logger.warn('canvas', `无法读取父目录 "${parent}"`);
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 诊断:列出项目根目录内容 ──
|
|
||||||
let projectEntries: string[] = [];
|
|
||||||
try {
|
|
||||||
projectEntries = fs.readdirSync(projectPath).filter((n) => !n.startsWith('.'));
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
logger.info('canvas', 'buildScanRoots 诊断 — 项目根目录内容(前 30 项)', {
|
|
||||||
count: projectEntries.length,
|
|
||||||
items: projectEntries.slice(0, 30),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (stage === 'Assets') {
|
|
||||||
const assetsDir = path.join(projectPath, 'assets');
|
|
||||||
logger.info('canvas', 'buildScanRoots — Assets 模式', {
|
|
||||||
assetsDir,
|
|
||||||
exists: fs.existsSync(assetsDir),
|
|
||||||
assetTypeFilter,
|
|
||||||
});
|
|
||||||
switch (assetTypeFilter) {
|
|
||||||
case '角色': return [path.join(assetsDir, 'chr')];
|
|
||||||
case '道具': return [path.join(assetsDir, 'prp')];
|
|
||||||
case '场景': return [path.join(assetsDir, 'set')];
|
|
||||||
default: return [assetsDir];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const shotsDir = path.join(projectPath, 'shots');
|
|
||||||
const shotsExists = fs.existsSync(shotsDir);
|
|
||||||
logger.info('canvas', 'buildScanRoots 诊断 — shotsDir', {
|
|
||||||
shotsDir,
|
|
||||||
exists: shotsExists,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!shotsExists) {
|
|
||||||
// 列出可能存在的子目录帮助用户对比
|
|
||||||
const dirsInRoot = projectEntries.filter((name) => {
|
|
||||||
try {
|
|
||||||
return fs.statSync(path.join(projectPath, name)).isDirectory();
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
logger.warn('canvas', '⚠️ shots 目录不存在,项目根下的子目录:', undefined, {
|
|
||||||
subdirs: dirsInRoot,
|
|
||||||
});
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const roots: string[] = [];
|
|
||||||
let episodes: string[];
|
|
||||||
try {
|
|
||||||
episodes = fs.readdirSync(shotsDir).filter((name) => {
|
|
||||||
const full = path.join(shotsDir, name);
|
|
||||||
return !name.startsWith('.') && fs.statSync(full).isDirectory();
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('canvas', '无法读取 shots 目录', err instanceof Error ? err : undefined, {
|
|
||||||
error: (err as Error).message,
|
|
||||||
});
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('canvas', `buildScanRoots — 发现 ${episodes.length} 个剧集目录`, {
|
|
||||||
episodes,
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const ep of episodes) {
|
|
||||||
const epDir = path.join(shotsDir, ep);
|
|
||||||
let shotNames: string[];
|
|
||||||
try {
|
|
||||||
shotNames = fs.readdirSync(epDir).filter((name) => {
|
|
||||||
const full = path.join(epDir, name);
|
|
||||||
return !name.startsWith('.') && fs.statSync(full).isDirectory();
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(
|
|
||||||
'canvas',
|
|
||||||
`无法读取剧集目录 "${ep}"`,
|
|
||||||
err instanceof Error ? err : undefined,
|
|
||||||
{ error: (err as Error).message },
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('canvas', ` 📂 ${ep} → ${shotNames.length} 个镜头`, {
|
|
||||||
shots: shotNames.slice(0, 20),
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const shot of shotNames) {
|
|
||||||
if (shotQuery && !shotNameMatchesQuery(shot, shotQuery)) continue;
|
|
||||||
const stageDir = path.join(epDir, shot, stage);
|
|
||||||
const stageExists = fs.existsSync(stageDir);
|
|
||||||
if (stageExists) {
|
|
||||||
roots.push(stageDir);
|
|
||||||
}
|
|
||||||
// 仅在前几个镜头时记录阶段目录存在性
|
|
||||||
if (roots.length <= 3 && !stageExists) {
|
|
||||||
logger.info('canvas', ` 阶段目录不存在: ${stageDir}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('canvas', `buildScanRoots 最终结果:${roots.length} 个扫描根`, {
|
|
||||||
roots: roots.slice(0, 10),
|
|
||||||
stage,
|
|
||||||
});
|
|
||||||
|
|
||||||
return roots;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 注册 ----------
|
|
||||||
|
|
||||||
export function registerCanvasIpcHandlers(): void {
|
|
||||||
// ---- 扫描项目(主进程内部 buildScanRoots + walkDirectory) ----
|
|
||||||
ipcMain.handle(
|
|
||||||
BIDIRECTIONAL.CANVAS_SCAN_PROJECT,
|
|
||||||
async (
|
|
||||||
_event,
|
|
||||||
params: {
|
|
||||||
projectPath: string;
|
|
||||||
stage: StageType;
|
|
||||||
assetTypeFilter: AssetTypeFilter;
|
|
||||||
shotQuery: string;
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
logger.info('canvas', '收到 CANVAS_SCAN_PROJECT', params);
|
|
||||||
|
|
||||||
const roots = buildScanRoots(params);
|
|
||||||
const extensions = getStageExtensions(params.stage);
|
|
||||||
|
|
||||||
logger.info('canvas', `buildScanRoots 结果:${roots.length} 个扫描根`, {
|
|
||||||
roots: roots.length <= 10 ? roots : roots.slice(0, 10).concat(`... 共 ${roots.length} 个`),
|
|
||||||
extensions,
|
|
||||||
stage: params.stage,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (roots.length === 0) {
|
|
||||||
logger.warn('canvas', '扫描根目录为空', undefined, {
|
|
||||||
projectPath: params.projectPath,
|
|
||||||
stage: params.stage,
|
|
||||||
shotQuery: params.shotQuery || '(无)',
|
|
||||||
});
|
|
||||||
return { success: true, data: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const allResults: Array<{
|
|
||||||
path: string;
|
|
||||||
fileName: string;
|
|
||||||
ext: string;
|
|
||||||
fileSize: number;
|
|
||||||
modifiedAt: string;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
for (const root of roots) {
|
|
||||||
const results = walkDirectory(root, extensions, true);
|
|
||||||
if (results.length > 0) {
|
|
||||||
logger.info('canvas', ` 📁 ${root} → ${results.length} 个文件`);
|
|
||||||
}
|
|
||||||
allResults.push(...results);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
'canvas',
|
|
||||||
`扫描项目完成:${allResults.length} 个文件(${roots.length} 个扫描根目录,阶段:${params.stage})`,
|
|
||||||
);
|
|
||||||
return { success: true, data: allResults };
|
|
||||||
} catch (err) {
|
|
||||||
const msg = (err as Error).message;
|
|
||||||
logger.warn('canvas', '项目扫描失败', err instanceof Error ? err : undefined);
|
|
||||||
return { success: false, error: msg };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---- 目录扫描(保留低级 API:外部提供扫描根) ----
|
|
||||||
// ---- 目录扫描 ----
|
|
||||||
ipcMain.handle(
|
|
||||||
BIDIRECTIONAL.CANVAS_SCAN_DIRECTORY,
|
|
||||||
async (
|
|
||||||
_event,
|
|
||||||
params: { roots: string[]; extensions: string[]; recursive: boolean },
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
const allResults: Array<{
|
|
||||||
path: string;
|
|
||||||
fileName: string;
|
|
||||||
ext: string;
|
|
||||||
fileSize: number;
|
|
||||||
modifiedAt: string;
|
|
||||||
}> = [];
|
|
||||||
for (const root of params.roots) {
|
|
||||||
if (!fs.existsSync(root)) {
|
|
||||||
logger.warn('canvas', `扫描根目录不存在,跳过:${root}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const results = walkDirectory(root, params.extensions, params.recursive);
|
|
||||||
allResults.push(...results);
|
|
||||||
}
|
|
||||||
logger.info(
|
|
||||||
'canvas',
|
|
||||||
`扫描完成:${allResults.length} 个文件(${params.roots.length} 个根目录)`,
|
|
||||||
);
|
|
||||||
return { success: true, data: allResults };
|
|
||||||
} catch (err) {
|
|
||||||
const msg = (err as Error).message;
|
|
||||||
logger.warn('canvas', '目录扫描失败', err instanceof Error ? err : undefined);
|
|
||||||
return { success: false, error: msg };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---- 读取 sidecar ----
|
|
||||||
ipcMain.handle(
|
|
||||||
BIDIRECTIONAL.CANVAS_READ_SIDECAR,
|
|
||||||
async (_event, params: { filePath: string }) => {
|
|
||||||
try {
|
|
||||||
const data = readSidecarFile(params.filePath);
|
|
||||||
return { success: true, data };
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('canvas', '读取 sidecar 失败', err instanceof Error ? err : undefined);
|
|
||||||
return { success: false, error: (err as Error).message };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---- 写入 sidecar ----
|
|
||||||
ipcMain.handle(
|
|
||||||
BIDIRECTIONAL.CANVAS_WRITE_SIDECAR,
|
|
||||||
async (_event, params: { filePath: string; metadata: object }) => {
|
|
||||||
try {
|
|
||||||
writeSidecarFile(params.filePath, params.metadata);
|
|
||||||
return { success: true };
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('canvas', '写入 sidecar 失败', err instanceof Error ? err : undefined);
|
|
||||||
return { success: false, error: (err as Error).message };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---- 生成缩略图 ----
|
|
||||||
ipcMain.handle(
|
|
||||||
BIDIRECTIONAL.CANVAS_GENERATE_THUMBNAIL,
|
|
||||||
async (_event, params: { filePath: string; size: number }) => {
|
|
||||||
try {
|
|
||||||
const result = await generateThumbnailImpl(params.filePath, params.size);
|
|
||||||
return { success: true, data: result };
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('canvas', '生成缩略图失败', err instanceof Error ? err : undefined);
|
|
||||||
return { success: false, error: (err as Error).message };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---- 复制文件 ----
|
|
||||||
ipcMain.handle(
|
|
||||||
BIDIRECTIONAL.CANVAS_COPY_FILE,
|
|
||||||
async (_event, params: { src: string; destDir: string; fileName: string }) => {
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(params.destDir)) {
|
|
||||||
fs.mkdirSync(params.destDir, { recursive: true });
|
|
||||||
}
|
|
||||||
const dest = path.join(params.destDir, params.fileName);
|
|
||||||
fs.copyFileSync(params.src, dest);
|
|
||||||
return { success: true, data: dest };
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('canvas', '复制文件失败', err instanceof Error ? err : undefined);
|
|
||||||
return { success: false, error: (err as Error).message };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---- 获取图片尺寸(使用 sharp 或 image-size) ----
|
|
||||||
ipcMain.handle(
|
|
||||||
BIDIRECTIONAL.CANVAS_GET_IMAGE_SIZE,
|
|
||||||
async (_event, params: { filePath: string }) => {
|
|
||||||
try {
|
|
||||||
if (sharp) {
|
|
||||||
const metadata = await sharp(params.filePath).metadata();
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
width: metadata.width || 0,
|
|
||||||
height: metadata.height || 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { success: false, error: 'sharp 未安装,无法获取图片尺寸' };
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('canvas', '获取图片尺寸失败', err instanceof Error ? err : undefined);
|
|
||||||
return { success: false, error: (err as Error).message };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---- 列出子文件夹(非递归,排除隐藏) ----
|
|
||||||
ipcMain.handle(
|
|
||||||
BIDIRECTIONAL.CANVAS_LIST_SUBDIRS,
|
|
||||||
async (_event, params: { parentPath: string }) => {
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(params.parentPath)) {
|
|
||||||
logger.info('canvas', `listSubdirs: 路径不存在,返回空数组 — ${params.parentPath}`);
|
|
||||||
return { success: true, data: [] };
|
|
||||||
}
|
|
||||||
const entries = fs.readdirSync(params.parentPath, { withFileTypes: true });
|
|
||||||
const dirs = entries
|
|
||||||
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
|
||||||
.map((e) => e.name);
|
|
||||||
logger.info('canvas', `listSubdirs: ${dirs.length} 个子文件夹 — ${params.parentPath}`);
|
|
||||||
return { success: true, data: dirs };
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('canvas', 'listSubdirs 失败', err instanceof Error ? err : undefined);
|
|
||||||
return { success: false, error: (err as Error).message };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---- 扫描阶段文件 ----
|
|
||||||
ipcMain.handle(
|
|
||||||
BIDIRECTIONAL.CANVAS_SCAN_STAGE_FILES,
|
|
||||||
async (
|
|
||||||
_event,
|
|
||||||
params: {
|
|
||||||
projectPath: string;
|
|
||||||
episode: string;
|
|
||||||
shot: string;
|
|
||||||
stage: string;
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
const stageDir = path.join(
|
|
||||||
params.projectPath,
|
|
||||||
'shots',
|
|
||||||
params.episode,
|
|
||||||
params.shot,
|
|
||||||
params.stage,
|
|
||||||
);
|
|
||||||
logger.info('canvas', `scanStageFiles: ${stageDir}`);
|
|
||||||
if (!fs.existsSync(stageDir)) {
|
|
||||||
logger.info('canvas', `scanStageFiles: 阶段目录不存在 — ${stageDir}`);
|
|
||||||
return { success: true, data: [] };
|
|
||||||
}
|
|
||||||
const extensions = getStageExtensions(params.stage as StageType);
|
|
||||||
const results = walkDirectory(stageDir, extensions, true);
|
|
||||||
logger.info('canvas', `scanStageFiles: ${results.length} 个文件 — ${stageDir}`);
|
|
||||||
return { success: true, data: results };
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('canvas', 'scanStageFiles 失败', err instanceof Error ? err : undefined);
|
|
||||||
return { success: false, error: (err as Error).message };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---- 扫描资产文件(深度递归) ----
|
|
||||||
ipcMain.handle(
|
|
||||||
BIDIRECTIONAL.CANVAS_SCAN_ASSET_FILES,
|
|
||||||
async (
|
|
||||||
_event,
|
|
||||||
params: { projectPath: string; assetTypeFilter: string },
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
let assetsDir: string;
|
|
||||||
switch (params.assetTypeFilter) {
|
|
||||||
case '角色':
|
|
||||||
assetsDir = path.join(params.projectPath, 'assets', 'chr');
|
|
||||||
break;
|
|
||||||
case '道具':
|
|
||||||
assetsDir = path.join(params.projectPath, 'assets', 'prp');
|
|
||||||
break;
|
|
||||||
case '场景':
|
|
||||||
assetsDir = path.join(params.projectPath, 'assets', 'set');
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
assetsDir = path.join(params.projectPath, 'assets');
|
|
||||||
}
|
|
||||||
logger.info('canvas', `scanAssetFiles: ${assetsDir} (filter=${params.assetTypeFilter})`);
|
|
||||||
if (!fs.existsSync(assetsDir)) {
|
|
||||||
logger.info('canvas', `scanAssetFiles: 资产目录不存在 — ${assetsDir}`);
|
|
||||||
return { success: true, data: [] };
|
|
||||||
}
|
|
||||||
// 资产深度递归:图片 + 视频
|
|
||||||
const extensions = ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'mp4', 'webm'];
|
|
||||||
const results = walkDirectory(assetsDir, extensions, true);
|
|
||||||
logger.info('canvas', `scanAssetFiles: ${results.length} 个文件 — ${assetsDir}`);
|
|
||||||
return { success: true, data: results };
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('canvas', 'scanAssetFiles 失败', err instanceof Error ? err : undefined);
|
|
||||||
return { success: false, error: (err as Error).message };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---- 创建目录 ----
|
|
||||||
ipcMain.handle(
|
|
||||||
BIDIRECTIONAL.CANVAS_CREATE_DIRECTORY,
|
|
||||||
async (_event, params: { parentPath: string; dirName: string }) => {
|
|
||||||
try {
|
|
||||||
// 安全校验:拒绝包含危险字符的名称
|
|
||||||
const dangerous = /[/\\:*?"<>|]/;
|
|
||||||
if (!params.dirName || dangerous.test(params.dirName)) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `无效的目录名: "${params.dirName}"`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const fullPath = path.join(params.parentPath, params.dirName);
|
|
||||||
if (fs.existsSync(fullPath)) {
|
|
||||||
return { success: false, error: `目录已存在: ${fullPath}` };
|
|
||||||
}
|
|
||||||
fs.mkdirSync(fullPath);
|
|
||||||
logger.info('canvas', `createDirectory: ${fullPath}`);
|
|
||||||
return { success: true, data: fullPath };
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('canvas', 'createDirectory 失败', err instanceof Error ? err : undefined);
|
|
||||||
return { success: false, error: (err as Error).message };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---- 启动原生文件拖拽(拖放到 Photoshop/Explorer 等外部应用) ----
|
|
||||||
ipcMain.handle(
|
|
||||||
BIDIRECTIONAL.CANVAS_START_DRAG,
|
|
||||||
async (_event, params: { filePath: string; fileName: string }) => {
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(params.filePath)) {
|
|
||||||
logger.warn('canvas', `startDrag: 文件不存在 — ${params.filePath}`);
|
|
||||||
return { success: false, error: '文件不存在' };
|
|
||||||
}
|
|
||||||
_event.sender.startDrag({
|
|
||||||
file: params.filePath,
|
|
||||||
icon: params.filePath,
|
|
||||||
});
|
|
||||||
logger.info('canvas', `startDrag: 已启动 — ${params.fileName}`);
|
|
||||||
return { success: true };
|
|
||||||
} catch (err) {
|
|
||||||
const msg = (err as Error).message;
|
|
||||||
logger.warn('canvas', 'startDrag 失败', err instanceof Error ? err : undefined);
|
|
||||||
return { success: false, error: msg };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info('canvas', '画布 IPC 处理器已注册(完整实现)');
|
|
||||||
}
|
|
||||||
352
electron/main/ipc/canvas/index.ts
Normal file
352
electron/main/ipc/canvas/index.ts
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
// ============================================================
|
||||||
|
// canvas/index.ts — 画布 IPC 处理器注册
|
||||||
|
//
|
||||||
|
// 职责:
|
||||||
|
// - 注册所有画布相关的 IPC 处理器
|
||||||
|
// - 组合使用各工具模块实现业务逻辑
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { ipcMain } from 'electron';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { BIDIRECTIONAL_CANVAS } from '../../../../shared/constants/ipc/canvas';
|
||||||
|
import { logger } from '../../logger';
|
||||||
|
import { walkDirectory } from './walk';
|
||||||
|
import { readSidecarFile, writeSidecarFile } from './sidecar';
|
||||||
|
import { generateCanvasThumbnail } from './thumbnail';
|
||||||
|
import { buildScanRoots, getStageExtensions } from './scan-roots';
|
||||||
|
import type { StageType, AssetTypeFilter } from './types';
|
||||||
|
|
||||||
|
/** 注册画布 IPC 处理器 */
|
||||||
|
export function registerCanvasIpcHandlers(): void {
|
||||||
|
// ---- 扫描项目(主进程内部 buildScanRoots + walkDirectory) ----
|
||||||
|
ipcMain.handle(
|
||||||
|
BIDIRECTIONAL_CANVAS.CANVAS_SCAN_PROJECT,
|
||||||
|
async (
|
||||||
|
_event,
|
||||||
|
params: {
|
||||||
|
projectPath: string;
|
||||||
|
stage: StageType;
|
||||||
|
assetTypeFilter: AssetTypeFilter;
|
||||||
|
shotQuery: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
logger.info('canvas', '收到 CANVAS_SCAN_PROJECT', params);
|
||||||
|
|
||||||
|
const roots = buildScanRoots(params);
|
||||||
|
const extensions = getStageExtensions(params.stage);
|
||||||
|
|
||||||
|
logger.info('canvas', `buildScanRoots 结果:${roots.length} 个扫描根`, {
|
||||||
|
roots: roots.length <= 10 ? roots : roots.slice(0, 10).concat(`... 共 ${roots.length} 个`),
|
||||||
|
extensions,
|
||||||
|
stage: params.stage,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (roots.length === 0) {
|
||||||
|
logger.warn('canvas', '扫描根目录为空', undefined, {
|
||||||
|
projectPath: params.projectPath,
|
||||||
|
stage: params.stage,
|
||||||
|
shotQuery: params.shotQuery || '(无)',
|
||||||
|
});
|
||||||
|
return { success: true, data: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const allResults: Array<{
|
||||||
|
path: string;
|
||||||
|
fileName: string;
|
||||||
|
ext: string;
|
||||||
|
fileSize: number;
|
||||||
|
modifiedAt: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const root of roots) {
|
||||||
|
const results = walkDirectory(root, extensions, true);
|
||||||
|
if (results.length > 0) {
|
||||||
|
logger.info('canvas', ` 📁 ${root} → ${results.length} 个文件`);
|
||||||
|
}
|
||||||
|
allResults.push(...results);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
'canvas',
|
||||||
|
`扫描项目完成:${allResults.length} 个文件(${roots.length} 个扫描根目录,阶段:${params.stage})`,
|
||||||
|
);
|
||||||
|
return { success: true, data: allResults };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as Error).message;
|
||||||
|
logger.warn('canvas', '项目扫描失败', err instanceof Error ? err : undefined);
|
||||||
|
return { success: false, error: msg };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- 目录扫描(保留低级 API:外部提供扫描根) ----
|
||||||
|
ipcMain.handle(
|
||||||
|
BIDIRECTIONAL_CANVAS.CANVAS_SCAN_DIRECTORY,
|
||||||
|
async (
|
||||||
|
_event,
|
||||||
|
params: { roots: string[]; extensions: string[]; recursive: boolean },
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const allResults: Array<{
|
||||||
|
path: string;
|
||||||
|
fileName: string;
|
||||||
|
ext: string;
|
||||||
|
fileSize: number;
|
||||||
|
modifiedAt: string;
|
||||||
|
}> = [];
|
||||||
|
for (const root of params.roots) {
|
||||||
|
if (!fs.existsSync(root)) {
|
||||||
|
logger.warn('canvas', `扫描根目录不存在,跳过:${root}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const results = walkDirectory(root, params.extensions, params.recursive);
|
||||||
|
allResults.push(...results);
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
'canvas',
|
||||||
|
`扫描完成:${allResults.length} 个文件(${params.roots.length} 个根目录)`,
|
||||||
|
);
|
||||||
|
return { success: true, data: allResults };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as Error).message;
|
||||||
|
logger.warn('canvas', '目录扫描失败', err instanceof Error ? err : undefined);
|
||||||
|
return { success: false, error: msg };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- 读取 sidecar ----
|
||||||
|
ipcMain.handle(
|
||||||
|
BIDIRECTIONAL_CANVAS.CANVAS_READ_SIDECAR,
|
||||||
|
async (_event, params: { filePath: string }) => {
|
||||||
|
try {
|
||||||
|
const data = readSidecarFile(params.filePath);
|
||||||
|
return { success: true, data };
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('canvas', '读取 sidecar 失败', err instanceof Error ? err : undefined);
|
||||||
|
return { success: false, error: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- 写入 sidecar ----
|
||||||
|
ipcMain.handle(
|
||||||
|
BIDIRECTIONAL_CANVAS.CANVAS_WRITE_SIDECAR,
|
||||||
|
async (_event, params: { filePath: string; metadata: object }) => {
|
||||||
|
try {
|
||||||
|
writeSidecarFile(params.filePath, params.metadata);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('canvas', '写入 sidecar 失败', err instanceof Error ? err : undefined);
|
||||||
|
return { success: false, error: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- 生成缩略图 ----
|
||||||
|
ipcMain.handle(
|
||||||
|
BIDIRECTIONAL_CANVAS.CANVAS_GENERATE_THUMBNAIL,
|
||||||
|
async (_event, params: { filePath: string; size: number }) => {
|
||||||
|
try {
|
||||||
|
const result = await generateCanvasThumbnail(params.filePath, params.size);
|
||||||
|
return { success: true, data: result };
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('canvas', '生成缩略图失败', err instanceof Error ? err : undefined);
|
||||||
|
return { success: false, error: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- 复制文件 ----
|
||||||
|
ipcMain.handle(
|
||||||
|
BIDIRECTIONAL_CANVAS.CANVAS_COPY_FILE,
|
||||||
|
async (_event, params: { src: string; destDir: string; fileName: string }) => {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(params.destDir)) {
|
||||||
|
fs.mkdirSync(params.destDir, { recursive: true });
|
||||||
|
}
|
||||||
|
const dest = path.join(params.destDir, params.fileName);
|
||||||
|
fs.copyFileSync(params.src, dest);
|
||||||
|
return { success: true, data: dest };
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('canvas', '复制文件失败', err instanceof Error ? err : undefined);
|
||||||
|
return { success: false, error: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- 获取图片尺寸(使用 sharp) ----
|
||||||
|
ipcMain.handle(
|
||||||
|
BIDIRECTIONAL_CANVAS.CANVAS_GET_IMAGE_SIZE,
|
||||||
|
async (_event, params: { filePath: string }) => {
|
||||||
|
try {
|
||||||
|
if (sharp) {
|
||||||
|
const metadata = await sharp(params.filePath).metadata();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
width: metadata.width || 0,
|
||||||
|
height: metadata.height || 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { success: false, error: 'sharp 未安装,无法获取图片尺寸' };
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('canvas', '获取图片尺寸失败', err instanceof Error ? err : undefined);
|
||||||
|
return { success: false, error: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- 列出子文件夹(非递归,排除隐藏) ----
|
||||||
|
ipcMain.handle(
|
||||||
|
BIDIRECTIONAL_CANVAS.CANVAS_LIST_SUBDIRS,
|
||||||
|
async (_event, params: { parentPath: string }) => {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(params.parentPath)) {
|
||||||
|
logger.info('canvas', `listSubdirs: 路径不存在,返回空数组 — ${params.parentPath}`);
|
||||||
|
return { success: true, data: [] };
|
||||||
|
}
|
||||||
|
const entries = fs.readdirSync(params.parentPath, { withFileTypes: true });
|
||||||
|
const dirs = entries
|
||||||
|
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
||||||
|
.map((e) => e.name);
|
||||||
|
logger.info('canvas', `listSubdirs: ${dirs.length} 个子文件夹 — ${params.parentPath}`);
|
||||||
|
return { success: true, data: dirs };
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('canvas', 'listSubdirs 失败', err instanceof Error ? err : undefined);
|
||||||
|
return { success: false, error: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- 扫描阶段文件 ----
|
||||||
|
ipcMain.handle(
|
||||||
|
BIDIRECTIONAL_CANVAS.CANVAS_SCAN_STAGE_FILES,
|
||||||
|
async (
|
||||||
|
_event,
|
||||||
|
params: {
|
||||||
|
projectPath: string;
|
||||||
|
episode: string;
|
||||||
|
shot: string;
|
||||||
|
stage: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const stageDir = path.join(
|
||||||
|
params.projectPath,
|
||||||
|
'shots',
|
||||||
|
params.episode,
|
||||||
|
params.shot,
|
||||||
|
params.stage,
|
||||||
|
);
|
||||||
|
logger.info('canvas', `scanStageFiles: ${stageDir}`);
|
||||||
|
if (!fs.existsSync(stageDir)) {
|
||||||
|
logger.info('canvas', `scanStageFiles: 阶段目录不存在 — ${stageDir}`);
|
||||||
|
return { success: true, data: [] };
|
||||||
|
}
|
||||||
|
const extensions = getStageExtensions(params.stage as StageType);
|
||||||
|
const results = walkDirectory(stageDir, extensions, true);
|
||||||
|
logger.info('canvas', `scanStageFiles: ${results.length} 个文件 — ${stageDir}`);
|
||||||
|
return { success: true, data: results };
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('canvas', 'scanStageFiles 失败', err instanceof Error ? err : undefined);
|
||||||
|
return { success: false, error: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- 扫描资产文件(深度递归) ----
|
||||||
|
ipcMain.handle(
|
||||||
|
BIDIRECTIONAL_CANVAS.CANVAS_SCAN_ASSET_FILES,
|
||||||
|
async (
|
||||||
|
_event,
|
||||||
|
params: { projectPath: string; assetTypeFilter: string },
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
let assetsDir: string;
|
||||||
|
switch (params.assetTypeFilter) {
|
||||||
|
case '角色':
|
||||||
|
assetsDir = path.join(params.projectPath, 'assets', 'chr');
|
||||||
|
break;
|
||||||
|
case '道具':
|
||||||
|
assetsDir = path.join(params.projectPath, 'assets', 'prp');
|
||||||
|
break;
|
||||||
|
case '场景':
|
||||||
|
assetsDir = path.join(params.projectPath, 'assets', 'set');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
assetsDir = path.join(params.projectPath, 'assets');
|
||||||
|
}
|
||||||
|
logger.info('canvas', `scanAssetFiles: ${assetsDir} (filter=${params.assetTypeFilter})`);
|
||||||
|
if (!fs.existsSync(assetsDir)) {
|
||||||
|
logger.info('canvas', `scanAssetFiles: 资产目录不存在 — ${assetsDir}`);
|
||||||
|
return { success: true, data: [] };
|
||||||
|
}
|
||||||
|
// 资产深度递归:图片 + 视频
|
||||||
|
const extensions = ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'mp4', 'webm'];
|
||||||
|
const results = walkDirectory(assetsDir, extensions, true);
|
||||||
|
logger.info('canvas', `scanAssetFiles: ${results.length} 个文件 — ${assetsDir}`);
|
||||||
|
return { success: true, data: results };
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('canvas', 'scanAssetFiles 失败', err instanceof Error ? err : undefined);
|
||||||
|
return { success: false, error: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- 创建目录 ----
|
||||||
|
ipcMain.handle(
|
||||||
|
BIDIRECTIONAL_CANVAS.CANVAS_CREATE_DIRECTORY,
|
||||||
|
async (_event, params: { parentPath: string; dirName: string }) => {
|
||||||
|
try {
|
||||||
|
// 安全校验:拒绝包含危险字符的名称
|
||||||
|
const dangerous = /[/\\:*?"<>|]/;
|
||||||
|
if (!params.dirName || dangerous.test(params.dirName)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `无效的目录名: "${params.dirName}"`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const fullPath = path.join(params.parentPath, params.dirName);
|
||||||
|
if (fs.existsSync(fullPath)) {
|
||||||
|
return { success: false, error: `目录已存在: ${fullPath}` };
|
||||||
|
}
|
||||||
|
fs.mkdirSync(fullPath);
|
||||||
|
logger.info('canvas', `createDirectory: ${fullPath}`);
|
||||||
|
return { success: true, data: fullPath };
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('canvas', 'createDirectory 失败', err instanceof Error ? err : undefined);
|
||||||
|
return { success: false, error: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- 启动原生文件拖拽(拖放到 Photoshop/Explorer 等外部应用) ----
|
||||||
|
ipcMain.handle(
|
||||||
|
BIDIRECTIONAL_CANVAS.CANVAS_START_DRAG,
|
||||||
|
async (_event, params: { filePath: string; fileName: string }) => {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(params.filePath)) {
|
||||||
|
logger.warn('canvas', `startDrag: 文件不存在 — ${params.filePath}`);
|
||||||
|
return { success: false, error: '文件不存在' };
|
||||||
|
}
|
||||||
|
_event.sender.startDrag({
|
||||||
|
file: params.filePath,
|
||||||
|
icon: params.filePath,
|
||||||
|
});
|
||||||
|
logger.info('canvas', `startDrag: 已启动 — ${params.fileName}`);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as Error).message;
|
||||||
|
logger.warn('canvas', 'startDrag 失败', err instanceof Error ? err : undefined);
|
||||||
|
return { success: false, error: msg };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('canvas', '画布 IPC 处理器已注册(完整实现)');
|
||||||
|
}
|
||||||
36
electron/main/ipc/canvas/mime.ts
Normal file
36
electron/main/ipc/canvas/mime.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// ============================================================
|
||||||
|
// canvas/mime.ts — MIME 类型推断
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
/** MIME 类型映射表 */
|
||||||
|
const MIME_MAP: Record<string, string> = {
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.bmp': 'image/bmp',
|
||||||
|
'.mp4': 'video/mp4',
|
||||||
|
'.webm': 'video/webm',
|
||||||
|
'.mkv': 'video/x-matroska',
|
||||||
|
'.mov': 'video/quicktime',
|
||||||
|
'.avi': 'video/x-msvideo',
|
||||||
|
'.mp3': 'audio/mpeg',
|
||||||
|
'.wav': 'audio/wav',
|
||||||
|
'.ogg': 'audio/ogg',
|
||||||
|
'.flac': 'audio/flac',
|
||||||
|
'.aac': 'audio/aac',
|
||||||
|
'.m4a': 'audio/mp4',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据文件扩展名推断 MIME 类型。
|
||||||
|
* @param filePath - 文件路径
|
||||||
|
* @returns MIME 类型字符串,未知类型返回 'application/octet-stream'
|
||||||
|
*/
|
||||||
|
export function getMimeType(filePath: string): string {
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
return MIME_MAP[ext] || 'application/octet-stream';
|
||||||
|
}
|
||||||
189
electron/main/ipc/canvas/scan-roots.ts
Normal file
189
electron/main/ipc/canvas/scan-roots.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
// ============================================================
|
||||||
|
// canvas/scan-roots.ts — 扫描根目录计算
|
||||||
|
//
|
||||||
|
// 职责:
|
||||||
|
// - 根据项目路径、阶段、资产类型计算扫描根目录列表
|
||||||
|
// - 支持 shots 和 assets 两种目录结构
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { logger } from '../../logger';
|
||||||
|
import type { StageType, AssetTypeFilter } from './types';
|
||||||
|
|
||||||
|
/** 阶段 → 允许的扩展名 */
|
||||||
|
export function getStageExtensions(stage: StageType): string[] {
|
||||||
|
switch (stage) {
|
||||||
|
case 'Storyboard':
|
||||||
|
case 'Keyframe':
|
||||||
|
return ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif'];
|
||||||
|
case 'Video':
|
||||||
|
return ['mp4', 'webm', 'mov', 'avi', 'mkv'];
|
||||||
|
case 'Audio':
|
||||||
|
case 'LipSync':
|
||||||
|
return ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'];
|
||||||
|
case 'Assets':
|
||||||
|
return ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'mp4', 'webm'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 镜头名匹配查询 */
|
||||||
|
export function shotNameMatchesQuery(shotName: string, query: string): boolean {
|
||||||
|
if (!query) return true;
|
||||||
|
const upperShot = shotName.toUpperCase();
|
||||||
|
const upperQuery = query.toUpperCase();
|
||||||
|
if (upperShot.includes(upperQuery)) return true;
|
||||||
|
if (/^\d+$/.test(query)) {
|
||||||
|
const digits = upperShot.replace(/\D/g, '');
|
||||||
|
if (digits.includes(query)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算扫描根目录列表。
|
||||||
|
*
|
||||||
|
* @param params - 扫描参数
|
||||||
|
* @returns 扫描根目录路径列表
|
||||||
|
*/
|
||||||
|
export function buildScanRoots(params: {
|
||||||
|
projectPath: string;
|
||||||
|
stage: StageType;
|
||||||
|
assetTypeFilter: AssetTypeFilter;
|
||||||
|
shotQuery: string;
|
||||||
|
}): string[] {
|
||||||
|
const { projectPath, stage, assetTypeFilter, shotQuery } = params;
|
||||||
|
|
||||||
|
// ── 诊断:检查 projectPath 本身是否存在 ──
|
||||||
|
const projectExists = fs.existsSync(projectPath);
|
||||||
|
logger.info('canvas', 'buildScanRoots 诊断 — projectPath', {
|
||||||
|
projectPath,
|
||||||
|
exists: projectExists,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!projectExists) {
|
||||||
|
logger.warn('canvas', '⚠️ 项目路径不可访问', undefined, { projectPath });
|
||||||
|
// 尝试列出父目录内容以便排查
|
||||||
|
const parent = path.dirname(projectPath);
|
||||||
|
try {
|
||||||
|
const siblings = fs.readdirSync(parent).filter((n) => !n.startsWith('.'));
|
||||||
|
logger.info('canvas', `父目录 "${parent}" 内容(前 20 项)`, {
|
||||||
|
count: siblings.length,
|
||||||
|
items: siblings.slice(0, 20),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
logger.warn('canvas', `无法读取父目录 "${parent}"`);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 诊断:列出项目根目录内容 ──
|
||||||
|
let projectEntries: string[] = [];
|
||||||
|
try {
|
||||||
|
projectEntries = fs.readdirSync(projectPath).filter((n) => !n.startsWith('.'));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
logger.info('canvas', 'buildScanRoots 诊断 — 项目根目录内容(前 30 项)', {
|
||||||
|
count: projectEntries.length,
|
||||||
|
items: projectEntries.slice(0, 30),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (stage === 'Assets') {
|
||||||
|
const assetsDir = path.join(projectPath, 'assets');
|
||||||
|
logger.info('canvas', 'buildScanRoots — Assets 模式', {
|
||||||
|
assetsDir,
|
||||||
|
exists: fs.existsSync(assetsDir),
|
||||||
|
assetTypeFilter,
|
||||||
|
});
|
||||||
|
switch (assetTypeFilter) {
|
||||||
|
case '角色': return [path.join(assetsDir, 'chr')];
|
||||||
|
case '道具': return [path.join(assetsDir, 'prp')];
|
||||||
|
case '场景': return [path.join(assetsDir, 'set')];
|
||||||
|
default: return [assetsDir];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const shotsDir = path.join(projectPath, 'shots');
|
||||||
|
const shotsExists = fs.existsSync(shotsDir);
|
||||||
|
logger.info('canvas', 'buildScanRoots 诊断 — shotsDir', {
|
||||||
|
shotsDir,
|
||||||
|
exists: shotsExists,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!shotsExists) {
|
||||||
|
// 列出可能存在的子目录帮助用户对比
|
||||||
|
const dirsInRoot = projectEntries.filter((name) => {
|
||||||
|
try {
|
||||||
|
return fs.statSync(path.join(projectPath, name)).isDirectory();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
logger.warn('canvas', '⚠️ shots 目录不存在,项目根下的子目录:', undefined, {
|
||||||
|
subdirs: dirsInRoot,
|
||||||
|
});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
let episodes: string[];
|
||||||
|
try {
|
||||||
|
episodes = fs.readdirSync(shotsDir).filter((name) => {
|
||||||
|
const full = path.join(shotsDir, name);
|
||||||
|
return !name.startsWith('.') && fs.statSync(full).isDirectory();
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('canvas', '无法读取 shots 目录', err instanceof Error ? err : undefined, {
|
||||||
|
error: (err as Error).message,
|
||||||
|
});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('canvas', `buildScanRoots — 发现 ${episodes.length} 个剧集目录`, {
|
||||||
|
episodes,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const ep of episodes) {
|
||||||
|
const epDir = path.join(shotsDir, ep);
|
||||||
|
let shotNames: string[];
|
||||||
|
try {
|
||||||
|
shotNames = fs.readdirSync(epDir).filter((name) => {
|
||||||
|
const full = path.join(epDir, name);
|
||||||
|
return !name.startsWith('.') && fs.statSync(full).isDirectory();
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
'canvas',
|
||||||
|
`无法读取剧集目录 "${ep}"`,
|
||||||
|
err instanceof Error ? err : undefined,
|
||||||
|
{ error: (err as Error).message },
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('canvas', ` 📂 ${ep} → ${shotNames.length} 个镜头`, {
|
||||||
|
shots: shotNames.slice(0, 20),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const shot of shotNames) {
|
||||||
|
if (shotQuery && !shotNameMatchesQuery(shot, shotQuery)) continue;
|
||||||
|
const stageDir = path.join(epDir, shot, stage);
|
||||||
|
const stageExists = fs.existsSync(stageDir);
|
||||||
|
if (stageExists) {
|
||||||
|
roots.push(stageDir);
|
||||||
|
}
|
||||||
|
// 仅在前几个镜头时记录阶段目录存在性
|
||||||
|
if (roots.length <= 3 && !stageExists) {
|
||||||
|
logger.info('canvas', ` 阶段目录不存在: ${stageDir}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('canvas', `buildScanRoots 最终结果:${roots.length} 个扫描根`, {
|
||||||
|
roots: roots.slice(0, 10),
|
||||||
|
stage,
|
||||||
|
});
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
47
electron/main/ipc/canvas/sidecar.ts
Normal file
47
electron/main/ipc/canvas/sidecar.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// ============================================================
|
||||||
|
// canvas/sidecar.ts — Sidecar 元数据操作
|
||||||
|
//
|
||||||
|
// 职责:
|
||||||
|
// - 读取/写入旁加载 JSON 元数据文件
|
||||||
|
// - sidecar 文件与媒体文件同目录、同名、.json 扩展名
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
/** 生成 sidecar JSON 文件路径:同目录、同名、.json 扩展名 */
|
||||||
|
export function sidecarPathForMedia(filePath: string): string {
|
||||||
|
const dir = path.dirname(filePath);
|
||||||
|
const baseName = path.basename(filePath, path.extname(filePath));
|
||||||
|
return path.join(dir, `${baseName}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取旁加载元数据。
|
||||||
|
* @param filePath - 媒体文件路径
|
||||||
|
* @returns 元数据对象,文件不存在返回 null
|
||||||
|
*/
|
||||||
|
export function readSidecarFile(filePath: string): object | null {
|
||||||
|
const sidecarPath = sidecarPathForMedia(filePath);
|
||||||
|
if (!fs.existsSync(sidecarPath)) return null;
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(sidecarPath, 'utf-8');
|
||||||
|
return JSON.parse(content);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入旁加载元数据(自动创建目录)。
|
||||||
|
* @param filePath - 媒体文件路径
|
||||||
|
* @param metadata - 元数据对象
|
||||||
|
*/
|
||||||
|
export function writeSidecarFile(filePath: string, metadata: object): void {
|
||||||
|
const sidecarPath = sidecarPathForMedia(filePath);
|
||||||
|
const dir = path.dirname(sidecarPath);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
fs.writeFileSync(sidecarPath, JSON.stringify(metadata, null, 2), 'utf-8');
|
||||||
|
}
|
||||||
84
electron/main/ipc/canvas/thumbnail.ts
Normal file
84
electron/main/ipc/canvas/thumbnail.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// ============================================================
|
||||||
|
// canvas/thumbnail.ts — 画布缩略图生成
|
||||||
|
//
|
||||||
|
// 职责:
|
||||||
|
// - 生成画布专用缩略图(返回 base64 数据)
|
||||||
|
// - 支持缓存优先策略(.cache 目录)
|
||||||
|
//
|
||||||
|
// 优先级:
|
||||||
|
// 1. .cache/{filename}.webp — 预生成 WEBP 缩略图(最快)
|
||||||
|
// 2. .cache/{filename} — 预生成 PNG 缩略图
|
||||||
|
// 3. sharp — 实时缩放(可选依赖)
|
||||||
|
// 4. 原始文件字节 — 浏览器端缩放回退
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { getMimeType } from './mime';
|
||||||
|
import type { ThumbnailResult } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成画布缩略图。
|
||||||
|
*
|
||||||
|
* @param filePath - 源文件路径
|
||||||
|
* @param size - 缩略图尺寸
|
||||||
|
* @returns 缩略图数据(base64)
|
||||||
|
*/
|
||||||
|
export async function generateCanvasThumbnail(
|
||||||
|
filePath: string,
|
||||||
|
size: number,
|
||||||
|
): Promise<ThumbnailResult> {
|
||||||
|
const dir = path.dirname(filePath);
|
||||||
|
const baseName = path.basename(filePath);
|
||||||
|
const cacheDir = path.join(dir, '.cache');
|
||||||
|
|
||||||
|
// ── 优先级 1:.cache/{filename}.webp ──
|
||||||
|
const cachedWebp = path.join(cacheDir, `${baseName}.webp`);
|
||||||
|
if (fs.existsSync(cachedWebp)) {
|
||||||
|
const buffer = fs.readFileSync(cachedWebp);
|
||||||
|
return {
|
||||||
|
data: buffer.toString('base64'),
|
||||||
|
mimeType: 'image/webp',
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 优先级 2:.cache/{filename}(PNG 副本) ──
|
||||||
|
const cachedPng = path.join(cacheDir, baseName);
|
||||||
|
if (fs.existsSync(cachedPng)) {
|
||||||
|
const buffer = fs.readFileSync(cachedPng);
|
||||||
|
return {
|
||||||
|
data: buffer.toString('base64'),
|
||||||
|
mimeType: 'image/png',
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sharp) {
|
||||||
|
const image = sharp(filePath);
|
||||||
|
const metadata = await image.metadata();
|
||||||
|
const resized = await image
|
||||||
|
.resize(size, size, { fit: 'inside', withoutEnlargement: true })
|
||||||
|
.jpeg({ quality: 85 })
|
||||||
|
.toBuffer();
|
||||||
|
return {
|
||||||
|
data: resized.toString('base64'),
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
width: metadata.width || 0,
|
||||||
|
height: metadata.height || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 优先级 4:回退原始文件 ──
|
||||||
|
const buffer = fs.readFileSync(filePath);
|
||||||
|
const mimeType = getMimeType(filePath);
|
||||||
|
return {
|
||||||
|
data: buffer.toString('base64'),
|
||||||
|
mimeType,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
26
electron/main/ipc/canvas/types.ts
Normal file
26
electron/main/ipc/canvas/types.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// ============================================================
|
||||||
|
// canvas/types.ts — 画布相关类型定义
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** 阶段类型 */
|
||||||
|
export type StageType = 'Storyboard' | 'Keyframe' | 'Video' | 'Audio' | 'LipSync' | 'Assets';
|
||||||
|
|
||||||
|
/** 资产类型过滤器 */
|
||||||
|
export type AssetTypeFilter = '全部' | '角色' | '道具' | '场景';
|
||||||
|
|
||||||
|
/** 扫描结果条目 */
|
||||||
|
export interface ScanEntry {
|
||||||
|
path: string;
|
||||||
|
fileName: string;
|
||||||
|
ext: string;
|
||||||
|
fileSize: number;
|
||||||
|
modifiedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 缩略图结果 */
|
||||||
|
export interface ThumbnailResult {
|
||||||
|
data: string;
|
||||||
|
mimeType: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
66
electron/main/ipc/canvas/walk.ts
Normal file
66
electron/main/ipc/canvas/walk.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// ============================================================
|
||||||
|
// canvas/walk.ts — 目录遍历算法
|
||||||
|
//
|
||||||
|
// 职责:
|
||||||
|
// - 递归遍历目录,收集所有匹配扩展名的文件
|
||||||
|
// - 跳过隐藏文件和文件夹
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { ScanEntry } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归遍历目录,收集所有匹配扩展名的文件。
|
||||||
|
*
|
||||||
|
* @param root - 根目录路径
|
||||||
|
* @param extensions - 允许的文件扩展名列表(不含点号)
|
||||||
|
* @param recursive - 是否递归遍历子目录
|
||||||
|
* @returns 文件列表
|
||||||
|
*/
|
||||||
|
export function walkDirectory(
|
||||||
|
root: string,
|
||||||
|
extensions: string[],
|
||||||
|
recursive: boolean,
|
||||||
|
): ScanEntry[] {
|
||||||
|
const results: ScanEntry[] = [];
|
||||||
|
const extSet = new Set(extensions.map((e) => e.toLowerCase()));
|
||||||
|
|
||||||
|
function walk(dir: string) {
|
||||||
|
let entries: fs.Dirent[];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return; // 权限不足等,跳过该目录
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
// 跳过隐藏文件和文件夹
|
||||||
|
if (entry.name.startsWith('.')) continue;
|
||||||
|
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (recursive) walk(fullPath);
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
const ext = path.extname(entry.name).slice(1).toLowerCase();
|
||||||
|
if (extSet.has(ext)) {
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(fullPath);
|
||||||
|
results.push({
|
||||||
|
path: fullPath,
|
||||||
|
fileName: entry.name,
|
||||||
|
ext,
|
||||||
|
fileSize: stat.size,
|
||||||
|
modifiedAt: stat.mtime.toISOString(),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// 文件不可访问,跳过
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(root);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { ipcMain } from 'electron';
|
import { ipcMain } from 'electron';
|
||||||
import { RENDERER_TO_MAIN } from '../../../shared/constants/ipc-channels';
|
import { RENDERER_TO_MAIN } from '../../../shared/constants/ipc/base';
|
||||||
import { writeRendererLog } from '../logger';
|
import { writeRendererLog } from '../logger';
|
||||||
import type { LogEntry } from '../../../shared/types/logging';
|
import type { LogEntry } from '../../../shared/types/logging';
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { ipcMain, safeStorage } from 'electron';
|
import { ipcMain, safeStorage } from 'electron';
|
||||||
import { BIDIRECTIONAL } from '../../../shared/constants/ipc-channels';
|
import { BIDIRECTIONAL_STORAGE } from '../../../shared/constants/ipc/storage';
|
||||||
import { logger } from '../logger';
|
import { logger } from '../logger';
|
||||||
|
|
||||||
/** 检查 safeStorage 是否可用,不可用时记录警告 */
|
/** 检查 safeStorage 是否可用,不可用时记录警告 */
|
||||||
@@ -34,7 +34,7 @@ function checkAvailability(): boolean {
|
|||||||
*/
|
*/
|
||||||
export function registerSafeStorageIpcHandlers(): void {
|
export function registerSafeStorageIpcHandlers(): void {
|
||||||
// ---------- 加密 ----------
|
// ---------- 加密 ----------
|
||||||
ipcMain.handle(BIDIRECTIONAL.SAFESTORAGE_ENCRYPT, (_event, plaintext: string) => {
|
ipcMain.handle(BIDIRECTIONAL_STORAGE.SAFESTORAGE_ENCRYPT, (_event, plaintext: string) => {
|
||||||
if (typeof plaintext !== 'string' || plaintext.length === 0) {
|
if (typeof plaintext !== 'string' || plaintext.length === 0) {
|
||||||
logger.warn('safe-storage', '加密参数无效');
|
logger.warn('safe-storage', '加密参数无效');
|
||||||
return { success: false, error: '参数无效:需要非空字符串' };
|
return { success: false, error: '参数无效:需要非空字符串' };
|
||||||
@@ -56,7 +56,7 @@ export function registerSafeStorageIpcHandlers(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------- 解密 ----------
|
// ---------- 解密 ----------
|
||||||
ipcMain.handle(BIDIRECTIONAL.SAFESTORAGE_DECRYPT, (_event, encryptedBase64: string) => {
|
ipcMain.handle(BIDIRECTIONAL_STORAGE.SAFESTORAGE_DECRYPT, (_event, encryptedBase64: string) => {
|
||||||
if (typeof encryptedBase64 !== 'string' || encryptedBase64.length === 0) {
|
if (typeof encryptedBase64 !== 'string' || encryptedBase64.length === 0) {
|
||||||
logger.warn('safe-storage', '解密参数无效');
|
logger.warn('safe-storage', '解密参数无效');
|
||||||
return { success: false, error: '参数无效:需要非空字符串' };
|
return { success: false, error: '参数无效:需要非空字符串' };
|
||||||
|
|||||||
@@ -16,8 +16,9 @@
|
|||||||
import { ipcMain, shell, app } from 'electron';
|
import { ipcMain, shell, app } from 'electron';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { BIDIRECTIONAL } from '../../../shared/constants/ipc-channels';
|
import { BIDIRECTIONAL_STORAGE } from '../../../shared/constants/ipc/storage';
|
||||||
import { logger } from '../logger';
|
import { logger } from '../logger';
|
||||||
|
import { resolveSafe, ensureDir, downloadToFile, generateThumbnail } from './utils';
|
||||||
|
|
||||||
// ---------- 内部工具 ----------
|
// ---------- 内部工具 ----------
|
||||||
|
|
||||||
@@ -33,25 +34,6 @@ function getDataDir(): string {
|
|||||||
return dataDirResolver ? dataDirResolver() : path.join(app.getPath('userData'), 'heixiu-data');
|
return dataDirResolver ? dataDirResolver() : path.join(app.getPath('userData'), 'heixiu-data');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 将相对路径解析为绝对路径,并校验是否在 dataDir 内。
|
|
||||||
* 防止路径穿越攻击。
|
|
||||||
*/
|
|
||||||
function resolveSafe(dataDir: string, relativePath: string): string {
|
|
||||||
const resolved = path.resolve(dataDir, relativePath);
|
|
||||||
if (!resolved.startsWith(dataDir + path.sep) && resolved !== dataDir) {
|
|
||||||
throw new Error(`路径越界:${relativePath}`);
|
|
||||||
}
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 确保目录存在(递归创建) */
|
|
||||||
function ensureDir(dirPath: string): void {
|
|
||||||
if (!fs.existsSync(dirPath)) {
|
|
||||||
fs.mkdirSync(dirPath, { recursive: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 注册 IPC 处理器 ----------
|
// ---------- 注册 IPC 处理器 ----------
|
||||||
|
|
||||||
export function registerStorageIpcHandlers(): void {
|
export function registerStorageIpcHandlers(): void {
|
||||||
@@ -60,28 +42,24 @@ export function registerStorageIpcHandlers(): void {
|
|||||||
logger.info('app', `本地存储目录:${dataDir}`);
|
logger.info('app', `本地存储目录:${dataDir}`);
|
||||||
|
|
||||||
// ---- 获取数据目录 ----
|
// ---- 获取数据目录 ----
|
||||||
ipcMain.handle(BIDIRECTIONAL.GET_DATA_DIR, () => {
|
ipcMain.handle(BIDIRECTIONAL_STORAGE.GET_DATA_DIR, () => {
|
||||||
return { success: true, data: dataDir };
|
return { success: true, data: dataDir };
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- 文件写入 ----
|
// ---- 文件写入 ----
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
BIDIRECTIONAL.FILE_WRITE,
|
BIDIRECTIONAL_STORAGE.FILE_WRITE,
|
||||||
async (
|
async (_event, params: { relativePath: string; data: string; encoding?: 'base64' | 'utf8' }) => {
|
||||||
_event,
|
|
||||||
params: { relativePath: string; data: string; encoding?: 'base64' | 'utf8' },
|
|
||||||
) => {
|
|
||||||
try {
|
try {
|
||||||
const filePath = resolveSafe(dataDir, params.relativePath);
|
const filePath = resolveSafe(dataDir, params.relativePath);
|
||||||
ensureDir(path.dirname(filePath));
|
ensureDir(path.dirname(filePath));
|
||||||
|
|
||||||
const buffer = Buffer.from(params.data, params.encoding || 'base64');
|
const buffer = Buffer.from(params.data, params.encoding || 'base64');
|
||||||
fs.writeFileSync(filePath, buffer);
|
fs.writeFileSync(filePath, buffer);
|
||||||
console.log(`[storage IPC] 文件写入成功:${filePath}(${buffer.length} bytes)`);
|
logger.info('storage', `文件写入成功:${filePath}(${buffer.length} bytes)`);
|
||||||
return { success: true, data: filePath };
|
return { success: true, data: filePath };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = (err as Error).message;
|
const msg = (err as Error).message;
|
||||||
console.error(`[storage IPC] 文件写入失败:${params.relativePath} — ${msg}`);
|
|
||||||
logger.warn('storage', `文件写入失败:${params.relativePath}`, err instanceof Error ? err : undefined);
|
logger.warn('storage', `文件写入失败:${params.relativePath}`, err instanceof Error ? err : undefined);
|
||||||
return { success: false, error: msg };
|
return { success: false, error: msg };
|
||||||
}
|
}
|
||||||
@@ -89,7 +67,7 @@ export function registerStorageIpcHandlers(): void {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ---- 文件读取(返回 Base64) ----
|
// ---- 文件读取(返回 Base64) ----
|
||||||
ipcMain.handle(BIDIRECTIONAL.FILE_READ, async (_event, params: { relativePath: string }) => {
|
ipcMain.handle(BIDIRECTIONAL_STORAGE.FILE_READ, async (_event, params: { relativePath: string }) => {
|
||||||
try {
|
try {
|
||||||
const filePath = resolveSafe(dataDir, params.relativePath);
|
const filePath = resolveSafe(dataDir, params.relativePath);
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
@@ -104,7 +82,7 @@ export function registerStorageIpcHandlers(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---- 文件删除 ----
|
// ---- 文件删除 ----
|
||||||
ipcMain.handle(BIDIRECTIONAL.FILE_DELETE, async (_event, params: { relativePath: string }) => {
|
ipcMain.handle(BIDIRECTIONAL_STORAGE.FILE_DELETE, async (_event, params: { relativePath: string }) => {
|
||||||
try {
|
try {
|
||||||
const filePath = resolveSafe(dataDir, params.relativePath);
|
const filePath = resolveSafe(dataDir, params.relativePath);
|
||||||
if (fs.existsSync(filePath)) {
|
if (fs.existsSync(filePath)) {
|
||||||
@@ -118,7 +96,7 @@ export function registerStorageIpcHandlers(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---- 创建目录 ----
|
// ---- 创建目录 ----
|
||||||
ipcMain.handle(BIDIRECTIONAL.MAKE_DIR, async (_event, params: { relativePath: string }) => {
|
ipcMain.handle(BIDIRECTIONAL_STORAGE.MAKE_DIR, async (_event, params: { relativePath: string }) => {
|
||||||
try {
|
try {
|
||||||
const dirPath = resolveSafe(dataDir, params.relativePath);
|
const dirPath = resolveSafe(dataDir, params.relativePath);
|
||||||
ensureDir(dirPath);
|
ensureDir(dirPath);
|
||||||
@@ -130,7 +108,7 @@ export function registerStorageIpcHandlers(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---- 在文件管理器中显示 ----
|
// ---- 在文件管理器中显示 ----
|
||||||
ipcMain.handle(BIDIRECTIONAL.SHOW_ITEM_IN_FOLDER, async (_event, params: { filePath: string }) => {
|
ipcMain.handle(BIDIRECTIONAL_STORAGE.SHOW_ITEM_IN_FOLDER, async (_event, params: { filePath: string }) => {
|
||||||
try {
|
try {
|
||||||
shell.showItemInFolder(params.filePath);
|
shell.showItemInFolder(params.filePath);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -141,7 +119,7 @@ export function registerStorageIpcHandlers(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---- 文件大小 ----
|
// ---- 文件大小 ----
|
||||||
ipcMain.handle(BIDIRECTIONAL.FILE_SIZE, async (_event, params: { relativePath: string }) => {
|
ipcMain.handle(BIDIRECTIONAL_STORAGE.FILE_SIZE, async (_event, params: { relativePath: string }) => {
|
||||||
try {
|
try {
|
||||||
const filePath = resolveSafe(dataDir, params.relativePath);
|
const filePath = resolveSafe(dataDir, params.relativePath);
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
@@ -156,7 +134,7 @@ export function registerStorageIpcHandlers(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---- 文件/目录是否存在 ----
|
// ---- 文件/目录是否存在 ----
|
||||||
ipcMain.handle(BIDIRECTIONAL.FILE_EXISTS, async (_event, params: { relativePath: string }) => {
|
ipcMain.handle(BIDIRECTIONAL_STORAGE.FILE_EXISTS, async (_event, params: { relativePath: string }) => {
|
||||||
try {
|
try {
|
||||||
const filePath = resolveSafe(dataDir, params.relativePath);
|
const filePath = resolveSafe(dataDir, params.relativePath);
|
||||||
return { success: true, data: fs.existsSync(filePath) };
|
return { success: true, data: fs.existsSync(filePath) };
|
||||||
@@ -165,5 +143,83 @@ export function registerStorageIpcHandlers(): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- 绝对路径文件写入(绕过沙箱,用于用户自定义输出目录) ----
|
||||||
|
ipcMain.handle(
|
||||||
|
BIDIRECTIONAL_STORAGE.FILE_WRITE_ABSOLUTE,
|
||||||
|
async (_event, params: { absolutePath: string; data: string; encoding?: 'base64' | 'utf8' }) => {
|
||||||
|
try {
|
||||||
|
ensureDir(path.dirname(params.absolutePath));
|
||||||
|
const buffer = Buffer.from(params.data, params.encoding || 'base64');
|
||||||
|
fs.writeFileSync(params.absolutePath, buffer);
|
||||||
|
logger.info('storage', `绝对路径写入成功:${params.absolutePath}(${buffer.length} bytes)`);
|
||||||
|
return { success: true, data: params.absolutePath };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as Error).message;
|
||||||
|
logger.warn('storage', `绝对路径写入失败:${params.absolutePath}`, err instanceof Error ? err : undefined);
|
||||||
|
return { success: false, error: msg };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- 绝对路径目录创建(绕过沙箱) ----
|
||||||
|
ipcMain.handle(BIDIRECTIONAL_STORAGE.MAKE_DIR_ABSOLUTE, async (_event, params: { absolutePath: string }) => {
|
||||||
|
try {
|
||||||
|
ensureDir(params.absolutePath);
|
||||||
|
return { success: true, data: params.absolutePath };
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('storage', `绝对路径目录创建失败:${params.absolutePath}`, err instanceof Error ? err : undefined);
|
||||||
|
return { success: false, error: (err as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 获取系统下载目录 ----
|
||||||
|
ipcMain.handle(BIDIRECTIONAL_STORAGE.GET_DOWNLOADS_PATH, () => {
|
||||||
|
try {
|
||||||
|
return { success: true, data: app.getPath('downloads') };
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: '无法获取下载目录' };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 主进程下载文件到绝对路径(绕过浏览器 CORS) ----
|
||||||
|
ipcMain.handle(
|
||||||
|
BIDIRECTIONAL_STORAGE.DOWNLOAD_TO_PATH,
|
||||||
|
async (
|
||||||
|
_event,
|
||||||
|
params: { url: string; destPath: string; headers?: Record<string, string> },
|
||||||
|
): Promise<{ success: boolean; data?: string; error?: string }> => {
|
||||||
|
const { url, destPath, headers } = params;
|
||||||
|
try {
|
||||||
|
await downloadToFile(url, destPath, headers);
|
||||||
|
logger.info('storage', `主进程下载成功:${destPath}`);
|
||||||
|
return { success: true, data: destPath };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as Error).message || '下载失败';
|
||||||
|
logger.warn('storage', `主进程下载失败:${url}`, err instanceof Error ? err : undefined);
|
||||||
|
return { success: false, error: msg };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- 生成图片缩略图(sharp 流式管线) ----
|
||||||
|
ipcMain.handle(
|
||||||
|
BIDIRECTIONAL_STORAGE.GENERATE_THUMBNAIL,
|
||||||
|
async (
|
||||||
|
_event,
|
||||||
|
params: { srcPath: string; destPath: string; maxWidth?: number; quality?: number },
|
||||||
|
): Promise<{ success: boolean; data?: string; error?: string }> => {
|
||||||
|
const { srcPath, destPath, maxWidth = 256, quality = 60 } = params;
|
||||||
|
try {
|
||||||
|
await generateThumbnail(srcPath, destPath, maxWidth, quality);
|
||||||
|
logger.info('storage', `缩略图生成成功:${destPath}`);
|
||||||
|
return { success: true, data: destPath };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as Error).message || '缩略图生成失败';
|
||||||
|
logger.warn('storage', `缩略图生成失败:${srcPath}`, err instanceof Error ? err : undefined);
|
||||||
|
return { success: false, error: msg };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
logger.info('app', '存储 IPC 处理器已注册');
|
logger.info('app', '存储 IPC 处理器已注册');
|
||||||
}
|
}
|
||||||
|
|||||||
97
electron/main/ipc/utils/download.ts
Normal file
97
electron/main/ipc/utils/download.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
// ============================================================
|
||||||
|
// download.ts — 文件下载工具函数
|
||||||
|
//
|
||||||
|
// 职责:
|
||||||
|
// - 通过 Electron net 模块下载文件到磁盘
|
||||||
|
// - 支持重定向、超时、自定义请求头
|
||||||
|
//
|
||||||
|
// 设计:
|
||||||
|
// - net 基于 Chromium 网络栈,自动携带浏览器标准头,不受 CORS 限制
|
||||||
|
// - 不能用 netRequest(它 chunk.toString() 会损坏二进制数据)
|
||||||
|
// - 不能用 Node.js https(缺少浏览器头导致 CDN 返回 400)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { net } from 'electron';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { ensureDir } from './path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 Electron net 模块下载文件到磁盘。
|
||||||
|
*
|
||||||
|
* @param url - 下载地址
|
||||||
|
* @param destPath - 目标文件路径
|
||||||
|
* @param headers - 可选的自定义请求头
|
||||||
|
*/
|
||||||
|
export function downloadToFile(
|
||||||
|
url: string,
|
||||||
|
destPath: string,
|
||||||
|
headers?: Record<string, string>,
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = net.request({ method: 'GET', url });
|
||||||
|
|
||||||
|
// 自定义请求头
|
||||||
|
if (headers) {
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
req.setHeader(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超时 120s
|
||||||
|
let timedOut = false;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
req.abort();
|
||||||
|
reject(new Error('下载超时(120s)'));
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
req.on('response', (response) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
const status = response.statusCode;
|
||||||
|
|
||||||
|
// 3xx 重定向
|
||||||
|
if (status >= 300 && status < 400) {
|
||||||
|
const location = response.headers['location']?.[0];
|
||||||
|
if (location && location !== url) {
|
||||||
|
downloadToFile(location, destPath, headers).then(resolve).catch(reject);
|
||||||
|
} else {
|
||||||
|
reject(new Error('重定向循环或缺少 Location 头'));
|
||||||
|
}
|
||||||
|
response.on('data', () => {}); // 消费响应体
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status >= 400) {
|
||||||
|
reject(new Error(`HTTP ${status}`));
|
||||||
|
response.on('data', () => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集二进制 chunks → 写入文件
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
response.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
response.on('end', () => {
|
||||||
|
try {
|
||||||
|
ensureDir(path.dirname(destPath));
|
||||||
|
const buffer = Buffer.concat(chunks);
|
||||||
|
fs.writeFileSync(destPath, buffer);
|
||||||
|
resolve();
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
response.on('error', (err) => {
|
||||||
|
reject(new Error(`响应读取失败: ${err.message}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (err) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (timedOut) return;
|
||||||
|
reject(new Error(`网络请求失败: ${err.message}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
7
electron/main/ipc/utils/index.ts
Normal file
7
electron/main/ipc/utils/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// ============================================================
|
||||||
|
// utils/index.ts — 工具函数汇总导出
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export { resolveSafe, ensureDir } from './path';
|
||||||
|
export { downloadToFile } from './download';
|
||||||
|
export { generateThumbnail } from './thumbnail';
|
||||||
29
electron/main/ipc/utils/path.ts
Normal file
29
electron/main/ipc/utils/path.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// ============================================================
|
||||||
|
// path.ts — 路径安全工具函数
|
||||||
|
//
|
||||||
|
// 职责:
|
||||||
|
// - 路径穿越防护
|
||||||
|
// - 目录创建
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将相对路径解析为绝对路径,并校验是否在 dataDir 内。
|
||||||
|
* 防止路径穿越攻击(../ 穿越)。
|
||||||
|
*/
|
||||||
|
export function resolveSafe(dataDir: string, relativePath: string): string {
|
||||||
|
const resolved = path.resolve(dataDir, relativePath);
|
||||||
|
if (!resolved.startsWith(dataDir + path.sep) && resolved !== dataDir) {
|
||||||
|
throw new Error(`路径越界:${relativePath}`);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确保目录存在(递归创建) */
|
||||||
|
export function ensureDir(dirPath: string): void {
|
||||||
|
if (!fs.existsSync(dirPath)) {
|
||||||
|
fs.mkdirSync(dirPath, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
60
electron/main/ipc/utils/thumbnail.ts
Normal file
60
electron/main/ipc/utils/thumbnail.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// ============================================================
|
||||||
|
// thumbnail.ts — 缩略图生成工具函数
|
||||||
|
//
|
||||||
|
// 职责:
|
||||||
|
// - 使用 sharp 库生成图片缩略图
|
||||||
|
// - 内存优化配置
|
||||||
|
//
|
||||||
|
// 依赖:
|
||||||
|
// - sharp (libvips) - 可选依赖,用于高效图片处理
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { ensureDir } from './path';
|
||||||
|
|
||||||
|
// ---------- sharp/libvips 内存优化 ----------
|
||||||
|
|
||||||
|
// 单块内存上限 128MB(默认 512MB,降低以适配 Electron 单进程内存压力)
|
||||||
|
sharp.cache({ memory: 128, items: 100 });
|
||||||
|
// libvips 并发数 1(减少大图并发处理时的内存峰值)
|
||||||
|
sharp.concurrency(1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成图片缩略图(sharp 流式管线)
|
||||||
|
*
|
||||||
|
* 特性:
|
||||||
|
* - 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<string> {
|
||||||
|
if (!fs.existsSync(srcPath)) {
|
||||||
|
throw new Error('源文件不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureDir(path.dirname(destPath));
|
||||||
|
|
||||||
|
await sharp(srcPath, {
|
||||||
|
animated: false, // 只取第一帧(GIF/APNG 等多帧格式)
|
||||||
|
sequentialRead: true,
|
||||||
|
limitInputPixels: 268402689,
|
||||||
|
})
|
||||||
|
.resize({ width: maxWidth, withoutEnlargement: true, fit: 'inside' })
|
||||||
|
.jpeg({ quality, mozjpeg: true }) // mozjpeg 压缩率更优
|
||||||
|
.toFile(destPath);
|
||||||
|
|
||||||
|
return destPath;
|
||||||
|
}
|
||||||
@@ -212,19 +212,19 @@ export async function request<T = unknown>(
|
|||||||
export const netRequest = {
|
export const netRequest = {
|
||||||
request,
|
request,
|
||||||
|
|
||||||
get<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
async get<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||||||
return request<T>(url, { ...config, method: 'GET' });
|
return await request<T>(url, { ...config, method: 'GET' });
|
||||||
},
|
},
|
||||||
|
|
||||||
post<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
async post<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||||||
return request<T>(url, { ...config, method: 'POST', data });
|
return request<T>(url, { ...config, method: 'POST', data });
|
||||||
},
|
},
|
||||||
|
|
||||||
put<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
async put<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||||||
return request<T>(url, { ...config, method: 'PUT', data });
|
return request<T>(url, { ...config, method: 'PUT', data });
|
||||||
},
|
},
|
||||||
|
|
||||||
del<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
async del<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||||||
return request<T>(url, { ...config, method: 'DELETE' });
|
return request<T>(url, { ...config, method: 'DELETE' });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,486 +0,0 @@
|
|||||||
// 自动更新 — HeiXiuProvider + electron-updater 增量下载
|
|
||||||
// 流程图:调用自有 API → 获取版本信息 → electron-updater 差分下载 → SHA512 校验 → 安装
|
|
||||||
|
|
||||||
import {app, BrowserWindow, ipcMain} from 'electron';
|
|
||||||
import {createHash} from 'node:crypto';
|
|
||||||
import {URL} from 'node:url';
|
|
||||||
|
|
||||||
import {
|
|
||||||
NsisUpdater,
|
|
||||||
MacUpdater,
|
|
||||||
AppImageUpdater,
|
|
||||||
Provider,
|
|
||||||
} from 'electron-updater';
|
|
||||||
import type {ProviderRuntimeOptions} from 'electron-updater/out/providers/Provider';
|
|
||||||
import type {UpdateInfo, UpdateFileInfo} from 'builder-util-runtime';
|
|
||||||
import type {ResolvedUpdateFileInfo} from 'electron-updater/out/types';
|
|
||||||
|
|
||||||
import type {UpdateCheckResult} from '../../shared/types';
|
|
||||||
import {APP_VERSION} from '../../shared/constants/version';
|
|
||||||
import {logger} from './logger';
|
|
||||||
import {netRequest} from './net-request';
|
|
||||||
|
|
||||||
// ---------- 构建时注入的渠道信息 ----------
|
|
||||||
|
|
||||||
/** 构建渠道(由 build.mjs 通过 VITE_BUILD_CHANNEL 注入,Vite 编译时内联) */
|
|
||||||
const BUILD_CHANNEL: string =
|
|
||||||
(typeof import.meta !== 'undefined' && (import.meta as Record<string, unknown>).env
|
|
||||||
? ((import.meta as Record<string, unknown>).env as Record<string, string>).VITE_BUILD_CHANNEL
|
|
||||||
: undefined)
|
|
||||||
|| process.env.VITE_BUILD_CHANNEL
|
|
||||||
|| 'stable';
|
|
||||||
|
|
||||||
/** 构建版本类型(由 build.mjs 通过 VITE_BUILD_EDITION 注入) */
|
|
||||||
const BUILD_EDITION: string =
|
|
||||||
(typeof import.meta !== 'undefined' && (import.meta as Record<string, unknown>).env
|
|
||||||
? ((import.meta as Record<string, unknown>).env as Record<string, string>).VITE_BUILD_EDITION
|
|
||||||
: undefined)
|
|
||||||
|| process.env.VITE_BUILD_EDITION
|
|
||||||
|| 'personal';
|
|
||||||
|
|
||||||
// ---------- 设备指纹(稳定的机器标识,用于灰度分组)----------
|
|
||||||
|
|
||||||
let _deviceFingerprint: string | null = null;
|
|
||||||
|
|
||||||
/** 获取设备指纹(基于机器特定信息的 SHA-256 前 16 位) */
|
|
||||||
function getDeviceFingerprint(): string {
|
|
||||||
if (_deviceFingerprint) return _deviceFingerprint;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parts = [
|
|
||||||
process.platform,
|
|
||||||
process.arch,
|
|
||||||
app.getPath('home'),
|
|
||||||
];
|
|
||||||
_deviceFingerprint = createHash('sha256')
|
|
||||||
.update(parts.join('|'))
|
|
||||||
.digest('hex')
|
|
||||||
.slice(0, 16);
|
|
||||||
} catch {
|
|
||||||
_deviceFingerprint = 'unknown-device';
|
|
||||||
}
|
|
||||||
|
|
||||||
return _deviceFingerprint;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- IPC 通道(对渲染进程暴露,与旧版兼容)----------
|
|
||||||
|
|
||||||
export const UPDATE_CHANNELS = {
|
|
||||||
UPDATE_AVAILABLE: 'update-available',
|
|
||||||
UPDATE_PROGRESS: 'update-progress',
|
|
||||||
UPDATE_DOWNLOADED: 'update-downloaded',
|
|
||||||
UPDATE_ERROR: 'update-error',
|
|
||||||
UPDATE_NOT_AVAILABLE: 'update-not-available',
|
|
||||||
INSTALL_UPDATE: 'install-update',
|
|
||||||
CHECK_FOR_UPDATE: 'check-for-update',
|
|
||||||
DOWNLOAD_UPDATE: 'download-update',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// ---------- 配置 ----------
|
|
||||||
|
|
||||||
function getApiBaseUrl(): string {
|
|
||||||
const url = (process.env.UPDATE_API_URL || 'https://www.heixiu.com').trim();
|
|
||||||
console.log(`[updater] UPDATE_API_URL=${process.env.UPDATE_API_URL} → 使用: ${url}`);
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 自定义 Provider
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
class HeiXiuProvider extends Provider<UpdateInfo> {
|
|
||||||
private apiBaseUrl: string;
|
|
||||||
/** 短时缓存:避免 checkForUpdates 预检 + electron-updater 内部调用导致两次 API 请求 */
|
|
||||||
private _cachedResult: { data: UpdateInfo; ts: number } | null = null;
|
|
||||||
|
|
||||||
constructor(runtimeOptions: ProviderRuntimeOptions) {
|
|
||||||
super(runtimeOptions);
|
|
||||||
this.apiBaseUrl = getApiBaseUrl();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 调用自有版本检查 API,返回 electron-updater 格式的 UpdateInfo。
|
|
||||||
* blockMapSize > 0 时 electron-updater 自动启用差分下载。
|
|
||||||
* 无更新时返回当前版本号(APP_VERSION)。
|
|
||||||
*/
|
|
||||||
async getLatestVersion(): Promise<UpdateInfo> {
|
|
||||||
// 短时缓存(500ms):预检和 electron-updater 内部调用共享同一结果
|
|
||||||
if (this._cachedResult && Date.now() - this._cachedResult.ts < 500) {
|
|
||||||
return this._cachedResult.data;
|
|
||||||
}
|
|
||||||
const data = await this._doGetLatestVersion();
|
|
||||||
this._cachedResult = { data, ts: Date.now() };
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _doGetLatestVersion(): Promise<UpdateInfo> {
|
|
||||||
const {data} = await netRequest.get<{ code: number; data?: UpdateCheckResult } & UpdateCheckResult>(
|
|
||||||
'/api/v1/update/check',
|
|
||||||
{
|
|
||||||
baseURL: this.apiBaseUrl,
|
|
||||||
params: {
|
|
||||||
platform: process.platform,
|
|
||||||
version: APP_VERSION,
|
|
||||||
channel: BUILD_CHANNEL,
|
|
||||||
edition: BUILD_EDITION,
|
|
||||||
deviceFingerprint: getDeviceFingerprint(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// 兼容两种 API 响应格式
|
|
||||||
const result: UpdateCheckResult =
|
|
||||||
(data as { data?: UpdateCheckResult }).data ?? (data as UpdateCheckResult);
|
|
||||||
|
|
||||||
// 无更新 → 返回当前版本号
|
|
||||||
if (!result.hasUpdate) {
|
|
||||||
return {
|
|
||||||
version: APP_VERSION,
|
|
||||||
files: [],
|
|
||||||
path: '',
|
|
||||||
sha512: '',
|
|
||||||
releaseDate: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const file: UpdateFileInfo = {
|
|
||||||
url: result.downloadUrl,
|
|
||||||
sha512: result.sha512,
|
|
||||||
size: result.fileSize,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 后端 API 返回 blockMapSize 时启用增量更新
|
|
||||||
if (result.blockMapSize) {
|
|
||||||
file.blockMapSize = result.blockMapSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('updater', 'Provider 获取到新版本', {
|
|
||||||
latestVersion: result.latestVersion,
|
|
||||||
hasBlockmap: file.blockMapSize != null,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
version: result.latestVersion,
|
|
||||||
files: [file],
|
|
||||||
path: result.downloadUrl,
|
|
||||||
sha512: result.sha512,
|
|
||||||
releaseDate: result.releaseDate,
|
|
||||||
releaseNotes: result.changelog,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将 UpdateInfo 中的相对路径解析为完整的下载 URL。
|
|
||||||
* electron-updater 在下载前调用此方法。
|
|
||||||
*/
|
|
||||||
resolveFiles(updateInfo: UpdateInfo): Array<ResolvedUpdateFileInfo> {
|
|
||||||
return updateInfo.files.map((f) => {
|
|
||||||
// 使用 downloadUrl 作为 base URL 来解析文件路径
|
|
||||||
const baseUrl = new URL(updateInfo.path);
|
|
||||||
const fileUrl = new URL(f.url, baseUrl.origin);
|
|
||||||
return {
|
|
||||||
url: fileUrl,
|
|
||||||
info: f,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 平台特定 Updater(注入自定义 Provider)
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 覆盖 getUpdateInfoAndProvider() 以注入 HeiXiuProvider。
|
|
||||||
* 其他行为(NSIS 安装、签名校验等)完全由父类处理。
|
|
||||||
*/
|
|
||||||
class HeiXiuNsisUpdater extends NsisUpdater {
|
|
||||||
private heiXiuProvider: HeiXiuProvider;
|
|
||||||
|
|
||||||
constructor(heiXiuProvider: HeiXiuProvider) {
|
|
||||||
// 传 undefined 跳过内置 Provider 创建,由我们自己注入
|
|
||||||
super(undefined);
|
|
||||||
this.heiXiuProvider = heiXiuProvider;
|
|
||||||
this.autoDownload = false; // 由我们手动控制流程,保持 IPC 兼容
|
|
||||||
this.autoInstallOnAppQuit = false;
|
|
||||||
this.forceDevUpdateConfig = true; // 绕过 electron-updater 的 dev 模式检查
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal — 注入自定义 Provider */
|
|
||||||
protected override async getUpdateInfoAndProvider(): Promise<{
|
|
||||||
info: UpdateInfo;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
provider: Provider<any>;
|
|
||||||
}> {
|
|
||||||
const info = await this.heiXiuProvider.getLatestVersion();
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
return {info, provider: this.heiXiuProvider as Provider<any>};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class HeiXiuMacUpdater extends MacUpdater {
|
|
||||||
private heiXiuProvider: HeiXiuProvider;
|
|
||||||
|
|
||||||
constructor(heiXiuProvider: HeiXiuProvider) {
|
|
||||||
super(undefined);
|
|
||||||
this.heiXiuProvider = heiXiuProvider;
|
|
||||||
this.autoDownload = false;
|
|
||||||
this.autoInstallOnAppQuit = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async getUpdateInfoAndProvider(): Promise<{
|
|
||||||
info: UpdateInfo;
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
provider: Provider<any>;
|
|
||||||
}> {
|
|
||||||
const info = await this.heiXiuProvider.getLatestVersion();
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
return {info, provider: this.heiXiuProvider as Provider<any>};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class HeiXiuAppImageUpdater extends AppImageUpdater {
|
|
||||||
private heiXiuProvider: HeiXiuProvider;
|
|
||||||
|
|
||||||
constructor(heiXiuProvider: HeiXiuProvider) {
|
|
||||||
super(null);
|
|
||||||
this.heiXiuProvider = heiXiuProvider;
|
|
||||||
this.autoDownload = false;
|
|
||||||
this.autoInstallOnAppQuit = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async getUpdateInfoAndProvider(): Promise<{
|
|
||||||
info: UpdateInfo;
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
provider: Provider<any>;
|
|
||||||
}> {
|
|
||||||
const info = await this.heiXiuProvider.getLatestVersion();
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
return {info, provider: this.heiXiuProvider as Provider<any>};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 工厂:按平台创建对应的 Updater 实例 */
|
|
||||||
function createPlatformUpdater(provider: HeiXiuProvider) {
|
|
||||||
switch (process.platform) {
|
|
||||||
case 'win32':
|
|
||||||
return new HeiXiuNsisUpdater(provider);
|
|
||||||
case 'darwin':
|
|
||||||
return new HeiXiuMacUpdater(provider);
|
|
||||||
default:
|
|
||||||
return new HeiXiuAppImageUpdater(provider);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 状态 ----------
|
|
||||||
|
|
||||||
let updateWindow: BrowserWindow | null = null;
|
|
||||||
let provider: HeiXiuProvider | null = null;
|
|
||||||
let platformUpdater: NsisUpdater | MacUpdater | AppImageUpdater | null = null;
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 初始化
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export function initUpdater(mainWindow: BrowserWindow): void {
|
|
||||||
updateWindow = mainWindow;
|
|
||||||
|
|
||||||
console.log('[updater] initUpdater 被调用');
|
|
||||||
console.log(`[updater] API URL: ${getApiBaseUrl()}`);
|
|
||||||
console.log(`[updater] 渠道: ${BUILD_CHANNEL}`);
|
|
||||||
console.log(`[updater] 版本类型: ${BUILD_EDITION}`);
|
|
||||||
console.log(`[updater] 当前版本: ${APP_VERSION}`);
|
|
||||||
console.log(`[updater] 设备指纹: ${getDeviceFingerprint()}`);
|
|
||||||
|
|
||||||
// if (import.meta.env.DEV) {
|
|
||||||
// logger.info('updater', '开发模式,跳过自动检查更新', { apiUrl: getApiBaseUrl() });
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// 创建自定义 Provider
|
|
||||||
provider = new HeiXiuProvider({
|
|
||||||
isUseMultipleRangeRequest: true,
|
|
||||||
platform: process.platform as ProviderRuntimeOptions['platform'],
|
|
||||||
executor: null as unknown as ProviderRuntimeOptions['executor'],
|
|
||||||
});
|
|
||||||
|
|
||||||
// 创建平台 Updater
|
|
||||||
platformUpdater = createPlatformUpdater(provider);
|
|
||||||
|
|
||||||
// 将 electron-updater 事件桥接到现有 IPC 通道
|
|
||||||
platformUpdater.on('checking-for-update', () => {
|
|
||||||
logger.debug('updater', '正在检查更新');
|
|
||||||
});
|
|
||||||
|
|
||||||
platformUpdater.on('update-available', (info: UpdateInfo) => {
|
|
||||||
// releaseNotes 可能是 string | ReleaseNoteInfo[] | null,统一转为 string
|
|
||||||
let notes = '';
|
|
||||||
if (typeof info.releaseNotes === 'string') {
|
|
||||||
notes = info.releaseNotes;
|
|
||||||
} else if (Array.isArray(info.releaseNotes)) {
|
|
||||||
notes = info.releaseNotes.map((n) => n.note).filter(Boolean).join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_AVAILABLE, {
|
|
||||||
version: info.version,
|
|
||||||
releaseDate: info.releaseDate,
|
|
||||||
releaseNotes: notes,
|
|
||||||
forceUpdate: false,
|
|
||||||
});
|
|
||||||
logger.info('updater', '发现新版本', {
|
|
||||||
version: info.version,
|
|
||||||
releaseNotes: notes ? `${notes.slice(0, 50)}...` : '(空)',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
platformUpdater.on('update-not-available', (info: UpdateInfo) => {
|
|
||||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_NOT_AVAILABLE);
|
|
||||||
logger.info('updater', '当前已是最新版本', {currentVersion: info.version});
|
|
||||||
});
|
|
||||||
|
|
||||||
platformUpdater.on('download-progress', (progress: {
|
|
||||||
percent: number;
|
|
||||||
bytesPerSecond: number;
|
|
||||||
transferred: number;
|
|
||||||
total: number
|
|
||||||
}) => {
|
|
||||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_PROGRESS, {
|
|
||||||
percent: Math.round(progress.percent),
|
|
||||||
bytesPerSecond: progress.bytesPerSecond,
|
|
||||||
transferred: progress.transferred,
|
|
||||||
total: progress.total,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
platformUpdater.on('update-downloaded', () => {
|
|
||||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_DOWNLOADED);
|
|
||||||
logger.info('updater', '更新已下载完成');
|
|
||||||
});
|
|
||||||
|
|
||||||
platformUpdater.on('error', (error: Error) => {
|
|
||||||
logger.error('updater', '更新流程出错', error);
|
|
||||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
|
|
||||||
message: error.message || '更新失败',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 启动 5 秒后静默检查
|
|
||||||
setTimeout(() => {
|
|
||||||
checkForUpdates();
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 操作方法
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
let updateChecked = false;
|
|
||||||
|
|
||||||
/** 静默检查更新(自动下载) */
|
|
||||||
export async function checkForUpdates(): Promise<void> {
|
|
||||||
if (updateChecked) {
|
|
||||||
logger.debug('updater', '已检查过更新,跳过');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!platformUpdater || !provider) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
updateChecked = true;
|
|
||||||
|
|
||||||
// ① 先自行检查有无更新(不经过 electron-updater)
|
|
||||||
const info = await provider.getLatestVersion();
|
|
||||||
if (info.version === APP_VERSION) {
|
|
||||||
// 无更新 → 直接通知 UI,不触发 electron-updater 的任何事件
|
|
||||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_NOT_AVAILABLE);
|
|
||||||
logger.debug('updater', '当前已是最新版本', {version: APP_VERSION});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ② 有新版本 → 交给 electron-updater 触发 'update-available' 事件(不自动下载)
|
|
||||||
await platformUpdater.checkForUpdates();
|
|
||||||
} catch (error) {
|
|
||||||
updateChecked = false;
|
|
||||||
logger.error('updater', '更新流程失败', error as Error, {apiUrl: getApiBaseUrl()});
|
|
||||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
|
|
||||||
message: (error as Error).message || '更新检查失败',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 手动检查更新(用户点击触发) */
|
|
||||||
export async function checkForUpdatesManual(): Promise<void> {
|
|
||||||
if (!platformUpdater || !provider) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
updateChecked = false;
|
|
||||||
|
|
||||||
// ① 先自行检查有无更新
|
|
||||||
const info = await provider.getLatestVersion();
|
|
||||||
if (info.version === APP_VERSION) {
|
|
||||||
// 无更新 → 直接通知 UI
|
|
||||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_NOT_AVAILABLE);
|
|
||||||
logger.debug('updater', '手动检查:当前已是最新版本', {version: APP_VERSION});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ② 有新版本 → 交给 electron-updater 触发 'update-available' 事件(不自动下载)
|
|
||||||
await platformUpdater.checkForUpdates();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('updater', '手动更新失败', error as Error);
|
|
||||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
|
|
||||||
message: (error as Error).message || '检查更新失败',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 用户确认后开始下载更新 */
|
|
||||||
export async function downloadUpdate(): Promise<void> {
|
|
||||||
if (!platformUpdater) {
|
|
||||||
logger.warn('updater', 'downloadUpdate: Updater 未初始化');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await platformUpdater.downloadUpdate();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('updater', '下载更新失败', error as Error);
|
|
||||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
|
|
||||||
message: (error as Error).message || '下载更新失败',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 安装已下载的更新并退出应用 */
|
|
||||||
export function installUpdateNow(): void {
|
|
||||||
if (!platformUpdater) {
|
|
||||||
logger.warn('updater', 'Updater 未初始化');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('updater', '开始安装更新并退出应用');
|
|
||||||
setImmediate(() => {
|
|
||||||
platformUpdater!.quitAndInstall(false, true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// IPC 注册
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export function registerUpdateIpcHandlers(): void {
|
|
||||||
ipcMain.handle(UPDATE_CHANNELS.CHECK_FOR_UPDATE, async () => {
|
|
||||||
await checkForUpdatesManual();
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle(UPDATE_CHANNELS.DOWNLOAD_UPDATE, async () => {
|
|
||||||
await downloadUpdate();
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.on(UPDATE_CHANNELS.INSTALL_UPDATE, () => {
|
|
||||||
installUpdateNow();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
219
electron/main/updater/index.ts
Normal file
219
electron/main/updater/index.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
// ============================================================
|
||||||
|
// updater/index.ts — 自动更新主入口
|
||||||
|
//
|
||||||
|
// 流程图:调用自有 API → 获取版本信息 → electron-updater 差分下载 → SHA512 校验 → 安装
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { BrowserWindow, ipcMain } from 'electron';
|
||||||
|
import type { NsisUpdater, MacUpdater, AppImageUpdater } from 'electron-updater';
|
||||||
|
import type { UpdateInfo } from 'builder-util-runtime';
|
||||||
|
|
||||||
|
import { APP_VERSION } from '../../../shared/constants/version';
|
||||||
|
import { MAIN_TO_RENDERER_UPDATER, RENDERER_TO_MAIN_UPDATER } from '../../../shared/constants/ipc/updater';
|
||||||
|
import { logger } from '../logger';
|
||||||
|
import { HeiXiuProvider, getApiBaseUrl, getDeviceFingerprint, BUILD_CHANNEL, BUILD_EDITION } from './provider';
|
||||||
|
import { createPlatformUpdater } from './platform-updaters';
|
||||||
|
|
||||||
|
// ---------- 状态 ----------
|
||||||
|
|
||||||
|
let updateWindow: BrowserWindow | null = null;
|
||||||
|
let provider: HeiXiuProvider | null = null;
|
||||||
|
let platformUpdater: NsisUpdater | MacUpdater | AppImageUpdater | null = null;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 初始化
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function initUpdater(mainWindow: BrowserWindow): void {
|
||||||
|
updateWindow = mainWindow;
|
||||||
|
|
||||||
|
console.log('[updater] initUpdater 被调用');
|
||||||
|
console.log(`[updater] API URL: ${getApiBaseUrl()}`);
|
||||||
|
console.log(`[updater] 渠道: ${BUILD_CHANNEL}`);
|
||||||
|
console.log(`[updater] 版本类型: ${BUILD_EDITION}`);
|
||||||
|
console.log(`[updater] 当前版本: ${APP_VERSION}`);
|
||||||
|
console.log(`[updater] 设备指纹: ${getDeviceFingerprint()}`);
|
||||||
|
|
||||||
|
// 创建自定义 Provider
|
||||||
|
provider = new HeiXiuProvider({
|
||||||
|
isUseMultipleRangeRequest: true,
|
||||||
|
platform: process.platform as 'win32' | 'darwin' | 'linux',
|
||||||
|
executor: null as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建平台 Updater
|
||||||
|
platformUpdater = createPlatformUpdater(provider);
|
||||||
|
|
||||||
|
// 将 electron-updater 事件桥接到现有 IPC 通道
|
||||||
|
platformUpdater.on('checking-for-update', () => {
|
||||||
|
logger.debug('updater', '正在检查更新');
|
||||||
|
});
|
||||||
|
|
||||||
|
platformUpdater.on('update-available', (info: UpdateInfo) => {
|
||||||
|
// releaseNotes 可能是 string | ReleaseNoteInfo[] | null,统一转为 string
|
||||||
|
let notes = '';
|
||||||
|
if (typeof info.releaseNotes === 'string') {
|
||||||
|
notes = info.releaseNotes;
|
||||||
|
} else if (Array.isArray(info.releaseNotes)) {
|
||||||
|
notes = info.releaseNotes.map((n) => n.note).filter(Boolean).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_AVAILABLE, {
|
||||||
|
version: info.version,
|
||||||
|
releaseDate: info.releaseDate,
|
||||||
|
releaseNotes: notes,
|
||||||
|
forceUpdate: false,
|
||||||
|
});
|
||||||
|
logger.info('updater', '发现新版本', {
|
||||||
|
version: info.version,
|
||||||
|
releaseNotes: notes ? `${notes.slice(0, 50)}...` : '(空)',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
platformUpdater.on('update-not-available', (info: UpdateInfo) => {
|
||||||
|
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_NOT_AVAILABLE);
|
||||||
|
logger.info('updater', '当前已是最新版本', { currentVersion: info.version });
|
||||||
|
});
|
||||||
|
|
||||||
|
platformUpdater.on('download-progress', (progress: {
|
||||||
|
percent: number;
|
||||||
|
bytesPerSecond: number;
|
||||||
|
transferred: number;
|
||||||
|
total: number;
|
||||||
|
}) => {
|
||||||
|
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_PROGRESS, {
|
||||||
|
percent: Math.round(progress.percent),
|
||||||
|
bytesPerSecond: progress.bytesPerSecond,
|
||||||
|
transferred: progress.transferred,
|
||||||
|
total: progress.total,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
platformUpdater.on('update-downloaded', () => {
|
||||||
|
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_DOWNLOADED);
|
||||||
|
logger.info('updater', '更新已下载完成');
|
||||||
|
});
|
||||||
|
|
||||||
|
platformUpdater.on('error', (error: Error) => {
|
||||||
|
logger.error('updater', '更新流程出错', error);
|
||||||
|
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_ERROR, {
|
||||||
|
message: error.message || '更新失败',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 启动 5 秒后静默检查
|
||||||
|
setTimeout(() => {
|
||||||
|
checkForUpdates();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 操作方法
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
let updateChecked = false;
|
||||||
|
|
||||||
|
/** 静默检查更新(自动下载) */
|
||||||
|
export async function checkForUpdates(): Promise<void> {
|
||||||
|
if (updateChecked) {
|
||||||
|
logger.debug('updater', '已检查过更新,跳过');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!platformUpdater || !provider) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateChecked = true;
|
||||||
|
|
||||||
|
// ① 先自行检查有无更新(不经过 electron-updater)
|
||||||
|
const info = await provider.getLatestVersion();
|
||||||
|
if (info.version === APP_VERSION) {
|
||||||
|
// 无更新 → 直接通知 UI,不触发 electron-updater 的任何事件
|
||||||
|
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_NOT_AVAILABLE);
|
||||||
|
logger.debug('updater', '当前已是最新版本', { version: APP_VERSION });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 有新版本 → 交给 electron-updater 触发 'update-available' 事件(不自动下载)
|
||||||
|
await platformUpdater.checkForUpdates();
|
||||||
|
} catch (error) {
|
||||||
|
updateChecked = false;
|
||||||
|
logger.error('updater', '更新流程失败', error as Error, { apiUrl: getApiBaseUrl() });
|
||||||
|
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_ERROR, {
|
||||||
|
message: (error as Error).message || '更新检查失败',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手动检查更新(用户点击触发) */
|
||||||
|
export async function checkForUpdatesManual(): Promise<void> {
|
||||||
|
if (!platformUpdater || !provider) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateChecked = false;
|
||||||
|
|
||||||
|
// ① 先自行检查有无更新
|
||||||
|
const info = await provider.getLatestVersion();
|
||||||
|
if (info.version === APP_VERSION) {
|
||||||
|
// 无更新 → 直接通知 UI
|
||||||
|
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_NOT_AVAILABLE);
|
||||||
|
logger.debug('updater', '手动检查:当前已是最新版本', { version: APP_VERSION });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 有新版本 → 交给 electron-updater 触发 'update-available' 事件(不自动下载)
|
||||||
|
await platformUpdater.checkForUpdates();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('updater', '手动更新失败', error as Error);
|
||||||
|
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_ERROR, {
|
||||||
|
message: (error as Error).message || '检查更新失败',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户确认后开始下载更新 */
|
||||||
|
export async function downloadUpdate(): Promise<void> {
|
||||||
|
if (!platformUpdater) {
|
||||||
|
logger.warn('updater', 'downloadUpdate: Updater 未初始化');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await platformUpdater.downloadUpdate();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('updater', '下载更新失败', error as Error);
|
||||||
|
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_ERROR, {
|
||||||
|
message: (error as Error).message || '下载更新失败',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 安装已下载的更新并退出应用 */
|
||||||
|
export function installUpdateNow(): void {
|
||||||
|
if (!platformUpdater) {
|
||||||
|
logger.warn('updater', 'Updater 未初始化');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('updater', '开始安装更新并退出应用');
|
||||||
|
setImmediate(() => {
|
||||||
|
platformUpdater!.quitAndInstall(false, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// IPC 注册
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function registerUpdateIpcHandlers(): void {
|
||||||
|
ipcMain.handle(RENDERER_TO_MAIN_UPDATER.CHECK_FOR_UPDATE, async () => {
|
||||||
|
await checkForUpdatesManual();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(RENDERER_TO_MAIN_UPDATER.DOWNLOAD_UPDATE, async () => {
|
||||||
|
await downloadUpdate();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on(RENDERER_TO_MAIN_UPDATER.INSTALL_UPDATE, () => {
|
||||||
|
installUpdateNow();
|
||||||
|
});
|
||||||
|
}
|
||||||
102
electron/main/updater/platform-updaters.ts
Normal file
102
electron/main/updater/platform-updaters.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
// ============================================================
|
||||||
|
// platform-updaters.ts — 平台特定 Updater
|
||||||
|
//
|
||||||
|
// 覆盖 getUpdateInfoAndProvider() 以注入 HeiXiuProvider。
|
||||||
|
// 其他行为(NSIS 安装、签名校验等)完全由父类处理。
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import {
|
||||||
|
NsisUpdater,
|
||||||
|
MacUpdater,
|
||||||
|
AppImageUpdater,
|
||||||
|
Provider,
|
||||||
|
} from 'electron-updater';
|
||||||
|
import type { UpdateInfo } from 'builder-util-runtime';
|
||||||
|
|
||||||
|
import type { HeiXiuProvider } from './provider';
|
||||||
|
|
||||||
|
// ---------- Windows NSIS ----------
|
||||||
|
|
||||||
|
export class HeiXiuNsisUpdater extends NsisUpdater {
|
||||||
|
private heiXiuProvider: HeiXiuProvider;
|
||||||
|
|
||||||
|
constructor(heiXiuProvider: HeiXiuProvider) {
|
||||||
|
// 传 undefined 跳过内置 Provider 创建,由我们自己注入
|
||||||
|
super(undefined);
|
||||||
|
this.heiXiuProvider = heiXiuProvider;
|
||||||
|
this.autoDownload = false; // 由我们手动控制流程,保持 IPC 兼容
|
||||||
|
this.autoInstallOnAppQuit = false;
|
||||||
|
this.forceDevUpdateConfig = true; // 绕过 electron-updater 的 dev 模式检查
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal — 注入自定义 Provider */
|
||||||
|
protected override async getUpdateInfoAndProvider(): Promise<{
|
||||||
|
info: UpdateInfo;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
provider: Provider<any>;
|
||||||
|
}> {
|
||||||
|
const info = await this.heiXiuProvider.getLatestVersion();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return { info, provider: this.heiXiuProvider as Provider<any> };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- macOS ----------
|
||||||
|
|
||||||
|
export class HeiXiuMacUpdater extends MacUpdater {
|
||||||
|
private heiXiuProvider: HeiXiuProvider;
|
||||||
|
|
||||||
|
constructor(heiXiuProvider: HeiXiuProvider) {
|
||||||
|
super(undefined);
|
||||||
|
this.heiXiuProvider = heiXiuProvider;
|
||||||
|
this.autoDownload = false;
|
||||||
|
this.autoInstallOnAppQuit = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async getUpdateInfoAndProvider(): Promise<{
|
||||||
|
info: UpdateInfo;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
provider: Provider<any>;
|
||||||
|
}> {
|
||||||
|
const info = await this.heiXiuProvider.getLatestVersion();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return { info, provider: this.heiXiuProvider as Provider<any> };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Linux AppImage ----------
|
||||||
|
|
||||||
|
export class HeiXiuAppImageUpdater extends AppImageUpdater {
|
||||||
|
private heiXiuProvider: HeiXiuProvider;
|
||||||
|
|
||||||
|
constructor(heiXiuProvider: HeiXiuProvider) {
|
||||||
|
super(null);
|
||||||
|
this.heiXiuProvider = heiXiuProvider;
|
||||||
|
this.autoDownload = false;
|
||||||
|
this.autoInstallOnAppQuit = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async getUpdateInfoAndProvider(): Promise<{
|
||||||
|
info: UpdateInfo;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
provider: Provider<any>;
|
||||||
|
}> {
|
||||||
|
const info = await this.heiXiuProvider.getLatestVersion();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return { info, provider: this.heiXiuProvider as Provider<any> };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 工厂函数 ----------
|
||||||
|
|
||||||
|
/** 工厂:按平台创建对应的 Updater 实例 */
|
||||||
|
export function createPlatformUpdater(provider: HeiXiuProvider) {
|
||||||
|
switch (process.platform) {
|
||||||
|
case 'win32':
|
||||||
|
return new HeiXiuNsisUpdater(provider);
|
||||||
|
case 'darwin':
|
||||||
|
return new HeiXiuMacUpdater(provider);
|
||||||
|
default:
|
||||||
|
return new HeiXiuAppImageUpdater(provider);
|
||||||
|
}
|
||||||
|
}
|
||||||
172
electron/main/updater/provider.ts
Normal file
172
electron/main/updater/provider.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
// ============================================================
|
||||||
|
// provider.ts — 自定义更新 Provider
|
||||||
|
//
|
||||||
|
// 封装与自有版本检查 API 的交互逻辑,
|
||||||
|
// 返回 electron-updater 格式的 UpdateInfo。
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { app } from 'electron';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { URL } from 'node:url';
|
||||||
|
import { Provider } from 'electron-updater';
|
||||||
|
import type { ProviderRuntimeOptions } from 'electron-updater/out/providers/Provider';
|
||||||
|
import type { UpdateInfo, UpdateFileInfo } from 'builder-util-runtime';
|
||||||
|
import type { ResolvedUpdateFileInfo } from 'electron-updater/out/types';
|
||||||
|
|
||||||
|
import type { UpdateCheckResult } from '../../../shared/types';
|
||||||
|
import { APP_VERSION } from '../../../shared/constants/version';
|
||||||
|
import { logger } from '../logger';
|
||||||
|
import { netRequest } from '../net-request';
|
||||||
|
|
||||||
|
// ---------- 构建时注入的渠道信息 ----------
|
||||||
|
|
||||||
|
/** 构建渠道(由 build.mjs 通过 VITE_BUILD_CHANNEL 注入,Vite 编译时内联) */
|
||||||
|
export const BUILD_CHANNEL: string =
|
||||||
|
(typeof import.meta !== 'undefined' && (import.meta as unknown as Record<string, unknown>).env
|
||||||
|
? ((import.meta as unknown as Record<string, unknown>).env as Record<string, string>).VITE_BUILD_CHANNEL
|
||||||
|
: undefined)
|
||||||
|
|| process.env.VITE_BUILD_CHANNEL
|
||||||
|
|| 'stable';
|
||||||
|
|
||||||
|
/** 构建版本类型(由 build.mjs 通过 VITE_BUILD_EDITION 注入) */
|
||||||
|
export const BUILD_EDITION: string =
|
||||||
|
(typeof import.meta !== 'undefined' && (import.meta as unknown as Record<string, unknown>).env
|
||||||
|
? ((import.meta as unknown as Record<string, unknown>).env as Record<string, string>).VITE_BUILD_EDITION
|
||||||
|
: undefined)
|
||||||
|
|| process.env.VITE_BUILD_EDITION
|
||||||
|
|| 'personal';
|
||||||
|
|
||||||
|
// ---------- 设备指纹(稳定的机器标识,用于灰度分组)----------
|
||||||
|
|
||||||
|
let _deviceFingerprint: string | null = null;
|
||||||
|
|
||||||
|
/** 获取设备指纹(基于机器特定信息的 SHA-256 前 16 位) */
|
||||||
|
export function getDeviceFingerprint(): string {
|
||||||
|
if (_deviceFingerprint) return _deviceFingerprint;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parts = [
|
||||||
|
process.platform,
|
||||||
|
process.arch,
|
||||||
|
app.getPath('home'),
|
||||||
|
];
|
||||||
|
_deviceFingerprint = createHash('sha256')
|
||||||
|
.update(parts.join('|'))
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 16);
|
||||||
|
} catch {
|
||||||
|
_deviceFingerprint = 'unknown-device';
|
||||||
|
}
|
||||||
|
|
||||||
|
return _deviceFingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 配置 ----------
|
||||||
|
|
||||||
|
export function getApiBaseUrl(): string {
|
||||||
|
const url = (process.env.UPDATE_API_URL || 'https://www.heixiu.com').trim();
|
||||||
|
console.log(`[updater] UPDATE_API_URL=${process.env.UPDATE_API_URL} → 使用: ${url}`);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// HeiXiuProvider
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export class HeiXiuProvider extends Provider<UpdateInfo> {
|
||||||
|
private apiBaseUrl: string;
|
||||||
|
/** 短时缓存:避免 checkForUpdates 预检 + electron-updater 内部调用导致两次 API 请求 */
|
||||||
|
private _cachedResult: { data: UpdateInfo; ts: number } | null = null;
|
||||||
|
|
||||||
|
constructor(runtimeOptions: ProviderRuntimeOptions) {
|
||||||
|
super(runtimeOptions);
|
||||||
|
this.apiBaseUrl = getApiBaseUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用自有版本检查 API,返回 electron-updater 格式的 UpdateInfo。
|
||||||
|
* blockMapSize > 0 时 electron-updater 自动启用差分下载。
|
||||||
|
* 无更新时返回当前版本号(APP_VERSION)。
|
||||||
|
*/
|
||||||
|
async getLatestVersion(): Promise<UpdateInfo> {
|
||||||
|
// 短时缓存(500ms):预检和 electron-updater 内部调用共享同一结果
|
||||||
|
if (this._cachedResult && Date.now() - this._cachedResult.ts < 500) {
|
||||||
|
return this._cachedResult.data;
|
||||||
|
}
|
||||||
|
const data = await this._doGetLatestVersion();
|
||||||
|
this._cachedResult = { data, ts: Date.now() };
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _doGetLatestVersion(): Promise<UpdateInfo> {
|
||||||
|
const { data } = await netRequest.get<{ code: number; data?: UpdateCheckResult } & UpdateCheckResult>(
|
||||||
|
'/api/v1/update/check',
|
||||||
|
{
|
||||||
|
baseURL: this.apiBaseUrl,
|
||||||
|
params: {
|
||||||
|
platform: process.platform,
|
||||||
|
version: APP_VERSION,
|
||||||
|
channel: BUILD_CHANNEL,
|
||||||
|
edition: BUILD_EDITION,
|
||||||
|
deviceFingerprint: getDeviceFingerprint(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 兼容两种 API 响应格式
|
||||||
|
const result: UpdateCheckResult =
|
||||||
|
(data as { data?: UpdateCheckResult }).data ?? (data as UpdateCheckResult);
|
||||||
|
|
||||||
|
// 无更新 → 返回当前版本号
|
||||||
|
if (!result.hasUpdate) {
|
||||||
|
return {
|
||||||
|
version: APP_VERSION,
|
||||||
|
files: [],
|
||||||
|
path: '',
|
||||||
|
sha512: '',
|
||||||
|
releaseDate: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const file: UpdateFileInfo = {
|
||||||
|
url: result.downloadUrl,
|
||||||
|
sha512: result.sha512,
|
||||||
|
size: result.fileSize,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 后端 API 返回 blockMapSize 时启用增量更新
|
||||||
|
if (result.blockMapSize) {
|
||||||
|
file.blockMapSize = result.blockMapSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('updater', 'Provider 获取到新版本', {
|
||||||
|
latestVersion: result.latestVersion,
|
||||||
|
hasBlockmap: file.blockMapSize != null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: result.latestVersion,
|
||||||
|
files: [file],
|
||||||
|
path: result.downloadUrl,
|
||||||
|
sha512: result.sha512,
|
||||||
|
releaseDate: result.releaseDate,
|
||||||
|
releaseNotes: result.changelog,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 UpdateInfo 中的相对路径解析为完整的下载 URL。
|
||||||
|
* electron-updater 在下载前调用此方法。
|
||||||
|
*/
|
||||||
|
resolveFiles(updateInfo: UpdateInfo): Array<ResolvedUpdateFileInfo> {
|
||||||
|
return updateInfo.files.map((f) => {
|
||||||
|
// 使用 downloadUrl 作为 base URL 来解析文件路径
|
||||||
|
const baseUrl = new URL(updateInfo.path);
|
||||||
|
const fileUrl = new URL(f.url, baseUrl.origin);
|
||||||
|
return {
|
||||||
|
url: fileUrl,
|
||||||
|
info: f,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,177 +1,39 @@
|
|||||||
import { ipcRenderer, contextBridge } from 'electron';
|
// ============================================================
|
||||||
import { BIDIRECTIONAL, RENDERER_TO_MAIN } from '../shared/constants/ipc-channels';
|
// preload.ts — Electron 预加载脚本(入口)
|
||||||
|
//
|
||||||
|
// 职责:
|
||||||
|
// - 调用各功能模块的 expose 函数,将安全 API 暴露给渲染进程
|
||||||
|
// - 所有 API 通过 contextBridge 桥接,确保进程隔离安全
|
||||||
|
//
|
||||||
|
// 模块拆分:
|
||||||
|
// - base.ts → ipcRenderer 基础通信
|
||||||
|
// - safe-storage.ts → 加密/解密 API
|
||||||
|
// - file-storage.ts → 文件读写 API
|
||||||
|
// - app-runtime.ts → 运行时 API(窗口标题等)
|
||||||
|
// - canvas.ts → 无限画布 API
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
// --------- Expose some API to the Renderer process ---------
|
import {
|
||||||
contextBridge.exposeInMainWorld('ipcRenderer', {
|
exposeBaseIpc,
|
||||||
on(...args: Parameters<typeof ipcRenderer.on>) {
|
exposeSafeStorage,
|
||||||
const [channel, listener] = args;
|
exposeFileStorage,
|
||||||
return ipcRenderer.on(channel, (event, ...args) => listener(event, ...args));
|
exposeAppRuntime,
|
||||||
},
|
exposeCanvas,
|
||||||
off(...args: Parameters<typeof ipcRenderer.off>) {
|
} from './preload/index';
|
||||||
const [channel, ...omit] = args;
|
|
||||||
return ipcRenderer.off(channel, ...omit);
|
|
||||||
},
|
|
||||||
send(...args: Parameters<typeof ipcRenderer.send>) {
|
|
||||||
const [channel, ...omit] = args;
|
|
||||||
return ipcRenderer.send(channel, ...omit);
|
|
||||||
},
|
|
||||||
invoke(...args: Parameters<typeof ipcRenderer.invoke>) {
|
|
||||||
const [channel, ...omit] = args;
|
|
||||||
return ipcRenderer.invoke(channel, ...omit);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// --------- 安全加密 API(基于 Electron safeStorage)---------
|
// --------- 按顺序暴露各模块 API ---------
|
||||||
contextBridge.exposeInMainWorld('safeStorage', {
|
|
||||||
/**
|
|
||||||
* 加密明文字符串
|
|
||||||
* @param plaintext - 待加密的明文
|
|
||||||
* @returns Base64 编码的密文,失败返回 null
|
|
||||||
*/
|
|
||||||
async encrypt(plaintext: string): Promise<string | null> {
|
|
||||||
const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_ENCRYPT, plaintext);
|
|
||||||
return result?.success ? result.data : null;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
// 1. 基础 IPC 通信(ipcRenderer.on/off/send/invoke)
|
||||||
* 解密 Base64 密文
|
exposeBaseIpc();
|
||||||
* @param encryptedBase64 - Base64 编码的密文
|
|
||||||
* @returns 解密后的明文,失败返回 null
|
|
||||||
*/
|
|
||||||
async decrypt(encryptedBase64: string): Promise<string | null> {
|
|
||||||
const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_DECRYPT, encryptedBase64);
|
|
||||||
return result?.success ? result.data : null;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// --------- 本地存储文件 API(主进程 fs 桥接)---------
|
// 2. 安全加密 API(基于 Electron safeStorage)
|
||||||
contextBridge.exposeInMainWorld('fileStorage', {
|
exposeSafeStorage();
|
||||||
/** 获取 heixiu-data 根目录路径 */
|
|
||||||
getDataDir(): Promise<string | null> {
|
|
||||||
return ipcRenderer.invoke(BIDIRECTIONAL.GET_DATA_DIR)
|
|
||||||
.then((r: { success: boolean; data: string }) => r?.success ? r.data : null);
|
|
||||||
},
|
|
||||||
/** 写入文件(Base64 → 磁盘),返回写入的绝对路径 */
|
|
||||||
writeFile(relativePath: string, data: string, encoding?: 'base64' | 'utf8'): Promise<string | null> {
|
|
||||||
return ipcRenderer.invoke(BIDIRECTIONAL.FILE_WRITE, { relativePath, data, encoding })
|
|
||||||
.then((r: { success: boolean; data: string }) => r?.success ? r.data : null);
|
|
||||||
},
|
|
||||||
/** 读取文件(磁盘 → Base64) */
|
|
||||||
readFile(relativePath: string): Promise<string | null> {
|
|
||||||
return ipcRenderer.invoke(BIDIRECTIONAL.FILE_READ, { relativePath })
|
|
||||||
.then((r: { success: boolean; data: string }) => r?.success ? r.data : null);
|
|
||||||
},
|
|
||||||
/** 删除文件 */
|
|
||||||
deleteFile(relativePath: string): Promise<boolean> {
|
|
||||||
return ipcRenderer.invoke(BIDIRECTIONAL.FILE_DELETE, { relativePath })
|
|
||||||
.then((r: { success: boolean }) => r?.success ?? false);
|
|
||||||
},
|
|
||||||
/** 递归创建目录 */
|
|
||||||
makeDir(relativePath: string): Promise<string | null> {
|
|
||||||
return ipcRenderer.invoke(BIDIRECTIONAL.MAKE_DIR, { relativePath })
|
|
||||||
.then((r: { success: boolean; data: string }) => r?.success ? r.data : null);
|
|
||||||
},
|
|
||||||
/** 在文件管理器中显示文件(绝对路径) */
|
|
||||||
showItemInFolder(filePath: string): Promise<boolean> {
|
|
||||||
return ipcRenderer.invoke(BIDIRECTIONAL.SHOW_ITEM_IN_FOLDER, { filePath })
|
|
||||||
.then((r: { success: boolean }) => r?.success ?? false);
|
|
||||||
},
|
|
||||||
/** 获取文件大小(字节) */
|
|
||||||
fileSize(relativePath: string): Promise<number | null> {
|
|
||||||
return ipcRenderer.invoke(BIDIRECTIONAL.FILE_SIZE, { relativePath })
|
|
||||||
.then((r: { success: boolean; data: number }) => r?.success ? r.data : null);
|
|
||||||
},
|
|
||||||
/** 检查文件/目录是否存在 */
|
|
||||||
fileExists(relativePath: string): Promise<boolean> {
|
|
||||||
return ipcRenderer.invoke(BIDIRECTIONAL.FILE_EXISTS, { relativePath })
|
|
||||||
.then((r: { success: boolean; data: boolean }) => r?.success ? r.data : false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/** 应用运行时 API */
|
// 3. 本地存储文件 API(主进程 fs 桥接)
|
||||||
contextBridge.exposeInMainWorld('appRuntime', {
|
exposeFileStorage();
|
||||||
/**
|
|
||||||
* 登录/登出后更新窗口标题中的版控后缀
|
|
||||||
* @param edition - 'personal' | 'enterprise' | null(null 清除后缀)
|
|
||||||
*/
|
|
||||||
setWindowEdition(edition: string | null): void {
|
|
||||||
ipcRenderer.send(RENDERER_TO_MAIN.SET_WINDOW_TITLE, edition);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// --------- 无限画布 API ---------
|
// 4. 应用运行时 API(窗口标题等)
|
||||||
contextBridge.exposeInMainWorld('canvas', {
|
exposeAppRuntime();
|
||||||
/** 打开画布窗口(主进程创建新 BrowserWindow) */
|
|
||||||
openWindow(projectPath?: string): void {
|
// 5. 无限画布 API
|
||||||
ipcRenderer.send(RENDERER_TO_MAIN.OPEN_CANVAS_WINDOW, { projectPath });
|
exposeCanvas();
|
||||||
},
|
|
||||||
/** 扫描目录,返回匹配的文件列表 */
|
|
||||||
scanDirectory(params: {
|
|
||||||
roots: string[];
|
|
||||||
extensions: string[];
|
|
||||||
recursive: boolean;
|
|
||||||
}): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }> {
|
|
||||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_DIRECTORY, params);
|
|
||||||
},
|
|
||||||
/** 扫描项目(主进程内部完成 buildScanRoots + walkDirectory,推荐使用) */
|
|
||||||
scanProject(params: {
|
|
||||||
projectPath: string;
|
|
||||||
stage: string;
|
|
||||||
assetTypeFilter: string;
|
|
||||||
shotQuery: string;
|
|
||||||
}): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }> {
|
|
||||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_PROJECT, params);
|
|
||||||
},
|
|
||||||
/** 读取旁加载 JSON 元数据 */
|
|
||||||
readSidecar(params: { filePath: string }): Promise<{ success: boolean; data: object | null }> {
|
|
||||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_READ_SIDECAR, params);
|
|
||||||
},
|
|
||||||
/** 写入旁加载 JSON 元数据 */
|
|
||||||
writeSidecar(params: { filePath: string; metadata: object }): Promise<{ success: boolean; error?: string }> {
|
|
||||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_WRITE_SIDECAR, params);
|
|
||||||
},
|
|
||||||
/** 生成缩略图(JPEG base64) */
|
|
||||||
generateThumbnail(params: { filePath: string; size: number }): Promise<{
|
|
||||||
success: boolean;
|
|
||||||
data: { data: string; mimeType: string; width: number; height: number };
|
|
||||||
error?: string;
|
|
||||||
}> {
|
|
||||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_GENERATE_THUMBNAIL, params);
|
|
||||||
},
|
|
||||||
/** 复制文件到目标目录(导入操作) */
|
|
||||||
copyFile(params: { src: string; destDir: string; fileName: string }): Promise<{ success: boolean; data: string; error?: string }> {
|
|
||||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_COPY_FILE, params);
|
|
||||||
},
|
|
||||||
/** 列出目录下的子文件夹(非递归,排除隐藏) */
|
|
||||||
listSubdirs(params: { parentPath: string }): Promise<{ success: boolean; data: string[]; error?: string }> {
|
|
||||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_LIST_SUBDIRS, params);
|
|
||||||
},
|
|
||||||
/** 扫描阶段文件(单个 episode/shot/stage 组合) */
|
|
||||||
scanStageFiles(params: { projectPath: string; episode: string; shot: string; stage: string }): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }> {
|
|
||||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_STAGE_FILES, params);
|
|
||||||
},
|
|
||||||
/** 扫描资产文件(深度递归扫描 assets/ 目录) */
|
|
||||||
scanAssetFiles(params: { projectPath: string; assetTypeFilter: string }): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }> {
|
|
||||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_ASSET_FILES, params);
|
|
||||||
},
|
|
||||||
/** 创建目录 */
|
|
||||||
createDirectory(params: { parentPath: string; dirName: string }): Promise<{ success: boolean; data: string; error?: string }> {
|
|
||||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_CREATE_DIRECTORY, params);
|
|
||||||
},
|
|
||||||
/** 将本地文件路径转换为可通过 local-media:// 协议加载的 URL */
|
|
||||||
getMediaUrl(filePath: string): string {
|
|
||||||
const normalized = filePath.replace(/\\/g, '/');
|
|
||||||
// UNC 路径(//server/share/…)开头 // 会被 URL 解析器视为 authority,
|
|
||||||
// 必须用 local-media://<host>/<path> 格式而非 local-media:/// 前缀
|
|
||||||
if (normalized.startsWith('//')) {
|
|
||||||
// normalized = "//192.168.110.250/share/path"
|
|
||||||
return 'local-media://' + normalized.slice(2);
|
|
||||||
}
|
|
||||||
// 本地路径:C:/path → local-media:///C:/path
|
|
||||||
return 'local-media:///' + normalized;
|
|
||||||
},
|
|
||||||
/** 启动原生文件拖拽(从缩略图拖放媒体文件到外部应用) */
|
|
||||||
startDrag(params: { filePath: string; fileName: string }): Promise<{ success: boolean; error?: string }> {
|
|
||||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_START_DRAG, params);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
22
electron/preload/app-runtime.ts
Normal file
22
electron/preload/app-runtime.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// ============================================================
|
||||||
|
// preload/app-runtime.ts — 应用运行时 API
|
||||||
|
//
|
||||||
|
// 提供窗口标题、版本控制等运行时接口
|
||||||
|
// 渲染进程通过 window.appRuntime 调用
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { ipcRenderer, contextBridge } from 'electron';
|
||||||
|
import { RENDERER_TO_MAIN } from '../../shared/constants/ipc';
|
||||||
|
|
||||||
|
/** 应用运行时 API */
|
||||||
|
export function exposeAppRuntime(): void {
|
||||||
|
contextBridge.exposeInMainWorld('appRuntime', {
|
||||||
|
/**
|
||||||
|
* 登录/登出后更新窗口标题中的版控后缀
|
||||||
|
* @param edition - 'personal' | 'enterprise' | null(null 清除后缀)
|
||||||
|
*/
|
||||||
|
setWindowEdition(edition: string | null): void {
|
||||||
|
ipcRenderer.send(RENDERER_TO_MAIN.SET_WINDOW_TITLE, edition);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
29
electron/preload/base.ts
Normal file
29
electron/preload/base.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// ============================================================
|
||||||
|
// preload/base.ts — 基础 ipcRenderer 桥接
|
||||||
|
//
|
||||||
|
// 提供安全的 IPC 通信接口,暴露给渲染进程
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { ipcRenderer, contextBridge } from 'electron';
|
||||||
|
|
||||||
|
/** 基础 IPC 通信 API */
|
||||||
|
export function exposeBaseIpc(): void {
|
||||||
|
contextBridge.exposeInMainWorld('ipcRenderer', {
|
||||||
|
on(...args: Parameters<typeof ipcRenderer.on>) {
|
||||||
|
const [channel, listener] = args;
|
||||||
|
return ipcRenderer.on(channel, (event, ...args) => listener(event, ...args));
|
||||||
|
},
|
||||||
|
off(...args: Parameters<typeof ipcRenderer.off>) {
|
||||||
|
const [channel, ...omit] = args;
|
||||||
|
return ipcRenderer.off(channel, ...omit);
|
||||||
|
},
|
||||||
|
send(...args: Parameters<typeof ipcRenderer.send>) {
|
||||||
|
const [channel, ...omit] = args;
|
||||||
|
return ipcRenderer.send(channel, ...omit);
|
||||||
|
},
|
||||||
|
invoke(...args: Parameters<typeof ipcRenderer.invoke>) {
|
||||||
|
const [channel, ...omit] = args;
|
||||||
|
return ipcRenderer.invoke(channel, ...omit);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
124
electron/preload/canvas.ts
Normal file
124
electron/preload/canvas.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
// ============================================================
|
||||||
|
// preload/canvas.ts — 无限画布 API
|
||||||
|
//
|
||||||
|
// 提供画布窗口、文件扫描、缩略图、拖拽等接口
|
||||||
|
// 渲染进程通过 window.canvas 调用
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { ipcRenderer, contextBridge } from 'electron';
|
||||||
|
import { BIDIRECTIONAL, RENDERER_TO_MAIN } from '../../shared/constants/ipc';
|
||||||
|
|
||||||
|
/** 无限画布 API */
|
||||||
|
export function exposeCanvas(): void {
|
||||||
|
contextBridge.exposeInMainWorld('canvas', {
|
||||||
|
/** 打开画布窗口(主进程创建新 BrowserWindow) */
|
||||||
|
openWindow(projectPath?: string): void {
|
||||||
|
ipcRenderer.send(RENDERER_TO_MAIN.OPEN_CANVAS_WINDOW, { projectPath });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 扫描目录,返回匹配的文件列表 */
|
||||||
|
scanDirectory(params: {
|
||||||
|
roots: string[];
|
||||||
|
extensions: string[];
|
||||||
|
recursive: boolean;
|
||||||
|
}): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_DIRECTORY, params);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 扫描项目(主进程内部完成 buildScanRoots + walkDirectory,推荐使用) */
|
||||||
|
scanProject(params: {
|
||||||
|
projectPath: string;
|
||||||
|
stage: string;
|
||||||
|
assetTypeFilter: string;
|
||||||
|
shotQuery: string;
|
||||||
|
}): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_PROJECT, params);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 读取旁加载 JSON 元数据 */
|
||||||
|
readSidecar(params: { filePath: string }): Promise<{ success: boolean; data: object | null }> {
|
||||||
|
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_READ_SIDECAR, params);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 写入旁加载 JSON 元数据 */
|
||||||
|
writeSidecar(params: { filePath: string; metadata: object }): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_WRITE_SIDECAR, params);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 生成缩略图(JPEG base64) */
|
||||||
|
generateThumbnail(params: { filePath: string; size: number }): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data: { data: string; mimeType: string; width: number; height: number };
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_GENERATE_THUMBNAIL, params);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 复制文件到目标目录(导入操作) */
|
||||||
|
copyFile(params: { src: string; destDir: string; fileName: string }): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_COPY_FILE, params);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 列出目录下的子文件夹(非递归,排除隐藏) */
|
||||||
|
listSubdirs(params: { parentPath: string }): Promise<{ success: boolean; data: string[]; error?: string }> {
|
||||||
|
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_LIST_SUBDIRS, params);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 扫描阶段文件(单个 episode/shot/stage 组合) */
|
||||||
|
scanStageFiles(params: { projectPath: string; episode: string; shot: string; stage: string }): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_STAGE_FILES, params);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 扫描资产文件(深度递归扫描 assets/ 目录) */
|
||||||
|
scanAssetFiles(params: { projectPath: string; assetTypeFilter: string }): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_ASSET_FILES, params);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 创建目录 */
|
||||||
|
createDirectory(params: { parentPath: string; dirName: string }): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_CREATE_DIRECTORY, params);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 将本地文件路径转换为可通过 local-media:// 协议加载的 URL */
|
||||||
|
getMediaUrl(filePath: string): string {
|
||||||
|
const normalized = filePath.replace(/\\/g, '/');
|
||||||
|
// UNC 路径(//server/share/…)开头 // 会被 URL 解析器视为 authority,
|
||||||
|
// 必须用 local-media://<host>/<path> 格式而非 local-media:/// 前缀
|
||||||
|
if (normalized.startsWith('//')) {
|
||||||
|
// normalized = "//192.168.110.250/share/path"
|
||||||
|
return 'local-media://' + normalized.slice(2);
|
||||||
|
}
|
||||||
|
// 本地路径:C:/path → local-media:///C:/path
|
||||||
|
return 'local-media:///' + normalized;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 启动原生文件拖拽(从缩略图拖放媒体文件到外部应用) */
|
||||||
|
startDrag(params: { filePath: string; fileName: string }): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_START_DRAG, params);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
94
electron/preload/file-storage.ts
Normal file
94
electron/preload/file-storage.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// ============================================================
|
||||||
|
// preload/file-storage.ts — 本地存储文件 API(主进程 fs 桥接)
|
||||||
|
//
|
||||||
|
// 提供文件读写、目录管理、下载等接口
|
||||||
|
// 渲染进程通过 window.fileStorage 调用
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { ipcRenderer, contextBridge } from 'electron';
|
||||||
|
import { BIDIRECTIONAL } from '../../shared/constants/ipc';
|
||||||
|
|
||||||
|
/** 本地存储文件 API */
|
||||||
|
export function exposeFileStorage(): void {
|
||||||
|
contextBridge.exposeInMainWorld('fileStorage', {
|
||||||
|
/** 获取 heixiu-data 根目录路径 */
|
||||||
|
async getDataDir(): Promise<string | null> {
|
||||||
|
const r = await ipcRenderer.invoke(BIDIRECTIONAL.GET_DATA_DIR);
|
||||||
|
return r?.success ? r.data : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 写入文件(Base64 → 磁盘),返回写入的绝对路径 */
|
||||||
|
async writeFile(relativePath: string, data: string, encoding?: 'base64' | 'utf8'): Promise<string | null> {
|
||||||
|
const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_WRITE, { relativePath, data, encoding });
|
||||||
|
return r?.success ? r.data : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 读取文件(磁盘 → Base64) */
|
||||||
|
async readFile(relativePath: string): Promise<string | null> {
|
||||||
|
const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_READ, { relativePath });
|
||||||
|
return r?.success ? r.data : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 删除文件 */
|
||||||
|
async deleteFile(relativePath: string): Promise<boolean> {
|
||||||
|
const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_DELETE, { relativePath });
|
||||||
|
return r?.success ?? false;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 递归创建目录 */
|
||||||
|
async makeDir(relativePath: string): Promise<string | null> {
|
||||||
|
const r = await ipcRenderer.invoke(BIDIRECTIONAL.MAKE_DIR, { relativePath });
|
||||||
|
return r?.success ? r.data : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 在文件管理器中显示文件(绝对路径) */
|
||||||
|
async showItemInFolder(filePath: string): Promise<boolean> {
|
||||||
|
const r = await ipcRenderer.invoke(BIDIRECTIONAL.SHOW_ITEM_IN_FOLDER, { filePath });
|
||||||
|
return r?.success ?? false;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 获取文件大小(字节) */
|
||||||
|
async fileSize(relativePath: string): Promise<number | null> {
|
||||||
|
const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_SIZE, { relativePath });
|
||||||
|
return r?.success ? r.data : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 检查文件/目录是否存在 */
|
||||||
|
async fileExists(relativePath: string): Promise<boolean> {
|
||||||
|
const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_EXISTS, { relativePath });
|
||||||
|
return r?.success ? r.data : false;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 绝对路径文件写入(绕过 heixiu-data 沙箱) */
|
||||||
|
async writeFileAbsolute(absolutePath: string, data: string, encoding?: 'base64' | 'utf8'): Promise<string | null> {
|
||||||
|
const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_WRITE_ABSOLUTE, { absolutePath, data, encoding });
|
||||||
|
return r?.success ? r.data : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 绝对路径目录创建(绕过 heixiu-data 沙箱) */
|
||||||
|
async makeDirAbsolute(absolutePath: string): Promise<string | null> {
|
||||||
|
const r = await ipcRenderer.invoke(BIDIRECTIONAL.MAKE_DIR_ABSOLUTE, { absolutePath });
|
||||||
|
return r?.success ? r.data : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 主进程下载文件到绝对路径(绕过浏览器 CORS 限制),失败时 reject 并携带错误信息 */
|
||||||
|
async downloadToPath(url: string, destPath: string, headers?: Record<string, string>): Promise<string> {
|
||||||
|
const r = await ipcRenderer.invoke(BIDIRECTIONAL.DOWNLOAD_TO_PATH, { url, destPath, headers });
|
||||||
|
if (r?.success) return r.data;
|
||||||
|
throw new Error(r?.error || '主进程下载失败');
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 获取系统下载目录路径 */
|
||||||
|
async getDownloadsPath(): Promise<string | null> {
|
||||||
|
const r = await ipcRenderer.invoke(BIDIRECTIONAL.GET_DOWNLOADS_PATH);
|
||||||
|
return r?.success ? r.data : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 生成图片缩略图(读取原图 → 缩放 → JPEG 写入目标路径) */
|
||||||
|
async generateThumbnail(srcPath: string, destPath: string, maxWidth?: number, quality?: number): Promise<string> {
|
||||||
|
const r = await ipcRenderer.invoke(BIDIRECTIONAL.GENERATE_THUMBNAIL, { srcPath, destPath, maxWidth, quality });
|
||||||
|
if (r?.success) return r.data;
|
||||||
|
throw new Error(r?.error || '缩略图生成失败');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
11
electron/preload/index.ts
Normal file
11
electron/preload/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// ============================================================
|
||||||
|
// preload/index.ts — 汇总导出所有 preload 模块
|
||||||
|
//
|
||||||
|
// 在主 preload.ts 中调用各模块的 expose 函数
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export { exposeBaseIpc } from './base';
|
||||||
|
export { exposeSafeStorage } from './safe-storage';
|
||||||
|
export { exposeFileStorage } from './file-storage';
|
||||||
|
export { exposeAppRuntime } from './app-runtime';
|
||||||
|
export { exposeCanvas } from './canvas';
|
||||||
33
electron/preload/safe-storage.ts
Normal file
33
electron/preload/safe-storage.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// ============================================================
|
||||||
|
// preload/safe-storage.ts — 安全加密 API(基于 Electron safeStorage)
|
||||||
|
//
|
||||||
|
// 提供加密/解密接口,渲染进程通过 window.safeStorage 调用
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { ipcRenderer, contextBridge } from 'electron';
|
||||||
|
import { BIDIRECTIONAL } from '../../shared/constants/ipc';
|
||||||
|
|
||||||
|
/** 安全加密 API */
|
||||||
|
export function exposeSafeStorage(): void {
|
||||||
|
contextBridge.exposeInMainWorld('safeStorage', {
|
||||||
|
/**
|
||||||
|
* 加密明文字符串
|
||||||
|
* @param plaintext - 待加密的明文
|
||||||
|
* @returns Base64 编码的密文,失败返回 null
|
||||||
|
*/
|
||||||
|
async encrypt(plaintext: string): Promise<string | null> {
|
||||||
|
const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_ENCRYPT, plaintext);
|
||||||
|
return result?.success ? result.data : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解密 Base64 密文
|
||||||
|
* @param encryptedBase64 - Base64 编码的密文
|
||||||
|
* @returns 解密后的明文,失败返回 null
|
||||||
|
*/
|
||||||
|
async decrypt(encryptedBase64: string): Promise<string | null> {
|
||||||
|
const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_DECRYPT, encryptedBase64);
|
||||||
|
return result?.success ? result.data : null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,95 +1,18 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// IPC 通道名称常量 —— 避免魔法字符串
|
// IPC 通道名称常量 — 兼容层(已拆分为 ipc/ 子模块)
|
||||||
|
//
|
||||||
|
// @deprecated 请使用新的模块化导入:
|
||||||
|
// import { BIDIRECTIONAL } from '@/shared/constants/ipc';
|
||||||
|
// import { BIDIRECTIONAL_STORAGE } from '@/shared/constants/ipc/storage';
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
/** 主进程 → 渲染进程 */
|
export {
|
||||||
export const MAIN_TO_RENDERER = {
|
MAIN_TO_RENDERER,
|
||||||
/** 主进程消息推送 */
|
RENDERER_TO_MAIN,
|
||||||
MAIN_PROCESS_MESSAGE: 'main-process-message',
|
BIDIRECTIONAL,
|
||||||
/** 主题变更通知 */
|
MAIN_TO_RENDERER_CANVAS,
|
||||||
THEME_CHANGED: 'theme-changed',
|
RENDERER_TO_MAIN_CANVAS,
|
||||||
/** 窗口聚焦通知 */
|
BIDIRECTIONAL_CANVAS,
|
||||||
WINDOW_FOCUSED: 'window-focused',
|
MAIN_TO_RENDERER_UPDATER,
|
||||||
/** 窗口失焦通知 */
|
RENDERER_TO_MAIN_UPDATER,
|
||||||
WINDOW_BLURRED: 'window-blurred',
|
} from './ipc';
|
||||||
/** 画布窗口准备就绪 */
|
|
||||||
CANVAS_READY: 'canvas:ready',
|
|
||||||
/** 画布扫描进度 */
|
|
||||||
CANVAS_SCAN_PROGRESS: 'canvas:scan-progress',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/** 渲染进程 → 主进程 */
|
|
||||||
export const RENDERER_TO_MAIN = {
|
|
||||||
/** 打开子窗口 */
|
|
||||||
OPEN_SUB_WINDOW: 'open-sub-window',
|
|
||||||
/** 关闭子窗口 */
|
|
||||||
CLOSE_SUB_WINDOW: 'close-sub-window',
|
|
||||||
/** 设置窗口标题 */
|
|
||||||
SET_WINDOW_TITLE: 'set-window-title',
|
|
||||||
/** 获取平台信息 */
|
|
||||||
GET_PLATFORM_INFO: 'get-platform-info',
|
|
||||||
/** 最小化窗口 */
|
|
||||||
MINIMIZE_WINDOW: 'minimize-window',
|
|
||||||
/** 最大化/还原窗口 */
|
|
||||||
TOGGLE_MAXIMIZE: 'toggle-maximize',
|
|
||||||
/** 关闭窗口 */
|
|
||||||
CLOSE_WINDOW: 'close-window',
|
|
||||||
/** 渲染进程日志上报 */
|
|
||||||
LOG_MESSAGE: 'log-message',
|
|
||||||
/** 画布:打开无限画布窗口 */
|
|
||||||
OPEN_CANVAS_WINDOW: 'canvas:open-window',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/** 双向通信通道 */
|
|
||||||
export const BIDIRECTIONAL = {
|
|
||||||
/** 读取应用配置 */
|
|
||||||
READ_CONFIG: 'read-config',
|
|
||||||
/** 写入应用配置 */
|
|
||||||
WRITE_CONFIG: 'write-config',
|
|
||||||
/** 文件对话框 */
|
|
||||||
FILE_DIALOG: 'file-dialog',
|
|
||||||
/** safeStorage 加密(明文 → Base64 密文) */
|
|
||||||
SAFESTORAGE_ENCRYPT: 'safe-storage:encrypt',
|
|
||||||
/** safeStorage 解密(Base64 密文 → 明文) */
|
|
||||||
SAFESTORAGE_DECRYPT: 'safe-storage:decrypt',
|
|
||||||
/** 文件写入(Blob Buffer → 磁盘) */
|
|
||||||
FILE_WRITE: 'storage:file-write',
|
|
||||||
/** 文件读取(磁盘 → Base64) */
|
|
||||||
FILE_READ: 'storage:file-read',
|
|
||||||
/** 文件删除 */
|
|
||||||
FILE_DELETE: 'storage:file-delete',
|
|
||||||
/** 创建目录(递归) */
|
|
||||||
MAKE_DIR: 'storage:make-dir',
|
|
||||||
/** 在文件管理器中显示文件 */
|
|
||||||
SHOW_ITEM_IN_FOLDER: 'storage:show-item-in-folder',
|
|
||||||
/** 获取应用数据目录路径 */
|
|
||||||
GET_DATA_DIR: 'storage:get-data-dir',
|
|
||||||
/** 获取文件大小 */
|
|
||||||
FILE_SIZE: 'storage:file-size',
|
|
||||||
/** 文件/目录是否存在 */
|
|
||||||
FILE_EXISTS: 'storage:file-exists',
|
|
||||||
/** 画布:扫描目录 */
|
|
||||||
CANVAS_SCAN_DIRECTORY: 'canvas:scan-directory',
|
|
||||||
/** 画布:扫描项目(接收 projectPath,主进程内部完成 buildScanRoots + walkDirectory) */
|
|
||||||
CANVAS_SCAN_PROJECT: 'canvas:scan-project',
|
|
||||||
/** 画布:读取 sidecar 元数据 */
|
|
||||||
CANVAS_READ_SIDECAR: 'canvas:read-sidecar',
|
|
||||||
/** 画布:写入 sidecar 元数据 */
|
|
||||||
CANVAS_WRITE_SIDECAR: 'canvas:write-sidecar',
|
|
||||||
/** 画布:生成缩略图 */
|
|
||||||
CANVAS_GENERATE_THUMBNAIL: 'canvas:generate-thumbnail',
|
|
||||||
/** 画布:复制文件(导入) */
|
|
||||||
CANVAS_COPY_FILE: 'canvas:copy-file',
|
|
||||||
/** 画布:获取原生图片尺寸 */
|
|
||||||
CANVAS_GET_IMAGE_SIZE: 'canvas:get-image-size',
|
|
||||||
/** 画布:列出目录下的子文件夹(非递归,排除隐藏) */
|
|
||||||
CANVAS_LIST_SUBDIRS: 'canvas:list-subdirs',
|
|
||||||
/** 画布:扫描阶段文件(单个 episode/shot/stage 组合) */
|
|
||||||
CANVAS_SCAN_STAGE_FILES: 'canvas:scan-stage-files',
|
|
||||||
/** 画布:扫描资产文件(深度递归扫描 assets/ 目录) */
|
|
||||||
CANVAS_SCAN_ASSET_FILES: 'canvas:scan-asset-files',
|
|
||||||
/** 画布:创建目录 */
|
|
||||||
CANVAS_CREATE_DIRECTORY: 'canvas:create-directory',
|
|
||||||
/** 画布:启动原生文件拖拽(拖放媒体文件到外部应用) */
|
|
||||||
CANVAS_START_DRAG: 'canvas:start-drag',
|
|
||||||
} as const;
|
|
||||||
|
|||||||
45
shared/constants/ipc/base.ts
Normal file
45
shared/constants/ipc/base.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// ============================================================
|
||||||
|
// IPC 通道常量 — 基础通道(窗口、日志、主题等)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** 主进程 → 渲染进程 */
|
||||||
|
export const MAIN_TO_RENDERER = {
|
||||||
|
/** 主进程消息推送 */
|
||||||
|
MAIN_PROCESS_MESSAGE: 'main-process-message',
|
||||||
|
/** 主题变更通知 */
|
||||||
|
THEME_CHANGED: 'theme-changed',
|
||||||
|
/** 窗口聚焦通知 */
|
||||||
|
WINDOW_FOCUSED: 'window-focused',
|
||||||
|
/** 窗口失焦通知 */
|
||||||
|
WINDOW_BLURRED: 'window-blurred',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** 渲染进程 → 主进程 */
|
||||||
|
export const RENDERER_TO_MAIN = {
|
||||||
|
/** 打开子窗口 */
|
||||||
|
OPEN_SUB_WINDOW: 'open-sub-window',
|
||||||
|
/** 关闭子窗口 */
|
||||||
|
CLOSE_SUB_WINDOW: 'close-sub-window',
|
||||||
|
/** 设置窗口标题 */
|
||||||
|
SET_WINDOW_TITLE: 'set-window-title',
|
||||||
|
/** 获取平台信息 */
|
||||||
|
GET_PLATFORM_INFO: 'get-platform-info',
|
||||||
|
/** 最小化窗口 */
|
||||||
|
MINIMIZE_WINDOW: 'minimize-window',
|
||||||
|
/** 最大化/还原窗口 */
|
||||||
|
TOGGLE_MAXIMIZE: 'toggle-maximize',
|
||||||
|
/** 关闭窗口 */
|
||||||
|
CLOSE_WINDOW: 'close-window',
|
||||||
|
/** 渲染进程日志上报 */
|
||||||
|
LOG_MESSAGE: 'log-message',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** 双向通信通道 — 应用配置 */
|
||||||
|
export const BIDIRECTIONAL_CONFIG = {
|
||||||
|
/** 读取应用配置 */
|
||||||
|
READ_CONFIG: 'read-config',
|
||||||
|
/** 写入应用配置 */
|
||||||
|
WRITE_CONFIG: 'write-config',
|
||||||
|
/** 文件对话框 */
|
||||||
|
FILE_DIALOG: 'file-dialog',
|
||||||
|
} as const;
|
||||||
45
shared/constants/ipc/canvas.ts
Normal file
45
shared/constants/ipc/canvas.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// ============================================================
|
||||||
|
// IPC 通道常量 — 无限画布相关通道
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** 主进程 → 渲染进程 — 画布 */
|
||||||
|
export const MAIN_TO_RENDERER_CANVAS = {
|
||||||
|
/** 画布窗口准备就绪 */
|
||||||
|
CANVAS_READY: 'canvas:ready',
|
||||||
|
/** 画布扫描进度 */
|
||||||
|
CANVAS_SCAN_PROGRESS: 'canvas:scan-progress',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** 渲染进程 → 主进程 — 画布 */
|
||||||
|
export const RENDERER_TO_MAIN_CANVAS = {
|
||||||
|
/** 画布:打开无限画布窗口 */
|
||||||
|
OPEN_CANVAS_WINDOW: 'canvas:open-window',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** 双向通信通道 — 画布 */
|
||||||
|
export const BIDIRECTIONAL_CANVAS = {
|
||||||
|
/** 画布:扫描目录 */
|
||||||
|
CANVAS_SCAN_DIRECTORY: 'canvas:scan-directory',
|
||||||
|
/** 画布:扫描项目(接收 projectPath,主进程内部完成 buildScanRoots + walkDirectory) */
|
||||||
|
CANVAS_SCAN_PROJECT: 'canvas:scan-project',
|
||||||
|
/** 画布:读取 sidecar 元数据 */
|
||||||
|
CANVAS_READ_SIDECAR: 'canvas:read-sidecar',
|
||||||
|
/** 画布:写入 sidecar 元数据 */
|
||||||
|
CANVAS_WRITE_SIDECAR: 'canvas:write-sidecar',
|
||||||
|
/** 画布:生成缩略图 */
|
||||||
|
CANVAS_GENERATE_THUMBNAIL: 'canvas:generate-thumbnail',
|
||||||
|
/** 画布:复制文件(导入) */
|
||||||
|
CANVAS_COPY_FILE: 'canvas:copy-file',
|
||||||
|
/** 画布:获取原生图片尺寸 */
|
||||||
|
CANVAS_GET_IMAGE_SIZE: 'canvas:get-image-size',
|
||||||
|
/** 画布:列出目录下的子文件夹(非递归,排除隐藏) */
|
||||||
|
CANVAS_LIST_SUBDIRS: 'canvas:list-subdirs',
|
||||||
|
/** 画布:扫描阶段文件(单个 episode/shot/stage 组合) */
|
||||||
|
CANVAS_SCAN_STAGE_FILES: 'canvas:scan-stage-files',
|
||||||
|
/** 画布:扫描资产文件(深度递归扫描 assets/ 目录) */
|
||||||
|
CANVAS_SCAN_ASSET_FILES: 'canvas:scan-asset-files',
|
||||||
|
/** 画布:创建目录 */
|
||||||
|
CANVAS_CREATE_DIRECTORY: 'canvas:create-directory',
|
||||||
|
/** 画布:启动原生文件拖拽(拖放媒体文件到外部应用) */
|
||||||
|
CANVAS_START_DRAG: 'canvas:start-drag',
|
||||||
|
} as const;
|
||||||
25
shared/constants/ipc/index.ts
Normal file
25
shared/constants/ipc/index.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// ============================================================
|
||||||
|
// IPC 通道常量 — 汇总导出
|
||||||
|
//
|
||||||
|
// 使用方式:
|
||||||
|
// import { MAIN_TO_RENDERER, BIDIRECTIONAL } from '@/shared/constants/ipc';
|
||||||
|
// import { BIDIRECTIONAL_STORAGE } from '@/shared/constants/ipc/storage';
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export { MAIN_TO_RENDERER, RENDERER_TO_MAIN, BIDIRECTIONAL_CONFIG } from './base';
|
||||||
|
export { BIDIRECTIONAL_STORAGE } from './storage';
|
||||||
|
export { MAIN_TO_RENDERER_CANVAS, RENDERER_TO_MAIN_CANVAS, BIDIRECTIONAL_CANVAS } from './canvas';
|
||||||
|
export { MAIN_TO_RENDERER_UPDATER, RENDERER_TO_MAIN_UPDATER } from './updater';
|
||||||
|
|
||||||
|
// ---------- 兼容性汇总(向后兼容) ----------
|
||||||
|
|
||||||
|
import { BIDIRECTIONAL_CONFIG } from './base';
|
||||||
|
import { BIDIRECTIONAL_STORAGE } from './storage';
|
||||||
|
import { BIDIRECTIONAL_CANVAS } from './canvas';
|
||||||
|
|
||||||
|
/** @deprecated 请使用按功能域拆分的导出 */
|
||||||
|
export const BIDIRECTIONAL = {
|
||||||
|
...BIDIRECTIONAL_CONFIG,
|
||||||
|
...BIDIRECTIONAL_STORAGE,
|
||||||
|
...BIDIRECTIONAL_CANVAS,
|
||||||
|
} as const;
|
||||||
37
shared/constants/ipc/storage.ts
Normal file
37
shared/constants/ipc/storage.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// ============================================================
|
||||||
|
// IPC 通道常量 — 存储相关通道
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** 双向通信通道 — 文件存储 */
|
||||||
|
export const BIDIRECTIONAL_STORAGE = {
|
||||||
|
/** safeStorage 加密(明文 → Base64 密文) */
|
||||||
|
SAFESTORAGE_ENCRYPT: 'safe-storage:encrypt',
|
||||||
|
/** safeStorage 解密(Base64 密文 → 明文) */
|
||||||
|
SAFESTORAGE_DECRYPT: 'safe-storage:decrypt',
|
||||||
|
/** 文件写入(Blob Buffer → 磁盘) */
|
||||||
|
FILE_WRITE: 'storage:file-write',
|
||||||
|
/** 文件读取(磁盘 → Base64) */
|
||||||
|
FILE_READ: 'storage:file-read',
|
||||||
|
/** 文件删除 */
|
||||||
|
FILE_DELETE: 'storage:file-delete',
|
||||||
|
/** 创建目录(递归) */
|
||||||
|
MAKE_DIR: 'storage:make-dir',
|
||||||
|
/** 在文件管理器中显示文件 */
|
||||||
|
SHOW_ITEM_IN_FOLDER: 'storage:show-item-in-folder',
|
||||||
|
/** 获取应用数据目录路径 */
|
||||||
|
GET_DATA_DIR: 'storage:get-data-dir',
|
||||||
|
/** 获取文件大小 */
|
||||||
|
FILE_SIZE: 'storage:file-size',
|
||||||
|
/** 文件/目录是否存在 */
|
||||||
|
FILE_EXISTS: 'storage:file-exists',
|
||||||
|
/** 绝对路径文件写入(绕过 heixiu-data 沙箱,用于用户自定义输出目录) */
|
||||||
|
FILE_WRITE_ABSOLUTE: 'storage:file-write-absolute',
|
||||||
|
/** 绝对路径目录创建(绕过 heixiu-data 沙箱) */
|
||||||
|
MAKE_DIR_ABSOLUTE: 'storage:make-dir-absolute',
|
||||||
|
/** 主进程下载文件到绝对路径(绕过浏览器 CORS 限制) */
|
||||||
|
DOWNLOAD_TO_PATH: 'storage:download-to-path',
|
||||||
|
/** 获取系统下载目录路径 */
|
||||||
|
GET_DOWNLOADS_PATH: 'storage:get-downloads-path',
|
||||||
|
/** 生成图片缩略图(读取原图 → 缩放 → 写入目标路径) */
|
||||||
|
GENERATE_THUMBNAIL: 'storage:generate-thumbnail',
|
||||||
|
} as const;
|
||||||
27
shared/constants/ipc/updater.ts
Normal file
27
shared/constants/ipc/updater.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// ============================================================
|
||||||
|
// IPC 通道常量 — 自动更新相关通道
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** 主进程 → 渲染进程 — 更新 */
|
||||||
|
export const MAIN_TO_RENDERER_UPDATER = {
|
||||||
|
/** 发现新版本 */
|
||||||
|
UPDATE_AVAILABLE: 'update-available',
|
||||||
|
/** 下载进度 */
|
||||||
|
UPDATE_PROGRESS: 'update-progress',
|
||||||
|
/** 更新已下载完成 */
|
||||||
|
UPDATE_DOWNLOADED: 'update-downloaded',
|
||||||
|
/** 更新出错 */
|
||||||
|
UPDATE_ERROR: 'update-error',
|
||||||
|
/** 当前已是最新版本 */
|
||||||
|
UPDATE_NOT_AVAILABLE: 'update-not-available',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** 渲染进程 → 主进程 — 更新 */
|
||||||
|
export const RENDERER_TO_MAIN_UPDATER = {
|
||||||
|
/** 安装更新 */
|
||||||
|
INSTALL_UPDATE: 'install-update',
|
||||||
|
/** 检查更新 */
|
||||||
|
CHECK_FOR_UPDATE: 'check-for-update',
|
||||||
|
/** 下载更新 */
|
||||||
|
DOWNLOAD_UPDATE: 'download-update',
|
||||||
|
} as const;
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { hasIpc, safeIpcInvoke } from '@/shared/utils/bus';
|
import { hasIpc, safeIpcInvoke } from '@/shared/utils/bus';
|
||||||
import { BIDIRECTIONAL } from '@shared/constants/ipc-channels';
|
import { BIDIRECTIONAL } from '@shared/constants/ipc';
|
||||||
import type {
|
import type {
|
||||||
ICanvasDataSource,
|
ICanvasDataSource,
|
||||||
ScanRequest,
|
ScanRequest,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { useSidecarSync } from '../../hooks/useSidecarSync';
|
|||||||
import { getMediaCardMenuItems } from '../../controllers/menus';
|
import { getMediaCardMenuItems } from '../../controllers/menus';
|
||||||
import { ContextMenu } from '@/shared/components/ContextMenu';
|
import { ContextMenu } from '@/shared/components/ContextMenu';
|
||||||
import { safeIpcInvoke } from '@/shared/utils/bus';
|
import { safeIpcInvoke } from '@/shared/utils/bus';
|
||||||
import { BIDIRECTIONAL } from '@shared/constants/ipc-channels';
|
import { BIDIRECTIONAL } from '@shared/constants/ipc';
|
||||||
import { ITEM_W, ITEM_H } from '../../utils/constants';
|
import { ITEM_W, ITEM_H } from '../../utils/constants';
|
||||||
import type { AuditStatus } from '../../stores/useCanvasStore';
|
import type { AuditStatus } from '../../stores/useCanvasStore';
|
||||||
|
|
||||||
|
|||||||
@@ -24,17 +24,18 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { FormInstance } from 'antd';
|
|
||||||
import { SendOutlined, ClockCircleOutlined, ReloadOutlined } from '@ant-design/icons';
|
import { SendOutlined, ClockCircleOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
import type { UploadFile } from 'antd/es/upload';
|
import type { UploadFile } from 'antd/es/upload';
|
||||||
|
|
||||||
import { useHomeContext } from '@/modules/home/shared/stores/HomeContext';
|
import { useHomeContext } from '@/modules/home/shared/stores/HomeContext';
|
||||||
import { useAppContext } from '@/app/providers/AppProvider';
|
import { useAppContext } from '@/app/providers/AppProvider';
|
||||||
import { useSubmitTask } from '@/shared/hooks/use-api';
|
import { useSubmitTask } from '@/shared/hooks/use-api';
|
||||||
import { emit, EVENTS } from '@/shared/utils/bus';
|
import { emit, on, off, EVENTS } from '@/shared/utils/bus';
|
||||||
|
import { saveParamSnapshot } from '@/shared/infrastructure/storage';
|
||||||
|
import { getCachedModels } from '@/services/cache/model-cache';
|
||||||
import { OutputTypeMap } from '@/services/modules/home/task';
|
import { OutputTypeMap } from '@/services/modules/home/task';
|
||||||
import type { OutputTypeString, TaskInfoItemResponseBody } 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 {
|
import {
|
||||||
calculateCost,
|
calculateCost,
|
||||||
formatYuan,
|
formatYuan,
|
||||||
@@ -49,102 +50,27 @@ import {
|
|||||||
execResetFont, hasTextSelection, isInputEmpty, getFontSizeLevel,
|
execResetFont, hasTextSelection, isInputEmpty, getFontSizeLevel,
|
||||||
} from '../controllers/menus/text-input-ops';
|
} from '../controllers/menus/text-input-ops';
|
||||||
|
|
||||||
|
import { handleFileWidgetValue, EMPTY_ARRAY, EMPTY_OBJ, safeNum } from './ModelInputForm/utils';
|
||||||
|
import { DependentFormItem } from './ModelInputForm/DependentFormItem';
|
||||||
|
|
||||||
const { Text, Title } = Typography;
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
// ---------- 工具 ----------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Form.Item 的 getValueFromEvent,兼容两种 onChange 参数形态:
|
|
||||||
* 1. Upload 组件的 DOM 事件:{ fileList: UploadFile[] } → 提取 fileList
|
|
||||||
* 2. enable_switch 的裸值:undefined / [] / [{...}] → 直接透传
|
|
||||||
*/
|
|
||||||
function handleFileWidgetValue(e: unknown): unknown {
|
|
||||||
if (e && typeof e === 'object' && 'fileList' in e) {
|
|
||||||
return (e as { fileList: unknown }).fileList;
|
|
||||||
}
|
|
||||||
return e;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 依赖字段包装组件 ----------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DependentFormItem — 为单个字段处理条件依赖禁用。
|
|
||||||
*
|
|
||||||
* 通过 Form.useWatch 监听 dependsOn 列表中的字段值,
|
|
||||||
* 任一依赖为空时自动禁用本字段(注入 disabled: true 到 widgetConfig)。
|
|
||||||
*
|
|
||||||
* 每个 DependentFormItem 实例的 dependsOn 长度固定,
|
|
||||||
* 切换模型时通过外层 key 变化自动重新挂载,满足 React hooks 规则。
|
|
||||||
*/
|
|
||||||
function DependentFormItem({
|
|
||||||
field,
|
|
||||||
form,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
disabled: formItemDisabled,
|
|
||||||
}: {
|
|
||||||
field: UIFieldConfig;
|
|
||||||
form: FormInstance;
|
|
||||||
/** Form.Item 通过 React.cloneElement 注入,必须透传给 widget */
|
|
||||||
value?: unknown;
|
|
||||||
onChange?: (value: unknown) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}) {
|
|
||||||
// Form.useWatch 必须在组件顶层无条件调用(不能放 .map() 或条件分支中)。
|
|
||||||
// dependsOn 长度由 param_schema 约束(当前 API 最多 2 个),
|
|
||||||
// 预留 3 个固定槽位覆盖未来扩展;无依赖时 watch 空串 → 返回 undefined。
|
|
||||||
const deps = field.dependsOn || [];
|
|
||||||
const d0 = Form.useWatch(deps[0] || '', form);
|
|
||||||
const d1 = Form.useWatch(deps[1] || '', form);
|
|
||||||
const d2 = Form.useWatch(deps[2] || '', form);
|
|
||||||
const depValues = [d0, d1, d2].slice(0, deps.length);
|
|
||||||
|
|
||||||
const isDepDisabled =
|
|
||||||
deps.some((_, i) => isValueEmpty(depValues[i]));
|
|
||||||
|
|
||||||
const effectiveDisabled = formItemDisabled || isDepDisabled;
|
|
||||||
const effectiveConfig = effectiveDisabled
|
|
||||||
? { ...field.widgetConfig, disabled: true }
|
|
||||||
: field.widgetConfig;
|
|
||||||
|
|
||||||
const Component = field.component;
|
|
||||||
return (
|
|
||||||
<Component
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
disabled={effectiveDisabled}
|
|
||||||
config={effectiveConfig}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 稳定空数组引用,避免 useMemo 重建 */
|
|
||||||
const EMPTY_ARRAY: never[] = [];
|
|
||||||
/** 稳定空对象引用,避免每次渲染创建新 {} 导致 useMemo 失效 */
|
|
||||||
const EMPTY_OBJ: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从自由格式 JSON 中安全读取数字(兼容 int / float / string)
|
|
||||||
*/
|
|
||||||
function safeNum(v: unknown): number | null {
|
|
||||||
if (v === null || v === undefined) return null;
|
|
||||||
if (typeof v === 'number' && !Number.isNaN(v)) return v;
|
|
||||||
if (typeof v === 'string') {
|
|
||||||
const n = Number(v.trim());
|
|
||||||
return Number.isNaN(n) ? null : n;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 主组件 ----------
|
// ---------- 主组件 ----------
|
||||||
|
|
||||||
export function ModelInputForm() {
|
export function ModelInputForm() {
|
||||||
const { token } = antTheme.useToken();
|
const { token } = antTheme.useToken();
|
||||||
const { isLoggedIn } = useAppContext();
|
const { isLoggedIn } = useAppContext();
|
||||||
const { selectedModel } = useHomeContext();
|
const { selectedModel, setSelectedModel } = useHomeContext();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [taskTag, setTaskTag] = useState('');
|
const [taskTag, setTaskTag] = useState('');
|
||||||
|
|
||||||
|
// ── 重新编辑 / 再次生成支持 ──
|
||||||
|
|
||||||
|
/** 待回填的参数(由 TASK_REEDIT / TASK_REGEN 事件设置) */
|
||||||
|
const pendingTaskRef = useRef<{ params: Record<string, unknown>; autoSubmit: boolean } | null>(null);
|
||||||
|
/** 自动提交标记(再次生成时设置) */
|
||||||
|
const autoSubmitRef = useRef(false);
|
||||||
|
|
||||||
// 跟踪表单当前值(用于动态计价,每次字段变化时更新)
|
// 跟踪表单当前值(用于动态计价,每次字段变化时更新)
|
||||||
const [currentValues, setCurrentValues] = useState<Record<string, unknown>>({});
|
const [currentValues, setCurrentValues] = useState<Record<string, unknown>>({});
|
||||||
|
|
||||||
@@ -174,6 +100,78 @@ export function ModelInputForm() {
|
|||||||
submitErrorRef.current = submitError;
|
submitErrorRef.current = submitError;
|
||||||
}, [submitError]);
|
}, [submitError]);
|
||||||
|
|
||||||
|
// ======== 重新编辑 / 再次生成事件处理 ========
|
||||||
|
|
||||||
|
/** 处理重新编辑事件:切换模型 + 回填参数 */
|
||||||
|
const handleReeditEvent = useCallback((...args: unknown[]) => {
|
||||||
|
const payload = args[0] as { record: TaskInfoItemResponseBody; savedParams?: Record<string, unknown> };
|
||||||
|
const { record, savedParams } = payload;
|
||||||
|
|
||||||
|
// 从缓存中查找模型
|
||||||
|
const models = getCachedModels();
|
||||||
|
const model = models?.find((m) => m.id === record.model_id);
|
||||||
|
if (!model) {
|
||||||
|
message.warning('未找到对应模型,可能模型列表未加载');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同模型 → 直接回填表单
|
||||||
|
if (selectedModel?.id === model.id && savedParams && Object.keys(savedParams).length > 0) {
|
||||||
|
form.setFieldsValue(savedParams);
|
||||||
|
setCurrentValues(savedParams);
|
||||||
|
setTaskTag('');
|
||||||
|
message.info('已回填历史参数,请修改后提交');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不同模型 → 切换模型 + 存储待回填参数
|
||||||
|
if (savedParams && Object.keys(savedParams).length > 0) {
|
||||||
|
pendingTaskRef.current = { params: savedParams, autoSubmit: false };
|
||||||
|
}
|
||||||
|
setSelectedModel(model);
|
||||||
|
}, [selectedModel, form, setSelectedModel]);
|
||||||
|
|
||||||
|
/** 处理再次生成事件:切换模型 + 回填参数 + 自动提交 */
|
||||||
|
const handleRegenEvent = useCallback((...args: unknown[]) => {
|
||||||
|
const payload = args[0] as { record: TaskInfoItemResponseBody; savedParams?: Record<string, unknown> };
|
||||||
|
const { record, savedParams } = payload;
|
||||||
|
|
||||||
|
const models = getCachedModels();
|
||||||
|
const model = models?.find((m) => m.id === record.model_id);
|
||||||
|
if (!model) {
|
||||||
|
message.warning('未找到对应模型,可能模型列表未加载');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!savedParams || Object.keys(savedParams).length === 0) {
|
||||||
|
message.warning('未找到原始参数,请手动重新提交');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同模型 → 直接回填 + 自动提交
|
||||||
|
if (selectedModel?.id === model.id) {
|
||||||
|
form.setFieldsValue(savedParams);
|
||||||
|
setCurrentValues(savedParams);
|
||||||
|
setTaskTag('');
|
||||||
|
autoSubmitRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不同模型 → 切换模型 + 存储待回填参数 + 标记自动提交
|
||||||
|
pendingTaskRef.current = { params: savedParams, autoSubmit: true };
|
||||||
|
setSelectedModel(model);
|
||||||
|
}, [selectedModel, form, setSelectedModel]);
|
||||||
|
|
||||||
|
// 注册事件监听
|
||||||
|
useEffect(() => {
|
||||||
|
on(EVENTS.TASK_REEDIT, handleReeditEvent);
|
||||||
|
on(EVENTS.TASK_REGEN, handleRegenEvent);
|
||||||
|
return () => {
|
||||||
|
off(EVENTS.TASK_REEDIT, handleReeditEvent);
|
||||||
|
off(EVENTS.TASK_REGEN, handleRegenEvent);
|
||||||
|
};
|
||||||
|
}, [handleReeditEvent, handleRegenEvent]);
|
||||||
|
|
||||||
// ======== useModelUI:param_schema → UIFieldConfig[] ========
|
// ======== useModelUI:param_schema → UIFieldConfig[] ========
|
||||||
|
|
||||||
const fields: UIFieldConfig[] = useModelUI(
|
const fields: UIFieldConfig[] = useModelUI(
|
||||||
@@ -196,12 +194,25 @@ export function ModelInputForm() {
|
|||||||
|
|
||||||
// 选中模型时加载默认值(useLayoutEffect 在 DOM 更新后、浏览器绘制前执行,避免闪烁)
|
// 选中模型时加载默认值(useLayoutEffect 在 DOM 更新后、浏览器绘制前执行,避免闪烁)
|
||||||
// antd 有外部 form 实例时 initialValues prop 被忽略,必须手动 setFieldsValue
|
// antd 有外部 form 实例时 initialValues prop 被忽略,必须手动 setFieldsValue
|
||||||
|
// 如果有待回填的重新编辑参数(pendingTaskRef),优先使用
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (selectedModel && Object.keys(initialValues).length > 0) {
|
if (selectedModel && Object.keys(initialValues).length > 0) {
|
||||||
|
// 检查是否有待回填的参数(来自重新编辑/再次生成)
|
||||||
|
const pending = pendingTaskRef.current;
|
||||||
|
if (pending) {
|
||||||
|
pendingTaskRef.current = null;
|
||||||
|
form.setFieldsValue(pending.params);
|
||||||
|
setCurrentValues(pending.params);
|
||||||
|
setTaskTag('');
|
||||||
|
if (pending.autoSubmit) {
|
||||||
|
autoSubmitRef.current = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
form.setFieldsValue(initialValues);
|
form.setFieldsValue(initialValues);
|
||||||
setTaskTag('');
|
setTaskTag('');
|
||||||
// 同步价格计算:切换模型时立即用默认值更新预估
|
// 同步价格计算:切换模型时立即用默认值更新预估
|
||||||
setCurrentValues(initialValues);
|
setCurrentValues(initialValues);
|
||||||
|
}
|
||||||
} else if (!selectedModel) {
|
} else if (!selectedModel) {
|
||||||
setCurrentValues({});
|
setCurrentValues({});
|
||||||
}
|
}
|
||||||
@@ -296,6 +307,12 @@ export function ModelInputForm() {
|
|||||||
|
|
||||||
if (result !== undefined) {
|
if (result !== undefined) {
|
||||||
submitCountRef.current += 1;
|
submitCountRef.current += 1;
|
||||||
|
// 保存参数快照(用于"再次生成"和"重新编辑"功能)
|
||||||
|
try {
|
||||||
|
saveParamSnapshot(result.id, selectedModel.id, selectedModel.name, params, taskTag.trim() || undefined);
|
||||||
|
} catch {
|
||||||
|
// 参数快照保存失败不影响主流程
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 余额不足 / 软件到期等错误已由 request.ts 拦截器 → 事件总线 → Modal 展示,
|
// 余额不足 / 软件到期等错误已由 request.ts 拦截器 → 事件总线 → Modal 展示,
|
||||||
// 此处检查 error.displayed 标记,避免 message.error toast 重复提示
|
// 此处检查 error.displayed 标记,避免 message.error toast 重复提示
|
||||||
@@ -342,6 +359,19 @@ export function ModelInputForm() {
|
|||||||
void doSubmit();
|
void doSubmit();
|
||||||
}, [isLoggedIn, selectedModel, doSubmit]);
|
}, [isLoggedIn, selectedModel, doSubmit]);
|
||||||
|
|
||||||
|
// 自动提交检测(再次生成时,回填参数后自动触发提交)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoSubmitRef.current || !selectedModel || submitting || !isLoggedIn) return;
|
||||||
|
autoSubmitRef.current = false;
|
||||||
|
// 延迟确保表单值已设置
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
// 跳过防抖和二次确认,直接提交
|
||||||
|
lastClickTimeRef.current = 0;
|
||||||
|
void doSubmit();
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
});
|
||||||
|
|
||||||
// ======== 动态预估消费金额(必须在所有提前返回之前) ========
|
// ======== 动态预估消费金额(必须在所有提前返回之前) ========
|
||||||
|
|
||||||
const estimatedCostDisplay: string = useMemo(() => {
|
const estimatedCostDisplay: string = useMemo(() => {
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ModelInputForm/DependentFormItem.tsx — 依赖字段包装组件
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Form } from 'antd';
|
||||||
|
import type { FormInstance } from 'antd';
|
||||||
|
|
||||||
|
import { isValueEmpty, type UIFieldConfig } from '../../hooks/useModelUI';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DependentFormItem — 为单个字段处理条件依赖禁用。
|
||||||
|
*
|
||||||
|
* 通过 Form.useWatch 监听 dependsOn 列表中的字段值,
|
||||||
|
* 任一依赖为空时自动禁用本字段(注入 disabled: true 到 widgetConfig)。
|
||||||
|
*
|
||||||
|
* 每个 DependentFormItem 实例的 dependsOn 长度固定,
|
||||||
|
* 切换模型时通过外层 key 变化自动重新挂载,满足 React hooks 规则。
|
||||||
|
*/
|
||||||
|
export function DependentFormItem({
|
||||||
|
field,
|
||||||
|
form,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled: formItemDisabled,
|
||||||
|
}: {
|
||||||
|
field: UIFieldConfig;
|
||||||
|
form: FormInstance;
|
||||||
|
/** Form.Item 通过 React.cloneElement 注入,必须透传给 widget */
|
||||||
|
value?: unknown;
|
||||||
|
onChange?: (value: unknown) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
// Form.useWatch 必须在组件顶层无条件调用(不能放 .map() 或条件分支中)。
|
||||||
|
// dependsOn 长度由 param_schema 约束(当前 API 最多 2 个),
|
||||||
|
// 预留 3 个固定槽位覆盖未来扩展;无依赖时 watch 空串 → 返回 undefined。
|
||||||
|
const deps = field.dependsOn || [];
|
||||||
|
const d0 = Form.useWatch(deps[0] || '', form);
|
||||||
|
const d1 = Form.useWatch(deps[1] || '', form);
|
||||||
|
const d2 = Form.useWatch(deps[2] || '', form);
|
||||||
|
const depValues = [d0, d1, d2].slice(0, deps.length);
|
||||||
|
|
||||||
|
const isDepDisabled =
|
||||||
|
deps.some((_, i) => isValueEmpty(depValues[i]));
|
||||||
|
|
||||||
|
const effectiveDisabled = formItemDisabled || isDepDisabled;
|
||||||
|
const effectiveConfig = effectiveDisabled
|
||||||
|
? { ...field.widgetConfig, disabled: true }
|
||||||
|
: field.widgetConfig;
|
||||||
|
|
||||||
|
const Component = field.component;
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={effectiveDisabled}
|
||||||
|
config={effectiveConfig}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/modules/home/center/views/ModelInputForm/utils.ts
Normal file
33
src/modules/home/center/views/ModelInputForm/utils.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ModelInputForm/utils.ts — 工具函数
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Form.Item 的 getValueFromEvent,兼容两种 onChange 参数形态:
|
||||||
|
* 1. Upload 组件的 DOM 事件:{ fileList: UploadFile[] } → 提取 fileList
|
||||||
|
* 2. enable_switch 的裸值:undefined / [] / [{...}] → 直接透传
|
||||||
|
*/
|
||||||
|
export function handleFileWidgetValue(e: unknown): unknown {
|
||||||
|
if (e && typeof e === 'object' && 'fileList' in e) {
|
||||||
|
return (e as { fileList: unknown }).fileList;
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 稳定空数组引用,避免 useMemo 重建 */
|
||||||
|
export const EMPTY_ARRAY: never[] = [];
|
||||||
|
/** 稳定空对象引用,避免每次渲染创建新 {} 导致 useMemo 失效 */
|
||||||
|
export const EMPTY_OBJ: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从自由格式 JSON 中安全读取数字(兼容 int / float / string)
|
||||||
|
*/
|
||||||
|
export function safeNum(v: unknown): number | null {
|
||||||
|
if (v === null || v === undefined) return null;
|
||||||
|
if (typeof v === 'number' && !Number.isNaN(v)) return v;
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
const n = Number(v.trim());
|
||||||
|
return Number.isNaN(n) ? null : n;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -6,3 +6,4 @@ export { getTaskListMenuItems } from './task-list-menu';
|
|||||||
export type { TaskListMenuOptions } from './task-list-menu';
|
export type { TaskListMenuOptions } from './task-list-menu';
|
||||||
|
|
||||||
export { buildTaskActions } from './task-list-actions';
|
export { buildTaskActions } from './task-list-actions';
|
||||||
|
export type { BuildTaskActionsOptions } from './task-list-actions';
|
||||||
|
|||||||
@@ -9,33 +9,46 @@
|
|||||||
// 设计原则:
|
// 设计原则:
|
||||||
// - View 不关心 IPC 细节、路径拼接逻辑、错误处理
|
// - View 不关心 IPC 细节、路径拼接逻辑、错误处理
|
||||||
// - 每个操作独立 async 函数,便于单独测试和复用
|
// - 每个操作独立 async 函数,便于单独测试和复用
|
||||||
|
// - 跨组件操作(重新编辑/再次生成)通过事件总线协调
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { message } from 'antd';
|
import {message, Modal} from "antd";
|
||||||
import { safeIpcInvoke, logger } from '@/shared/utils/bus';
|
import {safeIpcInvoke, logger, emit, EVENTS} from "@/shared/utils/bus";
|
||||||
import { BIDIRECTIONAL } from '@shared/constants/ipc-channels';
|
import {BIDIRECTIONAL} from "@shared/constants/ipc";
|
||||||
import { joinPaths } from '@/shared/utils/path-join';
|
import {joinPaths} from "@/shared/utils/path-join";
|
||||||
import type { TaskInfoItemResponseBody } from '@/services/modules/home/task';
|
import {getToken} from "@/services/request";
|
||||||
import type { TaskListMenuOptions } from './task-list-menu';
|
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 位 */
|
/** user_id → 文件系统安全标识:去非字母数字 → 小写 → 前 8 位 */
|
||||||
function cleanUserId(raw: string): string {
|
function cleanUserId(raw: string): string {
|
||||||
return raw
|
return raw
|
||||||
.replace(/[^a-zA-Z0-9]/g, '')
|
.replace(/[^a-zA-Z0-9]/g, "")
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.slice(0, 8);
|
.slice(0, 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ISO 日期 → "YYYY-MM" */
|
/** ISO 日期 → "YYYY-MM" */
|
||||||
function formatYearMonth(d: Date): string {
|
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" */
|
/** ISO 日期 → "DD" */
|
||||||
function formatDay(d: Date): string {
|
function formatDay(d: Date): string {
|
||||||
return String(d.getDate()).padStart(2, '0');
|
return String(d.getDate()).padStart(2, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** task_id → 任务目录键:前 8 字符 → t_{key} */
|
/** task_id → 任务目录键:前 8 字符 → t_{key} */
|
||||||
@@ -43,10 +56,49 @@ function taskDirKey(taskId: string): string {
|
|||||||
return `t_${taskId.slice(0, 8)}`;
|
return `t_${taskId.slice(0, 8)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 从 ui_params 中提取参考素材 URL 列表 */
|
||||||
|
function extractReferenceUrls(record: TaskInfoItemResponseBody): string[] {
|
||||||
|
const urls: string[] = [];
|
||||||
|
if (record.output_type === 'image' && record.ui_params.imageUrls) {
|
||||||
|
urls.push(...record.ui_params.imageUrls);
|
||||||
|
} else if (record.output_type === 'video' && record.ui_params.content) {
|
||||||
|
for (const item of record.ui_params.content) {
|
||||||
|
if (item.type === 'image_url' && 'image_url' in item) {
|
||||||
|
urls.push(item.image_url.url);
|
||||||
|
} else if (item.type === 'video_url' && 'video_url' in item) {
|
||||||
|
urls.push(item.video_url.url);
|
||||||
|
} else if (item.type === 'audio_url' && 'audio_url' in item) {
|
||||||
|
urls.push(item.audio_url.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return urls.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
// ── 操作实现 ──
|
// ── 操作实现 ──
|
||||||
|
|
||||||
/** 打开任务输出目录 */
|
/**
|
||||||
|
* 打开任务输出目录 — 自动导出任务数据到用户配置的产物路径。
|
||||||
|
*
|
||||||
|
* 目录结构:
|
||||||
|
* <outputPath>/repository/<userId>/<YYYY-MM>/<DD>/t_<taskId前8位>/
|
||||||
|
* ├── inputs/
|
||||||
|
* │ ├── params.json ← 输入参数(model、prompt、tags、ui_params)
|
||||||
|
* │ └── <ref_image>.png ← 参考素材(ui_params 中的 imageUrls / content)
|
||||||
|
* └── outputs/
|
||||||
|
* ├── response.json ← 完整任务 API 响应
|
||||||
|
* ├── <output_1>.png ← 输出媒体文件
|
||||||
|
* └── .cache/
|
||||||
|
* └── <output_1>.jpg ← 缩略图(最长边 256px,JPEG 60%)
|
||||||
|
*/
|
||||||
async function openOutputDir(record: TaskInfoItemResponseBody): Promise<void> {
|
async function openOutputDir(record: TaskInfoItemResponseBody): Promise<void> {
|
||||||
|
// ---- 1. 校验 outputPath ----
|
||||||
|
const outputPath = getOutputPath();
|
||||||
|
if (!outputPath) {
|
||||||
|
message.warning('请先在设置中配置"产物输出路径"');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const d = new Date(record.created_at);
|
const d = new Date(record.created_at);
|
||||||
if (isNaN(d.getTime())) {
|
if (isNaN(d.getTime())) {
|
||||||
logger.warn('ui', '任务创建时间无效', undefined, { taskId: record.id });
|
logger.warn('ui', '任务创建时间无效', undefined, { taskId: record.id });
|
||||||
@@ -54,35 +106,119 @@ async function openOutputDir(record: TaskInfoItemResponseBody): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// ---- 2. 构建目录结构 ----
|
||||||
const result = (await safeIpcInvoke(BIDIRECTIONAL.GET_DATA_DIR)) as
|
const taskDir = joinPaths(
|
||||||
| { success: boolean; data: string }
|
outputPath,
|
||||||
| undefined;
|
|
||||||
|
|
||||||
if (!result?.data) {
|
|
||||||
logger.warn('ui', 'GET_DATA_DIR 返回空', undefined, { taskId: record.id });
|
|
||||||
message.warning('无法获取本地数据目录');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const outputsDir = joinPaths(
|
|
||||||
result.data,
|
|
||||||
'repository',
|
'repository',
|
||||||
cleanUserId(record.user_id),
|
cleanUserId(record.user_id),
|
||||||
formatYearMonth(d),
|
formatYearMonth(d),
|
||||||
formatDay(d),
|
formatDay(d),
|
||||||
taskDirKey(record.id),
|
taskDirKey(record.id),
|
||||||
'outputs',
|
|
||||||
);
|
);
|
||||||
|
const inputsDir = joinPaths(taskDir, 'inputs');
|
||||||
|
const outputsDir = joinPaths(taskDir, 'outputs');
|
||||||
|
const cacheDir = joinPaths(outputsDir, '.cache');
|
||||||
|
|
||||||
await safeIpcInvoke(BIDIRECTIONAL.SHOW_ITEM_IN_FOLDER, {
|
try {
|
||||||
filePath: outputsDir,
|
// 创建三级目录
|
||||||
|
await makeDirAbsolute(inputsDir);
|
||||||
|
await makeDirAbsolute(outputsDir);
|
||||||
|
await makeDirAbsolute(cacheDir);
|
||||||
|
|
||||||
|
const token = getToken();
|
||||||
|
const authHeaders = token ? { Authorization: `Bearer ${token}` } : undefined;
|
||||||
|
|
||||||
|
// ---- 3. inputs/ — 导出参数 + 参考素材 ----
|
||||||
|
const paramEntry = getParamByTaskId(record.id);
|
||||||
|
const savedParams = paramEntry ? parseParams(paramEntry) : undefined;
|
||||||
|
const paramsJson = JSON.stringify(
|
||||||
|
{
|
||||||
|
task_id: record.id,
|
||||||
|
model_id: record.model_id,
|
||||||
|
model_name: record.model_name,
|
||||||
|
provider_name: record.provider_name,
|
||||||
|
prompt: record.prompt ?? null,
|
||||||
|
tags: record.tags,
|
||||||
|
ui_params: record.ui_params,
|
||||||
|
saved_params: savedParams ?? null,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
await writeFileAbsolute(joinPaths(inputsDir, 'params.json'), paramsJson, 'utf8');
|
||||||
|
|
||||||
|
// 下载参考素材到 inputs/
|
||||||
|
const refUrls = extractReferenceUrls(record);
|
||||||
|
for (let i = 0; i < refUrls.length; i++) {
|
||||||
|
try {
|
||||||
|
const urlPath = new URL(refUrls[i]).pathname;
|
||||||
|
const fileName = urlPath.split('/').pop() || `ref_${i + 1}`;
|
||||||
|
await downloadToPath(refUrls[i], joinPaths(inputsDir, fileName), authHeaders);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('ui', '参考素材下载失败', err instanceof Error ? err : undefined, {
|
||||||
|
taskId: record.id,
|
||||||
|
url: refUrls[i],
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 4. outputs/ — 导出响应数据 + 媒体文件 + 缩略图 ----
|
||||||
|
const responseJson = JSON.stringify(record, null, 2);
|
||||||
|
await writeFileAbsolute(joinPaths(outputsDir, 'response.json'), responseJson, 'utf8');
|
||||||
|
|
||||||
|
const assets = record.output_assets;
|
||||||
|
if (assets && assets.length > 0) {
|
||||||
|
let downloadCount = 0;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (const asset of assets) {
|
||||||
|
try {
|
||||||
|
const urlPath = new URL(asset.url).pathname;
|
||||||
|
const fileName = urlPath.split('/').pop() || `output_${downloadCount + 1}`;
|
||||||
|
const filePath = joinPaths(outputsDir, fileName);
|
||||||
|
|
||||||
|
// 下载输出媒体
|
||||||
|
await downloadToPath(asset.url, filePath, authHeaders);
|
||||||
|
downloadCount++;
|
||||||
|
|
||||||
|
// 生成缩略图到 .cache/(仅图片)
|
||||||
|
const ext = fileName.split('.').pop()?.toLowerCase();
|
||||||
|
if (ext && ['png', 'jpg', 'jpeg', 'webp', 'bmp'].includes(ext)) {
|
||||||
|
const thumbName = fileName.replace(/\.[^.]+$/, '.jpg');
|
||||||
|
try {
|
||||||
|
await generateThumbnail(filePath, joinPaths(cacheDir, thumbName));
|
||||||
|
} catch {
|
||||||
|
// 缩略图生成失败不阻塞主流程
|
||||||
|
logger.warn('ui', '缩略图生成失败', undefined, { taskId: record.id, file: fileName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const reason = err instanceof Error ? err.message : String(err);
|
||||||
|
errors.push(reason);
|
||||||
|
logger.warn('ui', '输出媒体下载失败', err instanceof Error ? err : new Error(String(err)), {
|
||||||
|
taskId: record.id,
|
||||||
|
url: asset.url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0 && downloadCount > 0) {
|
||||||
|
message.warning(`已导出 ${downloadCount} 个文件,${errors.length} 个下载失败`);
|
||||||
|
} else if (errors.length > 0) {
|
||||||
|
message.error(`媒体下载失败:${errors[0]}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 5. 打开任务根目录 ----
|
||||||
|
await safeIpcInvoke(BIDIRECTIONAL.SHOW_ITEM_IN_FOLDER, { filePath: taskDir });
|
||||||
|
message.success('已导出任务数据到本地目录');
|
||||||
|
logger.info('ui', '导出任务数据成功', { taskId: record.id, path: taskDir });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('ui', '打开输出目录失败', err instanceof Error ? err : new Error(String(err)), {
|
logger.warn('ui', '打开输出目录失败', err instanceof Error ? err : new Error(String(err)), {
|
||||||
taskId: record.id,
|
taskId: record.id,
|
||||||
});
|
});
|
||||||
message.warning('输出目录不存在或无法访问');
|
message.warning('输出目录创建失败或无法访问');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,42 +226,202 @@ async function openOutputDir(record: TaskInfoItemResponseBody): Promise<void> {
|
|||||||
async function copyResourceLink(record: TaskInfoItemResponseBody): Promise<void> {
|
async function copyResourceLink(record: TaskInfoItemResponseBody): Promise<void> {
|
||||||
const assets = record.output_assets;
|
const assets = record.output_assets;
|
||||||
if (!assets || assets.length === 0) {
|
if (!assets || assets.length === 0) {
|
||||||
message.warning('该任务无输出资源');
|
message.warning("该任务无输出资源");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(assets[0].url);
|
await navigator.clipboard.writeText(assets[0].url);
|
||||||
message.success('已复制资源链接');
|
message.success("已复制资源链接");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('ui', '复制资源链接失败', err instanceof Error ? err : new Error(String(err)));
|
logger.warn("ui", "复制资源链接失败", err instanceof Error ? err : new Error(String(err)));
|
||||||
message.error('复制失败');
|
message.error("复制失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重新编辑:通过事件总线通知 ModelInputForm 切换模型并回填参数 */
|
||||||
|
function reeditTask(record: TaskInfoItemResponseBody): void {
|
||||||
|
// 尝试从本地参数历史中恢复原始输入参数
|
||||||
|
const paramEntry = getParamByTaskId(record.id);
|
||||||
|
const savedParams = paramEntry ? parseParams(paramEntry) : undefined;
|
||||||
|
|
||||||
|
emit(EVENTS.TASK_REEDIT, {record, savedParams});
|
||||||
|
logger.info("ui", "触发重新编辑", {taskId: record.id, modelId: record.model_id});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 再次生成:切换模型 + 回填参数 + 自动提交 */
|
||||||
|
function regenTask(record: TaskInfoItemResponseBody): void {
|
||||||
|
const paramEntry = getParamByTaskId(record.id);
|
||||||
|
const savedParams = paramEntry ? parseParams(paramEntry) : undefined;
|
||||||
|
|
||||||
|
if (!savedParams) {
|
||||||
|
// 无参数快照 → 退化为重新编辑(用户手动提交)
|
||||||
|
message.info("未找到原始参数,请手动修改后重新提交");
|
||||||
|
emit(EVENTS.TASK_REEDIT, {record, savedParams: undefined});
|
||||||
|
logger.info("ui", "再次生成退化为重新编辑(无参数快照)", {taskId: record.id});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(EVENTS.TASK_REGEN, {record, savedParams});
|
||||||
|
logger.info("ui", "触发再次生成", {taskId: record.id, modelId: record.model_id});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拉取结果:重新从 API 获取任务详情并更新本地存储 */
|
||||||
|
async function pullResult(record: TaskInfoItemResponseBody): Promise<void> {
|
||||||
|
try {
|
||||||
|
const latest = await TaskAPI.getTaskInfo(record.id);
|
||||||
|
upsertTask(latest);
|
||||||
|
emit(EVENTS.TASK_LIST_REFRESH);
|
||||||
|
message.success("已拉取最新结果");
|
||||||
|
logger.info("ui", "拉取结果成功", {taskId: record.id, status: latest.status});
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn("ui", "拉取结果失败", err instanceof Error ? err : new Error(String(err)), {
|
||||||
|
taskId: record.id,
|
||||||
|
});
|
||||||
|
message.error("拉取结果失败,请稍后重试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重新下载:通过主进程下载任务的所有输出资源到系统下载目录 */
|
||||||
|
async function redownloadAssets(record: TaskInfoItemResponseBody): Promise<void> {
|
||||||
|
const assets = record.output_assets;
|
||||||
|
if (!assets || assets.length === 0) {
|
||||||
|
message.warning("该任务无输出资源");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取系统下载目录
|
||||||
|
const downloadsDir = await getDownloadsPath();
|
||||||
|
if (!downloadsDir) {
|
||||||
|
message.error("无法获取系统下载目录");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
const errors: string[] = [];
|
||||||
|
const token = getToken();
|
||||||
|
const authHeaders = token ? {Authorization: `Bearer ${token}`} : undefined;
|
||||||
|
|
||||||
|
for (const asset of assets) {
|
||||||
|
try {
|
||||||
|
const urlPath = new URL(asset.url).pathname;
|
||||||
|
const fileName = urlPath.split("/").pop() || `task_${record.id}_output`;
|
||||||
|
const destPath = joinPaths(downloadsDir, fileName);
|
||||||
|
|
||||||
|
// 通过主进程下载(绕过浏览器 CORS)
|
||||||
|
await downloadToPath(asset.url, destPath, authHeaders);
|
||||||
|
successCount++;
|
||||||
|
} catch (err) {
|
||||||
|
const reason = err instanceof Error ? err.message : String(err);
|
||||||
|
errors.push(reason);
|
||||||
|
logger.warn("ui", "重新下载失败", err instanceof Error ? err : new Error(String(err)), {
|
||||||
|
taskId: record.id,
|
||||||
|
url: asset.url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length === 0 && successCount > 0) {
|
||||||
|
message.success(`已下载 ${successCount} 个文件到下载目录`);
|
||||||
|
} else if (successCount > 0) {
|
||||||
|
message.warning(`${successCount} 个成功,${errors.length} 个失败:${errors[0]}`);
|
||||||
|
} else {
|
||||||
|
const detail = errors[0] || "未知错误";
|
||||||
|
message.error(`下载失败:${detail}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除记录:仅删除本地存储 */
|
||||||
|
async function deleteTaskRecord(
|
||||||
|
record: TaskInfoItemResponseBody,
|
||||||
|
onRefreshList?: () => void,
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: "确认删除",
|
||||||
|
content: `确定要删除任务 ${record.id.slice(0, 8)}... 的记录吗?此操作仅删除本地记录。`,
|
||||||
|
okText: "删除",
|
||||||
|
okType: "danger",
|
||||||
|
cancelText: "取消",
|
||||||
|
onOk: () => {
|
||||||
|
try {
|
||||||
|
deleteTask(record.id);
|
||||||
|
onRefreshList?.();
|
||||||
|
message.success("已删除任务记录");
|
||||||
|
logger.info("ui", "删除任务记录", {taskId: record.id});
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn("ui", "删除任务记录失败", err instanceof Error ? err : new Error(String(err)), {
|
||||||
|
taskId: record.id,
|
||||||
|
});
|
||||||
|
message.error("删除失败");
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
onCancel: () => resolve(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换收藏状态 */
|
||||||
|
function toggleFavorite(
|
||||||
|
record: TaskInfoItemResponseBody,
|
||||||
|
onRefreshList?: () => void,
|
||||||
|
): void {
|
||||||
|
const newState = !record.is_favorite;
|
||||||
|
try {
|
||||||
|
toggleFavoriteTask(record.id, newState);
|
||||||
|
onRefreshList?.();
|
||||||
|
message.success(newState ? "已收藏" : "已取消收藏");
|
||||||
|
logger.info("ui", "切换收藏状态", {taskId: record.id, isFavorite: newState});
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn("ui", "切换收藏状态失败", err instanceof Error ? err : new Error(String(err)), {
|
||||||
|
taskId: record.id,
|
||||||
|
});
|
||||||
|
message.error("操作失败");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 公开入口 ──
|
// ── 公开入口 ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* buildTaskActions 的额外配置项。
|
||||||
|
*
|
||||||
|
* 由 View 层传入,用于协调跨组件操作(如刷新列表)。
|
||||||
|
*/
|
||||||
|
export interface BuildTaskActionsOptions {
|
||||||
|
/** 删除/收藏后触发任务列表刷新 */
|
||||||
|
onRefreshList?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据任务记录构建完整的右键菜单操作配置。
|
* 根据任务记录构建完整的右键菜单操作配置。
|
||||||
*
|
*
|
||||||
* View 层调用此函数获取 TaskListMenuOptions,传入 getTaskListMenuItems()
|
* View 层调用此函数获取 TaskListMenuOptions,传入 getTaskListMenuItems()
|
||||||
* 即可得到渲染就绪的 ContextMenuItems。
|
* 即可得到渲染就绪的 ContextMenuItems。
|
||||||
|
*
|
||||||
|
* @param record 任务记录
|
||||||
|
* @param options 可选配置(列表刷新回调等)
|
||||||
*/
|
*/
|
||||||
export function buildTaskActions(record: TaskInfoItemResponseBody): TaskListMenuOptions {
|
export function buildTaskActions(
|
||||||
|
record: TaskInfoItemResponseBody,
|
||||||
|
options?: BuildTaskActionsOptions,
|
||||||
|
): TaskListMenuOptions {
|
||||||
|
const {onRefreshList} = options ?? {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
canOpenOutput: record.status === 'success',
|
canOpenOutput: record.status === "success",
|
||||||
canReedit: true,
|
canReedit: true,
|
||||||
canRegen: true,
|
canRegen: true,
|
||||||
canPullResult: record.status === 'success',
|
canPullResult: record.status === "success",
|
||||||
canRedownload: record.status === 'success',
|
canRedownload: record.status === "success",
|
||||||
isFavorited: record.is_favorite,
|
isFavorited: record.is_favorite,
|
||||||
|
|
||||||
onOpenOutputDir: () => void openOutputDir(record),
|
onOpenOutputDir: () => void openOutputDir(record),
|
||||||
onCopyResourceLink: () => void copyResourceLink(record),
|
onCopyResourceLink: () => void copyResourceLink(record),
|
||||||
onReedit: () => { message.info('重新编辑功能待实现'); },
|
onReedit: () => reeditTask(record),
|
||||||
onRegen: () => { message.info('再次生成功能待实现'); },
|
onRegen: () => regenTask(record),
|
||||||
onPullResult: () => { message.info('拉取结果功能待实现'); },
|
onPullResult: () => void pullResult(record),
|
||||||
onRedownload: () => { message.info('重新下载功能待实现'); },
|
onRedownload: () => void redownloadAssets(record),
|
||||||
onToggleFavorite: () => { message.info('收藏功能待实现'); },
|
onToggleFavorite: () => toggleFavorite(record, onRefreshList),
|
||||||
onDelete: () => { message.info('删除记录功能待实现'); },
|
onDelete: () => void deleteTaskRecord(record, onRefreshList),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import type { FilterValue } from 'antd/es/table/interface';
|
import type { FilterValue } from 'antd/es/table/interface';
|
||||||
import { TaskStatusMap, OutputTypeMap } from '@/services/modules/home/task';
|
import { TaskStatusMap, OutputTypeMap } from '@/services/modules/home/task';
|
||||||
import type { TaskInfoItemResponseBody } 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<string, { color: string; label: string }> = {
|
export const STATUS_CONFIG: Record<string, { color: string; label: string }> = {
|
||||||
pending: { color: 'blue', label: '排队中' },
|
pending: { color: 'blue', label: '处理中' },
|
||||||
submitted: { color: 'cyan', label: '已提交' },
|
submitted: { color: 'cyan', label: '已提交' },
|
||||||
processing: { color: 'orange', label: '处理中' },
|
processing: { color: 'orange', label: '处理中' },
|
||||||
success: { color: 'green', label: '已完成' },
|
success: { color: 'green', label: '已完成' },
|
||||||
|
|||||||
@@ -251,7 +251,11 @@ export function useTaskTable({ allModels }: UseTaskTableOptions): UseTaskTableRe
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
on(EVENTS.TASK_SUBMITTED, handleTaskSubmitted);
|
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]);
|
}, [handleTaskSubmitted]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export {
|
|||||||
buildTaskActions,
|
buildTaskActions,
|
||||||
} from './controllers/menus';
|
} from './controllers/menus';
|
||||||
export type { TaskListMenuOptions } from './controllers/menus';
|
export type { TaskListMenuOptions } from './controllers/menus';
|
||||||
|
export type { BuildTaskActionsOptions } from './controllers/menus/task-list-actions';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
STATUS_FILTERS,
|
STATUS_FILTERS,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// TaskHistory — 任务记录列表(纯 View 层)
|
// TaskHistory — 任务记录列表(纯 View 层)
|
||||||
//
|
|
||||||
// 职责:
|
// 职责:
|
||||||
// - 右键菜单状态(useState)与 DOM 事件绑定
|
// - 右键菜单状态(useState)与 DOM 事件绑定
|
||||||
// - 使用 useTaskTable Hook 获取所有数据与配置
|
// - 使用 useTaskTable Hook 获取所有数据与配置
|
||||||
@@ -29,6 +28,7 @@ import { useHomeContext } from '@/modules/home/shared/stores/HomeContext';
|
|||||||
import { getTaskListMenuItems, buildTaskActions } from '@/modules/home/left';
|
import { getTaskListMenuItems, buildTaskActions } from '@/modules/home/left';
|
||||||
import type { ContextMenuItems } from '@/shared/components/ContextMenu';
|
import type { ContextMenuItems } from '@/shared/components/ContextMenu';
|
||||||
import type { TaskInfoItemResponseBody } from '@/services/modules/home/task';
|
import type { TaskInfoItemResponseBody } from '@/services/modules/home/task';
|
||||||
|
import { emit, EVENTS } from '@/shared/utils/bus';
|
||||||
|
|
||||||
import { useTaskTable } from '@/modules/home/left';
|
import { useTaskTable } from '@/modules/home/left';
|
||||||
import { ALL_COLUMNS } from '@/modules/home/left';
|
import { ALL_COLUMNS } from '@/modules/home/left';
|
||||||
@@ -90,14 +90,17 @@ export function TaskHistory({ allModels }: TaskHistoryProps) {
|
|||||||
y: number;
|
y: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
/** ContextMenuItems → AntD MenuProps['items'] */
|
/** ContextMenuItems → AntD MenuProps['items'](包装 onClick 以在回调后关闭菜单) */
|
||||||
const toAntdMenu = useCallback((items: ContextMenuItems) =>
|
const toAntdMenu = useCallback((items: ContextMenuItems, onClose?: () => void) =>
|
||||||
items.map((item) => {
|
items.map((item) => {
|
||||||
if (item === 'divider') return { type: 'divider' as const };
|
if (item === 'divider') return { type: 'divider' as const };
|
||||||
return {
|
return {
|
||||||
key: item.key,
|
key: item.key,
|
||||||
label: item.label,
|
label: item.label,
|
||||||
onClick: item.onClick,
|
onClick: () => {
|
||||||
|
item.onClick?.();
|
||||||
|
onClose?.();
|
||||||
|
},
|
||||||
disabled: item.disabled,
|
disabled: item.disabled,
|
||||||
danger: item.danger,
|
danger: item.danger,
|
||||||
};
|
};
|
||||||
@@ -105,7 +108,9 @@ export function TaskHistory({ allModels }: TaskHistoryProps) {
|
|||||||
|
|
||||||
/** 根据任务记录生成右键菜单项 */
|
/** 根据任务记录生成右键菜单项 */
|
||||||
const buildTaskMenuItems = useCallback((record: TaskInfoItemResponseBody) => {
|
const buildTaskMenuItems = useCallback((record: TaskInfoItemResponseBody) => {
|
||||||
return toAntdMenu(getTaskListMenuItems(buildTaskActions(record)));
|
return toAntdMenu(getTaskListMenuItems(buildTaskActions(record, {
|
||||||
|
onRefreshList: () => emit(EVENTS.TASK_LIST_REFRESH),
|
||||||
|
})), () => setContextMenu(null));
|
||||||
}, [toAntdMenu]);
|
}, [toAntdMenu]);
|
||||||
|
|
||||||
// 菜单打开时监听全局 mousedown → 点击菜单外区域自动关闭
|
// 菜单打开时监听全局 mousedown → 点击菜单外区域自动关闭
|
||||||
@@ -265,7 +270,6 @@ export function TaskHistory({ allModels }: TaskHistoryProps) {
|
|||||||
onOpenChange={(open) => { if (!open) setContextMenu(null); }}
|
onOpenChange={(open) => { if (!open) setContextMenu(null); }}
|
||||||
menu={{
|
menu={{
|
||||||
items: contextMenu ? buildTaskMenuItems(contextMenu.record) : [],
|
items: contextMenu ? buildTaskMenuItems(contextMenu.record) : [],
|
||||||
onClick: () => setContextMenu(null),
|
|
||||||
}}
|
}}
|
||||||
trigger={[]}
|
trigger={[]}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const { Text } = Typography;
|
|||||||
// ---------- 状态标签配置 ----------
|
// ---------- 状态标签配置 ----------
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<TaskStatusString, { color: string; label: string }> = {
|
const STATUS_CONFIG: Record<TaskStatusString, { color: string; label: string }> = {
|
||||||
pending: { color: 'blue', label: '排队中' },
|
pending: { color: 'blue', label: '处理中...' },
|
||||||
submitted: { color: 'cyan', label: '已提交' },
|
submitted: { color: 'cyan', label: '已提交' },
|
||||||
processing: { color: 'orange', label: '处理中' },
|
processing: { color: 'orange', label: '处理中' },
|
||||||
success: { color: 'green', label: '已完成' },
|
success: { color: 'green', label: '已完成' },
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {App} from 'antd';
|
|||||||
import {useAppContext} from '@/app/providers/AppProvider';
|
import {useAppContext} from '@/app/providers/AppProvider';
|
||||||
import {useTheme} from '@/app/providers/ThemeProvider';
|
import {useTheme} from '@/app/providers/ThemeProvider';
|
||||||
import {emit, EVENTS, safeIpcSend} from '@/shared/utils/bus';
|
import {emit, EVENTS, safeIpcSend} from '@/shared/utils/bus';
|
||||||
import {RENDERER_TO_MAIN} from '@shared/constants/ipc-channels';
|
import {RENDERER_TO_MAIN_CANVAS} from '@shared/constants/ipc';
|
||||||
import {useAnnouncements} from '@/shared/hooks/use-announcements';
|
import {useAnnouncements} from '@/shared/hooks/use-announcements';
|
||||||
import {NavItem} from './NavItem';
|
import {NavItem} from './NavItem';
|
||||||
import {AnnouncementDrawer} from '@/modules/announcement/views/AnnouncementDrawer';
|
import {AnnouncementDrawer} from '@/modules/announcement/views/AnnouncementDrawer';
|
||||||
@@ -101,7 +101,7 @@ export function NavBar() {
|
|||||||
// 项目管理 → 打开独立画布窗口(新 BrowserWindow,非主窗口内叠加层)
|
// 项目管理 → 打开独立画布窗口(新 BrowserWindow,非主窗口内叠加层)
|
||||||
if (item.key === 'project-management') {
|
if (item.key === 'project-management') {
|
||||||
console.log('[NavBar] 🖼️ 点击"项目管理" → 发送 OPEN_CANVAS_WINDOW IPC');
|
console.log('[NavBar] 🖼️ 点击"项目管理" → 发送 OPEN_CANVAS_WINDOW IPC');
|
||||||
safeIpcSend(RENDERER_TO_MAIN.OPEN_CANVAS_WINDOW);
|
safeIpcSend(RENDERER_TO_MAIN_CANVAS.OPEN_CANVAS_WINDOW);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (item.target) {
|
if (item.target) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { FolderOpenOutlined, DeleteOutlined, EditOutlined } from '@ant-design/ic
|
|||||||
import type { Edition } from '@shared/types';
|
import type { Edition } from '@shared/types';
|
||||||
import { useSettings } from '@/app/providers/SettingsProvider';
|
import { useSettings } from '@/app/providers/SettingsProvider';
|
||||||
import { hasIpc, safeIpcInvoke } from '@/shared/utils/bus';
|
import { hasIpc, safeIpcInvoke } from '@/shared/utils/bus';
|
||||||
import { BIDIRECTIONAL } from '@shared/constants/ipc-channels';
|
import { BIDIRECTIONAL } from '@shared/constants/ipc';
|
||||||
|
|
||||||
const { Text, Paragraph } = Typography;
|
const { Text, Paragraph } = Typography;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {type Nullable} from "@/shared/utils/display";
|
import {type Nullable} from "@/shared/utils/display";
|
||||||
import {get} from "@/services/request.ts";
|
import {get} from "@/services/request";
|
||||||
|
|
||||||
const BillingUrlObj = {
|
const BillingUrlObj = {
|
||||||
getBalance: '/api/v1/billing/balance',
|
getBalance: '/api/v1/billing/balance',
|
||||||
|
|||||||
@@ -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<void> {
|
|
||||||
await initSafeStore(TOKEN_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取当前内存缓存中的 Token(同步,高频安全) */
|
|
||||||
export function getToken(): string | null {
|
|
||||||
return getSafeStore(TOKEN_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 设置 Token(异步加密存储 + 更新内存缓存) */
|
|
||||||
export async function setToken(token: string): Promise<void> {
|
|
||||||
await setSafeStore(TOKEN_KEY, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 清除 Token(同步清除内存缓存 + localStorage) */
|
|
||||||
export function clearToken(): void {
|
|
||||||
clearSafeStore(TOKEN_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Token 刷新(由外部注册,request.ts 不依赖任何 auth 模块) ----------
|
|
||||||
|
|
||||||
let refreshHandler: (() => Promise<string | null>) | null = null;
|
|
||||||
let isRefreshing = false;
|
|
||||||
let refreshPromise: Promise<string | null> | null = null;
|
|
||||||
/** 等待刷新完成的请求队列 */
|
|
||||||
let failedQueue: Array<{
|
|
||||||
resolve: (token: string) => void;
|
|
||||||
reject: (error: Error) => void;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
/** 由 auth 模块调用,注册 token 刷新回调 */
|
|
||||||
export function setTokenRefreshHandler(handler: () => Promise<string | null>): 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<AxiosResponse<ApiResponse>> {
|
|
||||||
// 已经在刷新中 → 排队等待
|
|
||||||
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<ApiResponse>) => {
|
|
||||||
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<T>(
|
|
||||||
url: string,
|
|
||||||
params?: Record<string, unknown>,
|
|
||||||
config?: AxiosRequestConfig,
|
|
||||||
): Promise<T> {
|
|
||||||
const res = await instance.get<ApiResponse<T>>(url, { ...config, params });
|
|
||||||
return res.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 发起 POST 请求(JSON)
|
|
||||||
* @param url - 请求路径
|
|
||||||
* @param data - 请求体
|
|
||||||
* @param config - Axios 配置覆盖
|
|
||||||
*/
|
|
||||||
export async function post<T>(
|
|
||||||
url: string,
|
|
||||||
data?: unknown,
|
|
||||||
config?: AxiosRequestConfig,
|
|
||||||
): Promise<T> {
|
|
||||||
const res = await instance.post<ApiResponse<T>>(url, data, config);
|
|
||||||
return res.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 发起 PUT 请求(JSON)
|
|
||||||
*/
|
|
||||||
export async function put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
|
||||||
const res = await instance.put<ApiResponse<T>>(url, data, config);
|
|
||||||
return res.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 发起 DELETE 请求
|
|
||||||
*/
|
|
||||||
export async function del<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
|
||||||
const res = await instance.delete<ApiResponse<T>>(url, config);
|
|
||||||
return res.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 文件上传(multipart/form-data)
|
|
||||||
* @param url - 上传接口路径
|
|
||||||
* @param formData - FormData 对象
|
|
||||||
* @param onProgress - 上传进度回调 (0~100)
|
|
||||||
*/
|
|
||||||
export async function upload<T>(
|
|
||||||
url: string,
|
|
||||||
formData: FormData,
|
|
||||||
onProgress?: (percent: number) => void,
|
|
||||||
): Promise<T> {
|
|
||||||
const res = await instance.post<ApiResponse<T>>(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<void> {
|
|
||||||
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: <T>(promise: Promise<T>): Promise<T> => {
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
|
||||||
promise.then(resolve).catch(reject);
|
|
||||||
controller.signal.addEventListener('abort', () => {
|
|
||||||
reject(new RequestError(RequestErrorType.CANCELLED, '请求已取消'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 导出 axios 实例(供高级场景直接使用) ----------
|
|
||||||
export { instance as axiosInstance };
|
|
||||||
export { RequestError, RequestErrorType };
|
|
||||||
63
src/services/request/index.ts
Normal file
63
src/services/request/index.ts
Normal file
@@ -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 };
|
||||||
293
src/services/request/interceptors.ts
Normal file
293
src/services/request/interceptors.ts
Normal file
@@ -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<AxiosResponse<ApiResponse>> {
|
||||||
|
// 已经在刷新中 → 排队等待
|
||||||
|
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<ApiResponse>) => {
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
147
src/services/request/methods.ts
Normal file
147
src/services/request/methods.ts
Normal file
@@ -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<T>(
|
||||||
|
url: string,
|
||||||
|
params?: Record<string, unknown>,
|
||||||
|
config?: AxiosRequestConfig,
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await instance.get<ApiResponse<T>>(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<T>(
|
||||||
|
url: string,
|
||||||
|
data?: unknown,
|
||||||
|
config?: AxiosRequestConfig,
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await instance.post<ApiResponse<T>>(url, data, config);
|
||||||
|
return res.data.data;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发起 PUT 请求(JSON)
|
||||||
|
*/
|
||||||
|
export function createPut(instance: AxiosInstance) {
|
||||||
|
return async function put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
||||||
|
const res = await instance.put<ApiResponse<T>>(url, data, config);
|
||||||
|
return res.data.data;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发起 DELETE 请求
|
||||||
|
*/
|
||||||
|
export function createDel(instance: AxiosInstance) {
|
||||||
|
return async function del<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||||
|
const res = await instance.delete<ApiResponse<T>>(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<T>(
|
||||||
|
url: string,
|
||||||
|
formData: FormData,
|
||||||
|
onProgress?: (percent: number) => void,
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await instance.post<ApiResponse<T>>(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<void> {
|
||||||
|
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: <T>(promise: Promise<T>): Promise<T> => {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
promise.then(resolve).catch(reject);
|
||||||
|
controller.signal.addEventListener('abort', () => {
|
||||||
|
reject(new RequestError(RequestErrorType.CANCELLED, '请求已取消'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
75
src/services/request/token.ts
Normal file
75
src/services/request/token.ts
Normal file
@@ -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<void> {
|
||||||
|
await initSafeStore(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前内存缓存中的 Token(同步,高频安全) */
|
||||||
|
export function getToken(): string | null {
|
||||||
|
return getSafeStore(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置 Token(异步加密存储 + 更新内存缓存) */
|
||||||
|
export async function setToken(token: string): Promise<void> {
|
||||||
|
await setSafeStore(TOKEN_KEY, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除 Token(同步清除内存缓存 + localStorage) */
|
||||||
|
export function clearToken(): void {
|
||||||
|
clearSafeStore(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Token 刷新(由外部注册,request.ts 不依赖任何 auth 模块) ----------
|
||||||
|
|
||||||
|
let refreshHandler: (() => Promise<string | null>) | null = null;
|
||||||
|
let isRefreshing = false;
|
||||||
|
let refreshPromise: Promise<string | null> | null = null;
|
||||||
|
/** 等待刷新完成的请求队列 */
|
||||||
|
let failedQueue: Array<{
|
||||||
|
resolve: (token: string) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
/** 由 auth 模块调用,注册 token 刷新回调 */
|
||||||
|
export function setTokenRefreshHandler(handler: () => Promise<string | null>): void {
|
||||||
|
refreshHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRefreshHandler(): (() => Promise<string | null>) | null {
|
||||||
|
return refreshHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIsRefreshing(): boolean {
|
||||||
|
return isRefreshing;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setIsRefreshing(value: boolean): void {
|
||||||
|
isRefreshing = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRefreshPromise(): Promise<string | null> | null {
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setRefreshPromise(value: Promise<string | null> | 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 = [];
|
||||||
|
}
|
||||||
@@ -74,6 +74,15 @@ export const DDL_STATEMENTS = [
|
|||||||
updated_at INTEGER NOT NULL
|
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 版本 ----
|
// ---- Schema 版本 ----
|
||||||
`CREATE TABLE IF NOT EXISTS _schema_version (version INTEGER PRIMARY KEY)`,
|
`CREATE TABLE IF NOT EXISTS _schema_version (version INTEGER PRIMARY KEY)`,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { logger } from '@/shared/utils/bus';
|
|||||||
export { openDatabase, closeDatabase, isOpen, exportDatabase, execute, queryAll, queryOne } from './core/db-core';
|
export { openDatabase, closeDatabase, isOpen, exportDatabase, execute, queryAll, queryOne } from './core/db-core';
|
||||||
export { loadDatabase, saveDatabase } from './core/db-encrypt';
|
export { loadDatabase, saveDatabase } from './core/db-encrypt';
|
||||||
export { getCurrentUserId, clearUserIdCache, getTodayKey, hashURL, formatBytes } from './services/user-scope';
|
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 { 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 { saveParamSnapshot, fillOutputData, getParamByTaskId, getParamHistory, parseParams, deleteParamHistory, clearUserParams } from './stores/param-store';
|
||||||
export { upsertMediaCache, getMediaByUrl, hasMedia, getMediaCount, getMediaTotalSize, deleteMediaByUrl, clearExpiredMedia, clearUserMedia } from './stores/media-store';
|
export { upsertMediaCache, getMediaByUrl, hasMedia, getMediaCount, getMediaTotalSize, deleteMediaByUrl, clearExpiredMedia, clearUserMedia } from './stores/media-store';
|
||||||
@@ -80,7 +80,13 @@ export async function initStorage(): Promise<boolean> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 清理过期媒体缓存
|
// 3. 加载已删除任务缓存到内存
|
||||||
|
try {
|
||||||
|
const { loadDeletedTaskIds } = await import('./stores/task-store');
|
||||||
|
loadDeletedTaskIds();
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
// 4. 清理过期媒体缓存
|
||||||
try {
|
try {
|
||||||
const { clearExpiredMedia } = await import('./stores/media-store');
|
const { clearExpiredMedia } = await import('./stores/media-store');
|
||||||
clearExpiredMedia();
|
clearExpiredMedia();
|
||||||
|
|||||||
@@ -11,14 +11,14 @@
|
|||||||
// media-store / db-encrypt 等均通过本模块操作文件。
|
// media-store / db-encrypt 等均通过本模块操作文件。
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { hasIpc } from '@/shared/utils/bus';
|
import {hasIpc} from "@/shared/utils/bus";
|
||||||
import { logger } from '@/shared/utils/bus';
|
import {logger} from "@/shared/utils/bus";
|
||||||
|
|
||||||
// ---------- 文件 API ----------
|
// ---------- 文件 API ----------
|
||||||
|
|
||||||
export async function writeFile(relativePath: string, data: string, encoding?: 'base64' | 'utf8'): Promise<string | null> {
|
export async function writeFile(relativePath: string, data: string, encoding?: "base64" | "utf8"): Promise<string | null> {
|
||||||
if (!hasIpc() || !window.fileStorage) {
|
if (!hasIpc() || !window.fileStorage) {
|
||||||
logger.warn('storage', 'fileStorage 不可用,文件写入跳过');
|
logger.warn("storage", "fileStorage 不可用,文件写入跳过");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return window.fileStorage.writeFile(relativePath, data, encoding);
|
return window.fileStorage.writeFile(relativePath, data, encoding);
|
||||||
@@ -54,9 +54,9 @@ export async function saveBlobToFile(relativePath: string, blob: Blob): Promise<
|
|||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
const base64 = (reader.result as string).split(',')[1]; // 去掉 data:mime;base64, 前缀
|
const base64 = (reader.result as string).split(",")[1]; // 去掉 data:mime;base64, 前缀
|
||||||
writeFile(relativePath, base64, 'base64').then(resolve).catch((err) => {
|
writeFile(relativePath, base64, "base64").then(resolve).catch((err) => {
|
||||||
logger.warn('storage', 'IPC 文件写入失败(saveBlobToFile)', err instanceof Error ? err : undefined);
|
logger.warn("storage", "IPC 文件写入失败(saveBlobToFile)", err instanceof Error ? err : undefined);
|
||||||
resolve(null);
|
resolve(null);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -77,3 +77,71 @@ export async function getDataDir(): Promise<string | null> {
|
|||||||
if (!hasIpc() || !window.fileStorage) return null;
|
if (!hasIpc() || !window.fileStorage) return null;
|
||||||
return window.fileStorage.getDataDir();
|
return window.fileStorage.getDataDir();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 绝对路径文件写入(绕过 heixiu-data 沙箱,用于用户自定义输出目录) */
|
||||||
|
export async function writeFileAbsolute(absolutePath: string, data: string, encoding?: "base64" | "utf8"): Promise<string | null> {
|
||||||
|
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<string | null> {
|
||||||
|
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<string, string>,
|
||||||
|
): Promise<string> {
|
||||||
|
if (!hasIpc() || !window.fileStorage) {
|
||||||
|
throw new Error('fileStorage 不可用,无法执行主进程下载');
|
||||||
|
}
|
||||||
|
return window.fileStorage.downloadToPath(url, destPath, headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取系统下载目录路径 */
|
||||||
|
export async function getDownloadsPath(): Promise<string | null> {
|
||||||
|
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<string> {
|
||||||
|
if (!hasIpc() || !window.fileStorage) {
|
||||||
|
throw new Error('fileStorage 不可用,无法生成缩略图');
|
||||||
|
}
|
||||||
|
return window.fileStorage.generateThumbnail(srcPath, destPath, maxWidth, quality);
|
||||||
|
}
|
||||||
|
|||||||
@@ -83,6 +83,20 @@ export function deleteTask(taskId: string): void {
|
|||||||
execute(`DELETE FROM task_records WHERE id = ?`, [taskId]);
|
execute(`DELETE FROM task_records WHERE id = ?`, [taskId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 切换任务收藏状态(仅更新 data_json 中的 is_favorite) */
|
||||||
|
export function toggleFavoriteTask(taskId: string, isFavorite: boolean): void {
|
||||||
|
const row = queryOne<TaskRecordEntry>(`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 {
|
export function getTaskCount(userId?: string): number {
|
||||||
const row = queryOne<{ cnt: number }>(
|
const row = queryOne<{ cnt: number }>(
|
||||||
`SELECT COUNT(*) as cnt FROM task_records WHERE user_id = ?`,
|
`SELECT COUNT(*) as cnt FROM task_records WHERE user_id = ?`,
|
||||||
@@ -95,3 +109,47 @@ export function clearUserTasks(): void {
|
|||||||
const uid = getCurrentUserId();
|
const uid = getCurrentUserId();
|
||||||
execute(`DELETE FROM task_records WHERE user_id = ?`, [uid]);
|
execute(`DELETE FROM task_records WHERE user_id = ?`, [uid]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 软删除机制 ----------
|
||||||
|
// 维护本地删除缓存,用于在拉取云端数据时排除已删除的记录
|
||||||
|
|
||||||
|
/** 内存缓存:已删除的任务 ID 集合(启动时从数据库加载) */
|
||||||
|
const deletedTaskIds = new Set<string>();
|
||||||
|
|
||||||
|
/** 初始化时加载已删除的任务 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<string> {
|
||||||
|
return new Set(deletedTaskIds);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
// 日志 — 通过 IPC 发送到主进程写文件(含事件总线审计钩子)
|
// 日志 — 通过 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 { sanitizeLogEntry } from '@shared/utils/sanitize';
|
||||||
import type { LogEntry, LogLevel, LogCategory, LogErrorSnapshot } from '@shared/types/logging';
|
import type { LogEntry, LogLevel, LogCategory, LogErrorSnapshot } from '@shared/types/logging';
|
||||||
|
|
||||||
@@ -97,6 +97,12 @@ export const EVENTS = {
|
|||||||
OPEN_PAGE: 'page:open',
|
OPEN_PAGE: 'page:open',
|
||||||
CLOSE_PAGE: 'page:close',
|
CLOSE_PAGE: 'page:close',
|
||||||
PAGE_LOCK: 'page:lock',
|
PAGE_LOCK: 'page:lock',
|
||||||
|
/** 任务列表数据变更(删除/收藏等),触发列表刷新 */
|
||||||
|
TASK_LIST_REFRESH: 'task:list-refresh',
|
||||||
|
/** 重新编辑任务(切换模型 + 回填参数) */
|
||||||
|
TASK_REEDIT: 'task:reedit',
|
||||||
|
/** 再次生成任务(切换模型 + 回填参数 + 自动提交) */
|
||||||
|
TASK_REGEN: 'task:regen',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
|
|||||||
@@ -1,488 +0,0 @@
|
|||||||
|
|
||||||
export interface ModelPricingFloorTableConfig {
|
|
||||||
field: string | null;
|
|
||||||
mapping: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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<string, unknown> | 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<string, unknown> | null | undefined,
|
|
||||||
dimensions: string[],
|
|
||||||
inputs: Record<string, unknown>,
|
|
||||||
): 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<string, unknown> {
|
|
||||||
return { ...(row.raw ?? {}), ...row as unknown as Record<string, unknown> };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 金额格式化函数
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将元数值格式化为显示字符串,保留两位小数,去除尾部无意义的零和小数点。
|
|
||||||
* 对应 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<string, unknown>,
|
|
||||||
): 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<string, unknown>,
|
|
||||||
): 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<string, unknown>,
|
|
||||||
): 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<string, unknown>,
|
|
||||||
): 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<string> {
|
|
||||||
const pricing = model.pricing_config;
|
|
||||||
if (!pricing) return new Set();
|
|
||||||
|
|
||||||
const fields = new Set<string>();
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
233
src/shared/utils/pricing/calculate.ts
Normal file
233
src/shared/utils/pricing/calculate.ts
Normal file
@@ -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<string, unknown>,
|
||||||
|
): 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<string, unknown>,
|
||||||
|
): 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<string, unknown>,
|
||||||
|
): 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<string, unknown>,
|
||||||
|
): 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;
|
||||||
|
}
|
||||||
72
src/shared/utils/pricing/fields.ts
Normal file
72
src/shared/utils/pricing/fields.ts
Normal file
@@ -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<string> {
|
||||||
|
const pricing = model.pricing_config;
|
||||||
|
if (!pricing) return new Set();
|
||||||
|
|
||||||
|
const fields = new Set<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
59
src/shared/utils/pricing/format.ts
Normal file
59
src/shared/utils/pricing/format.ts
Normal file
@@ -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}`;
|
||||||
|
}
|
||||||
77
src/shared/utils/pricing/helpers.ts
Normal file
77
src/shared/utils/pricing/helpers.ts
Normal file
@@ -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<string, unknown> | 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<string, unknown> | null | undefined,
|
||||||
|
dimensions: string[],
|
||||||
|
inputs: Record<string, unknown>,
|
||||||
|
): 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<string, unknown> {
|
||||||
|
return { ...(row.raw ?? {}), ...row as unknown as Record<string, unknown> };
|
||||||
|
}
|
||||||
26
src/shared/utils/pricing/index.ts
Normal file
26
src/shared/utils/pricing/index.ts
Normal file
@@ -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';
|
||||||
53
src/shared/utils/pricing/types.ts
Normal file
53
src/shared/utils/pricing/types.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// ============================================================
|
||||||
|
// pricing/types.ts — 定价相关类型定义
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface ModelPricingFloorTableConfig {
|
||||||
|
field: string | null;
|
||||||
|
mapping: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelDefinition {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
pricing_config: ModelPricingConfig | null;
|
||||||
|
}
|
||||||
10
src/vite-env.d.ts
vendored
10
src/vite-env.d.ts
vendored
@@ -23,6 +23,16 @@ declare global {
|
|||||||
showItemInFolder(filePath: string): Promise<boolean>;
|
showItemInFolder(filePath: string): Promise<boolean>;
|
||||||
fileSize(relativePath: string): Promise<number | null>;
|
fileSize(relativePath: string): Promise<number | null>;
|
||||||
fileExists(relativePath: string): Promise<boolean>;
|
fileExists(relativePath: string): Promise<boolean>;
|
||||||
|
/** 绝对路径文件写入(绕过 heixiu-data 沙箱) */
|
||||||
|
writeFileAbsolute(absolutePath: string, data: string, encoding?: 'base64' | 'utf8'): Promise<string | null>;
|
||||||
|
/** 绝对路径目录创建(绕过 heixiu-data 沙箱) */
|
||||||
|
makeDirAbsolute(absolutePath: string): Promise<string | null>;
|
||||||
|
/** 主进程下载文件到绝对路径(绕过浏览器 CORS 限制),失败时 reject */
|
||||||
|
downloadToPath(url: string, destPath: string, headers?: Record<string, string>): Promise<string>;
|
||||||
|
/** 获取系统下载目录路径 */
|
||||||
|
getDownloadsPath(): Promise<string | null>;
|
||||||
|
/** 生成图片缩略图(读取原图 → 缩放 → JPEG 写入目标路径),失败时 reject */
|
||||||
|
generateThumbnail(srcPath: string, destPath: string, maxWidth?: number, quality?: number): Promise<string>;
|
||||||
};
|
};
|
||||||
/** 应用运行时 API(由 preload 注入) */
|
/** 应用运行时 API(由 preload 注入) */
|
||||||
appRuntime: {
|
appRuntime: {
|
||||||
|
|||||||
Reference in New Issue
Block a user