mirror of
https://github.com/ILoveBingLu/CipherTalk.git
synced 2026-05-20 19:40:32 +08:00
feat: 添加系统托盘功能,支持窗口最小化到托盘及配置保存状态监控
This commit is contained in:
@@ -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''\\);\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
**一款现代化的微信聊天记录查看与分析工具**
|
||||
|
||||
[](LICENSE)
|
||||
[](package.json)
|
||||
[](package.json)
|
||||
[]()
|
||||
[]()
|
||||
[]()
|
||||
|
||||
+102
-2
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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: {}, // 空对象,用户配置后填充
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
? `<img src="${this.escapeHtml(exportData.meta.sessionAvatar)}" onerror="this.style.display='none';this.parentElement.textContent='${escapedSessionName.charAt(0)}'"/>`
|
||||
: escapedSessionName.charAt(0)
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
@@ -69,13 +75,14 @@ export class HtmlExportGenerator {
|
||||
<div class="app">
|
||||
<header class="chat-header">
|
||||
<div class="header-left">
|
||||
<div class="header-avatar">${escapedSessionName.charAt(0)}</div>
|
||||
<div class="header-avatar">${avatarHtml}</div>
|
||||
<div class="header-info">
|
||||
<h1>${escapedSessionName}</h1>
|
||||
<span class="header-meta">${exportData.messages.length} 条消息${dateRangeText ? ' · ' + dateRangeText : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="icon-btn" id="dateJumpToggle" title="跳转到指定日期">📅</button>
|
||||
<button class="icon-btn" id="themeToggle" title="切换主题">🌓</button>
|
||||
<button class="icon-btn" id="searchToggle" title="搜索">🔍</button>
|
||||
</div>
|
||||
@@ -87,6 +94,13 @@ export class HtmlExportGenerator {
|
||||
<button id="clearSearch">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="date-jump-bar" id="dateJumpBar">
|
||||
<input type="date" id="dateJumpInput" />
|
||||
<button id="dateJumpBtn">跳转</button>
|
||||
<span id="dateJumpHint"></span>
|
||||
<button id="closeDateJump">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="chat-body" id="chatBody">
|
||||
<div id="messagesContainer"></div>
|
||||
<div class="loading-indicator" id="loadingIndicator">加载中...</div>
|
||||
@@ -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) => {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ciphertalk",
|
||||
"version": "2.2.12",
|
||||
"version": "2.2.13",
|
||||
"description": "密语 - 微信聊天记录查看工具",
|
||||
"author": "ILoveBingLu",
|
||||
"license": "CC-BY-NC-SA-4.0",
|
||||
|
||||
@@ -12,13 +12,13 @@ function WhatsNewModal({ onClose, version }: WhatsNewModalProps) {
|
||||
{
|
||||
icon: <Package size={20} />,
|
||||
title: '优化',
|
||||
desc: '优化聊天消息去重逻辑。'
|
||||
desc: '优化html导出。'
|
||||
},
|
||||
{
|
||||
icon: <Package size={20} />,
|
||||
title: '优化',
|
||||
desc: '优化多个界面样式,重点优化设置界面。'
|
||||
},
|
||||
desc: '优化最小化至托盘功能。'
|
||||
}
|
||||
// {
|
||||
// icon: <Image size={20} />,
|
||||
// title: '聊天内图片',
|
||||
@@ -29,16 +29,16 @@ function WhatsNewModal({ onClose, version }: WhatsNewModalProps) {
|
||||
// title: '语音导出',
|
||||
// desc: '支持将语音消息解码为 WAV 格式导出,含转写文字。'
|
||||
// },
|
||||
{
|
||||
icon: <Filter size={20} />,
|
||||
title: '新增',
|
||||
desc: '新增API端点等功能。'
|
||||
},
|
||||
{
|
||||
icon: <Aperture size={20} />,
|
||||
title: '朋友圈',
|
||||
desc: '优化样式!'
|
||||
}
|
||||
// {
|
||||
// icon: <Filter size={20} />,
|
||||
// title: '新增',
|
||||
// desc: '新增API端点等功能。'
|
||||
// },
|
||||
// {
|
||||
// icon: <Aperture size={20} />,
|
||||
// title: '朋友圈',
|
||||
// desc: '优化样式!'
|
||||
// }
|
||||
]
|
||||
|
||||
const handleTelegram = () => {
|
||||
|
||||
+119
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存按钮有未保存更改时的样式
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
+120
-1
@@ -150,6 +150,10 @@ function SettingsPage() {
|
||||
const [logSize, setLogSize] = useState<number>(0)
|
||||
const [currentLogLevel, setCurrentLogLevel] = useState<string>('WARN')
|
||||
|
||||
// 配置变化状态
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
|
||||
const [initialConfig, setInitialConfig] = useState<any>(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() {
|
||||
</div>
|
||||
|
||||
{/* 悬浮保存按钮 */}
|
||||
<button className="floating-save-btn" onClick={handleSaveConfig} disabled={isLoading} title="保存配置">
|
||||
<button
|
||||
className={`floating-save-btn ${hasUnsavedChanges ? 'has-changes' : ''}`}
|
||||
onClick={handleSaveConfig}
|
||||
disabled={isLoading}
|
||||
title={hasUnsavedChanges ? '有未保存的更改,点击保存' : '保存配置'}
|
||||
>
|
||||
<Save size={20} />
|
||||
</button>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user