diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..940d6da --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(npx tsc --noEmit --pretty)", + "Bash(node -e \"const ts = require\\(''typescript''\\); const cfg = ts.readConfigFile\\(''tsconfig.json'', ts.sys.readFile\\); const parsed = ts.parseJsonConfigFileContent\\(cfg.config, ts.sys, ''.''\\); const prog = ts.createProgram\\(parsed.fileNames, parsed.options\\); const diags = ts.getPreEmitDiagnostics\\(prog\\); diags.forEach\\(d => { if\\(d.file && d.file.fileName.includes\\(''htmlExportGenerator''\\)\\) { const {line} = d.file.getLineAndCharacterOfPosition\\(d.start\\); console.log\\(d.file.fileName + '':'' + \\(line+1\\) + '' '' + ts.flattenDiagnosticMessageText\\(d.messageText, ''\\\\n''\\)\\); }}\\); if\\(diags.filter\\(d=>d.file&&d.file.fileName.includes\\(''htmlExportGenerator''\\)\\).length===0\\) console.log\\(''No errors in htmlExportGenerator''\\);\")", + "Bash(node -e \"const ts = require\\(''typescript''\\); const cfg = ts.readConfigFile\\(''tsconfig.json'', ts.sys.readFile\\); const parsed = ts.parseJsonConfigFileContent\\(cfg.config, ts.sys, ''.''\\); const prog = ts.createProgram\\(parsed.fileNames, parsed.options\\); const diags = ts.getPreEmitDiagnostics\\(prog\\); diags.forEach\\(d => { if\\(d.file && d.file.fileName.includes\\(''exportService''\\)\\) { const {line} = d.file.getLineAndCharacterOfPosition\\(d.start\\); console.log\\(d.file.fileName + '':'' + \\(line+1\\) + '' '' + ts.flattenDiagnosticMessageText\\(d.messageText, ''\\\\n''\\)\\); }}\\); if\\(diags.filter\\(d=>d.file&&d.file.fileName.includes\\(''exportService''\\)\\).length===0\\) console.log\\(''No errors in exportService''\\);\")" + ] + } +} diff --git a/README.md b/README.md index 4bca8e8..618600a 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ **一款现代化的微信聊天记录查看与分析工具** [![License](https://img.shields.io/badge/license-CC--BY--NC--SA--4.0-blue.svg)](LICENSE) -[![Version](https://img.shields.io/badge/version-2.2.12-green.svg)](package.json) +[![Version](https://img.shields.io/badge/version-2.2.13-green.svg)](package.json) [![Platform](https://img.shields.io/badge/platform-Windows-0078D6.svg?logo=windows)]() [![Electron](https://img.shields.io/badge/Electron-39-47848F.svg?logo=electron)]() [![React](https://img.shields.io/badge/React-19-61DAFB.svg?logo=react)]() diff --git a/electron/main.ts b/electron/main.ts index e0b475d..2fb7a9d 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, ipcMain, nativeTheme, protocol, net } from 'electron' +import { app, BrowserWindow, ipcMain, nativeTheme, protocol, net, Tray, Menu } from 'electron' import { join } from 'path' import { readFileSync, existsSync, mkdirSync } from 'fs' import { autoUpdater } from 'electron-updater' @@ -27,6 +27,13 @@ import { windowsHelloService, WindowsHelloResult } from './services/windowsHello import { shortcutService } from './services/shortcutService' import { httpApiService } from './services/httpApiService' +// 扩展 app 对象类型,添加 isQuitting 标志 +declare module 'electron' { + interface App { + isQuitting?: boolean + } +} + // 注册自定义协议为特权协议(必须在 app ready 之前) protocol.registerSchemesAsPrivileged([ { @@ -82,6 +89,9 @@ let dbService: DatabaseService | null = null let configService: ConfigService | null = null let logService: LogService | null = null +// 系统托盘实例 +let tray: Tray | null = null + // 聊天窗口实例 let chatWindow: BrowserWindow | null = null // 朋友圈窗口实例 @@ -130,6 +140,56 @@ function getAppIconPath(): string { } } +/** + * 创建系统托盘 + */ +function createTray() { + if (tray) return tray + + const iconPath = getAppIconPath() + tray = new Tray(iconPath) + + const contextMenu = Menu.buildFromTemplate([ + { + label: '显示主窗口', + click: () => { + if (mainWindow) { + if (mainWindow.isMinimized()) { + mainWindow.restore() + } + mainWindow.show() + mainWindow.focus() + } + } + }, + { type: 'separator' }, + { + label: '退出', + click: () => { + // 设置标志,允许真正退出 + app.isQuitting = true + app.quit() + } + } + ]) + + tray.setToolTip('密语 CipherTalk') + tray.setContextMenu(contextMenu) + + // 双击托盘图标显示窗口 + tray.on('double-click', () => { + if (mainWindow) { + if (mainWindow.isMinimized()) { + mainWindow.restore() + } + mainWindow.show() + mainWindow.focus() + } + }) + + return tray +} + function createWindow() { const iconPath = getAppIconPath() @@ -174,6 +234,29 @@ function createWindow() { win.show() }) + // 监听窗口关闭事件 + win.on('close', (event) => { + // 如果是真正退出应用,不阻止 + if (app.isQuitting) { + return + } + + // 获取关闭行为配置 + const closeToTray = configService?.get('closeToTray') + + // 如果配置为关闭到托盘(默认为 true) + if (closeToTray !== false) { + event.preventDefault() + win.hide() + + // 确保托盘已创建 + if (!tray) { + createTray() + } + } + // 否则允许窗口关闭,应用退出 + }) + // 开发环境加载 vite 服务器 if (process.env.VITE_DEV_SERVER_URL) { win.loadURL(process.env.VITE_DEV_SERVER_URL) @@ -3737,6 +3820,9 @@ app.whenReady().then(async () => { if (shouldShowSplash !== false || configService?.get('myWxid')) { // 创建主窗口(但不立即显示) mainWindow = createWindow() + + // 创建系统托盘 + createTray() } // 如果显示了启动屏,主窗口会在启动屏关闭后自动显示(通过 ready-to-show 事件) @@ -3748,20 +3834,34 @@ app.whenReady().then(async () => { app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { mainWindow = createWindow() + createTray() } }) }) app.on('window-all-closed', () => { + // macOS 上保持应用运行 if (process.platform !== 'darwin') { - app.quit() + // 如果托盘存在,不退出应用 + if (!tray) { + app.quit() + } } }) app.on('before-quit', () => { + // 设置退出标志 + app.isQuitting = true + httpApiService.stop().catch((e) => { console.error('[HttpApi] 停止失败:', e) }) // 关闭配置数据库连接 configService?.close() + + // 销毁托盘 + if (tray) { + tray.destroy() + tray = null + } }) diff --git a/electron/services/config.ts b/electron/services/config.ts index f5a5f30..9160fbe 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -59,6 +59,9 @@ interface ConfigSchema { httpApiPort: number httpApiToken: string + // 窗口关闭行为 + closeToTray: boolean + // AI 相关 aiCurrentProvider: string // 当前选中的提供商 aiProviderConfigs: { // 每个提供商的独立配置 @@ -107,6 +110,7 @@ const defaults: ConfigSchema = { httpApiEnabled: false, httpApiPort: 5031, httpApiToken: '', + closeToTray: true, // 默认最小化到托盘 // AI 默认配置 aiCurrentProvider: 'zhipu', aiProviderConfigs: {}, // 空对象,用户配置后填充 diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index b37a977..abf5bcb 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -2046,6 +2046,7 @@ class ExportService { meta: { sessionId, sessionName: sessionInfo.displayName, + sessionAvatar: sessionInfo.avatarUrl || undefined, isGroup, exportTime: Date.now(), messageCount: allMessages.length, @@ -2149,8 +2150,8 @@ class ExportService { detail: '正在读取消息...' }) - // 生成文件名(清理非法字符) - const safeName = sessionInfo.displayName.replace(/[<>:"\/\\|?*]/g, '_') + // 生成文件名(清理非法字符,移除末尾的"."以避免Windows无法识别文件夹) + const safeName = sessionInfo.displayName.replace(/[<>:"\/\\|?*]/g, '_').replace(/\.+$/, '').trim() let ext = '.json' if (options.format === 'chatlab-jsonl') ext = '.jsonl' else if (options.format === 'excel') ext = '.xlsx' diff --git a/electron/services/htmlExportGenerator.ts b/electron/services/htmlExportGenerator.ts index 33bf55e..c4ef58b 100644 --- a/electron/services/htmlExportGenerator.ts +++ b/electron/services/htmlExportGenerator.ts @@ -1,7 +1,7 @@ -/** +/** * HTML 导出生成器 * 生成现代风格的聊天记录 HTML 页面 - * 支持图片/视频内联显示、搜索、主题切换 + * 支持图片/视频内联显示、搜索、主题切换、日期跳转 */ export interface HtmlExportMessage { @@ -38,6 +38,7 @@ export interface HtmlExportData { meta: { sessionId: string sessionName: string + sessionAvatar?: string isGroup: boolean exportTime: number messageCount: number @@ -57,6 +58,11 @@ export class HtmlExportGenerator { ? `${new Date(exportData.meta.dateRange.start * 1000).toLocaleDateString('zh-CN')} - ${new Date(exportData.meta.dateRange.end * 1000).toLocaleDateString('zh-CN')}` : '' + // 头像 HTML:优先使用真实头像图片,回退到首字符 + const avatarHtml = exportData.meta.sessionAvatar + ? `` + : escapedSessionName.charAt(0) + return ` @@ -69,13 +75,14 @@ export class HtmlExportGenerator {
-
${escapedSessionName.charAt(0)}
+
${avatarHtml}

${escapedSessionName}

${exportData.messages.length} 条消息${dateRangeText ? ' · ' + dateRangeText : ''}
+
@@ -87,6 +94,13 @@ export class HtmlExportGenerator {
+
+ + + + +
+
加载中...
@@ -201,6 +215,13 @@ body { font-size: 18px; font-weight: 600; flex-shrink: 0; + overflow: hidden; +} + +.header-avatar img { + width: 100%; + height: 100%; + object-fit: cover; } .header-info { @@ -284,6 +305,60 @@ body { padding: 4px 8px; } +/* 日期跳转栏 */ +.date-jump-bar { + background: var(--header-bg); + padding: 0 16px 10px; + display: none; + align-items: center; + gap: 8px; +} + +.date-jump-bar.active { + display: flex; +} + +.date-jump-bar input[type="date"] { + padding: 6px 12px; + border: none; + border-radius: 8px; + background: rgba(255,255,255,0.15); + color: var(--header-text); + font-size: 14px; + outline: none; + color-scheme: dark; +} + +#dateJumpBtn { + padding: 6px 14px; + border: none; + border-radius: 8px; + background: rgba(255,255,255,0.25); + color: var(--header-text); + font-size: 13px; + cursor: pointer; + transition: background 0.2s; +} + +#dateJumpBtn:hover { + background: rgba(255,255,255,0.35); +} + +#dateJumpHint { + color: rgba(255,255,255,0.7); + font-size: 12px; + white-space: nowrap; +} + +#closeDateJump { + background: none; + border: none; + color: rgba(255,255,255,0.7); + font-size: 16px; + cursor: pointer; + padding: 4px 8px; +} + /* 聊天体 */ .chat-body { flex: 1; @@ -311,6 +386,17 @@ body { font-weight: 500; } +.date-divider.highlight span { + background: var(--link); + color: #fff; + animation: dateHighlight 2s ease-out forwards; +} + +@keyframes dateHighlight { + 0% { background: var(--link); color: #fff; } + 100% { background: var(--system-bg); color: var(--system-text); } +} + /* 系统消息 */ .system-msg { text-align: center; @@ -621,6 +707,7 @@ body { document.getElementById('searchToggle').addEventListener('click', () => { searchBar.classList.toggle('active'); + dateJumpBar.classList.remove('active'); if (searchBar.classList.contains('active')) searchInput.focus(); }); @@ -655,6 +742,115 @@ body { loadMore(); } + // 日期跳转 + const dateJumpBar = document.getElementById('dateJumpBar'); + const dateJumpInput = document.getElementById('dateJumpInput'); + const dateJumpHint = document.getElementById('dateJumpHint'); + + // 设置日期选择器的范围 + if (messages.length > 0) { + const minDate = new Date(messages[0].timestamp * 1000); + const maxDate = new Date(messages[messages.length - 1].timestamp * 1000); + dateJumpInput.min = toDateStr(minDate); + dateJumpInput.max = toDateStr(maxDate); + dateJumpInput.value = toDateStr(minDate); + } + + function toDateStr(d) { + return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); + } + + document.getElementById('dateJumpToggle').addEventListener('click', () => { + dateJumpBar.classList.toggle('active'); + searchBar.classList.remove('active'); + dateJumpHint.textContent = ''; + }); + + document.getElementById('closeDateJump').addEventListener('click', () => { + dateJumpBar.classList.remove('active'); + }); + + document.getElementById('dateJumpBtn').addEventListener('click', jumpToDate); + dateJumpInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') jumpToDate(); + }); + + function jumpToDate() { + const val = dateJumpInput.value; + if (!val) { + dateJumpHint.textContent = '请选择日期'; + return; + } + + // 将选择的日期转为当天 00:00:00 的时间戳 + const parts = val.split('-'); + const targetDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 0, 0, 0); + const targetTs = Math.floor(targetDate.getTime() / 1000); + + // 在当前过滤后的消息列表中,用二分查找找到目标日期第一条消息 + let lo = 0, hi = filteredMessages.length - 1, found = -1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (filteredMessages[mid].timestamp >= targetTs) { + found = mid; + hi = mid - 1; + } else { + lo = mid + 1; + } + } + + if (found === -1) { + dateJumpHint.textContent = '该日期之后无消息'; + return; + } + + // 检查找到的消息是否在目标日期当天 + const foundDate = new Date(filteredMessages[found].timestamp * 1000); + const targetDay = targetDate.toDateString(); + const foundDay = foundDate.toDateString(); + + if (foundDay !== targetDay) { + // 该日期无消息,提示跳转到最近的日期 + var nearFmt = foundDate.getFullYear() + '年' + (foundDate.getMonth() + 1) + '月' + foundDate.getDate() + '日'; + dateJumpHint.textContent = '该日期无消息,已跳转到最近: ' + nearFmt; + } else { + dateJumpHint.textContent = ''; + } + + // 确保消息已加载到 found 的位置 + if (found >= loadedCount) { + // 需要加载更多,一次加载到 found 之后一些 + var targetLoad = Math.min(found + BATCH, filteredMessages.length); + var html = ''; + for (var i = loadedCount; i < targetLoad; i++) { + var prev = i > 0 ? filteredMessages[i - 1] : null; + html += renderMsg(filteredMessages[i], prev); + } + container.insertAdjacentHTML('beforeend', html); + loadedCount = targetLoad; + } + + // 找到对应的 date-divider 或消息 DOM 元素并滚动到它 + var dividers = container.querySelectorAll('.date-divider'); + var scrollTarget = null; + + // 构建目标日期文本用于匹配 + var targetDateText = fmtDate(filteredMessages[found].timestamp); + for (var d = 0; d < dividers.length; d++) { + if (dividers[d].textContent.trim() === targetDateText) { + scrollTarget = dividers[d]; + break; + } + } + + if (scrollTarget) { + scrollTarget.scrollIntoView({ behavior: 'smooth', block: 'start' }); + // 高亮动画 + scrollTarget.classList.add('highlight'); + setTimeout(function() { scrollTarget.classList.remove('highlight'); }, 2500); + } + } + // 图片灯箱 lightbox.addEventListener('click', () => lightbox.classList.remove('active')); document.getElementById('lightboxClose').addEventListener('click', (e) => { diff --git a/package.json b/package.json index 396ef7f..e9d4850 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ciphertalk", - "version": "2.2.12", + "version": "2.2.13", "description": "密语 - 微信聊天记录查看工具", "author": "ILoveBingLu", "license": "CC-BY-NC-SA-4.0", diff --git a/src/components/WhatsNewModal.tsx b/src/components/WhatsNewModal.tsx index 02d9b34..17268da 100644 --- a/src/components/WhatsNewModal.tsx +++ b/src/components/WhatsNewModal.tsx @@ -12,13 +12,13 @@ function WhatsNewModal({ onClose, version }: WhatsNewModalProps) { { icon: , title: '优化', - desc: '优化聊天消息去重逻辑。' + desc: '优化html导出。' }, { icon: , title: '优化', - desc: '优化多个界面样式,重点优化设置界面。' - }, + desc: '优化最小化至托盘功能。' + } // { // icon: , // title: '聊天内图片', @@ -29,16 +29,16 @@ function WhatsNewModal({ onClose, version }: WhatsNewModalProps) { // title: '语音导出', // desc: '支持将语音消息解码为 WAV 格式导出,含转写文字。' // }, - { - icon: , - title: '新增', - desc: '新增API端点等功能。' - }, - { - icon: , - title: '朋友圈', - desc: '优化样式!' - } + // { + // icon: , + // title: '新增', + // desc: '新增API端点等功能。' + // }, + // { + // icon: , + // title: '朋友圈', + // desc: '优化样式!' + // } ] const handleTelegram = () => { diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index dad9c99..099c27d 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -3154,7 +3154,14 @@ to { // 用极淡的底框包裹整体,去除左上角的硬高光边框 border: 1px solid rgba(255, 255, 255, 0.15); + // 根据主题模式调整图标颜色 color: white; + + [data-mode="light"] & { + color: rgba(0, 0, 0, 0.7); + border: 1px solid rgba(0, 0, 0, 0.1); + } + display: flex; align-items: center; justify-content: center; @@ -3166,6 +3173,13 @@ to { inset 0 4px 10px -2px rgba(255, 255, 255, 0.4), inset 0 -2px 10px rgba(0, 0, 0, 0.05); + [data-mode="light"] & { + box-shadow: + 0 8px 32px rgba(var(--primary-rgb), 0.25), + inset 0 4px 10px -2px rgba(255, 255, 255, 0.6), + inset 0 -2px 10px rgba(0, 0, 0, 0.08); + } + transition: all 0.3s cubic-bezier(0.25, 1.5, 0.5, 1); z-index: 1000; @@ -3176,6 +3190,13 @@ to { 0 12px 40px rgba(var(--primary-rgb), 0.4), inset 0 6px 14px -3px rgba(255, 255, 255, 0.5), inset 0 -2px 10px rgba(0, 0, 0, 0.05); + + [data-mode="light"] & { + box-shadow: + 0 12px 40px rgba(var(--primary-rgb), 0.35), + inset 0 6px 14px -3px rgba(255, 255, 255, 0.7), + inset 0 -2px 10px rgba(0, 0, 0, 0.1); + } } &:active:not(:disabled) { @@ -3183,6 +3204,12 @@ to { box-shadow: 0 4px 16px rgba(var(--primary-rgb), 0.2), inset 0 1px 2px rgba(255, 255, 255, 0.2); + + [data-mode="light"] & { + box-shadow: + 0 4px 16px rgba(var(--primary-rgb), 0.15), + inset 0 1px 2px rgba(0, 0, 0, 0.1); + } } &:disabled { @@ -3193,4 +3220,95 @@ to { box-shadow: none; color: var(--text-tertiary); } -} \ No newline at end of file +} + +// 保存按钮有未保存更改时的样式 +.floating-save-btn.has-changes { + // 深色模式:红色渐变 + background: linear-gradient(135deg, rgba(239, 68, 68, 0.7) 0%, rgba(220, 38, 38, 0.4) 100%); + border-color: rgba(255, 255, 255, 0.25); + animation: pulse-save 2s ease-in-out infinite; + color: white; + + box-shadow: + 0 8px 32px rgba(239, 68, 68, 0.4), + inset 0 4px 10px -2px rgba(255, 255, 255, 0.5), + inset 0 -2px 10px rgba(0, 0, 0, 0.1); + + // 浅色模式:更鲜艳的红色,深色图标 + [data-mode="light"] & { + background: linear-gradient(135deg, rgba(239, 68, 68, 0.85) 0%, rgba(220, 38, 38, 0.6) 100%); + border-color: rgba(0, 0, 0, 0.15); + color: white; + animation: pulse-save-light 2s ease-in-out infinite; + + box-shadow: + 0 8px 32px rgba(239, 68, 68, 0.35), + inset 0 4px 10px -2px rgba(255, 255, 255, 0.7), + inset 0 -2px 10px rgba(0, 0, 0, 0.15); + } + + &:hover:not(:disabled) { + background: linear-gradient(135deg, rgba(239, 68, 68, 0.85) 0%, rgba(220, 38, 38, 0.5) 100%); + animation: none; + box-shadow: + 0 12px 40px rgba(239, 68, 68, 0.5), + inset 0 6px 14px -3px rgba(255, 255, 255, 0.6), + inset 0 -2px 10px rgba(0, 0, 0, 0.1); + + [data-mode="light"] & { + background: linear-gradient(135deg, rgba(239, 68, 68, 0.95) 0%, rgba(220, 38, 38, 0.7) 100%); + box-shadow: + 0 12px 40px rgba(239, 68, 68, 0.45), + inset 0 6px 14px -3px rgba(255, 255, 255, 0.8), + inset 0 -2px 10px rgba(0, 0, 0, 0.2); + } + } + + &:active:not(:disabled) { + transform: translateY(2px) scale(0.95); + box-shadow: + 0 4px 16px rgba(239, 68, 68, 0.3), + inset 0 1px 2px rgba(255, 255, 255, 0.3); + + [data-mode="light"] & { + box-shadow: + 0 4px 16px rgba(239, 68, 68, 0.25), + inset 0 1px 2px rgba(0, 0, 0, 0.15); + } + } +} + +// 保存按钮闪烁动画 - 深色模式 +@keyframes pulse-save { + 0%, 100% { + box-shadow: + 0 8px 32px rgba(239, 68, 68, 0.4), + inset 0 4px 10px -2px rgba(255, 255, 255, 0.5), + inset 0 -2px 10px rgba(0, 0, 0, 0.1); + } + 50% { + box-shadow: + 0 8px 40px rgba(239, 68, 68, 0.6), + 0 0 20px rgba(239, 68, 68, 0.4), + inset 0 4px 10px -2px rgba(255, 255, 255, 0.6), + inset 0 -2px 10px rgba(0, 0, 0, 0.1); + } +} + +// 保存按钮闪烁动画 - 浅色模式 +@keyframes pulse-save-light { + 0%, 100% { + box-shadow: + 0 8px 32px rgba(239, 68, 68, 0.35), + inset 0 4px 10px -2px rgba(255, 255, 255, 0.7), + inset 0 -2px 10px rgba(0, 0, 0, 0.15); + } + 50% { + box-shadow: + 0 8px 40px rgba(239, 68, 68, 0.5), + 0 0 20px rgba(239, 68, 68, 0.35), + inset 0 4px 10px -2px rgba(255, 255, 255, 0.8), + inset 0 -2px 10px rgba(0, 0, 0, 0.15); + } +} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index e12cd6e..6092081 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -150,6 +150,10 @@ function SettingsPage() { const [logSize, setLogSize] = useState(0) const [currentLogLevel, setCurrentLogLevel] = useState('WARN') + // 配置变化状态 + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) + const [initialConfig, setInitialConfig] = useState(null) + useEffect(() => { loadConfig() loadDefaultExportPath() @@ -230,6 +234,37 @@ function SettingsPage() { const savedCloseToTray = await configService.getCloseToTray() setCloseToTray(savedCloseToTray) + // 保存初始配置用于比较 + setInitialConfig({ + decryptKey: savedKey || '', + dbPath: savedPath || '', + wxid: savedWxid || '', + cachePath: savedCachePath || '', + imageXorKey: savedXorKey || '', + imageAesKey: savedAesKey || '', + exportPath: savedExportPath || '', + sttLanguages: savedSttLanguages && savedSttLanguages.length > 0 ? savedSttLanguages : ['zh'], + sttModelType: savedSttModelType, + skipIntegrityCheck: savedSkipIntegrityCheck, + autoUpdateDatabase: savedAutoUpdateDatabase, + autoUpdateCheckInterval: savedCheckInterval, + autoUpdateMinInterval: savedMinInterval, + autoUpdateDebounceTime: savedDebounceTime, + quoteStyle: savedQuoteStyle, + exportDefaultDateRange: savedExportDefaultDateRange, + exportDefaultAvatars: savedExportDefaultAvatars, + aiProvider: savedAiProvider, + aiApiKey: savedAiApiKey, + aiModel: savedAiModel, + aiDefaultTimeRange: savedAiDefaultTimeRange, + aiSummaryDetail: savedAiSummaryDetail, + aiSystemPromptPreset: savedAiSystemPromptPreset, + aiCustomSystemPrompt: savedAiCustomSystemPrompt, + aiEnableThinking: savedAiEnableThinking, + aiMessageLimit: savedAiMessageLimit, + closeToTray: savedCloseToTray + }) + } catch (e) { console.error('加载配置失败:', e) } @@ -244,6 +279,53 @@ function SettingsPage() { } } + // 监听配置变化 + useEffect(() => { + if (!initialConfig) return + + const currentConfig = { + decryptKey, + dbPath, + wxid, + cachePath, + imageXorKey, + imageAesKey, + exportPath, + sttLanguages, + sttModelType, + skipIntegrityCheck, + autoUpdateDatabase, + autoUpdateCheckInterval, + autoUpdateMinInterval, + autoUpdateDebounceTime, + quoteStyle, + exportDefaultDateRange, + exportDefaultAvatars, + aiProvider, + aiApiKey, + aiModel, + aiDefaultTimeRange, + aiSummaryDetail, + aiSystemPromptPreset, + aiCustomSystemPrompt, + aiEnableThinking, + aiMessageLimit, + closeToTray + } + + // 深度比较配置是否有变化 + const hasChanges = JSON.stringify(currentConfig) !== JSON.stringify(initialConfig) + setHasUnsavedChanges(hasChanges) + }, [ + decryptKey, dbPath, wxid, cachePath, imageXorKey, imageAesKey, exportPath, + sttLanguages, sttModelType, skipIntegrityCheck, autoUpdateDatabase, + autoUpdateCheckInterval, autoUpdateMinInterval, autoUpdateDebounceTime, + quoteStyle, exportDefaultDateRange, exportDefaultAvatars, + aiProvider, aiApiKey, aiModel, aiDefaultTimeRange, aiSummaryDetail, + aiSystemPromptPreset, aiCustomSystemPrompt, aiEnableThinking, aiMessageLimit, + closeToTray, initialConfig + ]) + const loadAppVersion = async () => { try { const version = await window.electronAPI.app.getVersion() @@ -776,6 +858,38 @@ function SettingsPage() { } showMessage('配置保存成功', true) + + // 保存成功后更新初始配置,重置变化状态 + setInitialConfig({ + decryptKey, + dbPath, + wxid, + cachePath, + imageXorKey, + imageAesKey, + exportPath, + sttLanguages, + sttModelType, + skipIntegrityCheck, + autoUpdateDatabase, + autoUpdateCheckInterval, + autoUpdateMinInterval, + autoUpdateDebounceTime, + quoteStyle, + exportDefaultDateRange, + exportDefaultAvatars, + aiProvider, + aiApiKey, + aiModel, + aiDefaultTimeRange, + aiSummaryDetail, + aiSystemPromptPreset, + aiCustomSystemPrompt, + aiEnableThinking, + aiMessageLimit, + closeToTray + }) + setHasUnsavedChanges(false) } catch (e) { showMessage(`保存配置失败: ${e}`, false) } finally { @@ -2682,7 +2796,12 @@ function SettingsPage() {
{/* 悬浮保存按钮 */} -