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:
2026-07-03 11:13:36 +08:00
parent 904edecd90
commit 136a17c0bf
69 changed files with 4139 additions and 2713 deletions

View File

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

View File

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

View File

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

View File

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