From e15e4cc3c895555b96cd71b6fdf92f0849898483 Mon Sep 17 00:00:00 2001 From: cc <98377878+hicccc77@users.noreply.github.com> Date: Mon, 2 Feb 2026 22:01:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=87=E5=AE=99=E8=B6=85=E7=BA=A7?= =?UTF-8?q?=E6=97=A0=E6=95=8C=E7=89=9B=E4=B8=94=E5=B8=85=E6=B0=94=E5=88=B0?= =?UTF-8?q?=E7=88=86=E7=82=B8=E7=9A=84=E5=8A=9F=E8=83=BD=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=92=8C=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main.ts | 12 +- electron/preload.ts | 16 +- electron/services/chatService.ts | 9 +- electron/services/config.ts | 26 ++- electron/windows/notificationWindow.ts | 200 ++++++++++++++++ resources/wcdb_api.dll | Bin 760832 -> 760832 bytes src/App.tsx | 32 ++- src/components/GlobalSessionMonitor.tsx | 258 +++++++++++++++++++++ src/components/NotificationToast.scss | 200 ++++++++++++++++ src/components/NotificationToast.tsx | 108 +++++++++ src/pages/AnnualReportWindow.tsx | 6 +- src/pages/ChatPage.tsx | 34 --- src/pages/NotificationWindow.scss | 54 +++++ src/pages/NotificationWindow.tsx | 165 +++++++++++++ src/pages/SettingsPage.scss | 293 +++++++++++++++++++++++- src/pages/SettingsPage.tsx | 287 ++++++++++++++++++++++- src/services/config.ts | 51 ++++- src/types/models.ts | 3 + 18 files changed, 1698 insertions(+), 56 deletions(-) create mode 100644 electron/windows/notificationWindow.ts create mode 100644 src/components/GlobalSessionMonitor.tsx create mode 100644 src/components/NotificationToast.scss create mode 100644 src/components/NotificationToast.tsx create mode 100644 src/pages/NotificationWindow.scss create mode 100644 src/pages/NotificationWindow.tsx diff --git a/electron/main.ts b/electron/main.ts index c4e8b79..20d0215 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -21,6 +21,7 @@ import { videoService } from './services/videoService' import { snsService } from './services/snsService' import { contactExportService } from './services/contactExportService' import { windowsHelloService } from './services/windowsHelloService' +import { registerNotificationHandlers, showNotification } from './windows/notificationWindow' // 配置自动更新 @@ -139,6 +140,14 @@ function createWindow(options: { autoShow?: boolean } = {}) { win.loadFile(join(__dirname, '../dist/index.html')) } + // Handle notification click navigation + ipcMain.on('notification-clicked', (_, sessionId) => { + if (win.isMinimized()) win.restore() + win.show() + win.focus() + win.webContents.send('navigate-to-session', sessionId) + }) + // 拦截请求,修改 Referer 和 User-Agent 以通过微信 CDN 鉴权 session.defaultSession.webRequest.onBeforeSendHeaders( { @@ -366,8 +375,6 @@ function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHe hash: `/video-player-window?${videoParam}` }) } - - return win } /** @@ -499,6 +506,7 @@ function showMainWindow() { // 注册 IPC 处理器 function registerIpcHandlers() { + registerNotificationHandlers() // 配置相关 ipcMain.handle('config:get', async (_, key: string) => { return configService?.get(key as any) diff --git a/electron/preload.ts b/electron/preload.ts index 5836625..a579ca1 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -9,6 +9,19 @@ contextBridge.exposeInMainWorld('electronAPI', { clear: () => ipcRenderer.invoke('config:clear') }, + // 通知 + notification: { + show: (data: any) => ipcRenderer.invoke('notification:show', data), + close: () => ipcRenderer.invoke('notification:close'), + click: (sessionId: string) => ipcRenderer.send('notification-clicked', sessionId), + ready: () => ipcRenderer.send('notification:ready'), + resize: (width: number, height: number) => ipcRenderer.send('notification:resize', { width, height }), + onShow: (callback: (event: any, data: any) => void) => { + ipcRenderer.on('notification:show', callback) + return () => ipcRenderer.removeAllListeners('notification:show') + } + }, + // 认证 auth: { hello: (message?: string) => ipcRenderer.invoke('auth:hello', message) @@ -48,7 +61,8 @@ contextBridge.exposeInMainWorld('electronAPI', { // 日志 log: { getPath: () => ipcRenderer.invoke('log:getPath'), - read: () => ipcRenderer.invoke('log:read') + read: () => ipcRenderer.invoke('log:read'), + debug: (data: any) => ipcRenderer.send('log:debug', data) }, // 窗口控制 diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index a53c374..43f6dae 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -30,6 +30,9 @@ export interface ChatSession { lastMsgType: number displayName?: string avatarUrl?: string + lastMsgSender?: string + lastSenderDisplayName?: string + selfWxid?: string } export interface Message { @@ -287,6 +290,7 @@ class ChatService { // 转换为 ChatSession(先加载缓存,但不等待数据库查询) const sessions: ChatSession[] = [] const now = Date.now() + const myWxid = this.configService.get('myWxid') for (const row of rows) { const username = @@ -340,7 +344,10 @@ class ChatService { lastTimestamp: lastTs, lastMsgType, displayName, - avatarUrl + avatarUrl, + lastMsgSender: row.last_msg_sender, // 数据库返回字段 + lastSenderDisplayName: row.last_sender_display_name, // 数据库返回字段 + selfWxid: myWxid }) } diff --git a/electron/services/config.ts b/electron/services/config.ts index 34f1fb8..b3d988e 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -36,12 +36,30 @@ interface ConfigSchema { // 更新相关 ignoredUpdateVersion: string + + // 通知 + notificationEnabled: boolean + notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' + notificationFilterMode: 'all' | 'whitelist' | 'blacklist' + notificationFilterList: string[] } export class ConfigService { - private store: Store + private static instance: ConfigService + private store!: Store + + static getInstance(): ConfigService { + if (!ConfigService.instance) { + ConfigService.instance = new ConfigService() + } + return ConfigService.instance + } constructor() { + if (ConfigService.instance) { + return ConfigService.instance + } + ConfigService.instance = this this.store = new Store({ name: 'WeFlow-config', defaults: { @@ -72,7 +90,11 @@ export class ConfigService { authPassword: '', authUseHello: false, - ignoredUpdateVersion: '' + ignoredUpdateVersion: '', + notificationEnabled: true, + notificationPosition: 'top-right', + notificationFilterMode: 'all', + notificationFilterList: [] } }) } diff --git a/electron/windows/notificationWindow.ts b/electron/windows/notificationWindow.ts new file mode 100644 index 0000000..28e7ea0 --- /dev/null +++ b/electron/windows/notificationWindow.ts @@ -0,0 +1,200 @@ +import { BrowserWindow, ipcMain, screen } from 'electron' +import { join } from 'path' +import { ConfigService } from '../services/config' + +let notificationWindow: BrowserWindow | null = null +let closeTimer: NodeJS.Timeout | null = null + +export function createNotificationWindow() { + if (notificationWindow && !notificationWindow.isDestroyed()) { + return notificationWindow + } + + const isDev = !!process.env.VITE_DEV_SERVER_URL + const iconPath = isDev + ? join(__dirname, '../../public/icon.ico') + : join(process.resourcesPath, 'icon.ico') + + console.log('[NotificationWindow] Creating window...') + const width = 344 + const height = 114 + + // Update default creation size + notificationWindow = new BrowserWindow({ + width: width, + height: height, + type: 'toolbar', // 有助于在某些操作系统上保持置顶 + frame: false, + transparent: true, + resizable: false, + show: false, + alwaysOnTop: true, + skipTaskbar: true, + focusable: false, // 不抢占焦点 + icon: iconPath, + webPreferences: { + preload: join(__dirname, 'preload.js'), // FIX: Use correct relative path (same dir in dist) + contextIsolation: true, + nodeIntegration: false, + // devTools: true // Enable DevTools + } + }) + + // notificationWindow.webContents.openDevTools({ mode: 'detach' }) // DEBUG: Force Open DevTools + notificationWindow.setIgnoreMouseEvents(true, { forward: true }) // 初始点击穿透 + + // 处理鼠标事件 (如果需要从渲染进程转发,但目前特定区域处理?) + // 实际上,我们希望窗口可点击。 + // 我们将在显示时将忽略鼠标事件设为 false。 + + const loadUrl = isDev + ? `${process.env.VITE_DEV_SERVER_URL}#/notification-window` + : `file://${join(__dirname, '../dist/index.html')}#/notification-window` + + console.log('[NotificationWindow] Loading URL:', loadUrl) + notificationWindow.loadURL(loadUrl) + + notificationWindow.on('closed', () => { + notificationWindow = null + }) + + return notificationWindow +} + +export async function showNotification(data: any) { + // 先检查配置 + const config = ConfigService.getInstance() + const enabled = await config.get('notificationEnabled') + if (enabled === false) return // 默认为 true + + // 检查会话过滤 + const filterMode = config.get('notificationFilterMode') || 'all' + const filterList = config.get('notificationFilterList') || [] + const sessionId = data.sessionId + + if (sessionId && filterMode !== 'all' && filterList.length > 0) { + const isInList = filterList.includes(sessionId) + if (filterMode === 'whitelist' && !isInList) { + // 白名单模式:不在列表中则不显示 + console.log('[NotificationWindow] Filtered by whitelist:', sessionId) + return + } + if (filterMode === 'blacklist' && isInList) { + // 黑名单模式:在列表中则不显示 + console.log('[NotificationWindow] Filtered by blacklist:', sessionId) + return + } + } + + let win = notificationWindow + if (!win || win.isDestroyed()) { + win = createNotificationWindow() + } + + if (!win) return + + // 确保加载完成 + if (win.webContents.isLoading()) { + win.once('ready-to-show', () => { + showAndSend(win!, data) + }) + } else { + showAndSend(win, data) + } +} + +let lastNotificationData: any = null + +async function showAndSend(win: BrowserWindow, data: any) { + lastNotificationData = data + const config = ConfigService.getInstance() + const position = (await config.get('notificationPosition')) || 'top-right' + + // 更新位置 + const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize + const winWidth = 344 + const winHeight = 114 + const padding = 20 + + let x = 0 + let y = 0 + + switch (position) { + case 'top-right': + x = screenWidth - winWidth - padding + y = padding + break + case 'bottom-right': + x = screenWidth - winWidth - padding + y = screenHeight - winHeight - padding + break + case 'top-left': + x = padding + y = padding + break + case 'bottom-left': + x = padding + y = screenHeight - winHeight - padding + break + } + + win.setPosition(Math.floor(x), Math.floor(y)) + win.setSize(winWidth, winHeight) // 确保尺寸 + + // 设为可交互 + win.setIgnoreMouseEvents(false) + win.showInactive() // 显示但不聚焦 + win.setAlwaysOnTop(true, 'screen-saver') // 最高层级 + + win.webContents.send('notification:show', data) + + // 自动关闭计时器通常由渲染进程管理 + // 渲染进程发送 'notification:close' 来隐藏窗口 +} + +export function registerNotificationHandlers() { + ipcMain.handle('notification:show', (_, data) => { + showNotification(data) + }) + + ipcMain.handle('notification:close', () => { + if (notificationWindow && !notificationWindow.isDestroyed()) { + notificationWindow.hide() + notificationWindow.setIgnoreMouseEvents(true, { forward: true }) + } + }) + + // Handle renderer ready event (fix race condition) + ipcMain.on('notification:ready', (event) => { + console.log('[NotificationWindow] Renderer ready, checking cached data') + if (lastNotificationData && notificationWindow && !notificationWindow.isDestroyed()) { + console.log('[NotificationWindow] Re-sending cached data') + notificationWindow.webContents.send('notification:show', lastNotificationData) + } + }) + + // Handle resize request from renderer + ipcMain.on('notification:resize', (event, { width, height }) => { + if (notificationWindow && !notificationWindow.isDestroyed()) { + // Enforce max-height if needed, or trust renderer + // Ensure it doesn't go off screen bottom? + // Logic in showAndSend handles position, but we need to keep anchor point (top-right usually). + // If we resize, we should re-calculate position to keep it anchored? + // Actually, setSize changes size. If it's top-right, x/y stays same -> window grows down. That's fine for top-right. + // If bottom-right, growing down pushes it off screen. + + // Simple version: just setSize. For V1 we assume Top-Right. + // But wait, the config supports bottom-right. + // We can re-call setPosition or just let it be. + // If bottom-right, y needs to prevent overflow. + + // Ideally we get current config position + const bounds = notificationWindow.getBounds() + // Check if we need to adjust Y? + // For now, let's just set the size as requested. + notificationWindow.setSize(Math.round(width), Math.round(height)) + } + }) + + // 'notification-clicked' 在 main.ts 中处理 (导航) +} diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index 9d0741ff4bf847b3755cfc4148cfad962584d78c..cf0b4edf4b35985b0e22c4fb980d77a1fcecb8ba 100644 GIT binary patch delta 193 zcmZqJq1UiOZvzJ-v&OuJ&D@L!lNpVh&!)7WO<@FLCLm@8Viq7~1!6WJX5W4`h2zUr zt;Y=$pX^=md{)bg{c~R|Z+WtR-Lok>pKjmuV$tfST??MBZ+p6S#*6ihj~kk{zqaDM zX=4qvAd>+In1C3>&^W=#ps|Y+B*Xxd;{mcofOrZ}ZVEzf`u}oHNstS-OIL6zIx(iS O+wS7rZo7-?@IwISb4~*Q delta 193 zcmZqJq1UiOZvzJ-^P~Lw&D@L!lNk+~&!)7WO<@FLCLm@8Viq7~1!6WJX5W4`h2zUr zt*7fcpSG@e+PnJc;?<8ECO&OzeY$Sv)9ss{b}e|izU}GS886m1K5l4YU|`t(+KTh0 zjWy7MOdyYe35Y=qjT4*<8oM|_LJTlD9w1u;h^GMMrXb{||1am11i5g#bOooP6Jv6_ O?Jmykw!63vKLh}+@lF*0 diff --git a/src/App.tsx b/src/App.tsx index 0cf6661..fe051f1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ import ImageWindow from './pages/ImageWindow' import SnsPage from './pages/SnsPage' import ContactsPage from './pages/ContactsPage' import ChatHistoryPage from './pages/ChatHistoryPage' +import NotificationWindow from './pages/NotificationWindow' import { useAppStore } from './stores/appStore' import { themes, useThemeStore, type ThemeId } from './stores/themeStore' @@ -31,10 +32,12 @@ import './App.scss' import UpdateDialog from './components/UpdateDialog' import UpdateProgressCapsule from './components/UpdateProgressCapsule' import LockScreen from './components/LockScreen' +import { GlobalSessionMonitor } from './components/GlobalSessionMonitor' function App() { const navigate = useNavigate() const location = useLocation() + const { setDbConnected, updateInfo, @@ -55,6 +58,7 @@ function App() { const isOnboardingWindow = location.pathname === '/onboarding-window' const isVideoPlayerWindow = location.pathname === '/video-player-window' const isChatHistoryWindow = location.pathname.startsWith('/chat-history/') + const isNotificationWindow = location.pathname === '/notification-window' const [themeHydrated, setThemeHydrated] = useState(false) // 锁定状态 @@ -74,7 +78,7 @@ function App() { const body = document.body const appRoot = document.getElementById('app') - if (isOnboardingWindow) { + if (isOnboardingWindow || isNotificationWindow) { root.style.background = 'transparent' body.style.background = 'transparent' body.style.overflow = 'hidden' @@ -100,10 +104,10 @@ function App() { // 更新窗口控件颜色以适配主题 const symbolColor = themeMode === 'dark' ? '#ffffff' : '#1a1a1a' - if (!isOnboardingWindow) { + if (!isOnboardingWindow && !isNotificationWindow) { window.electronAPI.window.setTitleBarOverlay({ symbolColor }) } - }, [currentTheme, themeMode, isOnboardingWindow]) + }, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow]) // 读取已保存的主题设置 useEffect(() => { @@ -173,21 +177,23 @@ function App() { // 监听启动时的更新通知 useEffect(() => { - const removeUpdateListener = window.electronAPI.app.onUpdateAvailable?.((info: any) => { + if (isNotificationWindow) return // Skip updates in notification window + + const removeUpdateListener = window.electronAPI?.app?.onUpdateAvailable?.((info: any) => { // 发现新版本时自动打开更新弹窗 if (info) { setUpdateInfo({ ...info, hasUpdate: true }) setShowUpdateDialog(true) } }) - const removeProgressListener = window.electronAPI.app.onDownloadProgress?.((progress: any) => { + const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => { setDownloadProgress(progress) }) return () => { removeUpdateListener?.() removeProgressListener?.() } - }, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog]) + }, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow]) const handleUpdateNow = async () => { setShowUpdateDialog(false) @@ -242,18 +248,18 @@ function App() { if (!onboardingDone) { await configService.setOnboardingDone(true) } - + const result = await window.electronAPI.chat.connect() if (result.success) { - + setDbConnected(true, dbPath) // 如果当前在欢迎页,跳转到首页 if (window.location.hash === '#/' || window.location.hash === '') { navigate('/home') } } else { - + // 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户 // 其他错误可能需要重新配置 const errorMsg = result.error || '' @@ -330,6 +336,11 @@ function App() { return } + // 独立通知窗口 + if (isNotificationWindow) { + return + } + // 主窗口 - 完整布局 return (
@@ -345,6 +356,9 @@ function App() { {/* 全局悬浮进度胶囊 (处理:新版本提示、下载进度、错误提示) */} + {/* 全局会话监听与通知 */} + + {/* 用户协议弹窗 */} {showAgreement && !agreementLoading && (
diff --git a/src/components/GlobalSessionMonitor.tsx b/src/components/GlobalSessionMonitor.tsx new file mode 100644 index 0000000..9077892 --- /dev/null +++ b/src/components/GlobalSessionMonitor.tsx @@ -0,0 +1,258 @@ +import { useEffect, useRef } from 'react' +import { useChatStore } from '../stores/chatStore' +import type { ChatSession } from '../types/models' +import { useNavigate } from 'react-router-dom' + +export function GlobalSessionMonitor() { + const navigate = useNavigate() + const { + sessions, + setSessions, + currentSessionId, + appendMessages, + messages + } = useChatStore() + + const sessionsRef = useRef(sessions) + + // 保持 ref 同步 + useEffect(() => { + sessionsRef.current = sessions + }, [sessions]) + + // 去重辅助函数:获取消息 key + const getMessageKey = (msg: any) => { + if (msg.localId && msg.localId > 0) return `l:${msg.localId}` + return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}` + } + + // 处理数据库变更 + useEffect(() => { + const handleDbChange = (_event: any, data: { type: string; json: string }) => { + try { + const payload = JSON.parse(data.json) + const tableName = payload.table + + // 只关注 Session 表 + if (tableName === 'Session' || tableName === 'session') { + refreshSessions() + } + } catch (e) { + console.error('解析数据库变更失败:', e) + } + } + + if (window.electronAPI.chat.onWcdbChange) { + const removeListener = window.electronAPI.chat.onWcdbChange(handleDbChange) + return () => { + removeListener() + } + } + return () => { } + }, []) // 空依赖数组 - 主要是静态的 + + const refreshSessions = async () => { + try { + const result = await window.electronAPI.chat.getSessions() + if (result.success && result.sessions && Array.isArray(result.sessions)) { + const newSessions = result.sessions as ChatSession[] + const oldSessions = sessionsRef.current + + // 1. 检测变更并通知 + checkForNewMessages(oldSessions, newSessions) + + // 2. 更新 store + setSessions(newSessions) + + // 3. 如果在活跃会话中,增量刷新消息 + const currentId = useChatStore.getState().currentSessionId + if (currentId) { + const currentSessionNew = newSessions.find(s => s.username === currentId) + const currentSessionOld = oldSessions.find(s => s.username === currentId) + + if (currentSessionNew && (!currentSessionOld || currentSessionNew.lastTimestamp > currentSessionOld.lastTimestamp)) { + void handleActiveSessionRefresh(currentId) + } + } + } + } catch (e) { + console.error('全局会话刷新失败:', e) + } + } + + const checkForNewMessages = async (oldSessions: ChatSession[], newSessions: ChatSession[]) => { + const oldMap = new Map(oldSessions.map(s => [s.username, s])) + + for (const newSession of newSessions) { + const oldSession = oldMap.get(newSession.username) + + // 条件: 新会话或时间戳更新 + const isCurrentSession = newSession.username === useChatStore.getState().currentSessionId + + if (!isCurrentSession && (!oldSession || newSession.lastTimestamp > oldSession.lastTimestamp)) { + // 这是新消息事件 + + // 1. 群聊过滤自己发送的消息 + if (newSession.username.includes('@chatroom')) { + // 如果是自己发的消息,不弹通知 + // 注意:lastMsgSender 需要后端支持返回 + // 使用宽松比较以处理 wxid_ 前缀差异 + if (newSession.lastMsgSender && newSession.selfWxid) { + const sender = newSession.lastMsgSender.replace(/^wxid_/, ''); + const self = newSession.selfWxid.replace(/^wxid_/, ''); + + // 使用主进程日志打印,方便用户查看 + const debugInfo = { + type: 'NotificationFilter', + username: newSession.username, + lastMsgSender: newSession.lastMsgSender, + selfWxid: newSession.selfWxid, + senderClean: sender, + selfClean: self, + match: sender === self + }; + + if (window.electronAPI.log?.debug) { + window.electronAPI.log.debug(debugInfo); + } else { + console.log('[NotificationFilter]', debugInfo); + } + + if (sender === self) { + if (window.electronAPI.log?.debug) { + window.electronAPI.log.debug('[NotificationFilter] Filtered own message'); + } else { + console.log('[NotificationFilter] Filtered own message'); + } + continue; + } + } else { + const missingInfo = { + type: 'NotificationFilter Missing info', + lastMsgSender: newSession.lastMsgSender, + selfWxid: newSession.selfWxid + }; + if (window.electronAPI.log?.debug) { + window.electronAPI.log.debug(missingInfo); + } else { + console.log('[NotificationFilter] Missing info:', missingInfo); + } + } + } + + let title = newSession.displayName || newSession.username + let avatarUrl = newSession.avatarUrl + let content = newSession.summary || '[新消息]' + + if (newSession.username.includes('@chatroom')) { + // 1. 群聊过滤自己发送的消息 + // 辅助函数:清理 wxid 后缀 (如 _8602) + const cleanWxid = (id: string) => { + if (!id) return ''; + const trimmed = id.trim(); + // 仅移除末尾的 _xxxx (4位字母数字) + const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/); + return suffixMatch ? suffixMatch[1] : trimmed; + } + + if (newSession.lastMsgSender && newSession.selfWxid) { + const senderClean = cleanWxid(newSession.lastMsgSender); + const selfClean = cleanWxid(newSession.selfWxid); + const match = senderClean === selfClean; + + if (match) { + continue; + } + } + + // 2. 群聊显示发送者名字 (放在内容中: "Name: Message") + // 标题保持为群聊名称 (title 变量) + if (newSession.lastSenderDisplayName) { + content = `${newSession.lastSenderDisplayName}: ${content}` + } + } + + // 修复 "Random User" 的逻辑 (缺少具体信息) + // 如果标题看起来像 wxid 或没有头像,尝试获取信息 + const needsEnrichment = !newSession.displayName || !newSession.avatarUrl || newSession.displayName === newSession.username + + if (needsEnrichment && newSession.username) { + try { + // 尝试丰富或获取联系人详情 + const contact = await window.electronAPI.chat.getContact(newSession.username) + if (contact) { + if (contact.remark || contact.nickname) { + title = contact.remark || contact.nickname + } + if (contact.avatarUrl) { + avatarUrl = contact.avatarUrl + } + } else { + // 如果不在缓存/数据库中 + const enrichResult = await window.electronAPI.chat.enrichSessionsContactInfo([newSession.username]) + if (enrichResult.success && enrichResult.contacts) { + const enrichedContact = enrichResult.contacts[newSession.username] + if (enrichedContact) { + if (enrichedContact.displayName) { + title = enrichedContact.displayName + } + if (enrichedContact.avatarUrl) { + avatarUrl = enrichedContact.avatarUrl + } + } + } + // 如果仍然没有有效名称,再尝试一次获取 + if (title === newSession.username || title.startsWith('wxid_')) { + const retried = await window.electronAPI.chat.getContact(newSession.username) + if (retried) { + title = retried.remark || retried.nickname || title + avatarUrl = retried.avatarUrl || avatarUrl + } + } + } + } catch (e) { + console.warn('获取通知的联系人信息失败', e) + } + } + + // 最终检查:如果标题仍是 wxid 格式,则跳过通知(避免显示乱跳用户) + // 群聊例外,因为群聊 username 包含 @chatroom + const isGroupChat = newSession.username.includes('@chatroom') + const isWxidTitle = title.startsWith('wxid_') && title === newSession.username + if (isWxidTitle && !isGroupChat) { + console.warn('[NotificationFilter] 跳过无法识别的用户通知:', newSession.username) + continue + } + + // 调用 IPC 以显示独立窗口通知 + window.electronAPI.notification?.show({ + title: title, + content: content, + avatarUrl: avatarUrl, + sessionId: newSession.username + }) + + // 我们不再为 Toast 设置本地状态 + } + } + } + + const handleActiveSessionRefresh = async (sessionId: string) => { + // 从 ChatPage 复制/调整的逻辑,以保持集中 + const state = useChatStore.getState() + const lastMsg = state.messages[state.messages.length - 1] + const minTime = lastMsg?.createTime || 0 + + try { + const result = await (window.electronAPI.chat as any).getNewMessages(sessionId, minTime) + if (result.success && result.messages && result.messages.length > 0) { + appendMessages(result.messages, false) // 追加到末尾 + } + } catch (e) { + console.warn('后台活跃会话刷新失败:', e) + } + } + + // 此组件不再渲染 UI + return null +} diff --git a/src/components/NotificationToast.scss b/src/components/NotificationToast.scss new file mode 100644 index 0000000..cb405a3 --- /dev/null +++ b/src/components/NotificationToast.scss @@ -0,0 +1,200 @@ +.notification-toast-container { + position: fixed; + z-index: 9999; + width: 320px; + background: var(--bg-secondary); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--border-light); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12); + padding: 12px; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + opacity: 0; + transform: scale(0.95); + pointer-events: none; // Allow clicking through when hidden + + &.visible { + opacity: 1; + transform: scale(1); + pointer-events: auto; + } + + &.static { + position: relative !important; + width: calc(100% - 4px) !important; // Leave 2px margin for anti-aliasing saftey + height: auto !important; // Fits content + min-height: 0; + top: 0 !important; + bottom: 0 !important; + left: 0 !important; + right: 0 !important; + transform: none !important; + margin: 2px !important; // 2px centered margin + border-radius: 12px !important; // Rounded corners + + + // Disable backdrop filter + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; + + // Ensure background is solid + background: var(--bg-secondary, #2c2c2c); + color: var(--text-primary, #ffffff); + + box-shadow: none !important; // NO SHADOW + border: 1px solid var(--border-light, rgba(255, 255, 255, 0.1)); + + display: flex; + padding: 16px; + padding-right: 32px; // Make space for close button + box-sizing: border-box; + + // Force close button to be visible but transparent background + .notification-close { + opacity: 1 !important; + top: 12px; + right: 12px; + background: transparent !important; // Transparent per user request + + &:hover { + color: var(--text-primary); + background: rgba(255, 255, 255, 0.1) !important; // Subtle hover effect + } + } + + .notification-time { + top: 24px; // Match padding + right: 40px; // Left of close button (12px + 20px + 8px) + } + } + + // Position variants + &.bottom-right { + bottom: 24px; + right: 24px; + transform: translate(0, 20px) scale(0.95); + + &.visible { + transform: translate(0, 0) scale(1); + } + } + + &.top-right { + top: 24px; + right: 24px; + transform: translate(0, -20px) scale(0.95); + + &.visible { + transform: translate(0, 0) scale(1); + } + } + + &.bottom-left { + bottom: 24px; + left: 24px; + transform: translate(0, 20px) scale(0.95); + + &.visible { + transform: translate(0, 0) scale(1); + } + } + + &.top-left { + top: 24px; + left: 24px; + transform: translate(0, -20px) scale(0.95); + + &.visible { + transform: translate(0, 0) scale(1); + } + } + + &:hover { + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.16) !important; + } + + .notification-content { + display: flex; + align-items: flex-start; + gap: 12px; + } + + .notification-avatar { + flex-shrink: 0; + } + + .notification-text { + flex: 1; + min-width: 0; + + .notification-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; + + .notification-title { + font-weight: 600; + font-size: 14px; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; // 允许缩放 + flex: 1; // 占据剩余空间 + min-width: 0; // 关键:允许 flex 子项收缩到内容以下 + margin-right: 60px; // Make space for absolute time + close button + } + + .notification-time { + font-size: 12px; + color: var(--text-tertiary); + position: absolute; + top: 16px; + right: 36px; // Left of close button (8px + 20px + 8px) + font-variant-numeric: tabular-nums; + } + } + + .notification-body { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-all; + } + } + + .notification-close { + position: absolute; + top: 8px; + right: 8px; + width: 20px; + height: 20px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + opacity: 0; + transition: all 0.2s; + + &:hover { + background: var(--bg-tertiary); + color: var(--text-primary); + } + } + + &:hover .notification-close { + opacity: 1; + } +} \ No newline at end of file diff --git a/src/components/NotificationToast.tsx b/src/components/NotificationToast.tsx new file mode 100644 index 0000000..886a878 --- /dev/null +++ b/src/components/NotificationToast.tsx @@ -0,0 +1,108 @@ +import React, { useEffect, useState } from 'react' +import { createPortal } from 'react-dom' +import { X } from 'lucide-react' +import { Avatar } from './Avatar' +import './NotificationToast.scss' + +export interface NotificationData { + id: string + sessionId: string + avatarUrl?: string + title: string + content: string + timestamp: number +} + +interface NotificationToastProps { + data: NotificationData | null + onClose: () => void + onClick: (sessionId: string) => void + duration?: number + position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' + isStatic?: boolean + initialVisible?: boolean +} + +export function NotificationToast({ + data, + onClose, + onClick, + duration = 5000, + position = 'top-right', + isStatic = false, + initialVisible = false +}: NotificationToastProps) { + const [isVisible, setIsVisible] = useState(initialVisible) + const [currentData, setCurrentData] = useState(null) + + useEffect(() => { + if (data) { + setCurrentData(data) + setIsVisible(true) + + const timer = setTimeout(() => { + setIsVisible(false) + // clean up data after animation + setTimeout(onClose, 300) + }, duration) + + return () => clearTimeout(timer) + } else { + setIsVisible(false) + } + }, [data, duration, onClose]) + + if (!currentData) return null + + const handleClose = (e: React.MouseEvent) => { + e.stopPropagation() + setIsVisible(false) + setTimeout(onClose, 300) + } + + const handleClick = () => { + setIsVisible(false) + setTimeout(() => { + onClose() + onClick(currentData.sessionId) + }, 300) + } + + const content = ( +
+
+
+ +
+
+
+ {currentData.title} + + {new Date(currentData.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + +
+
+ {currentData.content} +
+
+ +
+
+ ) + + if (isStatic) { + return content + } + + // Portal to document.body to ensure it's on top + return createPortal(content, document.body) +} diff --git a/src/pages/AnnualReportWindow.tsx b/src/pages/AnnualReportWindow.tsx index b4b0155..ed1bdcd 100644 --- a/src/pages/AnnualReportWindow.tsx +++ b/src/pages/AnnualReportWindow.tsx @@ -1017,14 +1017,14 @@ function AnnualReportWindow() { {midnightKing && (
深夜好友
-

当城市睡去

-

这一年你留下了

+

月光下的你

+

在这一年你留下了

{midnightKing.count} 条深夜的消息

- 其中 {midnightKing.displayName} 常常在深夜中陪着你。 + 其中 {midnightKing.displayName} 常常在深夜中陪着你胡思乱想。
你和Ta的对话占你深夜期间聊天的 {midnightKing.percentage}%

diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 1b17182..fdbc300 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -306,18 +306,7 @@ function ChatPage(_props: ChatPageProps) { const nextSessions = options?.silent ? mergeSessions(sessionsArray) : sessionsArray // 确保 nextSessions 也是数组 if (Array.isArray(nextSessions)) { - // 【核心优化】检查当前会话是否有更新(通过 lastTimestamp 对比) - const currentId = currentSessionRef.current - if (currentId) { - const newSession = nextSessions.find(s => s.username === currentId) - const oldSession = sessionsRef.current.find(s => s.username === currentId) - // 如果会话存在且时间戳变大(有新消息)或者之前没有该会话 - if (newSession && (!oldSession || newSession.lastTimestamp > oldSession.lastTimestamp)) { - console.log(`[Frontend] Detected update for current session ${currentId}, refreshing messages...`) - void handleIncrementalRefresh() - } - } setSessions(nextSessions) sessionsRef.current = nextSessions @@ -657,30 +646,7 @@ function ChatPage(_props: ChatPageProps) { } } - // 监听数据库变更实时刷新 - useEffect(() => { - const handleDbChange = (_event: any, data: { type: string; json: string }) => { - try { - const payload = JSON.parse(data.json) - const tableName = payload.table - // 会话列表更新(主要靠这个触发,因为 wcdb_api 已经只监控 session 了) - if (tableName === 'Session' || tableName === 'session') { - void loadSessions({ silent: true }) - } - } catch (e) { - console.error('解析数据库变更通知失败:', e) - } - } - - if (window.electronAPI.chat.onWcdbChange) { - const removeListener = window.electronAPI.chat.onWcdbChange(handleDbChange) - return () => { - removeListener() - } - } - return () => { } - }, [loadSessions, handleRefreshMessages]) // 加载消息 const loadMessages = async (sessionId: string, offset = 0, startTime = 0, endTime = 0) => { diff --git a/src/pages/NotificationWindow.scss b/src/pages/NotificationWindow.scss new file mode 100644 index 0000000..3e1515d --- /dev/null +++ b/src/pages/NotificationWindow.scss @@ -0,0 +1,54 @@ +@keyframes noti-enter { + 0% { + opacity: 0; + transform: translateY(-20px) scale(0.96); + } + + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes noti-exit { + 0% { + opacity: 1; + transform: scale(1) translateY(0); + filter: blur(0); + } + + 100% { + opacity: 0; + transform: scale(0.92) translateY(4px); + filter: blur(2px); + } +} + +body { + // Ensure the body background is transparent to let the rounded corners show + background: transparent; + overflow: hidden; + margin: 0; + padding: 0; +} + +#notification-root { + // Ensure the container allows 3D transforms + perspective: 1000px; +} + +#notification-current { + // New notification slides in + animation: noti-enter 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; + will-change: transform, opacity; +} + +#notification-prev { + // Old notification scales out + animation: noti-exit 0.35s cubic-bezier(0.33, 1, 0.68, 1) forwards; + transform-origin: center top; + will-change: transform, opacity, filter; + + // Ensure it stays behind + z-index: 0 !important; +} \ No newline at end of file diff --git a/src/pages/NotificationWindow.tsx b/src/pages/NotificationWindow.tsx new file mode 100644 index 0000000..deb6616 --- /dev/null +++ b/src/pages/NotificationWindow.tsx @@ -0,0 +1,165 @@ +import { useEffect, useState, useRef } from 'react' +import { NotificationToast, type NotificationData } from '../components/NotificationToast' +import '../components/NotificationToast.scss' +import './NotificationWindow.scss' + +export default function NotificationWindow() { + const [notification, setNotification] = useState(null) + const [prevNotification, setPrevNotification] = useState(null) + + // We need a ref to access the current notification inside the callback + // without satisfying the dependency array which would recreate the listener + // Actually, setNotification(prev => ...) pattern is better, but we need the VALUE of current to set as prev. + // So we use setNotification callback: setNotification(current => { ... return newNode }) + // But we need to update TWO states. + // So we use a ref to track "current displayed" for the event handler. + // Or just use functional updates, but we need to setPrev(current). + + const notificationRef = useRef(null) + + useEffect(() => { + notificationRef.current = notification + }, [notification]) + + useEffect(() => { + const handleShow = (_event: any, data: any) => { + // data: { title, content, avatarUrl, sessionId } + const timestamp = Math.floor(Date.now() / 1000) + const newNoti: NotificationData = { + id: `noti_${timestamp}_${Math.random().toString(36).substr(2, 9)}`, + sessionId: data.sessionId, + title: data.title, + content: data.content, + timestamp: timestamp, + avatarUrl: data.avatarUrl + } + + // Set previous to current (ref) + if (notificationRef.current) { + setPrevNotification(notificationRef.current) + } + setNotification(newNoti) + } + + if (window.electronAPI) { + const remove = window.electronAPI.notification?.onShow?.(handleShow) + window.electronAPI.notification?.ready?.() + return () => remove?.() + } + }, []) + + // Clean up prevNotification after transition + useEffect(() => { + if (prevNotification) { + const timer = setTimeout(() => { + setPrevNotification(null) + }, 400) + return () => clearTimeout(timer) + } + }, [prevNotification]) + + const handleClose = () => { + setNotification(null) + setPrevNotification(null) + window.electronAPI.notification?.close() + } + + const handleClick = (sessionId: string) => { + window.electronAPI.notification?.click(sessionId) + setNotification(null) + setPrevNotification(null) + // Main process handles window hide/close + } + + useEffect(() => { + // Measure only if we have a notification (current or prev) + if (!notification && !prevNotification) return + + // Prefer measuring the NEW one + const targetId = notification ? 'notification-current' : 'notification-prev' + + const timer = setTimeout(() => { + // Find the wrapper of the content + // Since we wrap them, we should measure the content inside + // But getting root is easier if size is set by relative child + const root = document.getElementById('notification-root') + if (root) { + const height = root.offsetHeight + const width = 344 + if (window.electronAPI?.notification?.resize) { + const finalHeight = Math.min(height + 4, 300) + window.electronAPI.notification.resize(width, finalHeight) + } + } + }, 50) + + return () => clearTimeout(timer) + }, [notification, prevNotification]) + + if (!notification && !prevNotification) return null + + return ( +
+ + {/* Previous Notification (Background / Fading Out) */} + {prevNotification && ( +
+ { }} // No-op for background item + onClick={() => { }} + position="top-right" + isStatic={true} + initialVisible={true} + /> +
+ )} + + {/* Current Notification (Foreground / Fading In) */} + {notification && ( +
+ +
+ )} +
+ ) +} diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index c598716..4615a1e 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -180,7 +180,7 @@ animation: pulse 1.5s ease-in-out infinite; } - input { + input:not(.filter-search-box input) { width: 100%; padding: 10px 16px; border: 1px solid var(--border-color); @@ -207,6 +207,7 @@ select { width: 100%; padding: 10px 16px; + padding-right: 36px; border: 1px solid var(--border-color); border-radius: 9999px; font-size: 14px; @@ -214,6 +215,9 @@ color: var(--text-primary); margin-bottom: 10px; cursor: pointer; + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; &:focus { outline: none; @@ -221,6 +225,124 @@ } } + .select-wrapper { + position: relative; + margin-bottom: 10px; + + select { + margin-bottom: 0; + } + + >svg { + position: absolute; + right: 14px; + top: 50%; + transform: translateY(-50%); + color: var(--text-tertiary); + pointer-events: none; + } + } + + // 自定义下拉选择框 + .custom-select { + position: relative; + margin-bottom: 10px; + } + + .custom-select-trigger { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 10px 16px; + border: 1px solid var(--border-color); + border-radius: 9999px; + font-size: 14px; + background: var(--bg-primary); + color: var(--text-primary); + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: var(--text-tertiary); + } + + &.open { + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent); + } + } + + .custom-select-value { + flex: 1; + } + + .custom-select-arrow { + color: var(--text-tertiary); + transition: transform 0.2s ease; + + &.rotate { + transform: rotate(180deg); + } + } + + .custom-select-dropdown { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + background: color-mix(in srgb, var(--bg-primary) 90%, var(--bg-secondary)); + border: 1px solid var(--border-color); + border-radius: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + overflow: hidden; + z-index: 100; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + + // 展开收起动画 + opacity: 0; + visibility: hidden; + transform: translateY(-8px) scaleY(0.95); + transform-origin: top center; + transition: all 0.2s cubic-bezier(0.2, 0, 0.2, 1); + + &.open { + opacity: 1; + visibility: visible; + transform: translateY(0) scaleY(1); + } + } + + .custom-select-option { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + font-size: 14px; + color: var(--text-primary); + cursor: pointer; + transition: background 0.15s; + + &:hover { + background: var(--bg-tertiary); + } + + &.selected { + color: var(--primary); + font-weight: 500; + + svg { + color: var(--primary); + } + } + + svg { + flex-shrink: 0; + } + } + + .select-field { position: relative; margin-bottom: 10px; @@ -1264,4 +1386,173 @@ border-top: 1px solid var(--border-primary); display: flex; justify-content: flex-end; +} + +// 通知过滤双列表容器 +.notification-filter-container { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-top: 12px; +} + +.filter-panel { + display: flex; + flex-direction: column; + background: var(--bg-tertiary); + border-radius: 12px; + overflow: hidden; + border: 1px solid var(--border-color); +} + +.filter-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 12px; + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + background: var(--bg-primary); + border-bottom: 1px solid var(--border-color); + + >span { + flex-shrink: 0; + } +} + +.filter-search-box { + display: flex; + align-items: center; + gap: 4px; + flex: 1; + max-width: 140px; + padding: 4px 8px; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 6px; + transition: all 0.2s; + + &:focus-within { + border-color: var(--primary); + background: var(--bg-primary); + } + + svg { + flex-shrink: 0; + width: 12px; + height: 12px; + color: var(--text-tertiary); + } + + input { + flex: 1; + min-width: 0; + border: none; + background: transparent; + font-size: 12px; + color: var(--text-primary); + outline: none; + + &::placeholder { + color: var(--text-tertiary); + } + } +} + +.filter-panel-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + font-size: 12px; + font-weight: 600; + color: white; + background: var(--primary); + border-radius: 10px; +} + +.filter-panel-list { + flex: 1; + min-height: 200px; + max-height: 300px; + overflow-y: auto; + padding: 8px; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 3px; + } +} + +.filter-panel-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + margin-bottom: 4px; + background: var(--bg-primary); + border-radius: 8px; + cursor: pointer; + transition: all 0.15s; + + &:last-child { + margin-bottom: 0; + } + + &:hover { + background: var(--bg-secondary); + + .filter-item-action { + opacity: 1; + color: var(--primary); + } + } + + &.selected { + background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary)); + border: 1px solid color-mix(in srgb, var(--primary) 20%, transparent); + + &:hover .filter-item-action { + color: var(--danger); + } + } + + .filter-item-name { + flex: 1; + font-size: 14px; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .filter-item-action { + font-size: 18px; + font-weight: 500; + color: var(--text-tertiary); + opacity: 0.5; + transition: all 0.15s; + } +} + +.filter-panel-empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + min-height: 100px; + font-size: 13px; + color: var(--text-tertiary); } \ No newline at end of file diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index f746c87..71e8143 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -9,14 +9,16 @@ import { Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy, RotateCcw, Trash2, Plug, Check, Sun, Moon, Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic, - ShieldCheck, Fingerprint, Lock, KeyRound + ShieldCheck, Fingerprint, Lock, KeyRound, Bell } from 'lucide-react' +import { Avatar } from '../components/Avatar' import './SettingsPage.scss' -type SettingsTab = 'appearance' | 'database' | 'whisper' | 'export' | 'cache' | 'security' | 'about' +type SettingsTab = 'appearance' | 'notification' | 'database' | 'whisper' | 'export' | 'cache' | 'security' | 'about' const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [ { id: 'appearance', label: '外观', icon: Palette }, + { id: 'notification', label: '通知', icon: Bell }, { id: 'database', label: '数据库连接', icon: Database }, { id: 'whisper', label: '语音识别模型', icon: Mic }, { id: 'export', label: '导出', icon: Download }, @@ -25,6 +27,7 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [ { id: 'about', label: '关于', icon: Info } ] + interface WxidOption { wxid: string modifiedTime: number @@ -83,6 +86,18 @@ function SettingsPage() { const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2) + const [notificationEnabled, setNotificationEnabled] = useState(true) + const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'>('top-right') + const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all') + const [notificationFilterList, setNotificationFilterList] = useState([]) + const [filterSearchKeyword, setFilterSearchKeyword] = useState('') + const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false) + const [positionDropdownOpen, setPositionDropdownOpen] = useState(false) + + + + + const [isLoading, setIsLoadingState] = useState(false) const [isTesting, setIsTesting] = useState(false) const [isDetectingPath, setIsDetectingPath] = useState(false) @@ -167,6 +182,24 @@ function SettingsPage() { } }, []) + // 点击外部关闭自定义下拉框 + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as HTMLElement + if (!target.closest('.custom-select')) { + setFilterModeDropdownOpen(false) + setPositionDropdownOpen(false) + } + } + if (filterModeDropdownOpen || positionDropdownOpen) { + document.addEventListener('click', handleClickOutside) + } + return () => { + document.removeEventListener('click', handleClickOutside) + } + }, [filterModeDropdownOpen, positionDropdownOpen]) + + const loadConfig = async () => { try { const savedKey = await configService.getDecryptKey() @@ -188,6 +221,11 @@ function SettingsPage() { const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns() const savedExportDefaultConcurrency = await configService.getExportDefaultConcurrency() + const savedNotificationEnabled = await configService.getNotificationEnabled() + const savedNotificationPosition = await configService.getNotificationPosition() + const savedNotificationFilterMode = await configService.getNotificationFilterMode() + const savedNotificationFilterList = await configService.getNotificationFilterList() + const savedAuthEnabled = await configService.getAuthEnabled() const savedAuthUseHello = await configService.getAuthUseHello() setAuthEnabled(savedAuthEnabled) @@ -221,6 +259,11 @@ function SettingsPage() { setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true) setExportDefaultConcurrency(savedExportDefaultConcurrency ?? 2) + setNotificationEnabled(savedNotificationEnabled) + setNotificationPosition(savedNotificationPosition) + setNotificationFilterMode(savedNotificationFilterMode) + setNotificationFilterList(savedNotificationFilterList) + // 如果语言列表为空,保存默认值 if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) { const defaultLanguages = ['zh'] @@ -842,6 +885,245 @@ function SettingsPage() {
) + const renderNotificationTab = () => { + const { sessions } = useChatStore.getState() + + // 获取已过滤会话的信息 + const getSessionInfo = (username: string) => { + const session = sessions.find(s => s.username === username) + return { + displayName: session?.displayName || username, + avatarUrl: session?.avatarUrl || '' + } + } + + // 添加会话到过滤列表 + const handleAddToFilterList = async (username: string) => { + if (notificationFilterList.includes(username)) return + const newList = [...notificationFilterList, username] + setNotificationFilterList(newList) + await configService.setNotificationFilterList(newList) + showMessage('已添加到过滤列表', true) + } + + // 从过滤列表移除会话 + const handleRemoveFromFilterList = async (username: string) => { + const newList = notificationFilterList.filter(u => u !== username) + setNotificationFilterList(newList) + await configService.setNotificationFilterList(newList) + showMessage('已从过滤列表移除', true) + } + + // 过滤掉已在列表中的会话,并根据搜索关键字过滤 + const availableSessions = sessions.filter(s => { + if (notificationFilterList.includes(s.username)) return false + if (filterSearchKeyword) { + const keyword = filterSearchKeyword.toLowerCase() + const displayName = (s.displayName || '').toLowerCase() + const username = s.username.toLowerCase() + return displayName.includes(keyword) || username.includes(keyword) + } + return true + }) + + return ( +
+
+ + 开启后,收到新消息时将显示桌面弹窗通知 +
+ {notificationEnabled ? '已开启' : '已关闭'} + +
+
+ +
+ + 选择通知弹窗在屏幕上的显示位置 +
+
setPositionDropdownOpen(!positionDropdownOpen)} + > + + {notificationPosition === 'top-right' ? '右上角' : + notificationPosition === 'bottom-right' ? '右下角' : + notificationPosition === 'top-left' ? '左上角' : '左下角'} + + +
+
+ {[ + { value: 'top-right', label: '右上角' }, + { value: 'bottom-right', label: '右下角' }, + { value: 'top-left', label: '左上角' }, + { value: 'bottom-left', label: '左下角' } + ].map(option => ( +
{ + const val = option.value as 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' + setNotificationPosition(val) + setPositionDropdownOpen(false) + await configService.setNotificationPosition(val) + showMessage('通知位置已更新', true) + }} + > + {option.label} + {notificationPosition === option.value && } +
+ ))} +
+
+
+ +
+ + 选择只接收特定会话的通知,或屏蔽特定会话的通知 +
+
setFilterModeDropdownOpen(!filterModeDropdownOpen)} + > + + {notificationFilterMode === 'all' ? '接收所有通知' : + notificationFilterMode === 'whitelist' ? '仅接收白名单' : '屏蔽黑名单'} + + +
+
+ {[ + { value: 'all', label: '接收所有通知' }, + { value: 'whitelist', label: '仅接收白名单' }, + { value: 'blacklist', label: '屏蔽黑名单' } + ].map(option => ( +
{ + const val = option.value as 'all' | 'whitelist' | 'blacklist' + setNotificationFilterMode(val) + setFilterModeDropdownOpen(false) + await configService.setNotificationFilterMode(val) + showMessage( + val === 'all' ? '已设为接收所有通知' : + val === 'whitelist' ? '已设为仅接收白名单通知' : '已设为屏蔽黑名单通知', + true + ) + }} + > + {option.label} + {notificationFilterMode === option.value && } +
+ ))} +
+
+
+ + {notificationFilterMode !== 'all' && ( +
+ + + {notificationFilterMode === 'whitelist' + ? '点击左侧会话添加到白名单,点击右侧会话从白名单移除' + : '点击左侧会话添加到黑名单,点击右侧会话从黑名单移除'} + + +
+ {/* 可选会话列表 */} +
+
+ 可选会话 +
+ + setFilterSearchKeyword(e.target.value)} + /> +
+
+
+ {availableSessions.length > 0 ? ( + availableSessions.map(session => ( +
handleAddToFilterList(session.username)} + > + + {session.displayName || session.username} + + +
+ )) + ) : ( +
+ {filterSearchKeyword ? '没有匹配的会话' : '暂无可添加的会话'} +
+ )} +
+
+ + {/* 已选会话列表 */} +
+
+ {notificationFilterMode === 'whitelist' ? '白名单' : '黑名单'} + {notificationFilterList.length > 0 && ( + {notificationFilterList.length} + )} +
+
+ {notificationFilterList.length > 0 ? ( + notificationFilterList.map(username => { + const info = getSessionInfo(username) + return ( +
handleRemoveFromFilterList(username)} + > + + {info.displayName} + × +
+ ) + }) + ) : ( +
尚未添加任何会话
+ )} +
+
+
+
+ )} +
+ ) + } + const renderDatabaseTab = () => (
@@ -1674,6 +1956,7 @@ function SettingsPage() {
{activeTab === 'appearance' && renderAppearanceTab()} + {activeTab === 'notification' && renderNotificationTab()} {activeTab === 'database' && renderDatabaseTab()} {activeTab === 'whisper' && renderWhisperTab()} {activeTab === 'export' && renderExportTab()} diff --git a/src/services/config.ts b/src/services/config.ts index 01b5cb7..d785cab 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -38,7 +38,13 @@ export const CONFIG_KEYS = { AUTH_USE_HELLO: 'authUseHello', // 更新 - IGNORED_UPDATE_VERSION: 'ignoredUpdateVersion' + IGNORED_UPDATE_VERSION: 'ignoredUpdateVersion', + + // 通知 + NOTIFICATION_ENABLED: 'notificationEnabled', + NOTIFICATION_POSITION: 'notificationPosition', + NOTIFICATION_FILTER_MODE: 'notificationFilterMode', + NOTIFICATION_FILTER_LIST: 'notificationFilterList' } as const export interface WxidConfig { @@ -416,3 +422,46 @@ export async function setIgnoredUpdateVersion(version: string): Promise { await config.set(CONFIG_KEYS.IGNORED_UPDATE_VERSION, version) } +// 获取通知开关 +export async function getNotificationEnabled(): Promise { + const value = await config.get(CONFIG_KEYS.NOTIFICATION_ENABLED) + return value !== false // 默认为 true +} + +// 设置通知开关 +export async function setNotificationEnabled(enabled: boolean): Promise { + await config.set(CONFIG_KEYS.NOTIFICATION_ENABLED, enabled) +} + +// 获取通知位置 +export async function getNotificationPosition(): Promise<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'> { + const value = await config.get(CONFIG_KEYS.NOTIFICATION_POSITION) + return (value as any) || 'top-right' +} + +// 设置通知位置 +export async function setNotificationPosition(position: string): Promise { + await config.set(CONFIG_KEYS.NOTIFICATION_POSITION, position) +} + +// 获取通知过滤模式 +export async function getNotificationFilterMode(): Promise<'all' | 'whitelist' | 'blacklist'> { + const value = await config.get(CONFIG_KEYS.NOTIFICATION_FILTER_MODE) + return (value as any) || 'all' +} + +// 设置通知过滤模式 +export async function setNotificationFilterMode(mode: 'all' | 'whitelist' | 'blacklist'): Promise { + await config.set(CONFIG_KEYS.NOTIFICATION_FILTER_MODE, mode) +} + +// 获取通知过滤列表 +export async function getNotificationFilterList(): Promise { + const value = await config.get(CONFIG_KEYS.NOTIFICATION_FILTER_LIST) + return Array.isArray(value) ? value : [] +} + +// 设置通知过滤列表 +export async function setNotificationFilterList(list: string[]): Promise { + await config.set(CONFIG_KEYS.NOTIFICATION_FILTER_LIST, list) +} diff --git a/src/types/models.ts b/src/types/models.ts index 2600c69..a3b0963 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -9,6 +9,9 @@ export interface ChatSession { lastMsgType: number displayName?: string avatarUrl?: string + lastMsgSender?: string + lastSenderDisplayName?: string + selfWxid?: string // Helper field to avoid extra API calls } // 联系人