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:
100
electron/main.ts
100
electron/main.ts
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
};
|
||||
}
|
||||
|
||||
// ── 优先级 3:sharp 实时缩放 ──
|
||||
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 };
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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() });
|
||||
|
||||
Reference in New Issue
Block a user