From 7c89e36e68db087649e2f4938ea4c06e6ad8bf6f Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Apr 2026 17:20:06 +0800 Subject: [PATCH 1/5] Add optional today-log persistence --- src/App.tsx | 45 ++++-- src/components/settings/DebugSection.tsx | 20 +++ src/i18n/locales/en-US.ts | 3 + src/stores/appStore.ts | 51 +++++-- src/stores/types.ts | 2 + src/types/config.ts | 2 + src/utils/runtimeLogPersistence.ts | 170 +++++++++++++++++++++++ 7 files changed, 269 insertions(+), 24 deletions(-) create mode 100644 src/utils/runtimeLogPersistence.ts diff --git a/src/App.tsx b/src/App.tsx index 1854ece..9eca36f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -59,6 +59,11 @@ import { useMaaCallbackLogger, useMaaAgentLogger } from '@/utils/useMaaCallbackL import { getInterfaceLangKey } from '@/i18n'; import { applyTheme, resolveThemeMode, registerCustomAccent, clearCustomAccents } from '@/themes'; import { loadWebUIAppearance, loadWebUILayout } from '@/services/appearanceStorage'; +import { + loadPersistedRuntimeLogs, + mergeRuntimeLogs, + persistRuntimeLogs, +} from '@/utils/runtimeLogPersistence'; import { isTauri, isValidWindowSize, @@ -654,22 +659,34 @@ 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 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, + })); + } + const restoredPersistentLogs = store.retainTodayLogsAfterRestart + ? 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, '个实例'); + if (store.retainTodayLogsAfterRestart) { + persistRuntimeLogs(mergedLogs, store.maxLogsPerInstance); + } + log.info('Restored runtime logs', Object.keys(mergedLogs).length, 'instances'); } } catch (err) { log.warn('恢复运行日志失败:', err); diff --git a/src/components/settings/DebugSection.tsx b/src/components/settings/DebugSection.tsx index 2e0ac20..c4fd1f9 100644 --- a/src/components/settings/DebugSection.tsx +++ b/src/components/settings/DebugSection.tsx @@ -29,6 +29,8 @@ export function DebugSection() { setDevMode, saveDraw, setSaveDraw, + retainTodayLogsAfterRestart, + setRetainTodayLogsAfterRestart, tcpCompatMode, setTcpCompatMode, allowLanAccess, @@ -354,6 +356,24 @@ export function DebugSection() { setSaveDraw(v)} /> +
+
+ +
+ + {t('debug.retainTodayLogsAfterRestart')} + +

+ {t('debug.retainTodayLogsAfterRestartHint')} +

+
+
+ setRetainTodayLogsAfterRestart(v)} + /> +
+ {/* 通信兼容模式 */}
diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index 1353a15..0e61043 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -528,6 +528,9 @@ export default { saveDraw: 'Save Debug Images', saveDrawHint: 'Save recognition and action debug images to log directory (auto-disabled on restart)', + retainTodayLogsAfterRestart: 'Retain today logs after restart', + retainTodayLogsAfterRestartHint: + "Keep today's runtime logs locally so they are still visible after restarting the app", tcpCompatMode: 'Communication Compat Mode', tcpCompatModeHint: 'Try enabling this if the app crashes immediately after starting tasks. Only use in this case, as it may reduce performance', diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index 7e26ed6..754094a 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -54,6 +54,7 @@ import { getCurrentControllerAndResource, isTaskCompatible, } from './helpers'; +import { clearPersistedRuntimeLogs, persistRuntimeLogs } from '@/utils/runtimeLogPersistence'; // 从独立模块导入类型和辅助函数 import type { AppState, LogEntry, TaskRunStatus } from './types'; @@ -121,6 +122,7 @@ export const useAppStore = create()( backgroundOpacity: 50, confirmBeforeDelete: false, maxLogsPerInstance: 2000, + retainTodayLogsAfterRestart: false, customAccents: [], setTheme: (theme) => { set({ theme }); @@ -150,10 +152,24 @@ 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(); + if (state.retainTodayLogsAfterRestart) { + persistRuntimeLogs(state.instanceLogs, state.maxLogsPerInstance); + } + }, + setRetainTodayLogsAfterRestart: (enabled) => { + set({ retainTodayLogsAfterRestart: enabled }); + const state = get(); + if (enabled) { + persistRuntimeLogs(state.instanceLogs, state.maxLogsPerInstance); + } else { + clearPersistedRuntimeLogs(); + } + }, addCustomAccent: (accent) => { set((state) => ({ customAccents: [...state.customAccents, accent], @@ -1140,6 +1156,7 @@ export const useAppStore = create()( backgroundOpacity: effectiveBgOpacity, confirmBeforeDelete: config.settings.confirmBeforeDelete ?? false, maxLogsPerInstance: config.settings.maxLogsPerInstance ?? 2000, + retainTodayLogsAfterRestart: config.settings.retainTodayLogsAfterRestart ?? false, customAccents: effectiveCustomAccents, selectedController, selectedResource, @@ -1793,22 +1810,34 @@ export const useAppStore = create()( : 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, + }; + if (state.retainTodayLogsAfterRestart) { + 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]: [], - }, - })); + }; + if (state.retainTodayLogsAfterRestart) { + persistRuntimeLogs(nextLogs, state.maxLogsPerInstance); + } else { + clearPersistedRuntimeLogs(instanceId); + } + return { + instanceLogs: nextLogs, + }; + }); }, // 回调 ID 与名称的映射 @@ -1899,6 +1928,7 @@ function generateConfig(): MxuConfig { backgroundOpacity: ba?.backgroundOpacity ?? state.backgroundOpacity, confirmBeforeDelete: state.confirmBeforeDelete, maxLogsPerInstance: state.maxLogsPerInstance, + retainTodayLogsAfterRestart: state.retainTodayLogsAfterRestart, windowSize: bl?.windowSize ?? state.windowSize, windowPosition: bl?.windowPosition ?? state.windowPosition, showOptionPreview: bl?.showOptionPreview ?? state.showOptionPreview, @@ -1984,6 +2014,7 @@ useAppStore.subscribe( }), confirmBeforeDelete: state.confirmBeforeDelete, maxLogsPerInstance: state.maxLogsPerInstance, + retainTodayLogsAfterRestart: state.retainTodayLogsAfterRestart, mirrorChyanSettings: state.mirrorChyanSettings, proxySettings: state.proxySettings, welcomeShownHash: state.welcomeShownHash, diff --git a/src/stores/types.ts b/src/stores/types.ts index 5eb4cfd..57c88b8 100644 --- a/src/stores/types.ts +++ b/src/stores/types.ts @@ -98,6 +98,7 @@ export interface AppState { confirmBeforeDelete: boolean; /** 每个实例最多保留的日志条数(超出自动丢弃最旧的) */ maxLogsPerInstance: number; + retainTodayLogsAfterRestart: 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; + setRetainTodayLogsAfterRestart: (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 adf9c62..49be04a 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -117,6 +117,7 @@ export interface AppSettings { confirmBeforeDelete?: boolean; /** 每个实例最多保留的日志条数(超出自动丢弃最旧的) */ maxLogsPerInstance?: number; + retainTodayLogsAfterRestart?: boolean; windowSize?: WindowSize; windowPosition?: WindowPosition; // 窗口位置 mirrorChyan?: MirrorChyanSettings; @@ -211,6 +212,7 @@ export const defaultConfig: MxuConfig = { language: 'system', confirmBeforeDelete: false, maxLogsPerInstance: 2000, + retainTodayLogsAfterRestart: false, windowSize: defaultWindowSize, mirrorChyan: defaultMirrorChyanSettings, }, diff --git a/src/utils/runtimeLogPersistence.ts b/src/utils/runtimeLogPersistence.ts new file mode 100644 index 0000000..df3721e --- /dev/null +++ b/src/utils/runtimeLogPersistence.ts @@ -0,0 +1,170 @@ +import type { LogEntry } from '@/stores/types'; + +const RUNTIME_LOG_STORAGE_KEY = 'mxu.runtime-logs.v1'; +const STORAGE_VERSION = 1; +const DEFAULT_MAX_LOGS_PER_INSTANCE = 2000; + +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 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 isSameLocalDay(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} + +function sanitizeLogEntries( + entries: readonly (LogEntry | PersistedLogEntry)[], + limit: number, + now: Date, +): 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()) || !isSameLocalDay(timestamp, now)) 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, + now: Date = new Date(), +): Record { + const limit = normalizeLimit(maxLogsPerInstance); + return Object.fromEntries( + Object.entries(logs) + .map( + ([instanceId, entries]) => [instanceId, sanitizeLogEntries(entries, limit, now)] 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, + })), + ]), + ), + }; + + window.localStorage.setItem(RUNTIME_LOG_STORAGE_KEY, JSON.stringify(payload)); +} + +export function clearPersistedRuntimeLogs(instanceId?: string): void { + if (!canUseLocalStorage()) return; + + if (!instanceId) { + window.localStorage.removeItem(RUNTIME_LOG_STORAGE_KEY); + return; + } + + const existing = loadPersistedRuntimeLogs(DEFAULT_MAX_LOGS_PER_INSTANCE); + 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, DEFAULT_MAX_LOGS_PER_INSTANCE); +} + +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); +} From ac0709f33f309b5134d2a28100a52037a9f57cd1 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Apr 2026 18:17:19 +0800 Subject: [PATCH 2/5] Add runtime log setting translations --- src/i18n/locales/ja-JP.ts | 3 +++ src/i18n/locales/ko-KR.ts | 3 +++ src/i18n/locales/zh-CN.ts | 3 +++ src/i18n/locales/zh-TW.ts | 3 +++ 4 files changed, 12 insertions(+) diff --git a/src/i18n/locales/ja-JP.ts b/src/i18n/locales/ja-JP.ts index ba40565..836fb3e 100644 --- a/src/i18n/locales/ja-JP.ts +++ b/src/i18n/locales/ja-JP.ts @@ -523,6 +523,9 @@ export default { saveDraw: 'デバッグ画像を保存', saveDrawHint: '認識と操作のデバッグ画像をログフォルダに保存します(再起動後は自動的にオフになります)', + retainTodayLogsAfterRestart: '再起動後も当日のログを保持', + retainTodayLogsAfterRestartHint: + '当日の実行ログをローカルに保存し、アプリを再起動した後も引き続き確認できるようにします', tcpCompatMode: '通信互換モード', tcpCompatModeHint: 'タスク開始後にアプリがすぐにクラッシュする場合は有効にしてください。この場合のみ使用し、それ以外は性能に影響します', diff --git a/src/i18n/locales/ko-KR.ts b/src/i18n/locales/ko-KR.ts index d8e855f..9807080 100644 --- a/src/i18n/locales/ko-KR.ts +++ b/src/i18n/locales/ko-KR.ts @@ -522,6 +522,9 @@ export default { saveDraw: '디버그 이미지 저장', saveDrawHint: '인식 및 작업의 디버그 이미지를 로그 폴더에 저장합니다 (재시작 후 자동으로 비활성화됨)', + retainTodayLogsAfterRestart: '재시작 후 오늘 로그 유지', + retainTodayLogsAfterRestartHint: + '오늘의 실행 로그를 로컬에 저장하여 앱을 다시 시작한 뒤에도 계속 확인할 수 있습니다', tcpCompatMode: '통신 호환 모드', tcpCompatModeHint: '작업 시작 후 앱이 즉시 충돌하면 활성화해 보세요. 이 경우에만 사용하세요, 성능에 영향을 줄 수 있습니다', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index d8edaf2..d7983c1 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -516,6 +516,9 @@ export default { devModeHint: '启用后允许按 F5 刷新 UI', saveDraw: '保存调试图像', saveDrawHint: '保存识别和操作的调试图像到日志目录(重启软件后自动关闭)', + retainTodayLogsAfterRestart: '重启后保留当天日志', + retainTodayLogsAfterRestartHint: + '将当天运行日志保存在本地,这样重启应用后仍然可以继续查看', tcpCompatMode: '通信兼容模式', tcpCompatModeHint: '若启动任务后软件立即闪退,可尝试开启。仅限此情况使用,否则会影响运行效率', webServerPort: 'Web 服务端口', diff --git a/src/i18n/locales/zh-TW.ts b/src/i18n/locales/zh-TW.ts index e26007c..0aa7641 100644 --- a/src/i18n/locales/zh-TW.ts +++ b/src/i18n/locales/zh-TW.ts @@ -512,6 +512,9 @@ export default { devModeHint: '啟用後允許按 F5 重新整理 UI', saveDraw: '儲存除錯圖像', saveDrawHint: '儲存識別和操作的除錯圖像到日誌目錄(重啟軟體後自動關閉)', + retainTodayLogsAfterRestart: '重啟後保留當天日誌', + retainTodayLogsAfterRestartHint: + '將當天執行日誌保存在本機,這樣重啟應用後仍然可以繼續查看', tcpCompatMode: '通訊相容模式', tcpCompatModeHint: '若啟動任務後軟體立即閃退,可嘗試開啟。僅限此情況使用,否則會影響運行效率', webServerPort: 'Web 服務連接埠', From 57df9f72e48e55f02b222320ddecafa15f1adab5 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Apr 2026 18:40:02 +0800 Subject: [PATCH 3/5] Respect log limit when clearing persisted logs --- src/stores/appStore.ts | 2 +- src/utils/runtimeLogPersistence.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index 4454d0b..6780193 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -1927,7 +1927,7 @@ export const useAppStore = create()( if (state.retainTodayLogsAfterRestart) { persistRuntimeLogs(nextLogs, state.maxLogsPerInstance); } else { - clearPersistedRuntimeLogs(instanceId); + clearPersistedRuntimeLogs(instanceId, state.maxLogsPerInstance); } return { instanceLogs: nextLogs, diff --git a/src/utils/runtimeLogPersistence.ts b/src/utils/runtimeLogPersistence.ts index df3721e..4d7b609 100644 --- a/src/utils/runtimeLogPersistence.ts +++ b/src/utils/runtimeLogPersistence.ts @@ -133,7 +133,10 @@ export function persistRuntimeLogs( window.localStorage.setItem(RUNTIME_LOG_STORAGE_KEY, JSON.stringify(payload)); } -export function clearPersistedRuntimeLogs(instanceId?: string): void { +export function clearPersistedRuntimeLogs( + instanceId?: string, + maxLogsPerInstance: number = DEFAULT_MAX_LOGS_PER_INSTANCE, +): void { if (!canUseLocalStorage()) return; if (!instanceId) { @@ -141,7 +144,7 @@ export function clearPersistedRuntimeLogs(instanceId?: string): void { return; } - const existing = loadPersistedRuntimeLogs(DEFAULT_MAX_LOGS_PER_INSTANCE); + const existing = loadPersistedRuntimeLogs(maxLogsPerInstance); if (!(instanceId in existing)) return; const { [instanceId]: _, ...rest } = existing; @@ -150,7 +153,7 @@ export function clearPersistedRuntimeLogs(instanceId?: string): void { return; } - persistRuntimeLogs(rest, DEFAULT_MAX_LOGS_PER_INSTANCE); + persistRuntimeLogs(rest, maxLogsPerInstance); } export function mergeRuntimeLogs( From 6f53ff71df7b016d834854b6689adab60d2dd4af Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Apr 2026 18:59:32 +0800 Subject: [PATCH 4/5] Harden runtime log persistence --- src/stores/appStore.ts | 6 +-- src/types/config.ts | 4 +- src/utils/runtimeLogPersistence.ts | 64 +++++++++++++++++++++++++++++- 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index 6780193..387c278 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, @@ -134,7 +135,7 @@ export const useAppStore = create()( backgroundImage: undefined, backgroundOpacity: 50, confirmBeforeDelete: false, - maxLogsPerInstance: 2000, + maxLogsPerInstance: DEFAULT_MAX_LOGS_PER_INSTANCE, retainTodayLogsAfterRestart: false, customAccents: [], setTheme: (theme) => { @@ -1183,7 +1184,7 @@ 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, retainTodayLogsAfterRestart: config.settings.retainTodayLogsAfterRestart ?? false, customAccents: effectiveCustomAccents, selectedController, @@ -1899,7 +1900,6 @@ 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; diff --git a/src/types/config.ts b/src/types/config.ts index f924eb0..3c8ce87 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; @@ -227,7 +229,7 @@ export const defaultConfig: MxuConfig = { accentColor: defaultAccentColor, language: 'system', confirmBeforeDelete: false, - maxLogsPerInstance: 2000, + maxLogsPerInstance: DEFAULT_MAX_LOGS_PER_INSTANCE, retainTodayLogsAfterRestart: false, windowSize: defaultWindowSize, mirrorChyan: defaultMirrorChyanSettings, diff --git a/src/utils/runtimeLogPersistence.ts b/src/utils/runtimeLogPersistence.ts index 4d7b609..cfaa750 100644 --- a/src/utils/runtimeLogPersistence.ts +++ b/src/utils/runtimeLogPersistence.ts @@ -1,8 +1,13 @@ 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 DEFAULT_MAX_LOGS_PER_INSTANCE = 2000; +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; @@ -22,6 +27,59 @@ 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))); @@ -130,7 +188,7 @@ export function persistRuntimeLogs( ), }; - window.localStorage.setItem(RUNTIME_LOG_STORAGE_KEY, JSON.stringify(payload)); + schedulePersistedLogsWrite(payload); } export function clearPersistedRuntimeLogs( @@ -140,6 +198,8 @@ export function clearPersistedRuntimeLogs( if (!canUseLocalStorage()) return; if (!instanceId) { + clearPendingPersistedLogsWrite(); + pendingPersistedLogsPayload = null; window.localStorage.removeItem(RUNTIME_LOG_STORAGE_KEY); return; } From 1008198ffb65e8e7cfe845ee38dee554e61196ad Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Apr 2026 05:22:05 +0800 Subject: [PATCH 5/5] Refine runtime log persistence and cleanup UX --- src-tauri/src/commands/file_ops.rs | 92 +++++++++++++ src-tauri/src/lib.rs | 2 + src/App.tsx | 27 ++-- src/components/LogsPanel.tsx | 164 ++++++++++++++++++++--- src/components/settings/DebugSection.tsx | 20 --- src/i18n/locales/en-US.ts | 5 +- src/i18n/locales/ja-JP.ts | 5 +- src/i18n/locales/ko-KR.ts | 5 +- src/i18n/locales/zh-CN.ts | 5 +- src/i18n/locales/zh-TW.ts | 5 +- src/stores/appStore.ts | 34 ++--- src/stores/types.ts | 4 +- src/types/config.ts | 4 +- src/utils/logger.ts | 121 ++++++++++++++++- src/utils/runtimeLogPersistence.ts | 16 +-- 15 files changed, 395 insertions(+), 114 deletions(-) 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 9eca36f..0c7d6bc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -59,11 +59,7 @@ import { useMaaCallbackLogger, useMaaAgentLogger } from '@/utils/useMaaCallbackL import { getInterfaceLangKey } from '@/i18n'; import { applyTheme, resolveThemeMode, registerCustomAccent, clearCustomAccents } from '@/themes'; import { loadWebUIAppearance, loadWebUILayout } from '@/services/appearanceStorage'; -import { - loadPersistedRuntimeLogs, - mergeRuntimeLogs, - persistRuntimeLogs, -} from '@/utils/runtimeLogPersistence'; +import { loadPersistedRuntimeLogs, mergeRuntimeLogs, persistRuntimeLogs } from '@/utils/runtimeLogPersistence'; import { isTauri, isValidWindowSize, @@ -660,6 +656,15 @@ function App() { try { const backendLogs = await getAllLogsFromBackend(); 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) => ({ @@ -670,9 +675,11 @@ function App() { html: e.html, })); } - const restoredPersistentLogs = store.retainTodayLogsAfterRestart - ? loadPersistedRuntimeLogs(store.maxLogsPerInstance) - : {}; + if (store.autoClearLogsOnLaunch) { + await clearLogFilesOnLaunch(); + } + + const restoredPersistentLogs = loadPersistedRuntimeLogs(store.maxLogsPerInstance); const mergedLogs = mergeRuntimeLogs( store.maxLogsPerInstance, store.instanceLogs, @@ -683,9 +690,7 @@ function App() { useAppStore.setState({ instanceLogs: mergedLogs, }); - if (store.retainTodayLogsAfterRestart) { - persistRuntimeLogs(mergedLogs, store.maxLogsPerInstance); - } + persistRuntimeLogs(mergedLogs, store.maxLogsPerInstance); log.info('Restored runtime logs', Object.keys(mergedLogs).length, 'instances'); } } catch (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/components/settings/DebugSection.tsx b/src/components/settings/DebugSection.tsx index c4fd1f9..2e0ac20 100644 --- a/src/components/settings/DebugSection.tsx +++ b/src/components/settings/DebugSection.tsx @@ -29,8 +29,6 @@ export function DebugSection() { setDevMode, saveDraw, setSaveDraw, - retainTodayLogsAfterRestart, - setRetainTodayLogsAfterRestart, tcpCompatMode, setTcpCompatMode, allowLanAccess, @@ -356,24 +354,6 @@ export function DebugSection() { setSaveDraw(v)} />
-
-
- -
- - {t('debug.retainTodayLogsAfterRestart')} - -

- {t('debug.retainTodayLogsAfterRestartHint')} -

-
-
- setRetainTodayLogsAfterRestart(v)} - /> -
- {/* 通信兼容模式 */}
diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index 69960d2..4034524 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -436,6 +436,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', @@ -536,9 +538,6 @@ export default { saveDraw: 'Save Debug Images', saveDrawHint: 'Save recognition and action debug images to log directory (auto-disabled on restart)', - retainTodayLogsAfterRestart: 'Retain today logs after restart', - retainTodayLogsAfterRestartHint: - "Keep today's runtime logs locally so they are still visible after restarting the app", tcpCompatMode: 'Communication Compat Mode', tcpCompatModeHint: 'Try enabling this if the app crashes immediately after starting tasks. Only use in this case, as it may reduce performance', diff --git a/src/i18n/locales/ja-JP.ts b/src/i18n/locales/ja-JP.ts index 2bdd848..45ac63a 100644 --- a/src/i18n/locales/ja-JP.ts +++ b/src/i18n/locales/ja-JP.ts @@ -433,6 +433,8 @@ export default { logs: { title: '実行ログ', clear: 'クリア', + autoClearOnLaunch: '起動時に自動クリア', + logDirSize: 'ログ:{{size}}', autoscroll: '自動スクロール', noLogs: 'ログがありません', copyAll: 'すべてコピー', @@ -531,9 +533,6 @@ export default { saveDraw: 'デバッグ画像を保存', saveDrawHint: '認識と操作のデバッグ画像をログフォルダに保存します(再起動後は自動的にオフになります)', - retainTodayLogsAfterRestart: '再起動後も当日のログを保持', - retainTodayLogsAfterRestartHint: - '当日の実行ログをローカルに保存し、アプリを再起動した後も引き続き確認できるようにします', tcpCompatMode: '通信互換モード', tcpCompatModeHint: 'タスク開始後にアプリがすぐにクラッシュする場合は有効にしてください。この場合のみ使用し、それ以外は性能に影響します', diff --git a/src/i18n/locales/ko-KR.ts b/src/i18n/locales/ko-KR.ts index ac1082b..70796f9 100644 --- a/src/i18n/locales/ko-KR.ts +++ b/src/i18n/locales/ko-KR.ts @@ -431,6 +431,8 @@ export default { logs: { title: '실행 로그', clear: '지우기', + autoClearOnLaunch: '시작 시 자동 지우기', + logDirSize: '로그: {{size}}', autoscroll: '자동 스크롤', noLogs: '로그가 없습니다', copyAll: '모두 복사', @@ -530,9 +532,6 @@ export default { saveDraw: '디버그 이미지 저장', saveDrawHint: '인식 및 작업의 디버그 이미지를 로그 폴더에 저장합니다 (재시작 후 자동으로 비활성화됨)', - retainTodayLogsAfterRestart: '재시작 후 오늘 로그 유지', - retainTodayLogsAfterRestartHint: - '오늘의 실행 로그를 로컬에 저장하여 앱을 다시 시작한 뒤에도 계속 확인할 수 있습니다', tcpCompatMode: '통신 호환 모드', tcpCompatModeHint: '작업 시작 후 앱이 즉시 충돌하면 활성화해 보세요. 이 경우에만 사용하세요, 성능에 영향을 줄 수 있습니다', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index e87070e..4c0fa77 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -427,6 +427,8 @@ export default { logs: { title: '运行日志', clear: '清空', + autoClearOnLaunch: '启动时自动清理', + logDirSize: '日志:{{size}}', autoscroll: '自动滚动', noLogs: '暂无日志', copyAll: '复制全部', @@ -524,9 +526,6 @@ export default { devModeHint: '启用后允许按 F5 刷新 UI', saveDraw: '保存调试图像', saveDrawHint: '保存识别和操作的调试图像到日志目录(重启软件后自动关闭)', - retainTodayLogsAfterRestart: '重启后保留当天日志', - retainTodayLogsAfterRestartHint: - '将当天运行日志保存在本地,这样重启应用后仍然可以继续查看', tcpCompatMode: '通信兼容模式', tcpCompatModeHint: '若启动任务后软件立即闪退,可尝试开启。仅限此情况使用,否则会影响运行效率', webServerPort: 'Web 服务端口', diff --git a/src/i18n/locales/zh-TW.ts b/src/i18n/locales/zh-TW.ts index 6bdf95c..04f114a 100644 --- a/src/i18n/locales/zh-TW.ts +++ b/src/i18n/locales/zh-TW.ts @@ -423,6 +423,8 @@ export default { logs: { title: '執行日誌', clear: '清空', + autoClearOnLaunch: '啟動時自動清理', + logDirSize: '日誌:{{size}}', autoscroll: '自動捲動', noLogs: '暫無日誌', copyAll: '複製全部', @@ -520,9 +522,6 @@ export default { devModeHint: '啟用後允許按 F5 重新整理 UI', saveDraw: '儲存除錯圖像', saveDrawHint: '儲存識別和操作的除錯圖像到日誌目錄(重啟軟體後自動關閉)', - retainTodayLogsAfterRestart: '重啟後保留當天日誌', - retainTodayLogsAfterRestartHint: - '將當天執行日誌保存在本機,這樣重啟應用後仍然可以繼續查看', tcpCompatMode: '通訊相容模式', tcpCompatModeHint: '若啟動任務後軟體立即閃退,可嘗試開啟。僅限此情況使用,否則會影響運行效率', webServerPort: 'Web 服務連接埠', diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index 387c278..6bbeb81 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -55,7 +55,7 @@ import { getCurrentControllerAndResource, isTaskCompatible, } from './helpers'; -import { clearPersistedRuntimeLogs, persistRuntimeLogs } from '@/utils/runtimeLogPersistence'; +import { persistRuntimeLogs } from '@/utils/runtimeLogPersistence'; // 从独立模块导入类型和辅助函数 import type { AppState, LogEntry, TaskRunStatus } from './types'; @@ -136,7 +136,7 @@ export const useAppStore = create()( backgroundOpacity: 50, confirmBeforeDelete: false, maxLogsPerInstance: DEFAULT_MAX_LOGS_PER_INSTANCE, - retainTodayLogsAfterRestart: false, + autoClearLogsOnLaunch: true, customAccents: [], setTheme: (theme) => { set({ theme }); @@ -171,18 +171,10 @@ export const useAppStore = create()( maxLogsPerInstance: Math.max(100, Math.min(10000, Math.floor(value))), }); const state = get(); - if (state.retainTodayLogsAfterRestart) { - persistRuntimeLogs(state.instanceLogs, state.maxLogsPerInstance); - } + persistRuntimeLogs(state.instanceLogs, state.maxLogsPerInstance); }, - setRetainTodayLogsAfterRestart: (enabled) => { - set({ retainTodayLogsAfterRestart: enabled }); - const state = get(); - if (enabled) { - persistRuntimeLogs(state.instanceLogs, state.maxLogsPerInstance); - } else { - clearPersistedRuntimeLogs(); - } + setAutoClearLogsOnLaunch: (enabled) => { + set({ autoClearLogsOnLaunch: enabled }); }, addCustomAccent: (accent) => { set((state) => ({ @@ -1185,7 +1177,7 @@ export const useAppStore = create()( backgroundOpacity: effectiveBgOpacity, confirmBeforeDelete: config.settings.confirmBeforeDelete ?? false, maxLogsPerInstance: config.settings.maxLogsPerInstance ?? DEFAULT_MAX_LOGS_PER_INSTANCE, - retainTodayLogsAfterRestart: config.settings.retainTodayLogsAfterRestart ?? false, + autoClearLogsOnLaunch: config.settings.autoClearLogsOnLaunch ?? true, customAccents: effectiveCustomAccents, selectedController, selectedResource, @@ -1909,9 +1901,7 @@ export const useAppStore = create()( ...state.instanceLogs, [instanceId]: updatedLogs, }; - if (state.retainTodayLogsAfterRestart) { - persistRuntimeLogs(nextLogs, state.maxLogsPerInstance); - } + persistRuntimeLogs(nextLogs, state.maxLogsPerInstance); return { instanceLogs: nextLogs, }; @@ -1924,11 +1914,7 @@ export const useAppStore = create()( ...state.instanceLogs, [instanceId]: [], }; - if (state.retainTodayLogsAfterRestart) { - persistRuntimeLogs(nextLogs, state.maxLogsPerInstance); - } else { - clearPersistedRuntimeLogs(instanceId, state.maxLogsPerInstance); - } + persistRuntimeLogs(nextLogs, state.maxLogsPerInstance); return { instanceLogs: nextLogs, }; @@ -2023,7 +2009,7 @@ function generateConfig(): MxuConfig { backgroundOpacity: ba?.backgroundOpacity ?? state.backgroundOpacity, confirmBeforeDelete: state.confirmBeforeDelete, maxLogsPerInstance: state.maxLogsPerInstance, - retainTodayLogsAfterRestart: state.retainTodayLogsAfterRestart, + autoClearLogsOnLaunch: state.autoClearLogsOnLaunch, windowSize: bl?.windowSize ?? state.windowSize, windowPosition: bl?.windowPosition ?? state.windowPosition, showOptionPreview: bl?.showOptionPreview ?? state.showOptionPreview, @@ -2109,7 +2095,7 @@ useAppStore.subscribe( }), confirmBeforeDelete: state.confirmBeforeDelete, maxLogsPerInstance: state.maxLogsPerInstance, - retainTodayLogsAfterRestart: state.retainTodayLogsAfterRestart, + 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 4cfebeb..1157a24 100644 --- a/src/stores/types.ts +++ b/src/stores/types.ts @@ -98,7 +98,7 @@ export interface AppState { confirmBeforeDelete: boolean; /** 每个实例最多保留的日志条数(超出自动丢弃最旧的) */ maxLogsPerInstance: number; - retainTodayLogsAfterRestart: boolean; + autoClearLogsOnLaunch: boolean; customAccents: CustomAccent[]; setTheme: (theme: Theme) => void; setAccentColor: (accent: AccentColor) => void; @@ -107,7 +107,7 @@ export interface AppState { setBackgroundOpacity: (opacity: number) => void; setConfirmBeforeDelete: (enabled: boolean) => void; setMaxLogsPerInstance: (value: number) => void; - setRetainTodayLogsAfterRestart: (enabled: boolean) => 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 3c8ce87..b6d6a9e 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -135,7 +135,7 @@ export interface AppSettings { confirmBeforeDelete?: boolean; /** 每个实例最多保留的日志条数(超出自动丢弃最旧的) */ maxLogsPerInstance?: number; - retainTodayLogsAfterRestart?: boolean; + autoClearLogsOnLaunch?: boolean; windowSize?: WindowSize; windowPosition?: WindowPosition; // 窗口位置 mirrorChyan?: MirrorChyanSettings; @@ -230,7 +230,7 @@ export const defaultConfig: MxuConfig = { language: 'system', confirmBeforeDelete: false, maxLogsPerInstance: DEFAULT_MAX_LOGS_PER_INSTANCE, - retainTodayLogsAfterRestart: false, + 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 index cfaa750..884e300 100644 --- a/src/utils/runtimeLogPersistence.ts +++ b/src/utils/runtimeLogPersistence.ts @@ -85,24 +85,15 @@ function normalizeLimit(limit: number): number { return Math.min(10000, Math.max(100, Math.floor(limit))); } -function isSameLocalDay(a: Date, b: Date): boolean { - return ( - a.getFullYear() === b.getFullYear() && - a.getMonth() === b.getMonth() && - a.getDate() === b.getDate() - ); -} - function sanitizeLogEntries( entries: readonly (LogEntry | PersistedLogEntry)[], limit: number, - now: Date, ): 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()) || !isSameLocalDay(timestamp, now)) continue; + if (Number.isNaN(timestamp.getTime())) continue; deduped.set(entry.id, { id: entry.id, @@ -121,14 +112,11 @@ function sanitizeLogEntries( function sanitizeRuntimeLogs( logs: Record, maxLogsPerInstance: number, - now: Date = new Date(), ): Record { const limit = normalizeLimit(maxLogsPerInstance); return Object.fromEntries( Object.entries(logs) - .map( - ([instanceId, entries]) => [instanceId, sanitizeLogEntries(entries, limit, now)] as const, - ) + .map(([instanceId, entries]) => [instanceId, sanitizeLogEntries(entries, limit)] as const) .filter(([, entries]) => entries.length > 0), ); }