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)
-[](package.json)
+[](package.json)
[]()
[]()
[]()
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 {
加载中...
@@ -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() {
{/* 悬浮保存按钮 */}
-