diff --git a/src-tauri/src/commands/file_ops.rs b/src-tauri/src/commands/file_ops.rs index 2e73875..9d3e7de 100644 --- a/src-tauri/src/commands/file_ops.rs +++ b/src-tauri/src/commands/file_ops.rs @@ -295,6 +295,98 @@ pub fn get_data_dir() -> Result { Ok(data_dir.to_string_lossy().to_string()) } +/// 统计 debug 目录下 .log 文件总大小,可排除当前正在写入的日志文件 +#[tauri::command] +pub fn get_log_dir_size(exclude_file_name: Option) -> Result { + let debug_dir = get_app_data_dir()?.join("debug"); + + if !debug_dir.exists() { + return Ok(0); + } + + let mut total = 0_u64; + let entries = + std::fs::read_dir(&debug_dir).map_err(|e| format!("读取日志目录失败 [{}]: {}", debug_dir.display(), e))?; + + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(_) => continue, + }; + let path = entry.path(); + if !path.is_file() { + continue; + } + + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + + if !name.ends_with(".log") { + continue; + } + + if exclude_file_name.as_deref() == Some(name) { + continue; + } + + match path.metadata() { + Ok(metadata) => { + total = total.saturating_add(metadata.len()); + } + Err(e) => { + log::debug!("Failed to read log file size [{}]: {}", path.display(), e); + } + } + } + + Ok(total) +} + +/// 删除 debug 目录中的 .log 文件,可选择排除一个当前正在使用的日志文件 +#[tauri::command] +pub fn clear_log_files(exclude_file_name: Option) -> Result { + let debug_dir = get_app_data_dir()?.join("debug"); + + if !debug_dir.exists() { + return Ok(0); + } + + let mut deleted = 0_u64; + let entries = + std::fs::read_dir(&debug_dir).map_err(|e| format!("读取日志目录失败 [{}]: {}", debug_dir.display(), e))?; + + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(_) => continue, + }; + let path = entry.path(); + if !path.is_file() { + continue; + } + + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + + if !name.ends_with(".log") { + continue; + } + + if exclude_file_name.as_deref() == Some(name) { + continue; + } + + match std::fs::remove_file(&path) { + Ok(()) => deleted = deleted.saturating_add(1), + Err(e) => log::debug!("Failed to delete log file [{}]: {}", path.display(), e), + } + } + + Ok(deleted) +} + /// 获取当前工作目录 #[tauri::command] pub fn get_cwd() -> Result { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 50b5a68..99e22c0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -238,6 +238,8 @@ pub fn run() { commands::file_ops::local_file_exists, commands::file_ops::get_exe_dir, commands::file_ops::get_data_dir, + commands::file_ops::get_log_dir_size, + commands::file_ops::clear_log_files, commands::file_ops::get_cwd, commands::file_ops::check_exe_path, commands::file_ops::set_executable, diff --git a/src/App.tsx b/src/App.tsx index 18566a6..b548972 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -60,6 +60,7 @@ import { getInterfaceLangKey } from '@/i18n'; import { applyTheme, resolveThemeMode, registerCustomAccent, clearCustomAccents } from '@/themes'; import { Toaster } from 'sonner'; import { loadWebUIAppearance, loadWebUILayout } from '@/services/appearanceStorage'; +import { loadPersistedRuntimeLogs, mergeRuntimeLogs, persistRuntimeLogs } from '@/utils/runtimeLogPersistence'; import { isTauri, isValidWindowSize, @@ -655,22 +656,43 @@ function App() { // 从后端恢复运行日志(跨页面刷新持久化) try { const backendLogs = await getAllLogsFromBackend(); - if (backendLogs && Object.keys(backendLogs).length > 0) { - const store = useAppStore.getState(); - const restoredLogs: Record = {}; - for (const [instanceId, entries] of Object.entries(backendLogs)) { - restoredLogs[instanceId] = entries.map((e) => ({ - id: e.id, - timestamp: new Date(e.timestamp), - type: e.type as import('@/stores/types').LogType, - message: e.message, - html: e.html, - })); + const store = useAppStore.getState(); + const clearLogFilesOnLaunch = async () => { + if (!isTauri()) return; + try { + const deleted = await invoke('clear_log_files', {}); + log.info('Auto-cleared log files on launch:', deleted); + } catch { + // ignore cleanup errors } + }; + const restoredBackendLogs: Record = {}; + for (const [instanceId, entries] of Object.entries(backendLogs || {})) { + restoredBackendLogs[instanceId] = entries.map((e) => ({ + id: e.id, + timestamp: new Date(e.timestamp), + type: e.type as import('@/stores/types').LogType, + message: e.message, + html: e.html, + })); + } + if (store.autoClearLogsOnLaunch) { + await clearLogFilesOnLaunch(); + } + + const restoredPersistentLogs = loadPersistedRuntimeLogs(store.maxLogsPerInstance); + const mergedLogs = mergeRuntimeLogs( + store.maxLogsPerInstance, + store.instanceLogs, + restoredBackendLogs, + restoredPersistentLogs, + ); + if (Object.keys(mergedLogs).length > 0) { useAppStore.setState({ - instanceLogs: { ...store.instanceLogs, ...restoredLogs }, + instanceLogs: mergedLogs, }); - log.info('已恢复运行日志:', Object.keys(restoredLogs).length, '个实例'); + persistRuntimeLogs(mergedLogs, store.maxLogsPerInstance); + log.info('Restored runtime logs', Object.keys(mergedLogs).length, 'instances'); } } catch (err) { log.warn('恢复运行日志失败:', err); diff --git a/src/components/LogsPanel.tsx b/src/components/LogsPanel.tsx index 6592a6e..915a982 100644 --- a/src/components/LogsPanel.tsx +++ b/src/components/LogsPanel.tsx @@ -1,20 +1,51 @@ -import { useRef, useEffect, useCallback } from 'react'; +import { useRef, useEffect, useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Trash2, Copy, ChevronUp, ChevronDown, Archive } from 'lucide-react'; import clsx from 'clsx'; +import { invoke } from '@tauri-apps/api/core'; import { useAppStore, type LogType } from '@/stores/appStore'; import { ContextMenu, useContextMenu, type MenuItem } from './ContextMenu'; -import { isTauri } from '@/utils/paths'; +import { getDebugDir, isTauri } from '@/utils/paths'; import { useExportLogs } from '@/utils/useExportLogs'; import { ExportLogsModal } from './settings/ExportLogsModal'; import { useIsMobile } from '@/hooks/useIsMobile'; +import { SwitchButton } from '@/components/FormControls'; +import { getBootLogDirSize, getCurrentLogFileName, LOG_RESET_KEY, loggers } from '@/utils/logger'; export function LogsPanel() { const { t } = useTranslation(); const isMobile = useIsMobile(); const logsContainerRef = useRef(null); - const { sidePanelExpanded, toggleSidePanelExpanded, activeInstanceId, instanceLogs, clearLogs } = - useAppStore(); + + function formatBytes(bytes: number) { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const idx = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + const value = bytes / Math.pow(1024, idx); + return `${value.toFixed(value >= 100 || idx === 0 ? 0 : value >= 10 ? 1 : 2)} ${units[idx]}`; + } + + function formatLogTime(date: Date) { + return date.toLocaleTimeString('zh-CN', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + } + + const [logDirSize, setLogDirSize] = useState(() => { + const bootSize = getBootLogDirSize(); + return bootSize === null ? '--' : formatBytes(bootSize); + }); + const { + sidePanelExpanded, + toggleSidePanelExpanded, + activeInstanceId, + instanceLogs, + autoClearLogsOnLaunch, + setAutoClearLogsOnLaunch, + } = useAppStore(); const { state: menuState, show: showMenu, hide: hideMenu } = useContextMenu(); const { exportModal, handleExportLogs, closeExportModal, openExportedFile } = useExportLogs(); @@ -28,11 +59,66 @@ export function LogsPanel() { } }, [logs]); - const handleClear = useCallback(() => { - if (activeInstanceId) { - clearLogs(activeInstanceId); + const refreshLogDirSize = useCallback(async () => { + if (!isTauri()) return; + try { + const logDir = await getDebugDir(); + const { exists } = await import('@tauri-apps/plugin-fs'); + const present = await exists(logDir); + if (!present) { + loggers.ui.debug('[Logs] log dir missing:', logDir); + setLogDirSize('0 B'); + return; + } + const size = await invoke('get_log_dir_size', { + excludeFileName: getCurrentLogFileName(), + }); + loggers.ui.debug( + '[Logs] dir size', + size, + 'bytes; current log', + getCurrentLogFileName(), + 'dir', + logDir, + ); + setLogDirSize(formatBytes(size)); + } catch (err) { + loggers.ui.debug('[Logs] failed to read log dir size:', err); + setLogDirSize('0 B'); } - }, [activeInstanceId, clearLogs]); + }, []); + + const clearLogFiles = useCallback(async () => { + if (!isTauri()) return; + try { + const currentLogName = getCurrentLogFileName(); + const deleted = await invoke('clear_log_files', { + excludeFileName: currentLogName, + }); + loggers.ui.debug('[Logs] cleared log files:', deleted, 'excluded:', currentLogName); + if (deleted > 0) { + try { + if (typeof window !== 'undefined' && window.localStorage) { + const today = new Date(); + const pad = (n: number) => String(n).padStart(2, '0'); + const resetDate = `${today.getFullYear()}-${pad(today.getMonth() + 1)}-${pad( + today.getDate(), + )}`; + window.localStorage.setItem(LOG_RESET_KEY, resetDate); + } + } catch { + // ignore localStorage failures + } + } + } finally { + setLogDirSize('0 B'); + refreshLogDirSize(); + } + }, [refreshLogDirSize]); + + const handleClear = useCallback(() => { + clearLogFiles(); + }, [clearLogFiles]); const handleCopyAll = useCallback(() => { const text = logs @@ -123,6 +209,15 @@ export function LogsPanel() { } }; + useEffect(() => { + if (!isTauri()) return; + refreshLogDirSize(); + const timer = window.setInterval(refreshLogDirSize, 15000); + return () => { + window.clearInterval(timer); + }; + }, [refreshLogDirSize]); + return (
+
e.stopPropagation()} + className="flex items-center gap-1 text-[10px] text-text-muted select-none" + title={t('logs.autoClearOnLaunch')} + > + {t('logs.autoClearOnLaunch')} + setAutoClearLogsOnLaunch(value)} + /> +
+ + {t('logs.logDirSize', { size: logDirSize })} + {/* 展开/折叠上方面板(仅桌面端) */} {!isMobile && ( {logs.length === 0 ? ( @@ -223,21 +335,35 @@ export function LogsPanel() { {logs.map((log) => log.html ? ( // 富文本内容(focus 消息支持 Markdown/HTML) -
- - [{log.timestamp.toLocaleTimeString()}] +
+ + {formatLogTime(log.timestamp)}
) : ( -
- - [{log.timestamp.toLocaleTimeString()}] +
+ + {formatLogTime(log.timestamp)} - + {getLogPrefix(log.type)} {log.message} diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index 8442f00..476b37b 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -427,6 +427,8 @@ export default { logs: { title: 'Logs', clear: 'Clear', + autoClearOnLaunch: 'Auto-clear on launch', + logDirSize: 'Logs: {{size}}', autoscroll: 'Auto Scroll', noLogs: 'No logs', copyAll: 'Copy All', diff --git a/src/i18n/locales/ja-JP.ts b/src/i18n/locales/ja-JP.ts index ff254bd..d9fea3a 100644 --- a/src/i18n/locales/ja-JP.ts +++ b/src/i18n/locales/ja-JP.ts @@ -424,6 +424,8 @@ export default { logs: { title: '実行ログ', clear: 'クリア', + autoClearOnLaunch: '起動時に自動クリア', + logDirSize: 'ログ:{{size}}', autoscroll: '自動スクロール', noLogs: 'ログがありません', copyAll: 'すべてコピー', diff --git a/src/i18n/locales/ko-KR.ts b/src/i18n/locales/ko-KR.ts index c25041e..1298e83 100644 --- a/src/i18n/locales/ko-KR.ts +++ b/src/i18n/locales/ko-KR.ts @@ -421,6 +421,8 @@ export default { logs: { title: '실행 로그', clear: '지우기', + autoClearOnLaunch: '시작 시 자동 지우기', + logDirSize: '로그: {{size}}', autoscroll: '자동 스크롤', noLogs: '로그가 없습니다', copyAll: '모두 복사', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 4062132..b251bc9 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -420,6 +420,8 @@ export default { logs: { title: '运行日志', clear: '清空', + autoClearOnLaunch: '启动时自动清理', + logDirSize: '日志:{{size}}', autoscroll: '自动滚动', noLogs: '暂无日志', copyAll: '复制全部', diff --git a/src/i18n/locales/zh-TW.ts b/src/i18n/locales/zh-TW.ts index 9b4cc48..9a5f07d 100644 --- a/src/i18n/locales/zh-TW.ts +++ b/src/i18n/locales/zh-TW.ts @@ -416,6 +416,8 @@ export default { logs: { title: '執行日誌', clear: '清空', + autoClearOnLaunch: '啟動時自動清理', + logDirSize: '日誌:{{size}}', autoscroll: '自動捲動', noLogs: '暫無日誌', copyAll: '複製全部', diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index 83fb371..105bb8d 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -12,6 +12,7 @@ import { } from '@/themes'; import type { MxuConfig, RecentlyClosedInstance, LegacyActionConfig } from '@/types/config'; import { + DEFAULT_MAX_LOGS_PER_INSTANCE, clampAddTaskPanelHeight, defaultAddTaskPanelHeight, defaultMirrorChyanSettings, @@ -54,6 +55,7 @@ import { getCurrentControllerAndResource, isTaskCompatible, } from './helpers'; +import { persistRuntimeLogs } from '@/utils/runtimeLogPersistence'; // 从独立模块导入类型和辅助函数 import type { AppState, LogEntry, TaskRunStatus } from './types'; @@ -133,7 +135,8 @@ export const useAppStore = create()( backgroundImage: undefined, backgroundOpacity: 50, confirmBeforeDelete: false, - maxLogsPerInstance: 2000, + maxLogsPerInstance: DEFAULT_MAX_LOGS_PER_INSTANCE, + autoClearLogsOnLaunch: true, customAccents: [], setTheme: (theme) => { set({ theme }); @@ -163,10 +166,16 @@ export const useAppStore = create()( if (!isTauri()) patchWebUIAppearance({ backgroundOpacity: clamped }); }, setConfirmBeforeDelete: (enabled) => set({ confirmBeforeDelete: enabled }), - setMaxLogsPerInstance: (value) => + setMaxLogsPerInstance: (value) => { set({ maxLogsPerInstance: Math.max(100, Math.min(10000, Math.floor(value))), - }), + }); + const state = get(); + persistRuntimeLogs(state.instanceLogs, state.maxLogsPerInstance); + }, + setAutoClearLogsOnLaunch: (enabled) => { + set({ autoClearLogsOnLaunch: enabled }); + }, addCustomAccent: (accent) => { set((state) => ({ customAccents: [...state.customAccents, accent], @@ -1167,7 +1176,8 @@ export const useAppStore = create()( backgroundImage: effectiveBgImage, backgroundOpacity: effectiveBgOpacity, confirmBeforeDelete: config.settings.confirmBeforeDelete ?? false, - maxLogsPerInstance: config.settings.maxLogsPerInstance ?? 2000, + maxLogsPerInstance: config.settings.maxLogsPerInstance ?? DEFAULT_MAX_LOGS_PER_INSTANCE, + autoClearLogsOnLaunch: config.settings.autoClearLogsOnLaunch ?? true, customAccents: effectiveCustomAccents, selectedController, selectedResource, @@ -1882,28 +1892,33 @@ export const useAppStore = create()( html: newLog.html, }); - const DEFAULT_MAX_LOGS_PER_INSTANCE = 2000; const rawLimit = Number.isFinite(state.maxLogsPerInstance) ? state.maxLogsPerInstance : DEFAULT_MAX_LOGS_PER_INSTANCE; const limit = Math.min(10000, Math.max(100, Math.floor(rawLimit))); const updatedLogs = [...logs, newLog].slice(-limit); + const nextLogs = { + ...state.instanceLogs, + [instanceId]: updatedLogs, + }; + persistRuntimeLogs(nextLogs, state.maxLogsPerInstance); return { - instanceLogs: { - ...state.instanceLogs, - [instanceId]: updatedLogs, - }, + instanceLogs: nextLogs, }; }), clearLogs: (instanceId) => { clearLogsOnBackend(instanceId); - set((state) => ({ - instanceLogs: { + set((state) => { + const nextLogs = { ...state.instanceLogs, [instanceId]: [], - }, - })); + }; + persistRuntimeLogs(nextLogs, state.maxLogsPerInstance); + return { + instanceLogs: nextLogs, + }; + }); }, // 回调 ID 与名称的映射 @@ -1994,6 +2009,7 @@ function generateConfig(): MxuConfig { backgroundOpacity: ba?.backgroundOpacity ?? state.backgroundOpacity, confirmBeforeDelete: state.confirmBeforeDelete, maxLogsPerInstance: state.maxLogsPerInstance, + autoClearLogsOnLaunch: state.autoClearLogsOnLaunch, windowSize: bl?.windowSize ?? state.windowSize, windowPosition: bl?.windowPosition ?? state.windowPosition, showOptionPreview: bl?.showOptionPreview ?? state.showOptionPreview, @@ -2079,6 +2095,7 @@ useAppStore.subscribe( }), confirmBeforeDelete: state.confirmBeforeDelete, maxLogsPerInstance: state.maxLogsPerInstance, + autoClearLogsOnLaunch: state.autoClearLogsOnLaunch, mirrorChyanSettings: state.mirrorChyanSettings, proxySettings: state.proxySettings, welcomeShownHash: state.welcomeShownHash, diff --git a/src/stores/types.ts b/src/stores/types.ts index 8d43c92..1157a24 100644 --- a/src/stores/types.ts +++ b/src/stores/types.ts @@ -98,6 +98,7 @@ export interface AppState { confirmBeforeDelete: boolean; /** 每个实例最多保留的日志条数(超出自动丢弃最旧的) */ maxLogsPerInstance: number; + autoClearLogsOnLaunch: boolean; customAccents: CustomAccent[]; setTheme: (theme: Theme) => void; setAccentColor: (accent: AccentColor) => void; @@ -106,6 +107,7 @@ export interface AppState { setBackgroundOpacity: (opacity: number) => void; setConfirmBeforeDelete: (enabled: boolean) => void; setMaxLogsPerInstance: (value: number) => void; + setAutoClearLogsOnLaunch: (enabled: boolean) => void; addCustomAccent: (accent: CustomAccent) => void; updateCustomAccent: (id: string, accent: CustomAccent) => void; removeCustomAccent: (id: string) => void; diff --git a/src/types/config.ts b/src/types/config.ts index 407bcdb..b6d6a9e 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -3,6 +3,8 @@ import type { OptionValue, ActionConfig } from './interface'; import type { AccentColor, CustomAccent } from '@/themes/types'; +export const DEFAULT_MAX_LOGS_PER_INSTANCE = 2000; + // 定时执行策略 export interface SchedulePolicy { id: string; @@ -133,6 +135,7 @@ export interface AppSettings { confirmBeforeDelete?: boolean; /** 每个实例最多保留的日志条数(超出自动丢弃最旧的) */ maxLogsPerInstance?: number; + autoClearLogsOnLaunch?: boolean; windowSize?: WindowSize; windowPosition?: WindowPosition; // 窗口位置 mirrorChyan?: MirrorChyanSettings; @@ -226,7 +229,8 @@ export const defaultConfig: MxuConfig = { accentColor: defaultAccentColor, language: 'system', confirmBeforeDelete: false, - maxLogsPerInstance: 2000, + maxLogsPerInstance: DEFAULT_MAX_LOGS_PER_INSTANCE, + autoClearLogsOnLaunch: true, windowSize: defaultWindowSize, mirrorChyan: defaultMirrorChyanSettings, }, diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 2d863f4..6bbf67f 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -15,6 +15,10 @@ const defaultLevel: LogLevel = isDev ? 'trace' : 'debug'; // 文件日志配置 let logsDir: string | null = null; +let logFileName: string | null = null; +export const LOG_RESET_KEY = 'mxu.log-reset-date'; +const LOG_DIR_SIZE_KEY = 'mxu.log-dir-size-on-boot'; +let bootLogDirSize: number | null = null; /** * 初始化文件日志(自动获取数据目录) @@ -25,10 +29,84 @@ async function initFileLogger(): Promise { try { logsDir = await getDebugDir(); - const { mkdir, exists } = await import('@tauri-apps/plugin-fs'); + const { mkdir, exists, readDir, remove } = await import('@tauri-apps/plugin-fs'); if (!(await exists(logsDir))) { await mkdir(logsDir, { recursive: true }); } + const today = formatLocalDateTime(new Date(), 'date'); + let maxIndex = 0; + let forceReset = false; + try { + if (typeof window !== 'undefined' && window.localStorage) { + const resetDate = window.localStorage.getItem(LOG_RESET_KEY); + if (resetDate === today) { + forceReset = true; + } + } + } catch { + // ignore localStorage issues + } + let entries: Awaited> = []; + try { + entries = await readDir(logsDir); + } catch { + entries = []; + } + try { + if (forceReset) { + for (const entry of entries) { + if (!entry.isFile) continue; + const name = entry.name ?? ''; + if (!name.endsWith('.log')) continue; + try { + await remove(`${logsDir}/${name}`); + } catch { + continue; + } + } + } + } catch { + // ignore cleanup failures + } + if (forceReset) { + try { + entries = await readDir(logsDir); + } catch { + entries = []; + } + } + bootLogDirSize = null; + try { + if (typeof window !== 'undefined' && window.localStorage) { + window.localStorage.removeItem(LOG_DIR_SIZE_KEY); + } + } catch { + // ignore localStorage failures + } + try { + if (!forceReset) { + for (const entry of entries) { + if (!entry.isFile) continue; + const name = entry.name ?? ''; + if (!name.startsWith(`${today}-`) || !name.endsWith('.log')) continue; + const idxText = name.slice(`${today}-`.length, -'.log'.length); + const idx = Number.parseInt(idxText, 10); + if (Number.isFinite(idx) && idx > maxIndex) { + maxIndex = idx; + } + } + } + } catch { + // ignore scan failures + } + logFileName = `${today}-${maxIndex + 1}.log`; + try { + if (forceReset && typeof window !== 'undefined' && window.localStorage) { + window.localStorage.removeItem(LOG_RESET_KEY); + } + } catch { + // ignore localStorage failures + } console.log('[Logger] File logger initialized, logs dir:', logsDir); } catch (err) { console.warn('[Logger] Failed to initialize file logger:', err); @@ -44,23 +122,33 @@ if (checkTauri()) { /** * 格式化本地日期时间 * @param date 日期对象 - * @param format 'date' 返回 YYYY-MM-DD,'datetime' 返回 YYYY-MM-DD HH:mm:ss + * @param format 'date' 返回 YYYY-MM-DD,'datetime' 返回 YYYY-MM-DD HH:mm:ss,'file' 适用于文件名 */ -function formatLocalDateTime(date: Date, format: 'date' | 'datetime' = 'datetime'): string { +function formatLocalDateTime( + date: Date, + format: 'date' | 'datetime' | 'file' = 'datetime', +): string { const pad = (n: number) => String(n).padStart(2, '0'); + const padMs = (n: number) => String(n).padStart(3, '0'); const datePart = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; if (format === 'date') return datePart; - return `${datePart} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; + const timePart = `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; + if (format === 'file') { + return `${datePart}_${pad(date.getHours())}-${pad(date.getMinutes())}-${pad( + date.getSeconds(), + )}.${padMs(date.getMilliseconds())}`; + } + return `${datePart} ${timePart}`; } /** * 直接写入日志到文件 */ async function writeLogToFile(line: string): Promise { - if (!logsDir) return; + if (!logsDir || !logFileName) return; - // 日志文件名:mxu-web-YYYY-MM-DD.log(使用本地日期) - const logFile = `${logsDir}/mxu-web-${formatLocalDateTime(new Date(), 'date')}.log`; + // 日志文件名:YYYY-MM-DD-.log(按当日启动次数递增) + const logFile = `${logsDir}/${logFileName}`; try { const { writeTextFile } = await import('@tauri-apps/plugin-fs'); @@ -168,6 +256,25 @@ export function getLogLevel(): LogLevel { return levels[log.getLevel()] || 'warn'; } +export function getCurrentLogFileName(): string | null { + return logFileName; +} + +export function getBootLogDirSize(): number | null { + if (bootLogDirSize !== null) return bootLogDirSize; + try { + if (typeof window !== 'undefined' && window.localStorage) { + const raw = window.localStorage.getItem(LOG_DIR_SIZE_KEY); + if (!raw) return null; + const parsed = Number(raw); + if (Number.isFinite(parsed)) return parsed; + } + } catch { + return null; + } + return null; +} + // 预创建常用模块的日志器 export const loggers = { maa: createLogger('MAA'), diff --git a/src/utils/runtimeLogPersistence.ts b/src/utils/runtimeLogPersistence.ts new file mode 100644 index 0000000..884e300 --- /dev/null +++ b/src/utils/runtimeLogPersistence.ts @@ -0,0 +1,221 @@ +import type { LogEntry } from '@/stores/types'; +import { DEFAULT_MAX_LOGS_PER_INSTANCE } from '@/types/config'; + +const RUNTIME_LOG_STORAGE_KEY = 'mxu.runtime-logs.v1'; +const STORAGE_VERSION = 1; +const STORAGE_WRITE_DEBOUNCE_MS = 250; + +let pendingPersistedLogsPayload: string | null = null; +let pendingPersistedLogsTimeout: ReturnType | null = null; +let pendingPersistedLogsIdleCallback: number | null = null; + +interface PersistedLogEntry { + id: string; + timestamp: string; + type: LogEntry['type']; + message: string; + html?: string; +} + +interface PersistedRuntimeLogs { + version: number; + savedAt: string; + logs: Record; +} + +function canUseLocalStorage(): boolean { + return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'; +} + +function clearPendingPersistedLogsWrite(): void { + if (pendingPersistedLogsTimeout !== null) { + window.clearTimeout(pendingPersistedLogsTimeout); + pendingPersistedLogsTimeout = null; + } + + if ( + pendingPersistedLogsIdleCallback !== null && + typeof window !== 'undefined' && + 'cancelIdleCallback' in window + ) { + window.cancelIdleCallback(pendingPersistedLogsIdleCallback); + pendingPersistedLogsIdleCallback = null; + } +} + +function flushPersistedLogsWrite(): void { + if (!canUseLocalStorage() || pendingPersistedLogsPayload === null) return; + + const payload = pendingPersistedLogsPayload; + pendingPersistedLogsPayload = null; + pendingPersistedLogsTimeout = null; + pendingPersistedLogsIdleCallback = null; + + try { + window.localStorage.setItem(RUNTIME_LOG_STORAGE_KEY, payload); + } catch { + try { + window.localStorage.removeItem(RUNTIME_LOG_STORAGE_KEY); + } catch { + // Ignore storage cleanup failures. Persistence is best-effort only. + } + } +} + +function schedulePersistedLogsWrite(payload: PersistedRuntimeLogs): void { + pendingPersistedLogsPayload = JSON.stringify(payload); + clearPendingPersistedLogsWrite(); + + pendingPersistedLogsTimeout = window.setTimeout(() => { + pendingPersistedLogsTimeout = null; + + if (typeof window !== 'undefined' && 'requestIdleCallback' in window) { + pendingPersistedLogsIdleCallback = window.requestIdleCallback(() => { + flushPersistedLogsWrite(); + }); + return; + } + + flushPersistedLogsWrite(); + }, STORAGE_WRITE_DEBOUNCE_MS); +} + +function normalizeLimit(limit: number): number { + if (!Number.isFinite(limit)) return DEFAULT_MAX_LOGS_PER_INSTANCE; + return Math.min(10000, Math.max(100, Math.floor(limit))); +} + +function sanitizeLogEntries( + entries: readonly (LogEntry | PersistedLogEntry)[], + limit: number, +): LogEntry[] { + const deduped = new Map(); + + for (const entry of entries) { + const timestamp = entry.timestamp instanceof Date ? entry.timestamp : new Date(entry.timestamp); + if (Number.isNaN(timestamp.getTime())) continue; + + deduped.set(entry.id, { + id: entry.id, + timestamp, + type: entry.type, + message: entry.message, + html: entry.html, + }); + } + + return [...deduped.values()] + .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()) + .slice(-limit); +} + +function sanitizeRuntimeLogs( + logs: Record, + maxLogsPerInstance: number, +): Record { + const limit = normalizeLimit(maxLogsPerInstance); + return Object.fromEntries( + Object.entries(logs) + .map(([instanceId, entries]) => [instanceId, sanitizeLogEntries(entries, limit)] as const) + .filter(([, entries]) => entries.length > 0), + ); +} + +export function loadPersistedRuntimeLogs(maxLogsPerInstance: number): Record { + if (!canUseLocalStorage()) return {}; + + try { + const raw = window.localStorage.getItem(RUNTIME_LOG_STORAGE_KEY); + if (!raw) return {}; + + const parsed = JSON.parse(raw) as PersistedRuntimeLogs; + if (parsed.version !== STORAGE_VERSION || !parsed.logs || typeof parsed.logs !== 'object') { + clearPersistedRuntimeLogs(); + return {}; + } + + const sanitized = sanitizeRuntimeLogs(parsed.logs, maxLogsPerInstance); + if (Object.keys(sanitized).length === 0) { + clearPersistedRuntimeLogs(); + return {}; + } + + return sanitized; + } catch { + clearPersistedRuntimeLogs(); + return {}; + } +} + +export function persistRuntimeLogs( + logs: Record, + maxLogsPerInstance: number, +): void { + if (!canUseLocalStorage()) return; + + const sanitized = sanitizeRuntimeLogs(logs, maxLogsPerInstance); + if (Object.keys(sanitized).length === 0) { + clearPersistedRuntimeLogs(); + return; + } + + const payload: PersistedRuntimeLogs = { + version: STORAGE_VERSION, + savedAt: new Date().toISOString(), + logs: Object.fromEntries( + Object.entries(sanitized).map(([instanceId, entries]) => [ + instanceId, + entries.map((entry) => ({ + id: entry.id, + timestamp: entry.timestamp.toISOString(), + type: entry.type, + message: entry.message, + html: entry.html, + })), + ]), + ), + }; + + schedulePersistedLogsWrite(payload); +} + +export function clearPersistedRuntimeLogs( + instanceId?: string, + maxLogsPerInstance: number = DEFAULT_MAX_LOGS_PER_INSTANCE, +): void { + if (!canUseLocalStorage()) return; + + if (!instanceId) { + clearPendingPersistedLogsWrite(); + pendingPersistedLogsPayload = null; + window.localStorage.removeItem(RUNTIME_LOG_STORAGE_KEY); + return; + } + + const existing = loadPersistedRuntimeLogs(maxLogsPerInstance); + if (!(instanceId in existing)) return; + + const { [instanceId]: _, ...rest } = existing; + if (Object.keys(rest).length === 0) { + window.localStorage.removeItem(RUNTIME_LOG_STORAGE_KEY); + return; + } + + persistRuntimeLogs(rest, maxLogsPerInstance); +} + +export function mergeRuntimeLogs( + maxLogsPerInstance: number, + ...sources: Array> +): Record { + const merged = new Map(); + + for (const source of sources) { + for (const [instanceId, entries] of Object.entries(source)) { + const current = merged.get(instanceId) ?? []; + merged.set(instanceId, [...current, ...entries]); + } + } + + return sanitizeRuntimeLogs(Object.fromEntries(merged.entries()), maxLogsPerInstance); +}