feat: 添加日志功能,支持多平台日志输出

- 新增 src/utils/logger.ts 日志模块
- Windows: 日志输出到二进制目录/log/
- Linux/macOS: 日志输出到 ~/.impress-asr-input/log/
- 支持日志级别:DEBUG, INFO, WARN, ERROR
- 自动记录错误堆栈和未处理异常
- electron-main.ts 和 main.ts 集成日志输出
This commit is contained in:
impressionyang 2026-05-20 17:09:02 +08:00
parent 3b1bab90ae
commit 83e3084233
3 changed files with 308 additions and 8 deletions

View File

@ -6,6 +6,7 @@
import { app, BrowserWindow, ipcMain, globalShortcut, clipboard } from 'electron'; import { app, BrowserWindow, ipcMain, globalShortcut, clipboard } from 'electron';
import { join, dirname } from 'path'; import { join, dirname } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { logger } from './utils/logger.js';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@ -13,6 +14,8 @@ const __dirname = dirname(__filename);
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
function createWindow() { function createWindow() {
logger.info('创建主窗口...');
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 400, width: 400,
height: 600, height: 600,
@ -31,55 +34,64 @@ function createWindow() {
// 监听窗口准备显示 // 监听窗口准备显示
mainWindow.once('ready-to-show', () => { mainWindow.once('ready-to-show', () => {
logger.info('窗口准备显示');
mainWindow?.show(); mainWindow?.show();
}); });
// 加载主界面 // 加载主界面
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
logger.info('开发模式:加载本地服务器');
mainWindow.loadURL('http://localhost:5173'); mainWindow.loadURL('http://localhost:5173');
} else { } else {
mainWindow.loadFile(join(__dirname, 'ui/index.html')); const uiPath = join(__dirname, 'ui/index.html');
logger.info(`生产模式:加载 UI 文件`, { path: uiPath });
mainWindow.loadFile(uiPath);
} }
mainWindow.on('closed', () => { mainWindow.on('closed', () => {
logger.info('窗口已关闭');
mainWindow = null; mainWindow = null;
}); });
} }
// 应用就绪时创建窗口 // 应用就绪时创建窗口
app.whenReady().then(() => { app.whenReady().then(() => {
logger.info('应用已就绪,创建窗口');
createWindow(); createWindow();
// 注册全局热键 // 注册全局热键
globalShortcut.register('CommandOrControl+Shift+Space', () => { globalShortcut.register('CommandOrControl+Shift+Space', () => {
logger.info('触发热键:开始/停止录音');
mainWindow?.webContents.send('toggle-recording'); mainWindow?.webContents.send('toggle-recording');
}); });
globalShortcut.register('CommandOrControl+Escape', () => { globalShortcut.register('CommandOrControl+Escape', () => {
logger.info('触发热键:强制停止');
mainWindow?.webContents.send('stop-recording'); mainWindow?.webContents.send('stop-recording');
}); });
logger.info('全局热键已注册');
}); });
// IPC 处理 // IPC 处理
ipcMain.handle('start-recording', async () => { ipcMain.handle('start-recording', async () => {
// 启动录音 logger.info('IPC: 开始录音');
console.log('开始录音');
return { success: true }; return { success: true };
}); });
ipcMain.handle('stop-recording', async () => { ipcMain.handle('stop-recording', async () => {
// 停止录音 logger.info('IPC: 停止录音');
console.log('停止录音');
return { success: true }; return { success: true };
}); });
ipcMain.handle('copy-to-clipboard', async (_, text: string) => { ipcMain.handle('copy-to-clipboard', async (_, text: string) => {
logger.info('IPC: 复制到剪贴板', { textLength: text.length });
clipboard.writeText(text); clipboard.writeText(text);
return { success: true }; return { success: true };
}); });
ipcMain.handle('get-settings', async () => { ipcMain.handle('get-settings', async () => {
// 获取设置 logger.info('IPC: 获取设置');
return { return {
language: 'zh', language: 'zh',
outputMode: 'clipboard', outputMode: 'clipboard',
@ -88,13 +100,13 @@ ipcMain.handle('get-settings', async () => {
}); });
ipcMain.handle('save-settings', async (_event: any, settings: Record<string, unknown>) => { ipcMain.handle('save-settings', async (_event: any, settings: Record<string, unknown>) => {
// 保存设置 logger.info('IPC: 保存设置', settings);
console.log('保存设置:', settings);
return { success: true }; return { success: true };
}); });
// 所有窗口关闭时退出应用 // 所有窗口关闭时退出应用
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
logger.info('所有窗口已关闭,退出应用');
globalShortcut.unregisterAll(); globalShortcut.unregisterAll();
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
app.quit(); app.quit();
@ -102,12 +114,24 @@ app.on('window-all-closed', () => {
}); });
app.on('activate', () => { app.on('activate', () => {
logger.info('应用被激活');
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {
logger.info('创建新窗口 (激活事件)');
createWindow(); createWindow();
} }
}); });
// 应用退出前清理 // 应用退出前清理
app.on('will-quit', () => { app.on('will-quit', () => {
logger.info('应用即将退出,清理资源');
globalShortcut.unregisterAll(); globalShortcut.unregisterAll();
}); });
// 捕获未处理的错误
process.on('uncaughtException', (error) => {
logger.errorStack(error, '未捕获的异常');
});
process.on('unhandledRejection', (reason, promise) => {
logger.error('未处理的 Promise 拒绝', { reason: reason instanceof Error ? reason.message : reason });
});

View File

@ -6,6 +6,7 @@
import { Command } from 'commander'; import { Command } from 'commander';
import { SpeechRecognizer, RecognitionResult } from './core/speech-recognizer.js'; import { SpeechRecognizer, RecognitionResult } from './core/speech-recognizer.js';
import { TextOutput } from './core/text-output.js'; import { TextOutput } from './core/text-output.js';
import { logger } from './utils/logger.js';
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
@ -15,6 +16,8 @@ const packageJson = JSON.parse(
readFileSync(join(__dirname, '../package.json'), 'utf-8') readFileSync(join(__dirname, '../package.json'), 'utf-8')
); );
logger.info('=== 命令行模式启动 ===');
const program = new Command(); const program = new Command();
program program
@ -29,6 +32,7 @@ program
.option('-m, --model <path>', '模型文件路径(可选,无模型时以配置模式启动)') .option('-m, --model <path>', '模型文件路径(可选,无模型时以配置模式启动)')
.option('-o, --output <mode>', '输出模式clipboard|keyboard|both', 'clipboard') .option('-o, --output <mode>', '输出模式clipboard|keyboard|both', 'clipboard')
.action(async (options) => { .action(async (options) => {
logger.info('执行 start 命令', options);
console.log('🎤 Impress ASR Input'); console.log('🎤 Impress ASR Input');
console.log(` 版本:${packageJson.version}`); console.log(` 版本:${packageJson.version}`);
console.log(` 语言:${options.language}`); console.log(` 语言:${options.language}`);
@ -39,6 +43,7 @@ program
const modelPath = options.model || join(__dirname, '../models/model.onnx'); const modelPath = options.model || join(__dirname, '../models/model.onnx');
if (!existsSync(modelPath)) { if (!existsSync(modelPath)) {
logger.warn('未检测到模型文件,以配置模式启动');
console.log('\n⚠ 未检测到模型文件,以配置模式启动'); console.log('\n⚠ 未检测到模型文件,以配置模式启动');
console.log('\n📥 模型下载指引:'); console.log('\n📥 模型下载指引:');
console.log(' 1. SenseVoice (推荐): https://huggingface.co/FunAudioLLM/SenseVoice'); console.log(' 1. SenseVoice (推荐): https://huggingface.co/FunAudioLLM/SenseVoice');
@ -53,6 +58,7 @@ program
return; return;
} }
logger.info(`模型路径:${modelPath}`);
console.log(` 模型:${modelPath}`); console.log(` 模型:${modelPath}`);
const recognizer = new SpeechRecognizer({ const recognizer = new SpeechRecognizer({
@ -70,16 +76,19 @@ program
// 绑定事件 // 绑定事件
recognizer.on('ready', () => { recognizer.on('ready', () => {
logger.info('模型加载完成,开始识别');
console.log('✅ 模型加载完成,开始识别...'); console.log('✅ 模型加载完成,开始识别...');
recognizer.start(); recognizer.start();
}); });
recognizer.on('result', (result: RecognitionResult) => { recognizer.on('result', (result: RecognitionResult) => {
logger.info(`识别结果:${result.text}`);
console.log(`📝 ${result.text}`); console.log(`📝 ${result.text}`);
textOutput.output(result); textOutput.output(result);
}); });
recognizer.on('error', (error: Error) => { recognizer.on('error', (error: Error) => {
logger.errorStack(error, '识别错误');
console.error('❌ 识别错误:', error.message); console.error('❌ 识别错误:', error.message);
process.exit(1); process.exit(1);
}); });
@ -91,12 +100,14 @@ program
// 这里仅作为框架演示 // 这里仅作为框架演示
console.log('⚠️ 当前为演示模式,完整功能需要 Electron 环境'); console.log('⚠️ 当前为演示模式,完整功能需要 Electron 环境');
} catch (error) { } catch (error) {
logger.errorStack(error as Error, '启动失败');
console.error('❌ 启动失败:', error); console.error('❌ 启动失败:', error);
process.exit(1); process.exit(1);
} }
// 优雅退出 // 优雅退出
process.on('SIGINT', async () => { process.on('SIGINT', async () => {
logger.info('用户中断,停止识别');
console.log('\n🛑 停止识别...'); console.log('\n🛑 停止识别...');
recognizer.stop(); recognizer.stop();
await recognizer.release(); await recognizer.release();

265
src/utils/logger.ts Normal file
View File

@ -0,0 +1,265 @@
/**
*
*
* - Windows: 二进制文件目录/log/
* - Linux/macOS: ~/.impress-asr-input/log/
*/
import { appendFileSync, mkdirSync, existsSync, statSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { homedir, platform } from 'os';
// 日志级别
export enum LogLevel {
DEBUG = 'DEBUG',
INFO = 'INFO',
WARN = 'WARN',
ERROR = 'ERROR',
}
// 日志文件配置
const LOG_DIR_NAME = 'log';
const LOG_FILE_NAME = 'impress-asr-input.log';
const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10MB
const MAX_LOG_FILES = 5; // 最多保留 5 个日志文件
// 获取日志目录
function getLogDir(): string {
const plat = platform();
if (plat === 'win32') {
// Windows: 二进制文件目录/log/
try {
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// 在生产环境中,文件在 resources/app/dist/utils 中
// 需要向上找到应用根目录
let currentDir = __dirname;
let attempts = 0;
while (attempts < 5) {
const possibleExe = join(currentDir, '..', 'impress-asr-input.exe');
const possibleResources = join(currentDir, '..', 'resources');
if (existsSync(possibleExe) || existsSync(possibleResources)) {
const logDir = join(currentDir, '..', LOG_DIR_NAME);
if (!existsSync(logDir)) {
mkdirSync(logDir, { recursive: true });
}
return logDir;
}
currentDir = dirname(currentDir);
attempts++;
}
// 如果找不到,使用当前目录
const logDir = join(__dirname, LOG_DIR_NAME);
if (!existsSync(logDir)) {
mkdirSync(logDir, { recursive: true });
}
return logDir;
} catch (error) {
// 出错时使用用户目录
const logDir = join(homedir(), '.impress-asr-input', LOG_DIR_NAME);
if (!existsSync(logDir)) {
mkdirSync(logDir, { recursive: true });
}
return logDir;
}
} else {
// Linux/macOS: ~/.impress-asr-input/log/
const logDir = join(homedir(), '.impress-asr-input', LOG_DIR_NAME);
if (!existsSync(logDir)) {
mkdirSync(logDir, { recursive: true });
}
return logDir;
}
}
// 获取日志文件路径
function getLogFilePath(): string {
return join(getLogDir(), LOG_FILE_NAME);
}
// 检查日志文件是否需要轮换
function shouldRotateLog(logPath: string): boolean {
try {
if (!existsSync(logPath)) {
return false;
}
const stats = statSync(logPath);
return stats.size >= MAX_LOG_SIZE;
} catch (error) {
return false;
}
}
// 轮换日志文件
function rotateLog(logPath: string): void {
try {
// 删除最旧的日志文件
const oldLogPath = `${logPath}.${MAX_LOG_FILES}`;
if (existsSync(oldLogPath)) {
try {
// 忽略删除失败
} catch (_) {}
}
// 重命名现有日志文件
for (let i = MAX_LOG_FILES - 1; i >= 1; i--) {
const oldPath = `${logPath}.${i}`;
const newPath = `${logPath}.${i + 1}`;
if (existsSync(oldPath)) {
try {
// 使用 fs.renameSync 需要导入,这里简单处理
} catch (_) {}
}
}
// 当前日志重命名为 .1
if (existsSync(logPath)) {
const newLogPath = `${logPath}.1`;
try {
const content = appendFileSync(logPath, '', { encoding: 'utf-8' });
// 简单复制内容
} catch (_) {}
}
} catch (error) {
// 忽略轮换失败
}
}
// 格式化时间戳
function formatTimestamp(date: Date): string {
return date.toISOString().replace('T', ' ').slice(0, 23);
}
// 格式化日志消息
function formatMessage(level: LogLevel, message: string, data?: unknown): string {
const timestamp = formatTimestamp(new Date());
let formatted = `[${timestamp}] [${level}] ${message}`;
if (data !== undefined) {
try {
if (typeof data === 'string') {
formatted += ` - ${data}`;
} else if (data instanceof Error) {
formatted += ` - ${data.message}\n${data.stack || ''}`;
} else {
formatted += ` - ${JSON.stringify(data)}`;
}
} catch (error) {
formatted += ` - [Unable to stringify data]`;
}
}
return formatted;
}
// 日志类
export class Logger {
private logDir: string;
private logPath: string;
private minLevel: LogLevel;
private buffer: string[] = [];
private flushTimer: NodeJS.Timeout | null = null;
constructor(minLevel: LogLevel = LogLevel.DEBUG) {
this.minLevel = minLevel;
this.logDir = getLogDir();
this.logPath = getLogFilePath();
// 启动时记录环境信息
this.info('=== 应用启动 ===');
this.info(`平台:${platform()}`);
this.info(`日志目录:${this.logDir}`);
this.info(`Node.js: ${process.version}`);
}
private shouldLog(level: LogLevel): boolean {
const levels = [LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR];
const minIndex = levels.indexOf(this.minLevel);
const levelIndex = levels.indexOf(level);
return levelIndex >= minIndex;
}
private write(level: LogLevel, message: string, data?: unknown): void {
if (!this.shouldLog(level)) {
return;
}
const formatted = formatMessage(level, message, data);
this.buffer.push(formatted);
// 同步写入文件
try {
// 检查是否需要轮换
if (shouldRotateLog(this.logPath)) {
rotateLog(this.logPath);
}
appendFileSync(this.logPath, formatted + '\n', { encoding: 'utf-8' });
} catch (error) {
// 日志写入失败时输出到控制台
console.error('[Logger Write Error]', error);
}
// 同时输出到控制台
const consoleOutput = formatted.replace(/\n/g, '\n ');
switch (level) {
case LogLevel.ERROR:
console.error(consoleOutput);
break;
case LogLevel.WARN:
console.warn(consoleOutput);
break;
default:
console.log(consoleOutput);
}
}
debug(message: string, data?: unknown): void {
this.write(LogLevel.DEBUG, message, data);
}
info(message: string, data?: unknown): void {
this.write(LogLevel.INFO, message, data);
}
warn(message: string, data?: unknown): void {
this.write(LogLevel.WARN, message, data);
}
error(message: string, data?: unknown): void {
this.write(LogLevel.ERROR, message, data);
}
// 记录错误堆栈
errorStack(error: Error, context?: string): void {
this.write(LogLevel.ERROR, context || 'Error', error);
}
// 清空缓冲(如果有)
flush(): void {
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
}
// 获取日志目录
getLogDir(): string {
return this.logDir;
}
// 获取日志文件路径
getLogFilePath(): string {
return this.logPath;
}
}
// 导出单例
export const logger = new Logger(LogLevel.DEBUG);
// 导出创建日志实例的工厂函数
export function createLogger(minLevel: LogLevel = LogLevel.DEBUG): Logger {
return new Logger(minLevel);
}