mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-23 23:01:21 +08:00
feat: 宇宙超级无敌牛且帅气到爆炸的功能更新和优化
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
// 窗口控制
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ConfigSchema>
|
||||
private static instance: ConfigService
|
||||
private store!: Store<ConfigSchema>
|
||||
|
||||
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<ConfigSchema>({
|
||||
name: 'WeFlow-config',
|
||||
defaults: {
|
||||
@@ -72,7 +90,11 @@ export class ConfigService {
|
||||
authPassword: '',
|
||||
authUseHello: false,
|
||||
|
||||
ignoredUpdateVersion: ''
|
||||
ignoredUpdateVersion: '',
|
||||
notificationEnabled: true,
|
||||
notificationPosition: 'top-right',
|
||||
notificationFilterMode: 'all',
|
||||
notificationFilterList: []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
200
electron/windows/notificationWindow.ts
Normal file
200
electron/windows/notificationWindow.ts
Normal file
@@ -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 中处理 (导航)
|
||||
}
|
||||
Binary file not shown.
32
src/App.tsx
32
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 <ChatHistoryPage />
|
||||
}
|
||||
|
||||
// 独立通知窗口
|
||||
if (isNotificationWindow) {
|
||||
return <NotificationWindow />
|
||||
}
|
||||
|
||||
// 主窗口 - 完整布局
|
||||
return (
|
||||
<div className="app-container">
|
||||
@@ -345,6 +356,9 @@ function App() {
|
||||
{/* 全局悬浮进度胶囊 (处理:新版本提示、下载进度、错误提示) */}
|
||||
<UpdateProgressCapsule />
|
||||
|
||||
{/* 全局会话监听与通知 */}
|
||||
<GlobalSessionMonitor />
|
||||
|
||||
{/* 用户协议弹窗 */}
|
||||
{showAgreement && !agreementLoading && (
|
||||
<div className="agreement-overlay">
|
||||
|
||||
258
src/components/GlobalSessionMonitor.tsx
Normal file
258
src/components/GlobalSessionMonitor.tsx
Normal file
@@ -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
|
||||
}
|
||||
200
src/components/NotificationToast.scss
Normal file
200
src/components/NotificationToast.scss
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
108
src/components/NotificationToast.tsx
Normal file
108
src/components/NotificationToast.tsx
Normal file
@@ -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<NotificationData | null>(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 = (
|
||||
<div
|
||||
className={`notification-toast-container ${position} ${isVisible ? 'visible' : ''} ${isStatic ? 'static' : ''}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="notification-content">
|
||||
<div className="notification-avatar">
|
||||
<Avatar
|
||||
src={currentData.avatarUrl}
|
||||
name={currentData.title}
|
||||
size={40}
|
||||
/>
|
||||
</div>
|
||||
<div className="notification-text">
|
||||
<div className="notification-header">
|
||||
<span className="notification-title">{currentData.title}</span>
|
||||
<span className="notification-time">
|
||||
{new Date(currentData.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="notification-body">
|
||||
{currentData.content}
|
||||
</div>
|
||||
</div>
|
||||
<button className="notification-close" onClick={handleClose}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (isStatic) {
|
||||
return content
|
||||
}
|
||||
|
||||
// Portal to document.body to ensure it's on top
|
||||
return createPortal(content, document.body)
|
||||
}
|
||||
@@ -1017,14 +1017,14 @@ function AnnualReportWindow() {
|
||||
{midnightKing && (
|
||||
<section className="section" ref={sectionRefs.midnightKing}>
|
||||
<div className="label-text">深夜好友</div>
|
||||
<h2 className="hero-title">当城市睡去</h2>
|
||||
<p className="hero-desc">这一年你留下了</p>
|
||||
<h2 className="hero-title">月光下的你</h2>
|
||||
<p className="hero-desc">在这一年你留下了</p>
|
||||
<div className="big-stat">
|
||||
<span className="stat-num">{midnightKing.count}</span>
|
||||
<span className="stat-unit">条深夜的消息</span>
|
||||
</div>
|
||||
<p className="hero-desc">
|
||||
其中 <span className="hl">{midnightKing.displayName}</span> 常常在深夜中陪着你。
|
||||
其中 <span className="hl">{midnightKing.displayName}</span> 常常在深夜中陪着你胡思乱想。
|
||||
<br />你和Ta的对话占你深夜期间聊天的 <span className="gold">{midnightKing.percentage}%</span>。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
54
src/pages/NotificationWindow.scss
Normal file
54
src/pages/NotificationWindow.scss
Normal file
@@ -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;
|
||||
}
|
||||
165
src/pages/NotificationWindow.tsx
Normal file
165
src/pages/NotificationWindow.tsx
Normal file
@@ -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<NotificationData | null>(null)
|
||||
const [prevNotification, setPrevNotification] = useState<NotificationData | null>(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<NotificationData | null>(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 (
|
||||
<div
|
||||
id="notification-root"
|
||||
style={{
|
||||
width: '100vw',
|
||||
height: 'auto',
|
||||
minHeight: '10px',
|
||||
background: 'transparent',
|
||||
position: 'relative', // Context for absolute children
|
||||
overflow: 'hidden', // Prevent scrollbars during transition
|
||||
padding: '2px', // Margin safe
|
||||
boxSizing: 'border-box'
|
||||
}}>
|
||||
|
||||
{/* Previous Notification (Background / Fading Out) */}
|
||||
{prevNotification && (
|
||||
<div
|
||||
id="notification-prev"
|
||||
key={prevNotification.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 2, // Match padding
|
||||
left: 2,
|
||||
width: 'calc(100% - 4px)', // Match width logic
|
||||
zIndex: 1,
|
||||
pointerEvents: 'none' // Disable interaction on old one
|
||||
}}
|
||||
>
|
||||
<NotificationToast
|
||||
key={prevNotification.id}
|
||||
data={prevNotification}
|
||||
onClose={() => { }} // No-op for background item
|
||||
onClick={() => { }}
|
||||
position="top-right"
|
||||
isStatic={true}
|
||||
initialVisible={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Notification (Foreground / Fading In) */}
|
||||
{notification && (
|
||||
<div
|
||||
id="notification-current"
|
||||
key={notification.id}
|
||||
style={{
|
||||
position: 'relative', // Takes up space
|
||||
zIndex: 2,
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
<NotificationToast
|
||||
key={notification.id} // Ensure remount for animation
|
||||
data={notification}
|
||||
onClose={handleClose}
|
||||
onClick={handleClick}
|
||||
position="top-right"
|
||||
isStatic={true}
|
||||
initialVisible={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<string[]>([])
|
||||
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() {
|
||||
</div>
|
||||
)
|
||||
|
||||
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 (
|
||||
<div className="tab-content">
|
||||
<div className="form-group">
|
||||
<label>新消息通知</label>
|
||||
<span className="form-hint">开启后,收到新消息时将显示桌面弹窗通知</span>
|
||||
<div className="log-toggle-line">
|
||||
<span className="log-status">{notificationEnabled ? '已开启' : '已关闭'}</span>
|
||||
<label className="switch" htmlFor="notification-enabled-toggle">
|
||||
<input
|
||||
id="notification-enabled-toggle"
|
||||
className="switch-input"
|
||||
type="checkbox"
|
||||
checked={notificationEnabled}
|
||||
onChange={async (e) => {
|
||||
const val = e.target.checked
|
||||
setNotificationEnabled(val)
|
||||
await configService.setNotificationEnabled(val)
|
||||
showMessage(val ? '已开启通知' : '已关闭通知', true)
|
||||
}}
|
||||
/>
|
||||
<span className="switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>通知显示位置</label>
|
||||
<span className="form-hint">选择通知弹窗在屏幕上的显示位置</span>
|
||||
<div className="custom-select">
|
||||
<div
|
||||
className={`custom-select-trigger ${positionDropdownOpen ? 'open' : ''}`}
|
||||
onClick={() => setPositionDropdownOpen(!positionDropdownOpen)}
|
||||
>
|
||||
<span className="custom-select-value">
|
||||
{notificationPosition === 'top-right' ? '右上角' :
|
||||
notificationPosition === 'bottom-right' ? '右下角' :
|
||||
notificationPosition === 'top-left' ? '左上角' : '左下角'}
|
||||
</span>
|
||||
<ChevronDown size={14} className={`custom-select-arrow ${positionDropdownOpen ? 'rotate' : ''}`} />
|
||||
</div>
|
||||
<div className={`custom-select-dropdown ${positionDropdownOpen ? 'open' : ''}`}>
|
||||
{[
|
||||
{ value: 'top-right', label: '右上角' },
|
||||
{ value: 'bottom-right', label: '右下角' },
|
||||
{ value: 'top-left', label: '左上角' },
|
||||
{ value: 'bottom-left', label: '左下角' }
|
||||
].map(option => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={`custom-select-option ${notificationPosition === option.value ? 'selected' : ''}`}
|
||||
onClick={async () => {
|
||||
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 && <Check size={14} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>会话过滤</label>
|
||||
<span className="form-hint">选择只接收特定会话的通知,或屏蔽特定会话的通知</span>
|
||||
<div className="custom-select">
|
||||
<div
|
||||
className={`custom-select-trigger ${filterModeDropdownOpen ? 'open' : ''}`}
|
||||
onClick={() => setFilterModeDropdownOpen(!filterModeDropdownOpen)}
|
||||
>
|
||||
<span className="custom-select-value">
|
||||
{notificationFilterMode === 'all' ? '接收所有通知' :
|
||||
notificationFilterMode === 'whitelist' ? '仅接收白名单' : '屏蔽黑名单'}
|
||||
</span>
|
||||
<ChevronDown size={14} className={`custom-select-arrow ${filterModeDropdownOpen ? 'rotate' : ''}`} />
|
||||
</div>
|
||||
<div className={`custom-select-dropdown ${filterModeDropdownOpen ? 'open' : ''}`}>
|
||||
{[
|
||||
{ value: 'all', label: '接收所有通知' },
|
||||
{ value: 'whitelist', label: '仅接收白名单' },
|
||||
{ value: 'blacklist', label: '屏蔽黑名单' }
|
||||
].map(option => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={`custom-select-option ${notificationFilterMode === option.value ? 'selected' : ''}`}
|
||||
onClick={async () => {
|
||||
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 && <Check size={14} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notificationFilterMode !== 'all' && (
|
||||
<div className="form-group">
|
||||
<label>{notificationFilterMode === 'whitelist' ? '白名单会话' : '黑名单会话'}</label>
|
||||
<span className="form-hint">
|
||||
{notificationFilterMode === 'whitelist'
|
||||
? '点击左侧会话添加到白名单,点击右侧会话从白名单移除'
|
||||
: '点击左侧会话添加到黑名单,点击右侧会话从黑名单移除'}
|
||||
</span>
|
||||
|
||||
<div className="notification-filter-container">
|
||||
{/* 可选会话列表 */}
|
||||
<div className="filter-panel">
|
||||
<div className="filter-panel-header">
|
||||
<span>可选会话</span>
|
||||
<div className="filter-search-box">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索会话..."
|
||||
value={filterSearchKeyword}
|
||||
onChange={(e) => setFilterSearchKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="filter-panel-list">
|
||||
{availableSessions.length > 0 ? (
|
||||
availableSessions.map(session => (
|
||||
<div
|
||||
key={session.username}
|
||||
className="filter-panel-item"
|
||||
onClick={() => handleAddToFilterList(session.username)}
|
||||
>
|
||||
<Avatar
|
||||
src={session.avatarUrl}
|
||||
name={session.displayName || session.username}
|
||||
size={28}
|
||||
/>
|
||||
<span className="filter-item-name">{session.displayName || session.username}</span>
|
||||
<span className="filter-item-action">+</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="filter-panel-empty">
|
||||
{filterSearchKeyword ? '没有匹配的会话' : '暂无可添加的会话'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 已选会话列表 */}
|
||||
<div className="filter-panel">
|
||||
<div className="filter-panel-header">
|
||||
<span>{notificationFilterMode === 'whitelist' ? '白名单' : '黑名单'}</span>
|
||||
{notificationFilterList.length > 0 && (
|
||||
<span className="filter-panel-count">{notificationFilterList.length}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="filter-panel-list">
|
||||
{notificationFilterList.length > 0 ? (
|
||||
notificationFilterList.map(username => {
|
||||
const info = getSessionInfo(username)
|
||||
return (
|
||||
<div
|
||||
key={username}
|
||||
className="filter-panel-item selected"
|
||||
onClick={() => handleRemoveFromFilterList(username)}
|
||||
>
|
||||
<Avatar
|
||||
src={info.avatarUrl}
|
||||
name={info.displayName}
|
||||
size={28}
|
||||
/>
|
||||
<span className="filter-item-name">{info.displayName}</span>
|
||||
<span className="filter-item-action">×</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="filter-panel-empty">尚未添加任何会话</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderDatabaseTab = () => (
|
||||
<div className="tab-content">
|
||||
<div className="form-group">
|
||||
@@ -1674,6 +1956,7 @@ function SettingsPage() {
|
||||
|
||||
<div className="settings-body">
|
||||
{activeTab === 'appearance' && renderAppearanceTab()}
|
||||
{activeTab === 'notification' && renderNotificationTab()}
|
||||
{activeTab === 'database' && renderDatabaseTab()}
|
||||
{activeTab === 'whisper' && renderWhisperTab()}
|
||||
{activeTab === 'export' && renderExportTab()}
|
||||
|
||||
@@ -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<void> {
|
||||
await config.set(CONFIG_KEYS.IGNORED_UPDATE_VERSION, version)
|
||||
}
|
||||
|
||||
// 获取通知开关
|
||||
export async function getNotificationEnabled(): Promise<boolean> {
|
||||
const value = await config.get(CONFIG_KEYS.NOTIFICATION_ENABLED)
|
||||
return value !== false // 默认为 true
|
||||
}
|
||||
|
||||
// 设置通知开关
|
||||
export async function setNotificationEnabled(enabled: boolean): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await config.set(CONFIG_KEYS.NOTIFICATION_FILTER_MODE, mode)
|
||||
}
|
||||
|
||||
// 获取通知过滤列表
|
||||
export async function getNotificationFilterList(): Promise<string[]> {
|
||||
const value = await config.get(CONFIG_KEYS.NOTIFICATION_FILTER_LIST)
|
||||
return Array.isArray(value) ? value : []
|
||||
}
|
||||
|
||||
// 设置通知过滤列表
|
||||
export async function setNotificationFilterList(list: string[]): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.NOTIFICATION_FILTER_LIST, list)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 联系人
|
||||
|
||||
Reference in New Issue
Block a user