feat: 构建系统重构 + VCHSM 架构迁移 + 右键菜单修复 + 尺寸参数统一

## 构建、打包与分发系统重构 (build/)

- 构建系统收敛至 build/ 目录:build.mjs + electron-builder.js + 6 个脚本

- electron-builder 配置从 package.json 提取为 build/electron-builder.js

- 新增 --edition (personal/enterprise) 和 --channel (stable/beta/alpha) 参数

- OSS 路径按渠道隔离

- 新增 pre-build-check.mjs:Git/CHANGELOG/版本/环境变量校验

- 新增 bump-version.mjs:版本号管理 + CHANGELOG 自动生成

- updater.ts 发送 channel/edition/deviceFingerprint

- 新增 src/shared/utils/rollout.ts 灰度测试工具

- 新增 10 条 NPM 脚本

## VCHSM 五层架构迁移

- 7 个业务模块 + ~500 处导入路径同步

## 修复

- MediaCardContextMenu 改用共享 ContextMenu 组件

- ComboBox 尺寸参数显示统一 (size-format.ts)

## 文档

- UpdateA.md / build/README.md / README.md / .env.example 更新

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-29 18:41:07 +08:00
parent e0d91335c7
commit 337d6c1205
187 changed files with 5252 additions and 1702 deletions

View File

@@ -9,10 +9,10 @@ import {buildWindowTitle} from '../shared/constants/app';
import {loadEnvFile} from './main/load-env';
import {initUpdater, registerUpdateIpcHandlers} from './main/updater';
import {setupAppMenu} from './main/menu';
import {initLogger, flushLogger, logger} from './main/logger';
import {initLogger, flushLogger, logger, setLogDirResolver} from './main/logger';
import {registerLogIpcHandlers} from './main/ipc/log-ipc';
import {registerSafeStorageIpcHandlers} from './main/ipc/safe-storage-ipc';
import {registerStorageIpcHandlers} from './main/ipc/storage-ipc';
import {registerStorageIpcHandlers, setDataDirResolver} from './main/ipc/storage-ipc';
import {registerCanvasIpcHandlers} from './main/ipc/canvas-ipc';
import {BIDIRECTIONAL, RENDERER_TO_MAIN, MAIN_TO_RENDERER} from '../shared/constants/ipc-channels';
@@ -229,16 +229,17 @@ app.on('before-quit', () => {
});
app.whenReady().then(() => {
// ── 存储路径配置 ──
// 修改以下两行即可将日志/数据存储到任意位置
setLogDirResolver(() => path.join(app.getPath('userData'), 'logs'));
setDataDirResolver(() => path.join(app.getPath('userData'), 'heixiu-data'));
initLogger();
registerLogIpcHandlers();
registerSafeStorageIpcHandlers();
registerStorageIpcHandlers();
registerCanvasIpcHandlers();
// 注册 local-media:// 自定义协议(用于全屏预览加载本地文件)
// 支持两种 URL 格式:
// 本地路径local-media:///C:/Users/... (authority 为空,路径以 / 开头)
// UNC 路径local-media://192.168.110.250/share/... (authority 为服务器主机名)
protocol.handle('local-media', (request) => {
const urlObj = new URL(request.url);
let filePath: string;
@@ -264,7 +265,7 @@ app.whenReady().then(() => {
// 否则会干扰 Chromium 内部的 file:// 协议处理,导致请求失败。
const rangeHeader = request.headers.get('Range');
if (rangeHeader) {
return net.fetch(fileUrl, { headers: { Range: rangeHeader } });
return net.fetch(fileUrl, {headers: {Range: rangeHeader}});
}
return net.fetch(fileUrl);
});
@@ -285,51 +286,64 @@ app.whenReady().then(() => {
// 文件对话框 IPC 处理(供渲染进程选择文件夹)
ipcMain.handle(BIDIRECTIONAL.FILE_DIALOG, async (_event, options: Electron.OpenDialogOptions) => {
const parentWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : canvasWindow;
if (!parentWindow) return {canceled: true, filePaths: []};
return dialog.showOpenDialog(parentWindow, {
title: options?.title || '选择文件夹',
defaultPath: options?.defaultPath || app.getPath('home'),
properties: options?.properties || ['openDirectory'],
});
try {
const parentWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : canvasWindow;
if (!parentWindow) return {canceled: true, filePaths: []};
return dialog.showOpenDialog(parentWindow, {
title: options?.title || '选择文件夹',
defaultPath: options?.defaultPath || app.getPath('home'),
properties: options?.properties || ['openDirectory'],
});
} catch (err) {
logger.warn('ipc', '文件对话框打开失败', err instanceof Error ? err : undefined);
return {canceled: true, filePaths: []};
}
});
// 窗口标题版控后缀(渲染进程登录/登出后调用)
ipcMain.on(RENDERER_TO_MAIN.SET_WINDOW_TITLE, (_event, edition: string | null) => {
currentEdition = edition;
if (!mainWindow) return;
const title = edition
? buildWindowTitle(currentPlatform, undefined, edition as 'personal' | 'enterprise')
: buildWindowTitle(currentPlatform);
mainWindow.setTitle(title);
try {
currentEdition = edition;
if (!mainWindow) return;
const title = edition
? buildWindowTitle(currentPlatform, undefined, edition as 'personal' | 'enterprise')
: buildWindowTitle(currentPlatform);
mainWindow.setTitle(title);
} catch (err) {
logger.warn('ipc', '设置窗口标题失败', err instanceof Error ? err : undefined);
}
});
// 打开无限画布窗口(渲染进程请求 → 主进程创建新 BrowserWindow
ipcMain.on(RENDERER_TO_MAIN.OPEN_CANVAS_WINDOW, (_event, payload?: { projectPath?: string }) => {
const existingAlive = canvasWindow && !canvasWindow.isDestroyed();
logger.info('canvas-window', '收到 OPEN_CANVAS_WINDOW IPC', {
projectPath: payload?.projectPath || '(未指定)',
existingAlive,
});
try {
const existingAlive = canvasWindow && !canvasWindow.isDestroyed();
logger.info('canvas-window', '收到 OPEN_CANVAS_WINDOW IPC', {
projectPath: payload?.projectPath || '(未指定)',
existingAlive,
});
if (existingAlive) {
logger.info('canvas-window', '画布窗口已存在,聚焦现有窗口');
canvasWindow?.focus();
} else {
logger.info('canvas-window', '画布窗口不存在或已销毁,创建新窗口');
canvasWindow = createCanvasWindow();
}
// 画布页面加载完成后,发送初始化参数
if (payload?.projectPath) {
const readyHandler = () => {
logger.info('canvas-window', '画布页面 did-finish-load发送 CANVAS_READY', {
projectPath: payload.projectPath,
});
canvasWindow?.webContents.send(MAIN_TO_RENDERER.CANVAS_READY, {
projectPath: payload.projectPath,
});
};
canvasWindow?.webContents.on('did-finish-load', readyHandler);
if (existingAlive) {
logger.info('canvas-window', '画布窗口已存在,聚焦现有窗口');
canvasWindow?.focus();
} else {
logger.info('canvas-window', '画布窗口不存在或已销毁,创建新窗口');
canvasWindow = createCanvasWindow();
}
// 画布页面加载完成后,发送初始化参数
if (payload?.projectPath) {
const readyHandler = () => {
logger.info('canvas-window', '画布页面 did-finish-load发送 CANVAS_READY', {
projectPath: payload.projectPath,
});
canvasWindow?.webContents.send(MAIN_TO_RENDERER.CANVAS_READY, {
projectPath: payload.projectPath,
});
};
canvasWindow?.webContents.on('did-finish-load', readyHandler);
}
} catch (err) {
logger.warn('canvas-window', '打开画布窗口失败', err instanceof Error ? err : undefined);
}
});

View File

@@ -13,7 +13,7 @@ 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';
@@ -169,14 +169,6 @@ async function generateThumbnailImpl(
};
}
// ── 优先级 3sharp 实时缩放 ──
let sharp: typeof import('sharp') | null = null;
try {
sharp = require('sharp');
} catch {
/* sharp 未安装 */
}
if (sharp) {
const image = sharp(filePath);
const metadata = await image.metadata();
@@ -487,6 +479,7 @@ export function registerCanvasIpcHandlers(): void {
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 };
}
},
@@ -500,6 +493,7 @@ export function registerCanvasIpcHandlers(): void {
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 };
}
},
@@ -513,6 +507,7 @@ export function registerCanvasIpcHandlers(): void {
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 };
}
},
@@ -530,6 +525,7 @@ export function registerCanvasIpcHandlers(): void {
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 };
}
},
@@ -540,13 +536,6 @@ export function registerCanvasIpcHandlers(): void {
BIDIRECTIONAL.CANVAS_GET_IMAGE_SIZE,
async (_event, params: { filePath: string }) => {
try {
// 尝试 sharp
let sharp: typeof import('sharp') | null = null;
try {
sharp = require('sharp');
} catch {
/* 未安装 */
}
if (sharp) {
const metadata = await sharp(params.filePath).metadata();
return {
@@ -559,6 +548,7 @@ export function registerCanvasIpcHandlers(): void {
}
return { success: false, error: 'sharp 未安装,无法获取图片尺寸' };
} catch (err) {
logger.warn('canvas', '获取图片尺寸失败', err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
},

View File

@@ -21,9 +21,16 @@ import { logger } from '../logger';
// ---------- 内部工具 ----------
let dataDirResolver: (() => string) | null = null;
/** 注入自定义数据目录解析器(在 registerStorageIpcHandlers() 之前调用) */
export function setDataDirResolver(resolver: () => string): void {
dataDirResolver = resolver;
}
/** 获取 heixiu-data 根目录 */
function getDataDir(): string {
return path.join(app.getPath('userData'), 'heixiu-data');
return dataDirResolver ? dataDirResolver() : path.join(app.getPath('userData'), 'heixiu-data');
}
/**
@@ -143,6 +150,7 @@ export function registerStorageIpcHandlers(): void {
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 };
}
});

View File

@@ -28,6 +28,7 @@ import { sanitizeLogEntry } from '../../shared/utils/sanitize';
let writeStream: WriteStream | null = null;
let currentDate = '';
let logDir = '';
let logDirResolver: (() => string) | null = null;
const writeQueue: string[] = [];
let isWriting = false;
const MAX_DAYS = 7;
@@ -46,7 +47,7 @@ function getLogFilePath(date: string): string {
function ensureDir(): void {
if (logDir) return;
try {
logDir = join(app.getPath('userData'), 'logs');
logDir = logDirResolver ? logDirResolver() : join(app.getPath('userData'), 'logs');
} catch {
// app.getPath 尚不可用app.ready 之前),静默跳过
return;
@@ -216,6 +217,11 @@ export const logger = {
},
};
/** 注入自定义日志目录解析器(在 initLogger() 之前调用) */
export function setLogDirResolver(resolver: () => string): void {
logDirResolver = resolver;
}
/** 预热日志模块(在 app.whenReady() 中调用) */
export function initLogger(): void {
ensureDir();

View File

@@ -16,6 +16,7 @@
// ============================================================
import { net } from 'electron';
import { logger } from './logger';
// ---------- 类型 ----------
@@ -128,9 +129,7 @@ export async function request<T = unknown>(
try {
void new URL(fullUrl);
} catch (urlErr) {
console.error('[net-request] 非法 URL:', JSON.stringify(fullUrl));
console.error('[net-request] URL 字符码:', [...fullUrl].map(c => c.charCodeAt(0)).join(','));
console.error('[net-request] config:', JSON.stringify(config));
logger.warn('request', `非法 URL: ${fullUrl}`, urlErr instanceof Error ? urlErr : undefined);
reject(new RequestError(`非法 URL: ${(urlErr as Error).message}`));
return;
}

View File

@@ -1,7 +1,8 @@
// 自动更新 — HeiXiuProvider + electron-updater 增量下载
// 流程图:调用自有 API → 获取版本信息 → electron-updater 差分下载 → SHA512 校验 → 安装
import {BrowserWindow, ipcMain} from 'electron';
import {app, BrowserWindow, ipcMain} from 'electron';
import {createHash} from 'node:crypto';
import {URL} from 'node:url';
import {
@@ -19,6 +20,49 @@ 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 = {
@@ -77,6 +121,9 @@ class HeiXiuProvider extends Provider<UpdateInfo> {
params: {
platform: process.platform,
version: APP_VERSION,
channel: BUILD_CHANNEL,
edition: BUILD_EDITION,
deviceFingerprint: getDeviceFingerprint(),
},
},
);
@@ -242,7 +289,12 @@ let platformUpdater: NsisUpdater | MacUpdater | AppImageUpdater | null = null;
export function initUpdater(mainWindow: BrowserWindow): void {
updateWindow = mainWindow;
console.log('[updater] initUpdater 被调用API URL:', getApiBaseUrl());
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() });