From 883f98065abb08a2c4a67fa7d8dd18d4bc192ced Mon Sep 17 00:00:00 2001 From: Misaka Date: Fri, 3 Apr 2026 20:53:53 +0800 Subject: [PATCH] refactor(logger): optimize logging architecture with 6 improvements 1. Extract shared module: consolidate getLogDir() and isProduction() into shared.ts, eliminate duplication across logger modules 2. Make retention config effective: delay file transport creation until config is loaded, apply appRetention/auditRetention from config.yaml 3. Add before-quit log flush: close logger and audit logger on app exit to prevent log loss 4. Unify logError entry point: remove duplicate logError from index.ts, re-export from error-utils.ts with richer error context 5. Renderer log level filtering: add client-side level check in preload to skip IPC for filtered-out messages 6. Child logger cache + audit cleanup: cache child loggers in IPC handler for performance, remove redundant timestamp format in audit logger Co-Authored-By: Claude Opus 4.6 --- docs/developer/architecture/README.md | 14 +-- docs/developer/guides/README.md | 12 +- docs/developer/modules/README.md | 14 +-- src/main/bootstrap/process-guards.ts | 8 +- src/main/ipc/extractor-handler.ts | 8 +- src/main/ipc/index.ts | 20 ++-- src/main/ipc/logger-handler.ts | 28 ++++- src/main/ipc/settings-handler.ts | 17 ++- src/main/services/config/config-manager.ts | 9 +- src/main/services/erp/extractor.ts | 11 +- src/main/services/logger/audit-logger.ts | 51 ++++----- src/main/services/logger/error-utils.ts | 5 +- src/main/services/logger/index.ts | 104 ++++++++---------- src/main/services/logger/shared.ts | 53 +++++++++ src/main/services/user/bip-users-dao.ts | 66 +++++------ src/preload/api/logger.ts | 29 +++++ src/preload/index.d.ts | 1 + .../ExtractorOperationHistoryModal.tsx | 9 +- .../components/ComparisonTooltip.tsx | 5 +- .../components/CustomTooltip.tsx | 5 +- src/renderer/src/hooks/useAppBootstrap.ts | 4 + src/shared/ipc-channels.ts | 3 +- 22 files changed, 300 insertions(+), 176 deletions(-) create mode 100644 src/main/services/logger/shared.ts diff --git a/docs/developer/architecture/README.md b/docs/developer/architecture/README.md index 7979141..9cab11e 100644 --- a/docs/developer/architecture/README.md +++ b/docs/developer/architecture/README.md @@ -48,13 +48,13 @@ graph TD ## 文档职责一览 -| 文档 | 主要回答的问题 | -| --- | --- | -| `overview.md` | 这个项目整体是什么、做什么、核心目录和主链路是什么 | -| `runtime-architecture.md` | `main / preload / renderer` 如何协作 | -| `data-flow.md` | 核心业务数据如何在各层之间流动 | -| `file-map.md` | 关键文件在哪里、应该先看哪些入口 | -| `decision-log.md` | 最近几轮重要重构和架构决策是什么 | +| 文档 | 主要回答的问题 | +| ------------------------- | -------------------------------------------------- | +| `overview.md` | 这个项目整体是什么、做什么、核心目录和主链路是什么 | +| `runtime-architecture.md` | `main / preload / renderer` 如何协作 | +| `data-flow.md` | 核心业务数据如何在各层之间流动 | +| `file-map.md` | 关键文件在哪里、应该先看哪些入口 | +| `decision-log.md` | 最近几轮重要重构和架构决策是什么 | ## 按问题选择阅读路径 diff --git a/docs/developer/guides/README.md b/docs/developer/guides/README.md index 32efac1..9857cfc 100644 --- a/docs/developer/guides/README.md +++ b/docs/developer/guides/README.md @@ -66,13 +66,13 @@ flowchart TD ## 当前文档一览 -| 文档 | 主要内容 | -| --- | --- | -| `local-development.md` | 环境准备、启动、构建、常用命令、本地验证 | -| `debugging.md` | 分层调试思路、调试入口、主链路定位方法 | +| 文档 | 主要内容 | +| ------------------------- | ---------------------------------------- | +| `local-development.md` | 环境准备、启动、构建、常用命令、本地验证 | +| `debugging.md` | 分层调试思路、调试入口、主链路定位方法 | | `renderer-development.md` | React 渲染层开发方式、页面/hook/组件边界 | -| `ipc-development.md` | 新增或修改 IPC 能力的推荐实现路径 | -| `release-process.md` | 构建、发布、更新产物与上传流程 | +| `ipc-development.md` | 新增或修改 IPC 能力的推荐实现路径 | +| `release-process.md` | 构建、发布、更新产物与上传流程 | ## 与其他文档目录的关系 diff --git a/docs/developer/modules/README.md b/docs/developer/modules/README.md index dca3129..3648152 100644 --- a/docs/developer/modules/README.md +++ b/docs/developer/modules/README.md @@ -59,14 +59,14 @@ graph TD ## 模块目录一览 -| 模块 | 文档 | 核心职责 | -| --- | --- | --- | -| Auth | `auth.md` | 登录、silent login、管理员代切用户、用户上下文同步 | -| Extractor | `extractor.md` | 订单号输入、提取执行、日志与共享订单号同步 | +| 模块 | 文档 | 核心职责 | +| ---------- | --------------- | --------------------------------------------------------- | +| Auth | `auth.md` | 登录、silent login、管理员代切用户、用户上下文同步 | +| Extractor | `extractor.md` | 订单号输入、提取执行、日志与共享订单号同步 | | Validation | `validation.md` | 共享 Production IDs、校验查询、结果富化、Cleaner 数据准备 | -| Cleaner | `cleaner.md` | 物料校验展示、删除计划保存、ERP 清理执行、报告展示 | -| Update | `update.md` | 更新目录、状态广播、下载、安装、用户/管理员更新视图 | -| Settings | `settings.md` | ERP 凭据加载与保存、当前用户配置管理 | +| Cleaner | `cleaner.md` | 物料校验展示、删除计划保存、ERP 清理执行、报告展示 | +| Update | `update.md` | 更新目录、状态广播、下载、安装、用户/管理员更新视图 | +| Settings | `settings.md` | ERP 凭据加载与保存、当前用户配置管理 | ## 模块入口地图 diff --git a/src/main/bootstrap/process-guards.ts b/src/main/bootstrap/process-guards.ts index e917fff..91a8700 100644 --- a/src/main/bootstrap/process-guards.ts +++ b/src/main/bootstrap/process-guards.ts @@ -1,6 +1,6 @@ import { app } from 'electron' import logger from '../services/logger/index' -import { logAudit } from '../services/logger/audit-logger' +import { logAudit, closeAuditLogger } from '../services/logger/audit-logger' export function setupProcessGuards(): void { process.on('uncaughtException', async (err) => { @@ -37,4 +37,10 @@ export function setupProcessGuards(): void { logger.error('Child process gone', { details }) console.error('Child process gone:', details) }) + + // Flush and close loggers before quit to prevent log loss + app.on('before-quit', () => { + logger.close() + closeAuditLogger() + }) } diff --git a/src/main/ipc/extractor-handler.ts b/src/main/ipc/extractor-handler.ts index 4ec64b8..4b885c5 100644 --- a/src/main/ipc/extractor-handler.ts +++ b/src/main/ipc/extractor-handler.ts @@ -259,7 +259,13 @@ export function registerExtractorHandlers(): void { // Write per-order record counts for (const { orderNumber, recordCount } of result.orderRecordCounts) { - await historyDao.updateRecordStatus(batchId, orderNumber, status, undefined, recordCount) + await historyDao.updateRecordStatus( + batchId, + orderNumber, + status, + undefined, + recordCount + ) } // Update batch status without recordCount (per-order counts are set individually) diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 3b7268f..fd2e5f6 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -68,15 +68,21 @@ export function withErrorHandling( } if (isBaseError(error)) { - logError(log, `[${context}] ${error.name}`, error, { - code, - cause: getErrorCauseMessage(error), - handler: context + logError(log, error, { + message: `[${context}] ${error.name}`, + context: { + code, + cause: getErrorCauseMessage(error), + handler: context + } }) } else { - logError(log, `[${context}] Error`, error, { - code, - handler: context + logError(log, error, { + message: `[${context}] Error`, + context: { + code, + handler: context + } }) } diff --git a/src/main/ipc/logger-handler.ts b/src/main/ipc/logger-handler.ts index a369efb..932d526 100644 --- a/src/main/ipc/logger-handler.ts +++ b/src/main/ipc/logger-handler.ts @@ -10,7 +10,9 @@ */ import { ipcMain } from 'electron' +import winston from 'winston' import { createLogger } from '../services/logger' +import logger from '../services/logger' import { IPC_CHANNELS, type LogLevel } from '../../shared/ipc-channels' const log = createLogger('LoggerHandler') @@ -41,6 +43,7 @@ class LoggerHandlerState { private buffer: LogEntry[] = [] private debounceTimer: NodeJS.Timeout | null = null private discardedCount = 0 + private childLoggerCache = new Map() /** * Add log entry to buffer @@ -131,16 +134,27 @@ class LoggerHandlerState { } } + /** + * Get or create a cached child logger for a component + * Avoids creating a new child logger for every log entry + * @param component - Component name for the child logger + */ + private getChildLogger(component: string): winston.Logger { + let child = this.childLoggerCache.get(component) + if (!child) { + child = log.child({ source: 'renderer', component }) + this.childLoggerCache.set(component, child) + } + return child + } + /** * Forward a single log entry to Winston logger * @param entry - Log entry to forward */ private forwardToWinston(entry: LogEntry): void { const context = (entry.context?.component as string) || 'renderer' - const childLogger = log.child({ - source: 'renderer', - component: context - }) + const childLogger = this.getChildLogger(context) const message = entry.context?.message ? `[${entry.context.message}] ${entry.message}` @@ -187,6 +201,7 @@ class LoggerHandlerState { } this.buffer = [] this.discardedCount = 0 + this.childLoggerCache.clear() } } @@ -197,6 +212,11 @@ const state = new LoggerHandlerState() * Register IPC handlers for logger */ export function registerLoggerHandlers(): void { + // Return current log level to preload for client-side filtering + ipcMain.handle(IPC_CHANNELS.LOGGER_GET_LEVEL, () => { + return logger.level as LogLevel + }) + // Use ipcMain.on with send() - fire-and-forget, non-blocking ipcMain.on(IPC_CHANNELS.LOGGER_FORWARD, (_event, entry: LogEntry) => { // Validate entry diff --git a/src/main/ipc/settings-handler.ts b/src/main/ipc/settings-handler.ts index a304ca0..9e3377a 100644 --- a/src/main/ipc/settings-handler.ts +++ b/src/main/ipc/settings-handler.ts @@ -27,16 +27,13 @@ export function registerSettingsHandlers(): void { const erpConfigService = UserErpConfigService.getInstance() ipcMain.handle(IPC_CHANNELS.SETTINGS_GET_USER_TYPE, async (): Promise> => { - return withErrorHandling( - async () => { - const userType = sessionManager.getUserType() - if (!userType) { - throw new ValidationError('未找到用户类型', 'VAL_INVALID_INPUT') - } - return userType as UserType - }, - 'settings:getUserType' - ) + return withErrorHandling(async () => { + const userType = sessionManager.getUserType() + if (!userType) { + throw new ValidationError('未找到用户类型', 'VAL_INVALID_INPUT') + } + return userType as UserType + }, 'settings:getUserType') }) ipcMain.handle( diff --git a/src/main/services/config/config-manager.ts b/src/main/services/config/config-manager.ts index bd05f81..e1e48b5 100644 --- a/src/main/services/config/config-manager.ts +++ b/src/main/services/config/config-manager.ts @@ -20,7 +20,8 @@ import { dirname } from 'path' import { app } from 'electron' import yaml from 'js-yaml' import { z } from 'zod' -import { createLogger, setLogLevel } from '../logger' +import { createLogger, applyLoggingConfig } from '../logger' +import { applyAuditConfig } from '../logger/audit-logger' import { fullConfigSchema, type FullConfig, @@ -170,7 +171,8 @@ export class ConfigManager { await this.saveConfig(DEFAULT_CONFIG) this.config = DEFAULT_CONFIG // Apply logging configuration from default config - setLogLevel(DEFAULT_CONFIG.logging.level) + applyLoggingConfig(DEFAULT_CONFIG.logging) + applyAuditConfig(DEFAULT_CONFIG.logging.auditRetention) return } @@ -190,7 +192,8 @@ export class ConfigManager { this.config = validated // Apply logging configuration - setLogLevel(validated.logging.level) + applyLoggingConfig(validated.logging) + applyAuditConfig(validated.logging.auditRetention) log.info('Configuration loaded and validated successfully') } catch (error) { diff --git a/src/main/services/erp/extractor.ts b/src/main/services/erp/extractor.ts index 27a0d7f..e8ca712 100644 --- a/src/main/services/erp/extractor.ts +++ b/src/main/services/erp/extractor.ts @@ -123,9 +123,7 @@ export class ExtractorService { * @param filePaths - Array of downloaded Excel file paths * @returns Merged file path, total record count, and optional error message */ - private async mergeFiles( - filePaths: string[] - ): Promise<{ + private async mergeFiles(filePaths: string[]): Promise<{ mergedFile: string | null recordCount: number error?: string @@ -197,7 +195,12 @@ export class ExtractorService { const errorStack = error instanceof Error ? error.stack : '' log.error('Failed to save merged file', { error: errorMsg, stack: errorStack }) // Return parsed record count and error info even if save fails - return { mergedFile: null, recordCount, orderRecordCounts, error: `保存合并文件失败:${errorMsg}` } + return { + mergedFile: null, + recordCount, + orderRecordCounts, + error: `保存合并文件失败:${errorMsg}` + } } } diff --git a/src/main/services/logger/audit-logger.ts b/src/main/services/logger/audit-logger.ts index ee25850..4ad39a7 100644 --- a/src/main/services/logger/audit-logger.ts +++ b/src/main/services/logger/audit-logger.ts @@ -6,8 +6,7 @@ import winston from 'winston' import DailyRotateFile from 'winston-daily-rotate-file' import path from 'path' -import { app } from 'electron' -import fs from 'fs' +import { getLogDir } from './shared' /** * Audit log entry structure @@ -32,22 +31,6 @@ export interface AuditEntry { metadata: Record } -/** - * Get the log directory for audit logs - * Uses app.getPath('logs') in production, local logs dir in development - */ -function getLogDir(): string { - if (app && app.isReady()) { - return app.getPath('logs') - } - // Fallback for development or before app is ready - const devLogDir = path.join(process.cwd(), 'logs') - if (!fs.existsSync(devLogDir)) { - fs.mkdirSync(devLogDir, { recursive: true }) - } - return devLogDir -} - /** * JSONL formatter - outputs one JSON object per line * This is the key difference from the standard JSON formatter @@ -59,26 +42,40 @@ const jsonlFormat = winston.format.printf(({ message }) => { /** * Create the audit logger instance with daily rotation - * Configured for 30-day retention as per requirements + * Initially silent (no transports). Call applyAuditConfig() after config is loaded. */ const auditLogger = winston.createLogger({ level: 'info', silent: false, - transports: [ + transports: [] +}) + +/** + * Apply audit log retention configuration + * Creates the DailyRotateFile transport with the configured retention period + * + * @param retentionDays - Number of days to retain audit logs + */ +export function applyAuditConfig(retentionDays: number): void { + // Remove existing DailyRotateFile transports + const existingTransports = auditLogger.transports.filter((t) => t instanceof DailyRotateFile) + for (const transport of existingTransports) { + auditLogger.remove(transport) + } + + // Add audit transport with configured retention + auditLogger.add( new DailyRotateFile({ filename: path.join(getLogDir(), 'audit-%DATE%.jsonl'), datePattern: 'YYYY-MM-DD', zippedArchive: true, maxSize: '20m', - maxFiles: '30d', // 30-day retention + maxFiles: `${retentionDays}d`, level: 'info', - format: winston.format.combine( - winston.format.timestamp({ format: 'YYYY-MM-DDTHH:mm:ss.SSSZ' }), - jsonlFormat - ) + format: jsonlFormat }) - ] -}) + ) +} /** * Log an audit event diff --git a/src/main/services/logger/error-utils.ts b/src/main/services/logger/error-utils.ts index b577b24..e4c9694 100644 --- a/src/main/services/logger/error-utils.ts +++ b/src/main/services/logger/error-utils.ts @@ -6,6 +6,7 @@ */ import type { ErrorLike, SerializedError } from '../../types/errors' +import { isProduction } from './shared' /** * Check if value is an Error or Error-like object @@ -84,7 +85,7 @@ export function sanitizeError(error: SerializedError): SerializedError { const sanitized: SerializedError = { ...error } // Sanitize message in production - if (process.env.NODE_ENV === 'production') { + if (isProduction()) { // Keep error name and structure, but sanitize message if (sensitiveKeys.some((key) => error.message?.toLowerCase().includes(key))) { sanitized.message = 'An error occurred due to invalid credentials or configuration' @@ -165,7 +166,7 @@ export function formatErrorForLogging( metadata: Record } { const serialized = serializeError(error) - const isProd = process.env.NODE_ENV === 'production' + const isProd = isProduction() const errorToLog = isProd ? sanitizeError(serialized) : serialized const errorContext = extractErrorContext(errorToLog) diff --git a/src/main/services/logger/index.ts b/src/main/services/logger/index.ts index 6764bca..56b8298 100644 --- a/src/main/services/logger/index.ts +++ b/src/main/services/logger/index.ts @@ -11,25 +11,8 @@ import winston from 'winston' import DailyRotateFile from 'winston-daily-rotate-file' import path from 'path' -import { app } from 'electron' -import fs from 'fs' import { serializeError, sanitizeError } from './error-utils' - -// Get log directory - use app.getPath('logs') in production, or local logs dir in development -function getLogDir(): string { - if (app && app.isReady()) { - return app.getPath('logs') - } - // Fallback for development or before app is ready - const devLogDir = path.join(process.cwd(), 'logs') - if (!fs.existsSync(devLogDir)) { - fs.mkdirSync(devLogDir, { recursive: true }) - } - return devLogDir -} - -// Check if running in production -const isProduction = app?.isPackaged ?? process.env.NODE_ENV === 'production' +import { getLogDir, isProduction } from './shared' // Custom format for console output - includes full error details const consoleFormat = winston.format.combine( @@ -41,7 +24,9 @@ const consoleFormat = winston.format.combine( // Format error with full stack trace let errorStr = '' if (error) { - const serialized = isProduction ? sanitizeError(serializeError(error)) : serializeError(error) + const serialized = isProduction() + ? sanitizeError(serializeError(error)) + : serializeError(error) if (serialized.stack) { errorStr = `\n${serialized.stack}` } else { @@ -60,7 +45,7 @@ const fileFormat = winston.format.combine( winston.format((info) => { // Serialize errors in metadata if (info.error) { - info.error = isProduction + info.error = isProduction() ? sanitizeError(serializeError(info.error)) : serializeError(info.error) } @@ -68,7 +53,7 @@ const fileFormat = winston.format.combine( // Serialize any error in meta fields for (const key of Object.keys(info)) { if (key !== 'error' && info[key] instanceof Error) { - info[key] = isProduction + info[key] = isProduction() ? sanitizeError(serializeError(info[key])) : serializeError(info[key]) } @@ -80,19 +65,20 @@ const fileFormat = winston.format.combine( ) // Daily rotate file transport configuration -const createFileTransport = (level?: string): DailyRotateFile => { +const createFileTransport = (level?: string, maxFiles?: string): DailyRotateFile => { return new DailyRotateFile({ filename: path.join(getLogDir(), 'app-%DATE%.log'), datePattern: 'YYYY-MM-DD', zippedArchive: true, maxSize: '20m', - maxFiles: '14d', + maxFiles: maxFiles || '14d', level, format: fileFormat }) } -// Create the logger instance with default level +// Create the logger instance with default level - Console only initially +// File transports are added after config is loaded via applyLoggingConfig() const logger = winston.createLogger({ level: 'info', // Default level, can be updated via setLogLevel() defaultMeta: { service: 'erpauto' }, @@ -100,9 +86,7 @@ const logger = winston.createLogger({ // Console transport - always enabled new winston.transports.Console({ format: consoleFormat - }), - // File transport for all levels - createFileTransport() + }) ] }) @@ -114,19 +98,40 @@ export function setLogLevel(level: string): void { logger.level = level } -// Add error-specific file transport in production -if (app?.isPackaged) { - logger.add( - new DailyRotateFile({ - filename: path.join(getLogDir(), 'error-%DATE%.log'), - datePattern: 'YYYY-MM-DD', - zippedArchive: true, - maxSize: '20m', - maxFiles: '14d', - level: 'error', - format: fileFormat - }) - ) +/** + * Apply logging configuration from config file + * Removes existing DailyRotateFile transports and recreates them with config values + * + * @param config - Logging configuration from config.yaml + */ +export function applyLoggingConfig(config: { level: string; appRetention: number }): void { + // Update log level + setLogLevel(config.level) + + // Remove existing DailyRotateFile transports + const existingFileTransports = logger.transports.filter((t) => t instanceof DailyRotateFile) + for (const transport of existingFileTransports) { + logger.remove(transport) + } + + // Add app log transport with configured retention + const retentionStr = `${config.appRetention}d` + logger.add(createFileTransport(undefined, retentionStr)) + + // Add error-specific file transport in production + if (isProduction()) { + logger.add( + new DailyRotateFile({ + filename: path.join(getLogDir(), 'error-%DATE%.log'), + datePattern: 'YYYY-MM-DD', + zippedArchive: true, + maxSize: '20m', + maxFiles: retentionStr, + level: 'error', + format: fileFormat + }) + ) + } } /** @@ -138,23 +143,8 @@ export function createLogger(context: string): winston.Logger { return logger.child({ context }) } -/** - * Log an error with full context and stack trace - * This is the recommended way to log errors in the application - * - * @param log - Logger instance - * @param message - Error message - * @param error - The error object (Error, BaseError, or any) - * @param meta - Additional metadata to include - */ -export function logError( - log: winston.Logger, - message: string, - error: unknown, - meta?: Record -): void { - log.error(message, { error, ...meta }) -} +// Re-export error utilities for convenience +export { logError, formatErrorForLogging, serializeError, extractErrorContext } from './error-utils' // Export the main logger for direct use export default logger diff --git a/src/main/services/logger/shared.ts b/src/main/services/logger/shared.ts new file mode 100644 index 0000000..989b5b0 --- /dev/null +++ b/src/main/services/logger/shared.ts @@ -0,0 +1,53 @@ +/** + * Shared Logger Utilities + * Common functions used across logger modules + */ + +import path from 'path' +import fs from 'fs' +import { app } from 'electron' + +/** + * Get log directory path + * Uses app.getPath('logs') in production, local logs dir in development + */ +export function getLogDir(): string { + if (app && app.isReady()) { + return app.getPath('logs') + } + // Fallback for development or before app is ready + const devLogDir = path.join(process.cwd(), 'logs') + if (!fs.existsSync(devLogDir)) { + fs.mkdirSync(devLogDir, { recursive: true }) + } + return devLogDir +} + +/** + * Check if running in production environment + * Uses app.isPackaged as the single source of truth + */ +export function isProduction(): boolean { + return app?.isPackaged ?? false +} + +/** + * Log level priority mapping (higher number = more severe) + */ +export const LOG_LEVEL_PRIORITY: Record = { + verbose: 0, + debug: 1, + info: 2, + warn: 3, + error: 4 +} + +/** + * Check if a log level should be logged given a threshold + * @param level - The log level of the message + * @param threshold - The minimum log level threshold + * @returns true if the message should be logged + */ +export function isLoggable(level: string, threshold: string): boolean { + return (LOG_LEVEL_PRIORITY[level] ?? 0) >= (LOG_LEVEL_PRIORITY[threshold] ?? 2) +} diff --git a/src/main/services/user/bip-users-dao.ts b/src/main/services/user/bip-users-dao.ts index 8d8f4f2..fbc59d5 100644 --- a/src/main/services/user/bip-users-dao.ts +++ b/src/main/services/user/bip-users-dao.ts @@ -163,10 +163,10 @@ export class BIPUsersDAO { return null } } catch (error) { - logError(log, 'Authenticate failed', error, { + logError(log, error, { + message: 'Authenticate failed', operation: 'authenticate', - username, - dbType: this.dbType + context: { username, dbType: this.dbType } }) return null } @@ -222,10 +222,10 @@ export class BIPUsersDAO { return null } } catch (error) { - logError(log, 'Silent login failed', error, { + logError(log, error, { + message: 'Silent login failed', operation: 'authenticateByComputerName', - computerName, - dbType: this.dbType + context: { computerName, dbType: this.dbType } }) return null } @@ -258,9 +258,10 @@ export class BIPUsersDAO { createTime: row.CreateTime as Date | undefined })) } catch (error) { - logError(log, 'Get all users failed', error, { + logError(log, error, { + message: 'Get all users failed', operation: 'getAllUsers', - dbType: this.dbType + context: { dbType: this.dbType } }) return [] } @@ -345,11 +346,10 @@ export class BIPUsersDAO { return true } } catch (error) { - logError(log, 'Create user failed', error, { + logError(log, error, { + message: 'Create user failed', operation: 'createUser', - username, - userType, - dbType: this.dbType + context: { username, userType, dbType: this.dbType } }) return false } @@ -389,11 +389,10 @@ export class BIPUsersDAO { return true } } catch (error) { - logError(log, 'Update user type failed', error, { + logError(log, error, { + message: 'Update user type failed', operation: 'updateUserType', - username, - userType, - dbType: this.dbType + context: { username, userType, dbType: this.dbType } }) return false } @@ -433,10 +432,10 @@ export class BIPUsersDAO { return true } } catch (error) { - logError(log, 'Update password failed', error, { + logError(log, error, { + message: 'Update password failed', operation: 'updatePassword', - username, - dbType: this.dbType + context: { username, dbType: this.dbType } }) return false } @@ -472,10 +471,10 @@ export class BIPUsersDAO { return true } } catch (error) { - logError(log, 'Delete user failed', error, { + logError(log, error, { + message: 'Delete user failed', operation: 'deleteUser', - username, - dbType: this.dbType + context: { username, dbType: this.dbType } }) return false } @@ -513,10 +512,10 @@ export class BIPUsersDAO { return result.rows.length > 0 && (result.rows[0].count as number) > 0 } } catch (error) { - logError(log, 'Check user exists failed', error, { + logError(log, error, { + message: 'Check user exists failed', operation: 'userExists', - username, - dbType: this.dbType + context: { username, dbType: this.dbType } }) return false } @@ -574,10 +573,10 @@ export class BIPUsersDAO { return null } } catch (error) { - logError(log, 'Get user ERP credentials failed', error, { + logError(log, error, { + message: 'Get user ERP credentials failed', operation: 'getUserErpCredentials', - username, - dbType: this.dbType + context: { username, dbType: this.dbType } }) return null } @@ -626,10 +625,10 @@ export class BIPUsersDAO { return true } } catch (error) { - logError(log, 'Update user ERP credentials failed', error, { + logError(log, error, { + message: 'Update user ERP credentials failed', operation: 'updateUserErpCredentials', - username, - dbType: this.dbType + context: { username, dbType: this.dbType } }) return false } @@ -668,9 +667,10 @@ export class BIPUsersDAO { erpUsername: (row[cols.ERP_USERNAME] as string) || '' })) } catch (error) { - logError(log, 'Get all users ERP config failed', error, { + logError(log, error, { + message: 'Get all users ERP config failed', operation: 'getAllUsersErpConfig', - dbType: this.dbType + context: { dbType: this.dbType } }) return [] } diff --git a/src/preload/api/logger.ts b/src/preload/api/logger.ts index 03ecd6d..069824c 100644 --- a/src/preload/api/logger.ts +++ b/src/preload/api/logger.ts @@ -2,13 +2,42 @@ import type { LogLevel } from '../../shared/ipc-channels' import { IPC_CHANNELS } from '../../shared/ipc-channels' import { ipcRenderer } from '../lib/ipc' +// Cached log level for client-side filtering (avoids IPC for filtered-out messages) +let cachedLevel: LogLevel = 'info' + +/** + * Check if a message at the given level should be logged + * Based on level priority: error > warn > info > debug > verbose + */ +function shouldLog(level: LogLevel): boolean { + const priorities: Record = { + verbose: 0, + debug: 1, + info: 2, + warn: 3, + error: 4 + } + return (priorities[level] ?? 0) >= (priorities[cachedLevel] ?? 2) +} + export const loggerApi = { log: (level: LogLevel, message: string, context?: Record): void => { + // Drop messages below the configured log level + if (!shouldLog(level)) return + ipcRenderer.send(IPC_CHANNELS.LOGGER_FORWARD, { level, message, context, timestamp: Date.now() }) + }, + + /** + * Fetch the current log level from main process and cache it + * Should be called early in renderer initialization + */ + fetchLevel: async (): Promise => { + cachedLevel = (await ipcRenderer.invoke(IPC_CHANNELS.LOGGER_GET_LEVEL)) as LogLevel } } as const diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 9e40259..a91ff1d 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -129,6 +129,7 @@ export interface ConfigAPI { export interface LoggerAPI { log: (level: LogLevel, message: string, context?: Record) => void + fetchLevel: () => Promise } export interface UpdateAPI { diff --git a/src/renderer/src/components/ExtractorOperationHistoryModal.tsx b/src/renderer/src/components/ExtractorOperationHistoryModal.tsx index a4428c6..22cea3c 100644 --- a/src/renderer/src/components/ExtractorOperationHistoryModal.tsx +++ b/src/renderer/src/components/ExtractorOperationHistoryModal.tsx @@ -221,9 +221,7 @@ export const ExtractorOperationHistoryModal: React.FC { setSelectedUsers((prev) => - prev.includes(username) - ? prev.filter((u) => u !== username) - : [...prev, username] + prev.includes(username) ? prev.filter((u) => u !== username) : [...prev, username] ) } @@ -274,7 +272,10 @@ export const ExtractorOperationHistoryModal: React.FC {isAdmin ? ( - 管理员模式:{selectedUsers.length > 0 ? `已选择 ${selectedUsers.length} 个用户` : '显示所有用户记录'} + 管理员模式: + {selectedUsers.length > 0 + ? `已选择 ${selectedUsers.length} 个用户` + : '显示所有用户记录'} ) : ( 仅显示您的操作记录 diff --git a/src/renderer/src/components/report-analysis/components/ComparisonTooltip.tsx b/src/renderer/src/components/report-analysis/components/ComparisonTooltip.tsx index 5847463..a97d031 100644 --- a/src/renderer/src/components/report-analysis/components/ComparisonTooltip.tsx +++ b/src/renderer/src/components/report-analysis/components/ComparisonTooltip.tsx @@ -47,7 +47,10 @@ export const ComparisonTooltip = React.memo( {user || '未分配'}: - {firstMetric === 'executionTimeSecs' ? Number(userEntry.value).toFixed(1) : userEntry.value} {firstMetric === 'executionTimeSecs' ? '秒' : ''} + {firstMetric === 'executionTimeSecs' + ? Number(userEntry.value).toFixed(1) + : userEntry.value}{' '} + {firstMetric === 'executionTimeSecs' ? '秒' : ''} ) diff --git a/src/renderer/src/components/report-analysis/components/CustomTooltip.tsx b/src/renderer/src/components/report-analysis/components/CustomTooltip.tsx index 6683735..dd72f81 100644 --- a/src/renderer/src/components/report-analysis/components/CustomTooltip.tsx +++ b/src/renderer/src/components/report-analysis/components/CustomTooltip.tsx @@ -40,7 +40,10 @@ export const CustomTooltip = React.memo( {entry.name}: - {entry.dataKey === 'executionTimeSecs' ? Number(entry.value).toFixed(1) : entry.value} {entry.dataKey === 'executionTimeSecs' ? '秒' : ''} + {entry.dataKey === 'executionTimeSecs' + ? Number(entry.value).toFixed(1) + : entry.value}{' '} + {entry.dataKey === 'executionTimeSecs' ? '秒' : ''} ))} diff --git a/src/renderer/src/hooks/useAppBootstrap.ts b/src/renderer/src/hooks/useAppBootstrap.ts index 4f93ff9..b65b159 100644 --- a/src/renderer/src/hooks/useAppBootstrap.ts +++ b/src/renderer/src/hooks/useAppBootstrap.ts @@ -68,6 +68,10 @@ export function useAppBootstrap() { const initializeAuth = useCallback(async () => { logger.info('=== Starting initializeAuth ===') + + // Fetch log level early so client-side filtering takes effect + await window.electron.logger.fetchLevel() + try { logger.debug('Getting computer name...') const computerNameResult = await window.electron.auth.getComputerName() diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 1566c42..da3c707 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -92,6 +92,7 @@ export const IPC_CHANNELS = { // Logger LOGGER_FORWARD: 'logger:forward', + LOGGER_GET_LEVEL: 'logger:getLevel', // Report REPORT_LIST_ALL: 'report:listAll', @@ -122,4 +123,4 @@ export const IPC_CHANNELS = { /** * Log level for logger service */ -export type LogLevel = 'debug' | 'info' | 'warn' | 'error' +export type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'verbose'