mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
feat: 宇宙超级无敌牛且帅气到爆炸的功能更新和优化
This commit is contained in:
@@ -21,6 +21,7 @@ import { videoService } from './services/videoService'
|
|||||||
import { snsService } from './services/snsService'
|
import { snsService } from './services/snsService'
|
||||||
import { contactExportService } from './services/contactExportService'
|
import { contactExportService } from './services/contactExportService'
|
||||||
import { windowsHelloService } from './services/windowsHelloService'
|
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'))
|
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 鉴权
|
// 拦截请求,修改 Referer 和 User-Agent 以通过微信 CDN 鉴权
|
||||||
session.defaultSession.webRequest.onBeforeSendHeaders(
|
session.defaultSession.webRequest.onBeforeSendHeaders(
|
||||||
{
|
{
|
||||||
@@ -366,8 +375,6 @@ function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHe
|
|||||||
hash: `/video-player-window?${videoParam}`
|
hash: `/video-player-window?${videoParam}`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return win
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -499,6 +506,7 @@ function showMainWindow() {
|
|||||||
|
|
||||||
// 注册 IPC 处理器
|
// 注册 IPC 处理器
|
||||||
function registerIpcHandlers() {
|
function registerIpcHandlers() {
|
||||||
|
registerNotificationHandlers()
|
||||||
// 配置相关
|
// 配置相关
|
||||||
ipcMain.handle('config:get', async (_, key: string) => {
|
ipcMain.handle('config:get', async (_, key: string) => {
|
||||||
return configService?.get(key as any)
|
return configService?.get(key as any)
|
||||||
|
|||||||
@@ -9,6 +9,19 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
clear: () => ipcRenderer.invoke('config:clear')
|
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: {
|
auth: {
|
||||||
hello: (message?: string) => ipcRenderer.invoke('auth:hello', message)
|
hello: (message?: string) => ipcRenderer.invoke('auth:hello', message)
|
||||||
@@ -48,7 +61,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
// 日志
|
// 日志
|
||||||
log: {
|
log: {
|
||||||
getPath: () => ipcRenderer.invoke('log:getPath'),
|
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
|
lastMsgType: number
|
||||||
displayName?: string
|
displayName?: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
|
lastMsgSender?: string
|
||||||
|
lastSenderDisplayName?: string
|
||||||
|
selfWxid?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
@@ -287,6 +290,7 @@ class ChatService {
|
|||||||
// 转换为 ChatSession(先加载缓存,但不等待数据库查询)
|
// 转换为 ChatSession(先加载缓存,但不等待数据库查询)
|
||||||
const sessions: ChatSession[] = []
|
const sessions: ChatSession[] = []
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
|
const myWxid = this.configService.get('myWxid')
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const username =
|
const username =
|
||||||
@@ -340,7 +344,10 @@ class ChatService {
|
|||||||
lastTimestamp: lastTs,
|
lastTimestamp: lastTs,
|
||||||
lastMsgType,
|
lastMsgType,
|
||||||
displayName,
|
displayName,
|
||||||
avatarUrl
|
avatarUrl,
|
||||||
|
lastMsgSender: row.last_msg_sender, // 数据库返回字段
|
||||||
|
lastSenderDisplayName: row.last_sender_display_name, // 数据库返回字段
|
||||||
|
selfWxid: myWxid
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,12 +36,30 @@ interface ConfigSchema {
|
|||||||
|
|
||||||
// 更新相关
|
// 更新相关
|
||||||
ignoredUpdateVersion: string
|
ignoredUpdateVersion: string
|
||||||
|
|
||||||
|
// 通知
|
||||||
|
notificationEnabled: boolean
|
||||||
|
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
|
||||||
|
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||||
|
notificationFilterList: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ConfigService {
|
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() {
|
constructor() {
|
||||||
|
if (ConfigService.instance) {
|
||||||
|
return ConfigService.instance
|
||||||
|
}
|
||||||
|
ConfigService.instance = this
|
||||||
this.store = new Store<ConfigSchema>({
|
this.store = new Store<ConfigSchema>({
|
||||||
name: 'WeFlow-config',
|
name: 'WeFlow-config',
|
||||||
defaults: {
|
defaults: {
|
||||||
@@ -72,7 +90,11 @@ export class ConfigService {
|
|||||||
authPassword: '',
|
authPassword: '',
|
||||||
authUseHello: false,
|
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.
26
src/App.tsx
26
src/App.tsx
@@ -21,6 +21,7 @@ import ImageWindow from './pages/ImageWindow'
|
|||||||
import SnsPage from './pages/SnsPage'
|
import SnsPage from './pages/SnsPage'
|
||||||
import ContactsPage from './pages/ContactsPage'
|
import ContactsPage from './pages/ContactsPage'
|
||||||
import ChatHistoryPage from './pages/ChatHistoryPage'
|
import ChatHistoryPage from './pages/ChatHistoryPage'
|
||||||
|
import NotificationWindow from './pages/NotificationWindow'
|
||||||
|
|
||||||
import { useAppStore } from './stores/appStore'
|
import { useAppStore } from './stores/appStore'
|
||||||
import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
|
import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
|
||||||
@@ -31,10 +32,12 @@ import './App.scss'
|
|||||||
import UpdateDialog from './components/UpdateDialog'
|
import UpdateDialog from './components/UpdateDialog'
|
||||||
import UpdateProgressCapsule from './components/UpdateProgressCapsule'
|
import UpdateProgressCapsule from './components/UpdateProgressCapsule'
|
||||||
import LockScreen from './components/LockScreen'
|
import LockScreen from './components/LockScreen'
|
||||||
|
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
setDbConnected,
|
setDbConnected,
|
||||||
updateInfo,
|
updateInfo,
|
||||||
@@ -55,6 +58,7 @@ function App() {
|
|||||||
const isOnboardingWindow = location.pathname === '/onboarding-window'
|
const isOnboardingWindow = location.pathname === '/onboarding-window'
|
||||||
const isVideoPlayerWindow = location.pathname === '/video-player-window'
|
const isVideoPlayerWindow = location.pathname === '/video-player-window'
|
||||||
const isChatHistoryWindow = location.pathname.startsWith('/chat-history/')
|
const isChatHistoryWindow = location.pathname.startsWith('/chat-history/')
|
||||||
|
const isNotificationWindow = location.pathname === '/notification-window'
|
||||||
const [themeHydrated, setThemeHydrated] = useState(false)
|
const [themeHydrated, setThemeHydrated] = useState(false)
|
||||||
|
|
||||||
// 锁定状态
|
// 锁定状态
|
||||||
@@ -74,7 +78,7 @@ function App() {
|
|||||||
const body = document.body
|
const body = document.body
|
||||||
const appRoot = document.getElementById('app')
|
const appRoot = document.getElementById('app')
|
||||||
|
|
||||||
if (isOnboardingWindow) {
|
if (isOnboardingWindow || isNotificationWindow) {
|
||||||
root.style.background = 'transparent'
|
root.style.background = 'transparent'
|
||||||
body.style.background = 'transparent'
|
body.style.background = 'transparent'
|
||||||
body.style.overflow = 'hidden'
|
body.style.overflow = 'hidden'
|
||||||
@@ -100,10 +104,10 @@ function App() {
|
|||||||
|
|
||||||
// 更新窗口控件颜色以适配主题
|
// 更新窗口控件颜色以适配主题
|
||||||
const symbolColor = themeMode === 'dark' ? '#ffffff' : '#1a1a1a'
|
const symbolColor = themeMode === 'dark' ? '#ffffff' : '#1a1a1a'
|
||||||
if (!isOnboardingWindow) {
|
if (!isOnboardingWindow && !isNotificationWindow) {
|
||||||
window.electronAPI.window.setTitleBarOverlay({ symbolColor })
|
window.electronAPI.window.setTitleBarOverlay({ symbolColor })
|
||||||
}
|
}
|
||||||
}, [currentTheme, themeMode, isOnboardingWindow])
|
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow])
|
||||||
|
|
||||||
// 读取已保存的主题设置
|
// 读取已保存的主题设置
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -173,21 +177,23 @@ function App() {
|
|||||||
|
|
||||||
// 监听启动时的更新通知
|
// 监听启动时的更新通知
|
||||||
useEffect(() => {
|
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) {
|
if (info) {
|
||||||
setUpdateInfo({ ...info, hasUpdate: true })
|
setUpdateInfo({ ...info, hasUpdate: true })
|
||||||
setShowUpdateDialog(true)
|
setShowUpdateDialog(true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const removeProgressListener = window.electronAPI.app.onDownloadProgress?.((progress: any) => {
|
const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => {
|
||||||
setDownloadProgress(progress)
|
setDownloadProgress(progress)
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
removeUpdateListener?.()
|
removeUpdateListener?.()
|
||||||
removeProgressListener?.()
|
removeProgressListener?.()
|
||||||
}
|
}
|
||||||
}, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog])
|
}, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow])
|
||||||
|
|
||||||
const handleUpdateNow = async () => {
|
const handleUpdateNow = async () => {
|
||||||
setShowUpdateDialog(false)
|
setShowUpdateDialog(false)
|
||||||
@@ -330,6 +336,11 @@ function App() {
|
|||||||
return <ChatHistoryPage />
|
return <ChatHistoryPage />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 独立通知窗口
|
||||||
|
if (isNotificationWindow) {
|
||||||
|
return <NotificationWindow />
|
||||||
|
}
|
||||||
|
|
||||||
// 主窗口 - 完整布局
|
// 主窗口 - 完整布局
|
||||||
return (
|
return (
|
||||||
<div className="app-container">
|
<div className="app-container">
|
||||||
@@ -345,6 +356,9 @@ function App() {
|
|||||||
{/* 全局悬浮进度胶囊 (处理:新版本提示、下载进度、错误提示) */}
|
{/* 全局悬浮进度胶囊 (处理:新版本提示、下载进度、错误提示) */}
|
||||||
<UpdateProgressCapsule />
|
<UpdateProgressCapsule />
|
||||||
|
|
||||||
|
{/* 全局会话监听与通知 */}
|
||||||
|
<GlobalSessionMonitor />
|
||||||
|
|
||||||
{/* 用户协议弹窗 */}
|
{/* 用户协议弹窗 */}
|
||||||
{showAgreement && !agreementLoading && (
|
{showAgreement && !agreementLoading && (
|
||||||
<div className="agreement-overlay">
|
<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 && (
|
{midnightKing && (
|
||||||
<section className="section" ref={sectionRefs.midnightKing}>
|
<section className="section" ref={sectionRefs.midnightKing}>
|
||||||
<div className="label-text">深夜好友</div>
|
<div className="label-text">深夜好友</div>
|
||||||
<h2 className="hero-title">当城市睡去</h2>
|
<h2 className="hero-title">月光下的你</h2>
|
||||||
<p className="hero-desc">这一年你留下了</p>
|
<p className="hero-desc">在这一年你留下了</p>
|
||||||
<div className="big-stat">
|
<div className="big-stat">
|
||||||
<span className="stat-num">{midnightKing.count}</span>
|
<span className="stat-num">{midnightKing.count}</span>
|
||||||
<span className="stat-unit">条深夜的消息</span>
|
<span className="stat-unit">条深夜的消息</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="hero-desc">
|
<p className="hero-desc">
|
||||||
其中 <span className="hl">{midnightKing.displayName}</span> 常常在深夜中陪着你。
|
其中 <span className="hl">{midnightKing.displayName}</span> 常常在深夜中陪着你胡思乱想。
|
||||||
<br />你和Ta的对话占你深夜期间聊天的 <span className="gold">{midnightKing.percentage}%</span>。
|
<br />你和Ta的对话占你深夜期间聊天的 <span className="gold">{midnightKing.percentage}%</span>。
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -306,18 +306,7 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
const nextSessions = options?.silent ? mergeSessions(sessionsArray) : sessionsArray
|
const nextSessions = options?.silent ? mergeSessions(sessionsArray) : sessionsArray
|
||||||
// 确保 nextSessions 也是数组
|
// 确保 nextSessions 也是数组
|
||||||
if (Array.isArray(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)
|
setSessions(nextSessions)
|
||||||
sessionsRef.current = 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) => {
|
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;
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input:not(.filter-search-box input) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -207,6 +207,7 @@
|
|||||||
select {
|
select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
|
padding-right: 36px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -214,6 +215,9 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
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 {
|
.select-field {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
@@ -1265,3 +1387,172 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
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,
|
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
||||||
RotateCcw, Trash2, Plug, Check, Sun, Moon,
|
RotateCcw, Trash2, Plug, Check, Sun, Moon,
|
||||||
Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic,
|
Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic,
|
||||||
ShieldCheck, Fingerprint, Lock, KeyRound
|
ShieldCheck, Fingerprint, Lock, KeyRound, Bell
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { Avatar } from '../components/Avatar'
|
||||||
import './SettingsPage.scss'
|
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 }[] = [
|
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
||||||
{ id: 'appearance', label: '外观', icon: Palette },
|
{ id: 'appearance', label: '外观', icon: Palette },
|
||||||
|
{ id: 'notification', label: '通知', icon: Bell },
|
||||||
{ id: 'database', label: '数据库连接', icon: Database },
|
{ id: 'database', label: '数据库连接', icon: Database },
|
||||||
{ id: 'whisper', label: '语音识别模型', icon: Mic },
|
{ id: 'whisper', label: '语音识别模型', icon: Mic },
|
||||||
{ id: 'export', label: '导出', icon: Download },
|
{ id: 'export', label: '导出', icon: Download },
|
||||||
@@ -25,6 +27,7 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
|||||||
{ id: 'about', label: '关于', icon: Info }
|
{ id: 'about', label: '关于', icon: Info }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
interface WxidOption {
|
interface WxidOption {
|
||||||
wxid: string
|
wxid: string
|
||||||
modifiedTime: number
|
modifiedTime: number
|
||||||
@@ -83,6 +86,18 @@ function SettingsPage() {
|
|||||||
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
||||||
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
|
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 [isLoading, setIsLoadingState] = useState(false)
|
||||||
const [isTesting, setIsTesting] = useState(false)
|
const [isTesting, setIsTesting] = useState(false)
|
||||||
const [isDetectingPath, setIsDetectingPath] = 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 () => {
|
const loadConfig = async () => {
|
||||||
try {
|
try {
|
||||||
const savedKey = await configService.getDecryptKey()
|
const savedKey = await configService.getDecryptKey()
|
||||||
@@ -188,6 +221,11 @@ function SettingsPage() {
|
|||||||
const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns()
|
const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns()
|
||||||
const savedExportDefaultConcurrency = await configService.getExportDefaultConcurrency()
|
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 savedAuthEnabled = await configService.getAuthEnabled()
|
||||||
const savedAuthUseHello = await configService.getAuthUseHello()
|
const savedAuthUseHello = await configService.getAuthUseHello()
|
||||||
setAuthEnabled(savedAuthEnabled)
|
setAuthEnabled(savedAuthEnabled)
|
||||||
@@ -221,6 +259,11 @@ function SettingsPage() {
|
|||||||
setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true)
|
setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true)
|
||||||
setExportDefaultConcurrency(savedExportDefaultConcurrency ?? 2)
|
setExportDefaultConcurrency(savedExportDefaultConcurrency ?? 2)
|
||||||
|
|
||||||
|
setNotificationEnabled(savedNotificationEnabled)
|
||||||
|
setNotificationPosition(savedNotificationPosition)
|
||||||
|
setNotificationFilterMode(savedNotificationFilterMode)
|
||||||
|
setNotificationFilterList(savedNotificationFilterList)
|
||||||
|
|
||||||
// 如果语言列表为空,保存默认值
|
// 如果语言列表为空,保存默认值
|
||||||
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
|
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
|
||||||
const defaultLanguages = ['zh']
|
const defaultLanguages = ['zh']
|
||||||
@@ -842,6 +885,245 @@ function SettingsPage() {
|
|||||||
</div>
|
</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 = () => (
|
const renderDatabaseTab = () => (
|
||||||
<div className="tab-content">
|
<div className="tab-content">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -1674,6 +1956,7 @@ function SettingsPage() {
|
|||||||
|
|
||||||
<div className="settings-body">
|
<div className="settings-body">
|
||||||
{activeTab === 'appearance' && renderAppearanceTab()}
|
{activeTab === 'appearance' && renderAppearanceTab()}
|
||||||
|
{activeTab === 'notification' && renderNotificationTab()}
|
||||||
{activeTab === 'database' && renderDatabaseTab()}
|
{activeTab === 'database' && renderDatabaseTab()}
|
||||||
{activeTab === 'whisper' && renderWhisperTab()}
|
{activeTab === 'whisper' && renderWhisperTab()}
|
||||||
{activeTab === 'export' && renderExportTab()}
|
{activeTab === 'export' && renderExportTab()}
|
||||||
|
|||||||
@@ -38,7 +38,13 @@ export const CONFIG_KEYS = {
|
|||||||
AUTH_USE_HELLO: 'authUseHello',
|
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
|
} as const
|
||||||
|
|
||||||
export interface WxidConfig {
|
export interface WxidConfig {
|
||||||
@@ -416,3 +422,46 @@ export async function setIgnoredUpdateVersion(version: string): Promise<void> {
|
|||||||
await config.set(CONFIG_KEYS.IGNORED_UPDATE_VERSION, version)
|
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
|
lastMsgType: number
|
||||||
displayName?: string
|
displayName?: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
|
lastMsgSender?: string
|
||||||
|
lastSenderDisplayName?: string
|
||||||
|
selfWxid?: string // Helper field to avoid extra API calls
|
||||||
}
|
}
|
||||||
|
|
||||||
// 联系人
|
// 联系人
|
||||||
|
|||||||
Reference in New Issue
Block a user