diff --git a/package.json b/package.json index aaa00a7..9701b9e 100644 --- a/package.json +++ b/package.json @@ -56,10 +56,12 @@ "extract-zip": "^2.0.1", "locales": "workspace:*", "lodash-es": "^4.17.21", + "logger": "workspace:^", "monaco-editor": "^0.52.2", "prettier": "^3.5.1", "rotating-file-stream": "^3.2.6", "sass": "^1.85.0", + "setting": "workspace:^", "simplebar-vue": "^2.4.0", "typescript": "^5.7.3", "unocss": "^0.64.1", diff --git a/packages/logger/common.ts b/packages/logger/common.ts new file mode 100644 index 0000000..08ab7e1 --- /dev/null +++ b/packages/logger/common.ts @@ -0,0 +1,10 @@ +// 日志级别定义 +export enum LogLevel { + TRACE = 0, + DEBUG = 1, + INFO = 2, + WARN = 3, + ERROR = 4, + FATAL = 5, + OFF = 6, +} diff --git a/packages/logger/main.ts b/packages/logger/main.ts new file mode 100644 index 0000000..4f0202c --- /dev/null +++ b/packages/logger/main.ts @@ -0,0 +1,274 @@ +import { app, ipcMain } from "electron" +import fs from "fs" +import path from "path" +import * as rfs from "rotating-file-stream" +import { LogLevel } from "./common" + +// 日志级别名称映射 +const LogLevelName: Record = { + [LogLevel.TRACE]: "TRACE", + [LogLevel.DEBUG]: "DEBUG", + [LogLevel.INFO]: "INFO", + [LogLevel.WARN]: "WARN", + [LogLevel.ERROR]: "ERROR", + [LogLevel.FATAL]: "FATAL", + [LogLevel.OFF]: "OFF", +} + +// 日志颜色映射(控制台输出用) +const LogLevelColor: Record = { + [LogLevel.TRACE]: "\x1b[90m", // 灰色 + [LogLevel.DEBUG]: "\x1b[36m", // 青色 + [LogLevel.INFO]: "\x1b[32m", // 绿色 + [LogLevel.WARN]: "\x1b[33m", // 黄色 + [LogLevel.ERROR]: "\x1b[31m", // 红色 + [LogLevel.FATAL]: "\x1b[35m", // 紫色 + [LogLevel.OFF]: "", // 无色 +} + +// 重置颜色的ANSI代码 +const RESET_COLOR = "\x1b[0m" + +// 日志配置接口 +export interface LoggerOptions { + level?: LogLevel // 日志级别 + namespace?: string // 日志命名空间 + console?: boolean // 是否输出到控制台 + file?: boolean // 是否输出到文件 + maxSize?: string // 单个日志文件最大大小 + maxFiles?: number // 保留的最大日志文件数量 +} + +// 默认配置 +const DEFAULT_OPTIONS: LoggerOptions = { + level: LogLevel.INFO, + namespace: "app", + console: true, + file: true, + maxSize: "10M", + maxFiles: 10, +} + +let logDir +const isElectronApp = !!process.versions.electron +if (isElectronApp && app) { + logDir = path.join(app.getPath("logs")) +} else { + // 非Electron环境下使用当前目录下的logs文件夹 + logDir = path.join(process.cwd(), "logs") +} + +/** + * 日志管理类 + */ +export class Logger { + private static instance: Logger + private options: LoggerOptions = DEFAULT_OPTIONS + private logStream: rfs.RotatingFileStream | null = null + private logDir: string = logDir + private currentLogFile: string = "" + private isElectronApp: boolean = !!process.versions.electron + private callInitialize: boolean = false + + /** + * 获取单例实例 + */ + public static getInstance(): Logger { + if (!Logger.instance) { + Logger.instance = new Logger() + } + if (Logger.instance.callInitialize) { + return Logger.instance + } else { + // 创建代理对象,确保只有在初始化后才能访问除init之外的方法 + const handler = { + get: function (target: any, prop: string) { + if (prop === "init") { + return target[prop] + } + if (!target.callInitialize) { + throw new Error(`Logger未初始化,不能调用${prop}方法,请先调用init()方法`) + } + return target[prop] + }, + } + Logger.instance = new Proxy(new Logger(), handler) + } + return Logger.instance + } + + /** + * 构造函数 + */ + private constructor() {} + + public init(options?: LoggerOptions): void { + this.callInitialize = true + this.options = { ...this.options, ...options } + + // 确保日志目录存在 + if (!fs.existsSync(this.logDir)) { + fs.mkdirSync(this.logDir, { recursive: true }) + } + + // 初始化日志文件 + this.initLogFile() + + // 如果在主进程中,设置IPC监听器接收渲染进程的日志 + if (this.isElectronApp && process.type === "browser") { + this.setupIPC() + } + } + + /** + * 初始化日志文件 + */ + private initLogFile(): void { + if (!this.options.file) return + + // 生成日志文件名 + const now = new Date() + const timestamp = now.toISOString().replace(/[:.]/g, "-") + this.currentLogFile = `app-logger-${timestamp}.log` + + // 创建日志流 + this.logStream = rfs.createStream(this.currentLogFile, { + path: this.logDir, + size: this.options.maxSize, + rotate: this.options.maxFiles, + }) + } + + /** + * 设置IPC通信,接收渲染进程日志 + */ + private setupIPC(): void { + if (!ipcMain) return + ipcMain.on("logger:log", (_, level: LogLevel, namespace: string, ...messages: any[]) => { + this.logWithLevel(level, namespace, ...messages) + }) + + // 处理日志级别设置请求 + ipcMain.on("logger:setLevel", (_, level: LogLevel) => { + this.setLevel(level) + }) + } + + /** + * 关闭日志流 + */ + public close(): void { + if (this.logStream) { + this.logStream.end() + this.logStream.destroy() + this.logStream = null + } + } + + /** + * 设置日志级别 + */ + public setLevel(level: LogLevel): void { + this.options.level = level + } + + /** + * 获取当前日志级别 + */ + public getLevel(): LogLevel { + return this.options.level || LogLevel.INFO + } + + /** + * 根据级别记录日志 + */ + private logWithLevel(level: LogLevel, namespace: string, ...messages: any[]): void { + // 检查日志级别 + if (level < this.getLevel() || level === LogLevel.OFF) return + + const timestamp = new Date().toISOString() + const levelName = LogLevelName[level] + const prefix = `[${timestamp}] [${namespace}] [${levelName}]` + + // 格式化消息 + const formattedMessages = messages.map(msg => { + if (typeof msg === "object") { + try { + return JSON.stringify(msg) + } catch (e) { + return String(msg) + } + } + return String(msg) + }) + + const message = formattedMessages.join(" ") + + // 输出到控制台 + if (this.options.console) { + const color = LogLevelColor[level] + console.log(`${color}${prefix} ${message}${RESET_COLOR}`) + } + + // 写入日志文件 + if (this.options.file && this.logStream) { + this.logStream.write(`${prefix} ${message}\n`) + } + } + + /** + * 记录跟踪级别日志 + */ + public trace(namespace: string, ...messages: any[]): void { + this.logWithLevel(LogLevel.TRACE, namespace, ...messages) + } + + /** + * 记录调试级别日志 + */ + public debug(namespace: string, ...messages: any[]): void { + this.logWithLevel(LogLevel.DEBUG, namespace, ...messages) + } + + /** + * 记录信息级别日志 + */ + public info(namespace: string, ...messages: any[]): void { + this.logWithLevel(LogLevel.INFO, namespace, ...messages) + } + + /** + * 记录警告级别日志 + */ + public warn(namespace: string, ...messages: any[]): void { + this.logWithLevel(LogLevel.WARN, namespace, ...messages) + } + + /** + * 记录错误级别日志 + */ + public error(namespace: string, ...messages: any[]): void { + this.logWithLevel(LogLevel.ERROR, namespace, ...messages) + } + + /** + * 记录致命错误级别日志 + */ + public fatal(namespace: string, ...messages: any[]): void { + this.logWithLevel(LogLevel.FATAL, namespace, ...messages) + } +} + +// 默认实例 +const logger = Logger.getInstance() +logger.init() + +// 应用退出时关闭日志流 +if (process.type === "browser" && app) { + app.on("before-quit", () => { + logger.info("app", "应用关闭") + logger.close() + }) +} + +export default logger diff --git a/packages/logger/package.json b/packages/logger/package.json new file mode 100644 index 0000000..6e5d9ce --- /dev/null +++ b/packages/logger/package.json @@ -0,0 +1,7 @@ +{ + "name": "logger", + "version": "1.0.0", + "keywords": [], + "author": "", + "license": "ISC" +} diff --git a/packages/logger/preload.ts b/packages/logger/preload.ts new file mode 100644 index 0000000..8a39788 --- /dev/null +++ b/packages/logger/preload.ts @@ -0,0 +1,119 @@ +import { contextBridge, ipcRenderer } from "electron" +import { LogLevel } from "./common" + +/** + * 渲染进程日志接口 + */ +interface IRendererLogger { + trace(namespace: string, ...messages: any[]): void + debug(namespace: string, ...messages: any[]): void + info(namespace: string, ...messages: any[]): void + warn(namespace: string, ...messages: any[]): void + error(namespace: string, ...messages: any[]): void + fatal(namespace: string, ...messages: any[]): void + setLevel(level: LogLevel): void +} + +// 日志级别名称映射 +const LogLevelName: Record = { + [LogLevel.TRACE]: "TRACE", + [LogLevel.DEBUG]: "DEBUG", + [LogLevel.INFO]: "INFO", + [LogLevel.WARN]: "WARN", + [LogLevel.ERROR]: "ERROR", + [LogLevel.FATAL]: "FATAL", + [LogLevel.OFF]: "OFF", +} + +// 日志颜色映射(控制台输出用) +const LogLevelColor: Record = { + [LogLevel.TRACE]: "\x1b[90m", // 灰色 + [LogLevel.DEBUG]: "\x1b[36m", // 青色 + [LogLevel.INFO]: "\x1b[32m", // 绿色 + [LogLevel.WARN]: "\x1b[33m", // 黄色 + [LogLevel.ERROR]: "\x1b[31m", // 红色 + [LogLevel.FATAL]: "\x1b[35m", // 紫色 + [LogLevel.OFF]: "", // 无色 +} + +// 重置颜色的ANSI代码 +const RESET_COLOR = "\x1b[0m" + +/** + * 创建渲染进程日志对象 + */ +const createRendererLogger = (): IRendererLogger => { + // 当前日志级别 + let currentLevel: LogLevel = LogLevel.INFO + + // 格式化消息 + const formatMessages = (messages: any[]): string => { + return messages.map(msg => { + if (typeof msg === "object") { + try { + return JSON.stringify(msg) + } catch (e) { + return String(msg) + } + } + return String(msg) + }).join(" ") + } + + // 本地打印日志 + const printLog = (level: LogLevel, namespace: string, ...messages: any[]): void => { + // 检查日志级别 + if (level < currentLevel || level === LogLevel.OFF) return + + const timestamp = new Date().toISOString() + const levelName = LogLevelName[level] + const prefix = `[${timestamp}] [${namespace}] [${levelName}]` + const message = formatMessages(messages) + + // 输出到控制台 + const color = LogLevelColor[level] + console.log(`${color}${prefix} ${message}${RESET_COLOR}`) + } + + // 通过IPC发送日志到主进程 + const sendLog = (level: LogLevel, namespace: string, ...messages: any[]) => { + // 本地打印 + printLog(level, namespace, ...messages) + + // 发送到主进程 + ipcRenderer.send("logger:log", level, namespace, ...messages) + } + + return { + trace(namespace: string, ...messages: any[]): void { + sendLog(LogLevel.TRACE, namespace, ...messages) + }, + debug(namespace: string, ...messages: any[]): void { + sendLog(LogLevel.DEBUG, namespace, ...messages) + }, + info(namespace: string, ...messages: any[]): void { + sendLog(LogLevel.INFO, namespace, ...messages) + }, + warn(namespace: string, ...messages: any[]): void { + sendLog(LogLevel.WARN, namespace, ...messages) + }, + error(namespace: string, ...messages: any[]): void { + sendLog(LogLevel.ERROR, namespace, ...messages) + }, + fatal(namespace: string, ...messages: any[]): void { + sendLog(LogLevel.FATAL, namespace, ...messages) + }, + setLevel(level: LogLevel): void { + // 更新本地日志级别 + currentLevel = level + // 设置日志级别(可选,如果需要在渲染进程中动态调整日志级别) + ipcRenderer.send("logger:setLevel", level) + }, + } +} + +// 暴露logger对象到渲染进程全局 +contextBridge.exposeInMainWorld("logger", createRendererLogger()) + +// 导出类型定义,方便在渲染进程中使用 +export type { IRendererLogger } diff --git a/src/main/modules/setting/index.ts b/packages/setting/main.ts similarity index 97% rename from src/main/modules/setting/index.ts rename to packages/setting/main.ts index 8215191..9ef038b 100644 --- a/src/main/modules/setting/index.ts +++ b/packages/setting/main.ts @@ -2,11 +2,10 @@ import fs from "fs-extra" import { app } from "electron" import path from "path" import { cloneDeep } from "lodash" -import { injectable } from "inversify" import Config from "config" import type { IDefaultConfig } from "config" import _debug from "debug" -import BaseClass from "main/base/base" +import logger from "logger/main" const debug = _debug("app:setting") @@ -59,18 +58,13 @@ function isEmptyDir(fPath: string) { } } -@injectable() -class Setting extends BaseClass { +class SettingClass { constructor() { - super() + logger.info("setting", "aaaa") debug(`Setting inited`) this.init() } - destroy() { - // TODO - } - #cb: [IT, IOnFunc][] = [] onChange(fn: IOnFunc, that?: any) @@ -232,5 +226,7 @@ class Setting extends BaseClass { } } +const Setting = new SettingClass() + export default Setting export { Setting } diff --git a/packages/setting/package.json b/packages/setting/package.json new file mode 100644 index 0000000..c9dd09f --- /dev/null +++ b/packages/setting/package.json @@ -0,0 +1,7 @@ +{ + "name": "setting", + "version": "1.0.0", + "keywords": [], + "author": "", + "license": "ISC" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99d90c4..17effe0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,6 +96,9 @@ importers: lodash-es: specifier: ^4.17.21 version: 4.17.21 + logger: + specifier: workspace:^ + version: link:packages/logger monaco-editor: specifier: ^0.52.2 version: 0.52.2 @@ -108,6 +111,9 @@ importers: sass: specifier: ^1.85.0 version: 1.85.0 + setting: + specifier: workspace:^ + version: link:packages/setting simplebar-vue: specifier: ^2.4.0 version: 2.4.0(vue@3.5.13(typescript@5.7.3)) @@ -153,6 +159,10 @@ importers: packages/locales: {} + packages/logger: {} + + packages/setting: {} + packages: 7zip-bin@5.2.0: diff --git a/src/main/App.ts b/src/main/App.ts index e87ba17..6c2bcd9 100644 --- a/src/main/App.ts +++ b/src/main/App.ts @@ -3,7 +3,7 @@ import { inject, injectable } from "inversify" // import DB from "./modules/db" import Api from "./modules/api" import WindowManager from "./modules/window-manager" -import { app, nativeTheme, protocol } from "electron" +import { app, protocol } from "electron" import { electronApp } from "@electron-toolkit/utils" import Command from "./modules/commands" import BaseClass from "./base/base" diff --git a/src/main/index.ts b/src/main/index.ts index b61912c..0931bad 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,7 @@ import "reflect-metadata" +import "logger/main" +import "setting/main" + import { _ioc } from "main/_ioc" import { App } from "main/App" @@ -14,61 +17,45 @@ console.log(`日志地址:${logsPath}`) const LOG_ROOT = path.join(logsPath) -// 缓存已创建的文件流(避免重复创建) -const streams = new Map() +// 缓存当前应用启动的日志文件流 +let currentLogStream: rfs.RotatingFileStream | null = null -// 转换命名空间为安全路径 -function sanitizeNamespace(namespace) { - return namespace - .split(":") // 按层级分隔符拆分 - .map(part => part.replace(/[\\/:*?"<>|]/g, "_")) // 替换非法字符 - .join(path.sep) // 拼接为系统路径分隔符(如 / 或 \) +// 生成当前启动时的日志文件名 +const getLogFileName = () => { + const now = new Date() + const timestamp = now.toISOString().replace(/[:.]/g, '-') + return `app-${timestamp}.log` } // 覆盖 debug.log 方法 const originalLog = debug.log debug.log = function (...args) { - // 保留原始控制台输出(可选) + // 保留原始控制台输出 originalLog.apply(this, args) - // 获取当前命名空间 - // @ts-ignore ... - const namespace = this.namespace - if (!namespace) { - // TODO 增加容错机制,如果没有命名空间就输出到一个默认文件中 - return - } - - // 生成日志文件路径(示例:logs/app/server.log) - const sanitizedPath = sanitizeNamespace(namespace) - // const logFilePath = path.join(LOG_ROOT, `${sanitizedPath}.log`) - - const today = new Date().toISOString().split("T")[0] - const logFilePath = path.join(LOG_ROOT, sanitizedPath, `${today}.log`) - // 确保目录存在 - const dir = path.dirname(logFilePath) - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }) // 自动创建多级目录 + // 确保日志目录存在 + if (!fs.existsSync(LOG_ROOT)) { + fs.mkdirSync(LOG_ROOT, { recursive: true }) } - // 获取或创建文件流 - let stream = streams.get(logFilePath) - if (!stream) { - // stream = fs.createWriteStream(logFilePath, { flags: "a" }) // 追加模式 - stream = rfs.createStream(path.parse(logFilePath).base, { - path: dir, + // 延迟初始化日志流,直到第一次写入 + if (!currentLogStream) { + const logFileName = getLogFileName() + currentLogStream = rfs.createStream(logFileName, { + path: LOG_ROOT, size: "10M", // 单个文件最大 10MB - rotate: 5, // 保留最近 5 个文件 + rotate: 10, // 保留最近 10 个文件 }) - streams.set(logFilePath, stream) } - // 写入日志(添加时间戳) - const message = args.join(" ") - stream.write(`${message}\n`) + // 获取当前命名空间 + // @ts-ignore + const namespace = this.namespace || 'unknown' - // const timestamp = new Date().toISOString() - // stream.write(`[${timestamp}] ${message}\n`) + // 写入日志(添加时间戳和命名空间) + const timestamp = new Date().toISOString() + const message = args.join(" ") + currentLogStream.write(`[${timestamp}] [${namespace}] ${message}\n`) } const curApp = _ioc.get(App) @@ -77,9 +64,9 @@ curApp.init() const _debug = debug("app:app") app.on("before-quit", () => { _debug("应用关闭") - streams.forEach(stream => { - stream.end() - stream.destroy() - }) - streams.clear() + if (currentLogStream) { + currentLogStream.end() + currentLogStream.destroy() + currentLogStream = null + } }) diff --git a/src/main/modules/_ioc.ts b/src/main/modules/_ioc.ts index c9ade3e..52cfab7 100644 --- a/src/main/modules/_ioc.ts +++ b/src/main/modules/_ioc.ts @@ -1,5 +1,4 @@ import { Container, ContainerModule } from "inversify" -import { Setting } from "./setting" import { DB } from "./db" import { Api } from "./api" import { WindowManager } from "./window-manager" @@ -9,7 +8,6 @@ import Zephyr from "./zephyr" import Updater from "./updater" const modules = new ContainerModule(bind => { - bind(Setting).toConstantValue(new Setting()) bind(Zephyr).toSelf().inSingletonScope() bind(Updater).toSelf().inSingletonScope() bind(Api).toSelf().inSingletonScope() @@ -21,7 +19,6 @@ const modules = new ContainerModule(bind => { async function destroyAllModules(ioc: Container) { await Promise.all([ - ioc.get(Setting).destroy(), ioc.get(WindowManager).destroy(), ioc.get(Commands).destroy(), ioc.get(Updater).destroy(), diff --git a/src/main/modules/db/index.ts b/src/main/modules/db/index.ts index 78fd619..4cc077d 100644 --- a/src/main/modules/db/index.ts +++ b/src/main/modules/db/index.ts @@ -1,5 +1,5 @@ import { inject, injectable } from "inversify" -import Setting from "../setting" +import Setting from "setting/main" import { CustomAdapter, CustomLow } from "./custom" import path from "node:path" import BaseClass from "main/base/base" @@ -14,7 +14,7 @@ class DB extends BaseClass { } Modules: Record> = {} - constructor(@inject(Setting) private _setting: Setting) { + constructor() { super() } @@ -31,12 +31,12 @@ class DB extends BaseClass { getDB(dbName: string) { if (this.Modules[dbName] === undefined) { - const filepath = path.resolve(this._setting.values("storagePath"), "./db/" + dbName + ".json") + const filepath = path.resolve(Setting.values("storagePath"), "./db/" + dbName + ".json") this.Modules[dbName] = this.create(filepath) return this.Modules[dbName] } else { const cur = this.Modules[dbName] - const filepath = path.resolve(this._setting.values("storagePath"), "./db/" + dbName + ".json") + const filepath = path.resolve(Setting.values("storagePath"), "./db/" + dbName + ".json") if (cur.filepath != filepath) { this.Modules[dbName] = this.create(filepath) } diff --git a/src/preload/index.ts b/src/preload/index.ts index 6e816ed..33cb5cf 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,6 +1,7 @@ import { contextBridge, ipcRenderer, IpcRendererEvent } from "electron" import { electronAPI } from "@electron-toolkit/preload" import { call, callLong, callSync } from "./call" +import "logger/preload" import { IPopupMenuOption } from "#/popup-menu" document.addEventListener("DOMContentLoaded", () => { const initStyle = document.createElement("style") diff --git a/src/renderer/src/App.vue b/src/renderer/src/App.vue index 62ef43b..f113113 100644 --- a/src/renderer/src/App.vue +++ b/src/renderer/src/App.vue @@ -1,4 +1,8 @@ - +