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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,8 +16,9 @@
import { ipcMain, shell, app } from 'electron';
import fs from 'node:fs';
import path from 'node:path';
import { BIDIRECTIONAL } from '../../../shared/constants/ipc-channels';
import { BIDIRECTIONAL_STORAGE } from '../../../shared/constants/ipc/storage';
import { logger } from '../logger';
import { resolveSafe, ensureDir, downloadToFile, generateThumbnail } from './utils';
// ---------- 内部工具 ----------
@@ -25,145 +26,200 @@ let dataDirResolver: (() => string) | null = null;
/** 注入自定义数据目录解析器(在 registerStorageIpcHandlers() 之前调用) */
export function setDataDirResolver(resolver: () => string): void {
dataDirResolver = resolver;
dataDirResolver = resolver;
}
/** 获取 heixiu-data 根目录 */
function getDataDir(): string {
return dataDirResolver ? dataDirResolver() : path.join(app.getPath('userData'), 'heixiu-data');
}
/**
* 将相对路径解析为绝对路径,并校验是否在 dataDir 内。
* 防止路径穿越攻击。
*/
function resolveSafe(dataDir: string, relativePath: string): string {
const resolved = path.resolve(dataDir, relativePath);
if (!resolved.startsWith(dataDir + path.sep) && resolved !== dataDir) {
throw new Error(`路径越界:${relativePath}`);
}
return resolved;
}
/** 确保目录存在(递归创建) */
function ensureDir(dirPath: string): void {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
return dataDirResolver ? dataDirResolver() : path.join(app.getPath('userData'), 'heixiu-data');
}
// ---------- 注册 IPC 处理器 ----------
export function registerStorageIpcHandlers(): void {
const dataDir = getDataDir();
ensureDir(dataDir);
logger.info('app', `本地存储目录:${dataDir}`);
const dataDir = getDataDir();
ensureDir(dataDir);
logger.info('app', `本地存储目录:${dataDir}`);
// ---- 获取数据目录 ----
ipcMain.handle(BIDIRECTIONAL.GET_DATA_DIR, () => {
return { success: true, data: dataDir };
});
// ---- 获取数据目录 ----
ipcMain.handle(BIDIRECTIONAL_STORAGE.GET_DATA_DIR, () => {
return { success: true, data: dataDir };
});
// ---- 文件写入 ----
ipcMain.handle(
BIDIRECTIONAL.FILE_WRITE,
async (
_event,
params: { relativePath: string; data: string; encoding?: 'base64' | 'utf8' },
) => {
// ---- 文件写入 ----
ipcMain.handle(
BIDIRECTIONAL_STORAGE.FILE_WRITE,
async (_event, params: { relativePath: string; data: string; encoding?: 'base64' | 'utf8' }) => {
try {
const filePath = resolveSafe(dataDir, params.relativePath);
ensureDir(path.dirname(filePath));
const buffer = Buffer.from(params.data, params.encoding || 'base64');
fs.writeFileSync(filePath, buffer);
logger.info('storage', `文件写入成功:${filePath}${buffer.length} bytes`);
return { success: true, data: filePath };
} catch (err) {
const msg = (err as Error).message;
logger.warn('storage', `文件写入失败:${params.relativePath}`, err instanceof Error ? err : undefined);
return { success: false, error: msg };
}
},
);
// ---- 文件读取(返回 Base64 ----
ipcMain.handle(BIDIRECTIONAL_STORAGE.FILE_READ, async (_event, params: { relativePath: string }) => {
try {
const filePath = resolveSafe(dataDir, params.relativePath);
ensureDir(path.dirname(filePath));
const buffer = Buffer.from(params.data, params.encoding || 'base64');
fs.writeFileSync(filePath, buffer);
console.log(`[storage IPC] 文件写入成功:${filePath}${buffer.length} bytes`);
return { success: true, data: filePath };
const filePath = resolveSafe(dataDir, params.relativePath);
if (!fs.existsSync(filePath)) {
return { success: false, error: '文件不存在' };
}
const buffer = fs.readFileSync(filePath);
return { success: true, data: buffer.toString('base64') };
} catch (err) {
const msg = (err as Error).message;
console.error(`[storage IPC] 文件写入失败:${params.relativePath}${msg}`);
logger.warn('storage', `文件写入失败:${params.relativePath}`, err instanceof Error ? err : undefined);
return { success: false, error: msg };
logger.warn('storage', `文件读取失败:${params.relativePath}`, err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
},
);
});
// ---- 文件读取(返回 Base64 ----
ipcMain.handle(BIDIRECTIONAL.FILE_READ, async (_event, params: { relativePath: string }) => {
try {
const filePath = resolveSafe(dataDir, params.relativePath);
if (!fs.existsSync(filePath)) {
return { success: false, error: '文件不存在' };
// ---- 文件删除 ----
ipcMain.handle(BIDIRECTIONAL_STORAGE.FILE_DELETE, async (_event, params: { relativePath: string }) => {
try {
const filePath = resolveSafe(dataDir, params.relativePath);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
return { success: true };
} catch (err) {
logger.warn('storage', `文件删除失败:${params.relativePath}`, err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
const buffer = fs.readFileSync(filePath);
return { success: true, data: buffer.toString('base64') };
} catch (err) {
logger.warn('storage', `文件读取失败:${params.relativePath}`, err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
});
});
// ---- 文件删除 ----
ipcMain.handle(BIDIRECTIONAL.FILE_DELETE, async (_event, params: { relativePath: string }) => {
try {
const filePath = resolveSafe(dataDir, params.relativePath);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
// ---- 创建目录 ----
ipcMain.handle(BIDIRECTIONAL_STORAGE.MAKE_DIR, async (_event, params: { relativePath: string }) => {
try {
const dirPath = resolveSafe(dataDir, params.relativePath);
ensureDir(dirPath);
return { success: true, data: dirPath };
} catch (err) {
logger.warn('storage', `目录创建失败:${params.relativePath}`, err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
return { success: true };
} catch (err) {
logger.warn('storage', `文件删除失败:${params.relativePath}`, err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
});
});
// ---- 创建目录 ----
ipcMain.handle(BIDIRECTIONAL.MAKE_DIR, async (_event, params: { relativePath: string }) => {
try {
const dirPath = resolveSafe(dataDir, params.relativePath);
ensureDir(dirPath);
return { success: true, data: dirPath };
} catch (err) {
logger.warn('storage', `目录创建失败:${params.relativePath}`, err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
});
// ---- 在文件管理器中显示 ----
ipcMain.handle(BIDIRECTIONAL.SHOW_ITEM_IN_FOLDER, async (_event, params: { filePath: string }) => {
try {
shell.showItemInFolder(params.filePath);
return { success: true };
} catch (err) {
logger.warn('storage', `showItemInFolder 失败:${params.filePath}`, err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
});
// ---- 文件大小 ----
ipcMain.handle(BIDIRECTIONAL.FILE_SIZE, async (_event, params: { relativePath: string }) => {
try {
const filePath = resolveSafe(dataDir, params.relativePath);
if (!fs.existsSync(filePath)) {
return { success: false, error: '文件不存在' };
// ---- 在文件管理器中显示 ----
ipcMain.handle(BIDIRECTIONAL_STORAGE.SHOW_ITEM_IN_FOLDER, async (_event, params: { filePath: string }) => {
try {
shell.showItemInFolder(params.filePath);
return { success: true };
} catch (err) {
logger.warn('storage', `showItemInFolder 失败:${params.filePath}`, err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
const stat = fs.statSync(filePath);
return { success: true, data: stat.size };
} catch (err) {
logger.warn('storage', '获取文件大小失败', err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
});
});
// ---- 文件/目录是否存在 ----
ipcMain.handle(BIDIRECTIONAL.FILE_EXISTS, async (_event, params: { relativePath: string }) => {
try {
const filePath = resolveSafe(dataDir, params.relativePath);
return { success: true, data: fs.existsSync(filePath) };
} catch {
return { success: true, data: false };
}
});
// ---- 文件大小 ----
ipcMain.handle(BIDIRECTIONAL_STORAGE.FILE_SIZE, async (_event, params: { relativePath: string }) => {
try {
const filePath = resolveSafe(dataDir, params.relativePath);
if (!fs.existsSync(filePath)) {
return { success: false, error: '文件不存在' };
}
const stat = fs.statSync(filePath);
return { success: true, data: stat.size };
} catch (err) {
logger.warn('storage', '获取文件大小失败', err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
});
logger.info('app', '存储 IPC 处理器已注册');
// ---- 文件/目录是否存在 ----
ipcMain.handle(BIDIRECTIONAL_STORAGE.FILE_EXISTS, async (_event, params: { relativePath: string }) => {
try {
const filePath = resolveSafe(dataDir, params.relativePath);
return { success: true, data: fs.existsSync(filePath) };
} catch {
return { success: true, data: false };
}
});
// ---- 绝对路径文件写入(绕过沙箱,用于用户自定义输出目录) ----
ipcMain.handle(
BIDIRECTIONAL_STORAGE.FILE_WRITE_ABSOLUTE,
async (_event, params: { absolutePath: string; data: string; encoding?: 'base64' | 'utf8' }) => {
try {
ensureDir(path.dirname(params.absolutePath));
const buffer = Buffer.from(params.data, params.encoding || 'base64');
fs.writeFileSync(params.absolutePath, buffer);
logger.info('storage', `绝对路径写入成功:${params.absolutePath}${buffer.length} bytes`);
return { success: true, data: params.absolutePath };
} catch (err) {
const msg = (err as Error).message;
logger.warn('storage', `绝对路径写入失败:${params.absolutePath}`, err instanceof Error ? err : undefined);
return { success: false, error: msg };
}
},
);
// ---- 绝对路径目录创建(绕过沙箱) ----
ipcMain.handle(BIDIRECTIONAL_STORAGE.MAKE_DIR_ABSOLUTE, async (_event, params: { absolutePath: string }) => {
try {
ensureDir(params.absolutePath);
return { success: true, data: params.absolutePath };
} catch (err) {
logger.warn('storage', `绝对路径目录创建失败:${params.absolutePath}`, err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
});
// ---- 获取系统下载目录 ----
ipcMain.handle(BIDIRECTIONAL_STORAGE.GET_DOWNLOADS_PATH, () => {
try {
return { success: true, data: app.getPath('downloads') };
} catch {
return { success: false, error: '无法获取下载目录' };
}
});
// ---- 主进程下载文件到绝对路径(绕过浏览器 CORS ----
ipcMain.handle(
BIDIRECTIONAL_STORAGE.DOWNLOAD_TO_PATH,
async (
_event,
params: { url: string; destPath: string; headers?: Record<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 处理器已注册');
}

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