Compare commits

...

11 Commits

Author SHA1 Message Date
xuncha
c9216aabad 视频解密优化 2026-02-02 22:59:30 +08:00
xuncha
79d6aef480 同步了密语的头像处理 2026-02-02 22:59:30 +08:00
xuncha
8134d62056 增加对xml的处理 2026-02-02 22:59:30 +08:00
cc
8664ebf6f5 feat: 宇宙超级无敌牛且帅气到爆炸的功能更新和优化 2026-02-02 22:59:30 +08:00
xuncha
7b832ac2ef 给密语的图片查看器搬过来了 2026-02-02 22:59:30 +08:00
xuncha
5934fc33ce 从密语同步了一下图片解密 2026-02-02 22:59:30 +08:00
cc
b6d10f79de feat: 超级无敌帅气的更新和修复 2026-02-02 22:59:30 +08:00
cc
f90822694f feat: 一些非常帅气的优化 2026-02-02 22:59:30 +08:00
cc
123a088a39 feat: 支持忽略更新 2026-02-02 22:59:30 +08:00
xuncha
cb37f534ac Merge pull request #163 from xunchahaha:main
Main
2026-02-01 17:04:06 +08:00
xuncha
50903b35cf 11 2026-02-01 17:03:47 +08:00
46 changed files with 3239 additions and 326 deletions

View File

@@ -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,6 +375,64 @@ function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHe
hash: `/video-player-window?${videoParam}` hash: `/video-player-window?${videoParam}`
}) })
} }
}
/**
* 创建独立的图片查看窗口
*/
function createImageViewerWindow(imagePath: string) {
const isDev = !!process.env.VITE_DEV_SERVER_URL
const iconPath = isDev
? join(__dirname, '../public/icon.ico')
: join(process.resourcesPath, 'icon.ico')
const win = new BrowserWindow({
width: 900,
height: 700,
minWidth: 400,
minHeight: 300,
icon: iconPath,
webPreferences: {
preload: join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
webSecurity: false // 允许加载本地文件
},
titleBarStyle: 'hidden',
titleBarOverlay: {
color: '#00000000',
symbolColor: '#ffffff',
height: 40
},
show: false,
backgroundColor: '#000000',
autoHideMenuBar: true
})
win.once('ready-to-show', () => {
win.show()
})
const imageParam = `imagePath=${encodeURIComponent(imagePath)}`
if (process.env.VITE_DEV_SERVER_URL) {
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/image-viewer-window?${imageParam}`)
win.webContents.on('before-input-event', (event, input) => {
if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
if (win.webContents.isDevToolsOpened()) {
win.webContents.closeDevTools()
} else {
win.webContents.openDevTools()
}
event.preventDefault()
}
})
} else {
win.loadFile(join(__dirname, '../dist/index.html'), {
hash: `/image-viewer-window?${imageParam}`
})
}
return win return win
} }
@@ -439,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)
@@ -552,6 +620,11 @@ function registerIpcHandlers() {
} }
}) })
ipcMain.handle('app:ignoreUpdate', async (_, version: string) => {
configService?.set('ignoredUpdateVersion', version)
return { success: true }
})
// 窗口控制 // 窗口控制
ipcMain.on('window:minimize', (event) => { ipcMain.on('window:minimize', (event) => {
BrowserWindow.fromWebContents(event.sender)?.minimize() BrowserWindow.fromWebContents(event.sender)?.minimize()
@@ -719,6 +792,10 @@ function registerIpcHandlers() {
return chatService.getLatestMessages(sessionId, limit) return chatService.getLatestMessages(sessionId, limit)
}) })
ipcMain.handle('chat:getNewMessages', async (_, sessionId: string, minTime: number, limit?: number) => {
return chatService.getNewMessages(sessionId, minTime, limit)
})
ipcMain.handle('chat:getContact', async (_, username: string) => { ipcMain.handle('chat:getContact', async (_, username: string) => {
return await chatService.getContact(username) return await chatService.getContact(username)
}) })
@@ -932,6 +1009,11 @@ function registerIpcHandlers() {
return true return true
}) })
// 打开图片查看窗口
ipcMain.handle('window:openImageViewerWindow', (_, imagePath: string) => {
createImageViewerWindow(imagePath)
})
// 完成引导,关闭引导窗口并显示主窗口 // 完成引导,关闭引导窗口并显示主窗口
ipcMain.handle('window:completeOnboarding', async () => { ipcMain.handle('window:completeOnboarding', async () => {
try { try {
@@ -1159,7 +1241,16 @@ function checkForUpdatesOnStartup() {
if (result && result.updateInfo) { if (result && result.updateInfo) {
const currentVersion = app.getVersion() const currentVersion = app.getVersion()
const latestVersion = result.updateInfo.version const latestVersion = result.updateInfo.version
// 检查是否有新版本
if (latestVersion !== currentVersion && mainWindow) { if (latestVersion !== currentVersion && mainWindow) {
// 检查该版本是否被用户忽略
const ignoredVersion = configService?.get('ignoredUpdateVersion')
if (ignoredVersion === latestVersion) {
return
}
// 通知渲染进程有新版本 // 通知渲染进程有新版本
mainWindow.webContents.send('app:updateAvailable', { mainWindow.webContents.send('app:updateAvailable', {
version: latestVersion, version: latestVersion,

View File

@@ -29,7 +29,7 @@ function enforceLocalDllPriority() {
process.env.PATH = dllPaths process.env.PATH = dllPaths
} }
console.log('[WeFlow] Environment PATH updated to enforce local DLL priority:', dllPaths)
} }
try { try {

View File

@@ -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)
@@ -34,6 +47,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
getVersion: () => ipcRenderer.invoke('app:getVersion'), getVersion: () => ipcRenderer.invoke('app:getVersion'),
checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'), checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'),
downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'), downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'),
ignoreUpdate: (version: string) => ipcRenderer.invoke('app:ignoreUpdate', version),
onDownloadProgress: (callback: (progress: any) => void) => { onDownloadProgress: (callback: (progress: any) => void) => {
ipcRenderer.on('app:downloadProgress', (_, progress) => callback(progress)) ipcRenderer.on('app:downloadProgress', (_, progress) => callback(progress))
return () => ipcRenderer.removeAllListeners('app:downloadProgress') return () => ipcRenderer.removeAllListeners('app:downloadProgress')
@@ -47,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)
}, },
// 窗口控制 // 窗口控制
@@ -63,6 +78,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight), ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
resizeToFitVideo: (videoWidth: number, videoHeight: number) => resizeToFitVideo: (videoWidth: number, videoHeight: number) =>
ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight), ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight),
openImageViewerWindow: (imagePath: string) =>
ipcRenderer.invoke('window:openImageViewerWindow', imagePath),
openChatHistoryWindow: (sessionId: string, messageId: number) => openChatHistoryWindow: (sessionId: string, messageId: number) =>
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId) ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId)
}, },
@@ -110,6 +127,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending), ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending),
getLatestMessages: (sessionId: string, limit?: number) => getLatestMessages: (sessionId: string, limit?: number) =>
ipcRenderer.invoke('chat:getLatestMessages', sessionId, limit), ipcRenderer.invoke('chat:getLatestMessages', sessionId, limit),
getNewMessages: (sessionId: string, minTime: number, limit?: number) =>
ipcRenderer.invoke('chat:getNewMessages', sessionId, minTime, limit),
getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username), getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username),
getContactAvatar: (username: string) => ipcRenderer.invoke('chat:getContactAvatar', username), getContactAvatar: (username: string) => ipcRenderer.invoke('chat:getContactAvatar', username),
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'), getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
@@ -131,7 +150,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('chat:execQuery', kind, path, sql), ipcRenderer.invoke('chat:execQuery', kind, path, sql),
getContacts: () => ipcRenderer.invoke('chat:getContacts'), getContacts: () => ipcRenderer.invoke('chat:getContacts'),
getMessage: (sessionId: string, localId: number) => getMessage: (sessionId: string, localId: number) =>
ipcRenderer.invoke('chat:getMessage', sessionId, localId) ipcRenderer.invoke('chat:getMessage', sessionId, localId),
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => {
ipcRenderer.on('wcdb-change', callback)
return () => ipcRenderer.removeListener('wcdb-change', callback)
}
}, },

View File

@@ -107,7 +107,11 @@ class AnalyticsService {
if (match) return match[1] if (match) return match[1]
return trimmed return trimmed
} }
return trimmed
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
return cleaned
} }
private isPrivateSession(username: string, cleanedWxid: string): boolean { private isPrivateSession(username: string, cleanedWxid: string): boolean {
@@ -245,6 +249,9 @@ class AnalyticsService {
} }
private async computeAggregateByCursor(sessionIds: string[], beginTimestamp = 0, endTimestamp = 0): Promise<any> { private async computeAggregateByCursor(sessionIds: string[], beginTimestamp = 0, endTimestamp = 0): Promise<any> {
const wxid = this.configService.get('myWxid')
const cleanedWxid = wxid ? this.cleanAccountDirName(wxid) : ''
const aggregate = { const aggregate = {
total: 0, total: 0,
sent: 0, sent: 0,
@@ -269,8 +276,22 @@ class AnalyticsService {
if (endTimestamp > 0 && createTime > endTimestamp) return if (endTimestamp > 0 && createTime > endTimestamp) return
const localType = parseInt(row.local_type || row.type || '1', 10) const localType = parseInt(row.local_type || row.type || '1', 10)
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? 0 const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend
const isSend = String(isSendRaw) === '1' || isSendRaw === 1 || isSendRaw === true let isSend = String(isSendRaw) === '1' || isSendRaw === 1 || isSendRaw === true
// 如果底层没有提供 is_send则根据发送者用户名推断
const senderUsername = row.sender_username || row.senderUsername || row.sender
if (isSendRaw === undefined || isSendRaw === null) {
if (senderUsername && (cleanedWxid)) {
const senderLower = String(senderUsername).toLowerCase()
const myWxidLower = cleanedWxid.toLowerCase()
isSend = (
senderLower === myWxidLower ||
// 兼容非 wxid 开头的账号(如果文件夹名带后缀,如 custom_backup而 sender 是 custom
(myWxidLower.startsWith(senderLower + '_'))
)
}
}
aggregate.total += 1 aggregate.total += 1
sessionStat.total += 1 sessionStat.total += 1

View File

@@ -115,8 +115,9 @@ class AnnualReportService {
return trimmed return trimmed
} }
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
if (suffixMatch) return suffixMatch[1] const cleaned = suffixMatch ? suffixMatch[1] : trimmed
return trimmed
return cleaned
} }
private async ensureConnectedWithConfig( private async ensureConnectedWithConfig(
@@ -596,9 +597,22 @@ class AnnualReportService {
if (!createTime) continue if (!createTime) continue
const isSendRaw = row.computed_is_send ?? row.is_send ?? '0' const isSendRaw = row.computed_is_send ?? row.is_send ?? '0'
const isSent = parseInt(isSendRaw, 10) === 1 let isSent = parseInt(isSendRaw, 10) === 1
const localType = parseInt(row.local_type || row.type || '1', 10) const localType = parseInt(row.local_type || row.type || '1', 10)
// 兼容逻辑
if (isSendRaw === undefined || isSendRaw === null || isSendRaw === '0') {
const sender = String(row.sender_username || row.sender || row.talker || '').toLowerCase()
if (sender) {
const rawLower = rawWxid.toLowerCase()
const cleanedLower = cleanedWxid.toLowerCase()
if (sender === rawLower || sender === cleanedLower ||
rawLower.startsWith(sender + '_') || cleanedLower.startsWith(sender + '_')) {
isSent = true
}
}
}
// 响应速度 & 对话发起 // 响应速度 & 对话发起
if (!conversationStarts.has(sessionId)) { if (!conversationStarts.has(sessionId)) {
conversationStarts.set(sessionId, { initiated: 0, received: 0 }) conversationStarts.set(sessionId, { initiated: 0, received: 0 })

View File

@@ -1,5 +1,5 @@
import { join, dirname, basename, extname } from 'path' import { join, dirname, basename, extname } from 'path'
import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync } from 'fs' import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, watch } from 'fs'
import * as path from 'path' import * as path from 'path'
import * as fs from 'fs' import * as fs from 'fs'
import * as https from 'https' import * as https from 'https'
@@ -7,7 +7,7 @@ import * as http from 'http'
import * as fzstd from 'fzstd' import * as fzstd from 'fzstd'
import * as crypto from 'crypto' import * as crypto from 'crypto'
import Database from 'better-sqlite3' import Database from 'better-sqlite3'
import { app } from 'electron' import { app, BrowserWindow } from 'electron'
import { ConfigService } from './config' import { ConfigService } from './config'
import { wcdbService } from './wcdbService' import { wcdbService } from './wcdbService'
import { MessageCacheService } from './messageCacheService' import { MessageCacheService } from './messageCacheService'
@@ -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 {
@@ -152,9 +155,9 @@ class ChatService {
} }
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
if (suffixMatch) return suffixMatch[1] const cleaned = suffixMatch ? suffixMatch[1] : trimmed
return trimmed return cleaned
} }
/** /**
@@ -186,6 +189,9 @@ class ChatService {
this.connected = true this.connected = true
// 设置数据库监控
this.setupDbMonitor()
// 预热 listMediaDbs 缓存(后台异步执行,不阻塞连接) // 预热 listMediaDbs 缓存(后台异步执行,不阻塞连接)
this.warmupMediaDbsCache() this.warmupMediaDbsCache()
@@ -196,6 +202,24 @@ class ChatService {
} }
} }
private monitorSetup = false
private setupDbMonitor() {
if (this.monitorSetup) return
this.monitorSetup = true
// 使用 C++ DLL 内部的文件监控 (ReadDirectoryChangesW)
// 这种方式更高效,且不占用 JS 线程,并能直接监听 session/message 目录变更
wcdbService.setMonitor((type, json) => {
// 广播给所有渲染进程窗口
BrowserWindow.getAllWindows().forEach((win) => {
if (!win.isDestroyed()) {
win.webContents.send('wcdb-change', { type, json })
}
})
})
}
/** /**
* 预热 media 数据库列表缓存(后台异步执行) * 预热 media 数据库列表缓存(后台异步执行)
*/ */
@@ -266,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 =
@@ -319,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
}) })
} }
@@ -543,7 +571,7 @@ class ChatService {
FROM contact FROM contact
` `
console.log('查询contact.db...')
const contactResult = await wcdbService.execQuery('contact', null, contactQuery) const contactResult = await wcdbService.execQuery('contact', null, contactQuery)
if (!contactResult.success || !contactResult.rows) { if (!contactResult.success || !contactResult.rows) {
@@ -551,13 +579,13 @@ class ChatService {
return { success: false, error: contactResult.error || '查询联系人失败' } return { success: false, error: contactResult.error || '查询联系人失败' }
} }
console.log('查询到', contactResult.rows.length, '条联系人记录')
const rows = contactResult.rows as Record<string, any>[] const rows = contactResult.rows as Record<string, any>[]
// 调试显示前5条数据样本 // 调试显示前5条数据样本
console.log('📋 前5条数据样本:')
rows.slice(0, 5).forEach((row, idx) => { rows.slice(0, 5).forEach((row, idx) => {
console.log(` ${idx + 1}. username: ${row.username}, local_type: ${row.local_type}, remark: ${row.remark || '无'}, nick_name: ${row.nick_name || '无'}`)
}) })
// 调试统计local_type分布 // 调试统计local_type分布
@@ -566,7 +594,7 @@ class ChatService {
const lt = row.local_type || 0 const lt = row.local_type || 0
localTypeStats.set(lt, (localTypeStats.get(lt) || 0) + 1) localTypeStats.set(lt, (localTypeStats.get(lt) || 0) + 1)
}) })
console.log('📊 local_type分布:', Object.fromEntries(localTypeStats))
// 获取会话表的最后联系时间用于排序 // 获取会话表的最后联系时间用于排序
const lastContactTimeMap = new Map<string, number>() const lastContactTimeMap = new Map<string, number>()
@@ -642,13 +670,8 @@ class ChatService {
}) })
} }
console.log('过滤后得到', contacts.length, '个有效联系人')
console.log('📊 按类型统计:', {
friends: contacts.filter(c => c.type === 'friend').length,
groups: contacts.filter(c => c.type === 'group').length,
officials: contacts.filter(c => c.type === 'official').length,
other: contacts.filter(c => c.type === 'other').length
})
// 按最近联系时间排序 // 按最近联系时间排序
contacts.sort((a, b) => { contacts.sort((a, b) => {
@@ -665,7 +688,7 @@ class ChatService {
// 移除临时的lastContactTime字段 // 移除临时的lastContactTime字段
const result = contacts.map(({ lastContactTime, ...rest }) => rest) const result = contacts.map(({ lastContactTime, ...rest }) => rest)
console.log('返回', result.length, '个联系人')
return { success: true, contacts: result } return { success: true, contacts: result }
} catch (e) { } catch (e) {
console.error('ChatService: 获取通讯录失败:', e) console.error('ChatService: 获取通讯录失败:', e)
@@ -731,7 +754,7 @@ class ChatService {
// 如果需要跳过消息(offset > 0),逐批获取但不返回 // 如果需要跳过消息(offset > 0),逐批获取但不返回
if (offset > 0) { if (offset > 0) {
console.log(`[ChatService] 跳过消息: offset=${offset}`)
let skipped = 0 let skipped = 0
while (skipped < offset) { while (skipped < offset) {
const skipBatch = await wcdbService.fetchMessageBatch(state.cursor) const skipBatch = await wcdbService.fetchMessageBatch(state.cursor)
@@ -740,17 +763,17 @@ class ChatService {
return { success: false, error: skipBatch.error || '跳过消息失败' } return { success: false, error: skipBatch.error || '跳过消息失败' }
} }
if (!skipBatch.rows || skipBatch.rows.length === 0) { if (!skipBatch.rows || skipBatch.rows.length === 0) {
console.log('[ChatService] 跳过时没有更多消息')
return { success: true, messages: [], hasMore: false } return { success: true, messages: [], hasMore: false }
} }
skipped += skipBatch.rows.length skipped += skipBatch.rows.length
state.fetched += skipBatch.rows.length state.fetched += skipBatch.rows.length
if (!skipBatch.hasMore) { if (!skipBatch.hasMore) {
console.log('[ChatService] 跳过时已到达末尾')
return { success: true, messages: [], hasMore: false } return { success: true, messages: [], hasMore: false }
} }
} }
console.log(`[ChatService] 跳过完成: skipped=${skipped}, fetched=${state.fetched}`)
} }
} else if (state && offset !== state.fetched) { } else if (state && offset !== state.fetched) {
// offset 与 fetched 不匹配,说明状态不一致 // offset 与 fetched 不匹配,说明状态不一致
@@ -913,6 +936,40 @@ class ChatService {
} }
} }
async getNewMessages(sessionId: string, minTime: number, limit: number = this.messageBatchDefault): Promise<{ success: boolean; messages?: Message[]; error?: string }> {
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) {
return { success: false, error: connectResult.error || '数据库未连接' }
}
const res = await wcdbService.getNewMessages(sessionId, minTime, limit)
if (!res.success || !res.messages) {
return { success: false, error: res.error || '获取新消息失败' }
}
// 转换为 Message 对象
const messages = this.mapRowsToMessages(res.messages as Record<string, any>[])
const normalized = this.normalizeMessageOrder(messages)
// 并发检查并修复缺失 CDN URL 的表情包
const fixPromises: Promise<void>[] = []
for (const msg of normalized) {
if (msg.localType === 47 && !msg.emojiCdnUrl && msg.emojiMd5) {
fixPromises.push(this.fallbackEmoticon(msg))
}
}
if (fixPromises.length > 0) {
await Promise.allSettled(fixPromises)
}
return { success: true, messages: normalized }
} catch (e) {
console.error('ChatService: 获取增量消息失败:', e)
return { success: false, error: String(e) }
}
}
private normalizeMessageOrder(messages: Message[]): Message[] { private normalizeMessageOrder(messages: Message[]): Message[] {
if (messages.length < 2) return messages if (messages.length < 2) return messages
const first = messages[0] const first = messages[0]
@@ -1019,13 +1076,19 @@ class ChatService {
if (senderUsername && (myWxidLower || cleanedWxidLower)) { if (senderUsername && (myWxidLower || cleanedWxidLower)) {
const senderLower = String(senderUsername).toLowerCase() const senderLower = String(senderUsername).toLowerCase()
const expectedIsSend = (senderLower === myWxidLower || senderLower === cleanedWxidLower) ? 1 : 0 const expectedIsSend = (
senderLower === myWxidLower ||
senderLower === cleanedWxidLower ||
// 兼容非 wxid 开头的账号(如果文件夹名带后缀,如 custom_backup而 sender 是 custom
(myWxidLower && myWxidLower.startsWith(senderLower + '_')) ||
(cleanedWxidLower && cleanedWxidLower.startsWith(senderLower + '_'))
) ? 1 : 0
if (isSend === null) { if (isSend === null) {
isSend = expectedIsSend isSend = expectedIsSend
// [DEBUG] Issue #34: 记录 isSend 推断过程 // [DEBUG] Issue #34: 记录 isSend 推断过程
if (expectedIsSend === 0 && localType === 1) { if (expectedIsSend === 0 && localType === 1) {
// 仅在被判为接收且是文本消息时记录,避免刷屏 // 仅在被判为接收且是文本消息时记录,避免刷屏
// console.log(`[ChatService] inferred isSend=0: sender=${senderUsername}, myWxid=${myWxid} (cleaned=${cleanedWxid})`) //
} }
} }
} else if (senderUsername && !myWxid) { } else if (senderUsername && !myWxid) {
@@ -1610,7 +1673,7 @@ class ChatService {
// 提取文件大小 // 提取文件大小
const fileSizeStr = this.extractXmlValue(content, 'totallen') || const fileSizeStr = this.extractXmlValue(content, 'totallen') ||
this.extractXmlValue(content, 'filesize') this.extractXmlValue(content, 'filesize')
if (fileSizeStr) { if (fileSizeStr) {
const size = parseInt(fileSizeStr, 10) const size = parseInt(fileSizeStr, 10)
if (!isNaN(size)) { if (!isNaN(size)) {
@@ -1683,7 +1746,7 @@ class ChatService {
// 提取缩略图 // 提取缩略图
const thumbUrl = this.extractXmlValue(content, 'thumburl') || const thumbUrl = this.extractXmlValue(content, 'thumburl') ||
this.extractXmlValue(content, 'cdnthumburl') this.extractXmlValue(content, 'cdnthumburl')
if (thumbUrl) { if (thumbUrl) {
result.linkThumb = thumbUrl result.linkThumb = thumbUrl
} }
@@ -1712,7 +1775,7 @@ class ChatService {
result.linkUrl = url result.linkUrl = url
const thumbUrl = this.extractXmlValue(content, 'thumburl') || const thumbUrl = this.extractXmlValue(content, 'thumburl') ||
this.extractXmlValue(content, 'cdnthumburl') this.extractXmlValue(content, 'cdnthumburl')
if (thumbUrl) { if (thumbUrl) {
result.linkThumb = thumbUrl result.linkThumb = thumbUrl
} }
@@ -2132,7 +2195,7 @@ class ChatService {
private decodeMaybeCompressed(raw: any, fieldName: string = 'unknown'): string { private decodeMaybeCompressed(raw: any, fieldName: string = 'unknown'): string {
if (!raw) return '' if (!raw) return ''
// console.log(`[ChatService] Decoding ${fieldName}: type=${typeof raw}`, raw) //
// 如果是 Buffer/Uint8Array // 如果是 Buffer/Uint8Array
if (Buffer.isBuffer(raw) || raw instanceof Uint8Array) { if (Buffer.isBuffer(raw) || raw instanceof Uint8Array) {
@@ -2148,7 +2211,7 @@ class ChatService {
const bytes = Buffer.from(raw, 'hex') const bytes = Buffer.from(raw, 'hex')
if (bytes.length > 0) { if (bytes.length > 0) {
const result = this.decodeBinaryContent(bytes, raw) const result = this.decodeBinaryContent(bytes, raw)
// console.log(`[ChatService] HEX decoded result: ${result}`) //
return result return result
} }
} }
@@ -2200,7 +2263,7 @@ class ChatService {
// 如果提供了 fallbackValue且解码结果看起来像二进制垃圾则返回 fallbackValue // 如果提供了 fallbackValue且解码结果看起来像二进制垃圾则返回 fallbackValue
if (fallbackValue && replacementCount > 0) { if (fallbackValue && replacementCount > 0) {
// console.log(`[ChatService] Binary garbage detected, using fallback: ${fallbackValue}`) //
return fallbackValue return fallbackValue
} }
@@ -2794,7 +2857,7 @@ class ChatService {
const t1 = Date.now() const t1 = Date.now()
const msgResult = await this.getMessageByLocalId(sessionId, localId) const msgResult = await this.getMessageByLocalId(sessionId, localId)
const t2 = Date.now() const t2 = Date.now()
console.log(`[Voice] getMessageByLocalId: ${t2 - t1}ms`)
if (msgResult.success && msgResult.message) { if (msgResult.success && msgResult.message) {
const msg = msgResult.message as any const msg = msgResult.message as any
@@ -2813,7 +2876,7 @@ class ChatService {
// 检查 WAV 内存缓存 // 检查 WAV 内存缓存
const wavCache = this.voiceWavCache.get(cacheKey) const wavCache = this.voiceWavCache.get(cacheKey)
if (wavCache) { if (wavCache) {
console.log(`[Voice] 内存缓存命中,总耗时: ${Date.now() - startTime}ms`)
return { success: true, data: wavCache.toString('base64') } return { success: true, data: wavCache.toString('base64') }
} }
@@ -2825,7 +2888,7 @@ class ChatService {
const wavData = readFileSync(wavFilePath) const wavData = readFileSync(wavFilePath)
// 同时缓存到内存 // 同时缓存到内存
this.cacheVoiceWav(cacheKey, wavData) this.cacheVoiceWav(cacheKey, wavData)
console.log(`[Voice] 文件缓存命中,总耗时: ${Date.now() - startTime}ms`)
return { success: true, data: wavData.toString('base64') } return { success: true, data: wavData.toString('base64') }
} catch (e) { } catch (e) {
console.error('[Voice] 读取缓存文件失败:', e) console.error('[Voice] 读取缓存文件失败:', e)
@@ -2855,7 +2918,7 @@ class ChatService {
// 从数据库读取 silk 数据 // 从数据库读取 silk 数据
const silkData = await this.getVoiceDataFromMediaDb(msgCreateTime, candidates) const silkData = await this.getVoiceDataFromMediaDb(msgCreateTime, candidates)
const t4 = Date.now() const t4 = Date.now()
console.log(`[Voice] getVoiceDataFromMediaDb: ${t4 - t3}ms`)
if (!silkData) { if (!silkData) {
return { success: false, error: '未找到语音数据 (请确保已在微信中播放过该语音)' } return { success: false, error: '未找到语音数据 (请确保已在微信中播放过该语音)' }
@@ -2865,7 +2928,7 @@ class ChatService {
// 使用 silk-wasm 解码 // 使用 silk-wasm 解码
const pcmData = await this.decodeSilkToPcm(silkData, 24000) const pcmData = await this.decodeSilkToPcm(silkData, 24000)
const t6 = Date.now() const t6 = Date.now()
console.log(`[Voice] decodeSilkToPcm: ${t6 - t5}ms`)
if (!pcmData) { if (!pcmData) {
return { success: false, error: 'Silk 解码失败' } return { success: false, error: 'Silk 解码失败' }
@@ -2875,7 +2938,7 @@ class ChatService {
// PCM -> WAV // PCM -> WAV
const wavData = this.createWavBuffer(pcmData, 24000) const wavData = this.createWavBuffer(pcmData, 24000)
const t8 = Date.now() const t8 = Date.now()
console.log(`[Voice] createWavBuffer: ${t8 - t7}ms`)
// 缓存 WAV 数据到内存 // 缓存 WAV 数据到内存
this.cacheVoiceWav(cacheKey, wavData) this.cacheVoiceWav(cacheKey, wavData)
@@ -2883,7 +2946,7 @@ class ChatService {
// 缓存 WAV 数据到文件(异步,不阻塞返回) // 缓存 WAV 数据到文件(异步,不阻塞返回)
this.cacheVoiceWavToFile(cacheKey, wavData) this.cacheVoiceWavToFile(cacheKey, wavData)
console.log(`[Voice] 总耗时: ${Date.now() - startTime}ms`)
return { success: true, data: wavData.toString('base64') } return { success: true, data: wavData.toString('base64') }
} catch (e) { } catch (e) {
console.error('ChatService: getVoiceData 失败:', e) console.error('ChatService: getVoiceData 失败:', e)
@@ -2920,11 +2983,11 @@ class ChatService {
let mediaDbFiles: string[] let mediaDbFiles: string[]
if (this.mediaDbsCache) { if (this.mediaDbsCache) {
mediaDbFiles = this.mediaDbsCache mediaDbFiles = this.mediaDbsCache
console.log(`[Voice] listMediaDbs (缓存): 0ms`)
} else { } else {
const mediaDbsResult = await wcdbService.listMediaDbs() const mediaDbsResult = await wcdbService.listMediaDbs()
const t2 = Date.now() const t2 = Date.now()
console.log(`[Voice] listMediaDbs: ${t2 - t1}ms`)
let files = mediaDbsResult.success && mediaDbsResult.data ? (mediaDbsResult.data as string[]) : [] let files = mediaDbsResult.success && mediaDbsResult.data ? (mediaDbsResult.data as string[]) : []
@@ -2956,7 +3019,7 @@ class ChatService {
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'VoiceInfo%'" "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'VoiceInfo%'"
) )
const t4 = Date.now() const t4 = Date.now()
console.log(`[Voice] 查询VoiceInfo表: ${t4 - t3}ms`)
if (!tablesResult.success || !tablesResult.rows || tablesResult.rows.length === 0) { if (!tablesResult.success || !tablesResult.rows || tablesResult.rows.length === 0) {
continue continue
@@ -2969,7 +3032,7 @@ class ChatService {
`PRAGMA table_info('${voiceTable}')` `PRAGMA table_info('${voiceTable}')`
) )
const t6 = Date.now() const t6 = Date.now()
console.log(`[Voice] 查询表结构: ${t6 - t5}ms`)
if (!columnsResult.success || !columnsResult.rows) { if (!columnsResult.success || !columnsResult.rows) {
continue continue
@@ -3006,7 +3069,7 @@ class ChatService {
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Name2Id%'" "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Name2Id%'"
) )
const t8 = Date.now() const t8 = Date.now()
console.log(`[Voice] 查询Name2Id表: ${t8 - t7}ms`)
const name2IdTable = (name2IdTablesResult.success && name2IdTablesResult.rows && name2IdTablesResult.rows.length > 0) const name2IdTable = (name2IdTablesResult.success && name2IdTablesResult.rows && name2IdTablesResult.rows.length > 0)
? name2IdTablesResult.rows[0].name ? name2IdTablesResult.rows[0].name
@@ -3033,7 +3096,7 @@ class ChatService {
`SELECT user_name, rowid FROM ${schema.name2IdTable} WHERE user_name IN (${candidatesStr})` `SELECT user_name, rowid FROM ${schema.name2IdTable} WHERE user_name IN (${candidatesStr})`
) )
const t10 = Date.now() const t10 = Date.now()
console.log(`[Voice] 查询chat_name_id: ${t10 - t9}ms`)
if (name2IdResult.success && name2IdResult.rows && name2IdResult.rows.length > 0) { if (name2IdResult.success && name2IdResult.rows && name2IdResult.rows.length > 0) {
// 构建 chat_name_id 列表 // 构建 chat_name_id 列表
@@ -3046,13 +3109,13 @@ class ChatService {
`SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.chatNameIdColumn} IN (${chatNameIdsStr}) AND ${schema.timeColumn} = ${createTime} LIMIT 1` `SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.chatNameIdColumn} IN (${chatNameIdsStr}) AND ${schema.timeColumn} = ${createTime} LIMIT 1`
) )
const t12 = Date.now() const t12 = Date.now()
console.log(`[Voice] 策略1查询语音: ${t12 - t11}ms`)
if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) { if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) {
const row = voiceResult.rows[0] const row = voiceResult.rows[0]
const silkData = this.decodeVoiceBlob(row.data) const silkData = this.decodeVoiceBlob(row.data)
if (silkData) { if (silkData) {
console.log(`[Voice] getVoiceDataFromMediaDb总耗时: ${Date.now() - startTime}ms`)
return silkData return silkData
} }
} }
@@ -3066,13 +3129,13 @@ class ChatService {
`SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.timeColumn} = ${createTime} LIMIT 1` `SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.timeColumn} = ${createTime} LIMIT 1`
) )
const t14 = Date.now() const t14 = Date.now()
console.log(`[Voice] 策略2查询语音: ${t14 - t13}ms`)
if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) { if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) {
const row = voiceResult.rows[0] const row = voiceResult.rows[0]
const silkData = this.decodeVoiceBlob(row.data) const silkData = this.decodeVoiceBlob(row.data)
if (silkData) { if (silkData) {
console.log(`[Voice] getVoiceDataFromMediaDb总耗时: ${Date.now() - startTime}ms`)
return silkData return silkData
} }
} }
@@ -3085,13 +3148,13 @@ class ChatService {
`SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.timeColumn} BETWEEN ${createTime - 5} AND ${createTime + 5} ORDER BY ABS(${schema.timeColumn} - ${createTime}) LIMIT 1` `SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.timeColumn} BETWEEN ${createTime - 5} AND ${createTime + 5} ORDER BY ABS(${schema.timeColumn} - ${createTime}) LIMIT 1`
) )
const t16 = Date.now() const t16 = Date.now()
console.log(`[Voice] 策略3查询语音: ${t16 - t15}ms`)
if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) { if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) {
const row = voiceResult.rows[0] const row = voiceResult.rows[0]
const silkData = this.decodeVoiceBlob(row.data) const silkData = this.decodeVoiceBlob(row.data)
if (silkData) { if (silkData) {
console.log(`[Voice] getVoiceDataFromMediaDb总耗时: ${Date.now() - startTime}ms`)
return silkData return silkData
} }
} }
@@ -3322,7 +3385,7 @@ class ChatService {
senderWxid?: string senderWxid?: string
): Promise<{ success: boolean; transcript?: string; error?: string }> { ): Promise<{ success: boolean; transcript?: string; error?: string }> {
const startTime = Date.now() const startTime = Date.now()
console.log(`[Transcribe] 开始转写: sessionId=${sessionId}, msgId=${msgId}, createTime=${createTime}`)
try { try {
let msgCreateTime = createTime let msgCreateTime = createTime
@@ -3333,12 +3396,12 @@ class ChatService {
const t1 = Date.now() const t1 = Date.now()
const msgResult = await this.getMessageById(sessionId, parseInt(msgId, 10)) const msgResult = await this.getMessageById(sessionId, parseInt(msgId, 10))
const t2 = Date.now() const t2 = Date.now()
console.log(`[Transcribe] getMessageById: ${t2 - t1}ms`)
if (msgResult.success && msgResult.message) { if (msgResult.success && msgResult.message) {
msgCreateTime = msgResult.message.createTime msgCreateTime = msgResult.message.createTime
serverId = msgResult.message.serverId serverId = msgResult.message.serverId
console.log(`[Transcribe] 获取到 createTime=${msgCreateTime}, serverId=${serverId}`)
} }
} }
@@ -3349,19 +3412,19 @@ class ChatService {
// 使用正确的 cacheKey包含 createTime // 使用正确的 cacheKey包含 createTime
const cacheKey = this.getVoiceCacheKey(sessionId, msgId, msgCreateTime) const cacheKey = this.getVoiceCacheKey(sessionId, msgId, msgCreateTime)
console.log(`[Transcribe] cacheKey=${cacheKey}`)
// 检查转写缓存 // 检查转写缓存
const cached = this.voiceTranscriptCache.get(cacheKey) const cached = this.voiceTranscriptCache.get(cacheKey)
if (cached) { if (cached) {
console.log(`[Transcribe] 缓存命中,总耗时: ${Date.now() - startTime}ms`)
return { success: true, transcript: cached } return { success: true, transcript: cached }
} }
// 检查是否正在转写 // 检查是否正在转写
const pending = this.voiceTranscriptPending.get(cacheKey) const pending = this.voiceTranscriptPending.get(cacheKey)
if (pending) { if (pending) {
console.log(`[Transcribe] 正在转写中,等待结果`)
return pending return pending
} }
@@ -3370,7 +3433,7 @@ class ChatService {
// 检查内存中是否有 WAV 数据 // 检查内存中是否有 WAV 数据
let wavData = this.voiceWavCache.get(cacheKey) let wavData = this.voiceWavCache.get(cacheKey)
if (wavData) { if (wavData) {
console.log(`[Transcribe] WAV内存缓存命中大小: ${wavData.length} bytes`)
} else { } else {
// 检查文件缓存 // 检查文件缓存
const voiceCacheDir = this.getVoiceCacheDir() const voiceCacheDir = this.getVoiceCacheDir()
@@ -3378,7 +3441,7 @@ class ChatService {
if (existsSync(wavFilePath)) { if (existsSync(wavFilePath)) {
try { try {
wavData = readFileSync(wavFilePath) wavData = readFileSync(wavFilePath)
console.log(`[Transcribe] WAV文件缓存命中大小: ${wavData.length} bytes`)
// 同时缓存到内存 // 同时缓存到内存
this.cacheVoiceWav(cacheKey, wavData) this.cacheVoiceWav(cacheKey, wavData)
} catch (e) { } catch (e) {
@@ -3388,39 +3451,39 @@ class ChatService {
} }
if (!wavData) { if (!wavData) {
console.log(`[Transcribe] WAV缓存未命中调用 getVoiceData`)
const t3 = Date.now() const t3 = Date.now()
// 调用 getVoiceData 获取并解码 // 调用 getVoiceData 获取并解码
const voiceResult = await this.getVoiceData(sessionId, msgId, msgCreateTime, serverId, senderWxid) const voiceResult = await this.getVoiceData(sessionId, msgId, msgCreateTime, serverId, senderWxid)
const t4 = Date.now() const t4 = Date.now()
console.log(`[Transcribe] getVoiceData: ${t4 - t3}ms, success=${voiceResult.success}`)
if (!voiceResult.success || !voiceResult.data) { if (!voiceResult.success || !voiceResult.data) {
console.error(`[Transcribe] 语音解码失败: ${voiceResult.error}`) console.error(`[Transcribe] 语音解码失败: ${voiceResult.error}`)
return { success: false, error: voiceResult.error || '语音解码失败' } return { success: false, error: voiceResult.error || '语音解码失败' }
} }
wavData = Buffer.from(voiceResult.data, 'base64') wavData = Buffer.from(voiceResult.data, 'base64')
console.log(`[Transcribe] WAV数据大小: ${wavData.length} bytes`)
} }
// 转写 // 转写
console.log(`[Transcribe] 开始调用 transcribeWavBuffer`)
const t5 = Date.now() const t5 = Date.now()
const result = await voiceTranscribeService.transcribeWavBuffer(wavData, (text) => { const result = await voiceTranscribeService.transcribeWavBuffer(wavData, (text) => {
console.log(`[Transcribe] 部分结果: ${text}`)
onPartial?.(text) onPartial?.(text)
}) })
const t6 = Date.now() const t6 = Date.now()
console.log(`[Transcribe] transcribeWavBuffer: ${t6 - t5}ms, success=${result.success}`)
if (result.success && result.transcript) { if (result.success && result.transcript) {
console.log(`[Transcribe] 转写成功: ${result.transcript}`)
this.cacheVoiceTranscript(cacheKey, result.transcript) this.cacheVoiceTranscript(cacheKey, result.transcript)
} else { } else {
console.error(`[Transcribe] 转写失败: ${result.error}`) console.error(`[Transcribe] 转写失败: ${result.error}`)
} }
console.log(`[Transcribe] 总耗时: ${Date.now() - startTime}ms`)
return result return result
} catch (error) { } catch (error) {
console.error(`[Transcribe] 异常:`, error) console.error(`[Transcribe] 异常:`, error)

View File

@@ -33,12 +33,33 @@ interface ConfigSchema {
authEnabled: boolean authEnabled: boolean
authPassword: string // SHA-256 hash authPassword: string // SHA-256 hash
authUseHello: boolean authUseHello: boolean
// 更新相关
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: {
@@ -67,7 +88,13 @@ export class ConfigService {
authEnabled: false, authEnabled: false,
authPassword: '', authPassword: '',
authUseHello: false authUseHello: false,
ignoredUpdateVersion: '',
notificationEnabled: true,
notificationPosition: 'top-right',
notificationFilterMode: 'all',
notificationFilterList: []
} }
}) })
} }

View File

@@ -74,8 +74,9 @@ class DualReportService {
return trimmed return trimmed
} }
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
if (suffixMatch) return suffixMatch[1] const cleaned = suffixMatch ? suffixMatch[1] : trimmed
return trimmed
return cleaned
} }
private async ensureConnectedWithConfig( private async ensureConnectedWithConfig(
@@ -202,7 +203,12 @@ class DualReportService {
if (!sender) return false if (!sender) return false
const rawLower = rawWxid ? rawWxid.toLowerCase() : '' const rawLower = rawWxid ? rawWxid.toLowerCase() : ''
const cleanedLower = cleanedWxid ? cleanedWxid.toLowerCase() : '' const cleanedLower = cleanedWxid ? cleanedWxid.toLowerCase() : ''
return sender === rawLower || sender === cleanedLower return !!(
sender === rawLower ||
sender === cleanedLower ||
(rawLower && rawLower.startsWith(sender + '_')) ||
(cleanedLower && cleanedLower.startsWith(sender + '_'))
)
} }
private async getFirstMessages( private async getFirstMessages(

View File

@@ -43,6 +43,7 @@ interface ChatLabMessage {
timestamp: number timestamp: number
type: number type: number
content: string | null content: string | null
chatRecords?: any[] // 嵌套的聊天记录
} }
interface ChatLabExport { interface ChatLabExport {
@@ -157,8 +158,9 @@ class ExportService {
return trimmed return trimmed
} }
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
if (suffixMatch) return suffixMatch[1] const cleaned = suffixMatch ? suffixMatch[1] : trimmed
return trimmed
return cleaned
} }
private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> { private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> {
@@ -227,20 +229,27 @@ class ExportService {
* 转换微信消息类型到 ChatLab 类型 * 转换微信消息类型到 ChatLab 类型
*/ */
private convertMessageType(localType: number, content: string): number { private convertMessageType(localType: number, content: string): number {
if (localType === 49) { // 检查 XML 中的 type 标签(支持大 localType 的情况)
const typeMatch = /<type>(\d+)<\/type>/i.exec(content) const xmlTypeMatch = /<type>(\d+)<\/type>/i.exec(content)
if (typeMatch) { const xmlType = xmlTypeMatch ? parseInt(xmlTypeMatch[1]) : null
const subType = parseInt(typeMatch[1])
switch (subType) { // 特殊处理 type 49 或 XML type
case 6: return 4 // 文件 -> FILE if (localType === 49 || xmlType) {
case 33: const subType = xmlType || 0
case 36: return 24 // 小程序 -> SHARE switch (subType) {
case 57: return 25 // 引用回复 -> REPLY case 6: return 4 // 文件 -> FILE
default: return 7 // 链接 -> LINK case 19: return 7 // 聊天记录 -> LINK (ChatLab 没有专门的聊天记录类型)
} case 33:
case 36: return 24 // 小程序 -> SHARE
case 57: return 25 // 引用回复 -> REPLY
case 2000: return 99 // 转账 -> OTHER (ChatLab 没有转账类型)
case 5:
case 49: return 7 // 链接 -> LINK
default:
if (xmlType) return 7 // 有 XML type 但未知,默认为链接
} }
} }
return MESSAGE_TYPE_MAP[localType] ?? 99 return MESSAGE_TYPE_MAP[localType] ?? 99 // 未知类型 -> OTHER
} }
/** /**
@@ -345,30 +354,87 @@ class ExportService {
* 解析消息内容为可读文本 * 解析消息内容为可读文本
* 注意:语音消息在这里返回占位符,实际转文字在导出时异步处理 * 注意:语音消息在这里返回占位符,实际转文字在导出时异步处理
*/ */
private parseMessageContent(content: string, localType: number): string | null { private parseMessageContent(content: string, localType: number, sessionId?: string, createTime?: number): string | null {
if (!content) return null if (!content) return null
// 检查 XML 中的 type 标签(支持大 localType 的情况)
const xmlTypeMatch = /<type>(\d+)<\/type>/i.exec(content)
const xmlType = xmlTypeMatch ? xmlTypeMatch[1] : null
switch (localType) { switch (localType) {
case 1: case 1: // 文本
return this.stripSenderPrefix(content) return this.stripSenderPrefix(content)
case 3: return '[图片]' case 3: return '[图片]'
case 34: return '[语音消息]' // 占位符,导出时会替换为转文字结果 case 34: {
// 语音消息 - 尝试获取转写文字
if (sessionId && createTime) {
const transcript = voiceTranscribeService.getCachedTranscript(sessionId, createTime)
if (transcript) {
return `[语音消息] ${transcript}`
}
}
return '[语音消息]' // 占位符,导出时会替换为转文字结果
}
case 42: return '[名片]' case 42: return '[名片]'
case 43: return '[视频]' case 43: return '[视频]'
case 47: return '[动画表情]' case 47: return '[动画表情]'
case 48: return '[位置]' case 48: return '[位置]'
case 49: { case 49: {
const title = this.extractXmlValue(content, 'title') const title = this.extractXmlValue(content, 'title')
return title || '[链接]' const type = this.extractXmlValue(content, 'type')
// 转账消息特殊处理
if (type === '2000') {
const feedesc = this.extractXmlValue(content, 'feedesc')
const payMemo = this.extractXmlValue(content, 'pay_memo')
if (feedesc) {
return payMemo ? `[转账] ${feedesc} ${payMemo}` : `[转账] ${feedesc}`
}
return '[转账]'
}
if (type === '6') return title ? `[文件] ${title}` : '[文件]'
if (type === '19') return title ? `[聊天记录] ${title}` : '[聊天记录]'
if (type === '33' || type === '36') return title ? `[小程序] ${title}` : '[小程序]'
if (type === '57') return title || '[引用消息]'
if (type === '5' || type === '49') return title ? `[链接] ${title}` : '[链接]'
return title ? `[链接] ${title}` : '[链接]'
} }
case 50: return this.parseVoipMessage(content) case 50: return this.parseVoipMessage(content)
case 10000: return this.cleanSystemMessage(content) case 10000: return this.cleanSystemMessage(content)
case 266287972401: return this.cleanSystemMessage(content) // 拍一拍 case 266287972401: return this.cleanSystemMessage(content) // 拍一拍
case 244813135921: {
// 引用消息
const title = this.extractXmlValue(content, 'title')
return title || '[引用消息]'
}
default: default:
if (content.includes('<type>57</type>')) { // 对于未知的 localType检查 XML type 来判断消息类型
if (xmlType) {
const title = this.extractXmlValue(content, 'title') const title = this.extractXmlValue(content, 'title')
return title || '[引用消息]'
// 转账消息
if (xmlType === '2000') {
const feedesc = this.extractXmlValue(content, 'feedesc')
const payMemo = this.extractXmlValue(content, 'pay_memo')
if (feedesc) {
return payMemo ? `[转账] ${feedesc} ${payMemo}` : `[转账] ${feedesc}`
}
return '[转账]'
}
// 其他类型
if (xmlType === '6') return title ? `[文件] ${title}` : '[文件]'
if (xmlType === '19') return title ? `[聊天记录] ${title}` : '[聊天记录]'
if (xmlType === '33' || xmlType === '36') return title ? `[小程序] ${title}` : '[小程序]'
if (xmlType === '57') return title || '[引用消息]'
if (xmlType === '5' || xmlType === '49') return title ? `[链接] ${title}` : '[链接]'
// 有 title 就返回 title
if (title) return title
} }
// 最后尝试提取文本内容
return this.stripSenderPrefix(content) || null return this.stripSenderPrefix(content) || null
} }
} }
@@ -429,15 +495,14 @@ class ExportService {
const typeMatch = /<type>(\d+)<\/type>/i.exec(normalized) const typeMatch = /<type>(\d+)<\/type>/i.exec(normalized)
const subType = typeMatch ? parseInt(typeMatch[1], 10) : 0 const subType = typeMatch ? parseInt(typeMatch[1], 10) : 0
const title = this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'appname') const title = this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'appname')
if (subType === 3 || normalized.includes('<musicurl') || normalized.includes('<songname')) {
const songName = this.extractXmlValue(normalized, 'songname') || title || '音乐' // 转账消息特殊处理
return `[音乐]${songName}` if (subType === 2000 || title.includes('转账') || normalized.includes('transfer')) {
} const feedesc = this.extractXmlValue(normalized, 'feedesc')
if (subType === 6) { const payMemo = this.extractXmlValue(normalized, 'pay_memo')
const fileName = this.extractXmlValue(normalized, 'filename') || title || '文件' if (feedesc) {
return `[文件]${fileName}` return payMemo ? `[转账]${feedesc} ${payMemo}` : `[转账]${feedesc}`
} }
if (title.includes('转账') || normalized.includes('transfer')) {
const amount = this.extractAmountFromText( const amount = this.extractAmountFromText(
[ [
title, title,
@@ -451,6 +516,15 @@ class ExportService {
) )
return amount ? `[转账]${amount}` : '[转账]' return amount ? `[转账]${amount}` : '[转账]'
} }
if (subType === 3 || normalized.includes('<musicurl') || normalized.includes('<songname')) {
const songName = this.extractXmlValue(normalized, 'songname') || title || '音乐'
return `[音乐]${songName}`
}
if (subType === 6) {
const fileName = this.extractXmlValue(normalized, 'filename') || title || '文件'
return `[文件]${fileName}`
}
if (title.includes('红包') || normalized.includes('hongbao')) { if (title.includes('红包') || normalized.includes('hongbao')) {
return `[红包]${title || '微信红包'}` return `[红包]${title || '微信红包'}`
} }
@@ -466,6 +540,9 @@ class ExportService {
const appName = this.extractXmlValue(normalized, 'appname') || title || '小程序' const appName = this.extractXmlValue(normalized, 'appname') || title || '小程序'
return `[小程序]${appName}` return `[小程序]${appName}`
} }
if (subType === 57) {
return title || '[引用消息]'
}
if (title) { if (title) {
return `[链接]${title}` return `[链接]${title}`
} }
@@ -601,7 +678,25 @@ class ExportService {
/** /**
* 获取消息类型名称 * 获取消息类型名称
*/ */
private getMessageTypeName(localType: number): string { private getMessageTypeName(localType: number, content?: string): string {
// 检查 XML 中的 type 标签(支持大 localType 的情况)
if (content) {
const xmlTypeMatch = /<type>(\d+)<\/type>/i.exec(content)
const xmlType = xmlTypeMatch ? xmlTypeMatch[1] : null
if (xmlType) {
switch (xmlType) {
case '2000': return '转账消息'
case '5': return '链接消息'
case '6': return '文件消息'
case '19': return '聊天记录'
case '33':
case '36': return '小程序消息'
case '57': return '引用消息'
}
}
}
const typeNames: Record<number, string> = { const typeNames: Record<number, string> = {
1: '文本消息', 1: '文本消息',
3: '图片消息', 3: '图片消息',
@@ -612,7 +707,8 @@ class ExportService {
48: '位置消息', 48: '位置消息',
49: '链接消息', 49: '链接消息',
50: '通话消息', 50: '通话消息',
10000: '系统消息' 10000: '系统消息',
244813135921: '引用消息'
} }
return typeNames[localType] || '其他消息' return typeNames[localType] || '其他消息'
} }
@@ -689,6 +785,71 @@ class ExportService {
return this.htmlStyleCache return this.htmlStyleCache
} }
/**
* 解析合并转发的聊天记录 (Type 19)
*/
private parseChatHistory(content: string): any[] | undefined {
try {
const type = this.extractXmlValue(content, 'type')
if (type !== '19') return undefined
// 提取 recorditem 中的 CDATA
const match = /<recorditem>[\s\S]*?<!\[CDATA\[([\s\S]*?)\]\]>[\s\S]*?<\/recorditem>/.exec(content)
if (!match) return undefined
const innerXml = match[1]
const items: any[] = []
const itemRegex = /<dataitem\s+(.*?)>([\s\S]*?)<\/dataitem>/g
let itemMatch
while ((itemMatch = itemRegex.exec(innerXml)) !== null) {
const attrs = itemMatch[1]
const body = itemMatch[2]
const datatypeMatch = /datatype="(\d+)"/.exec(attrs)
const datatype = datatypeMatch ? parseInt(datatypeMatch[1]) : 0
const sourcename = this.extractXmlValue(body, 'sourcename')
const sourcetime = this.extractXmlValue(body, 'sourcetime')
const sourceheadurl = this.extractXmlValue(body, 'sourceheadurl')
const datadesc = this.extractXmlValue(body, 'datadesc')
const datatitle = this.extractXmlValue(body, 'datatitle')
const fileext = this.extractXmlValue(body, 'fileext')
const datasize = parseInt(this.extractXmlValue(body, 'datasize') || '0')
items.push({
datatype,
sourcename,
sourcetime,
sourceheadurl,
datadesc: this.decodeHtmlEntities(datadesc),
datatitle: this.decodeHtmlEntities(datatitle),
fileext,
datasize
})
}
return items.length > 0 ? items : undefined
} catch (e) {
console.error('ExportService: 解析聊天记录失败:', e)
return undefined
}
}
/**
* 解码 HTML 实体
*/
private decodeHtmlEntities(text: string): string {
if (!text) return ''
return text
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'")
}
private normalizeAppMessageContent(content: string): string { private normalizeAppMessageContent(content: string): string {
if (!content) return '' if (!content) return ''
if (content.includes('&lt;') && content.includes('&gt;')) { if (content.includes('&lt;') && content.includes('&gt;')) {
@@ -968,11 +1129,11 @@ class ExportService {
const emojiMd5 = msg.emojiMd5 const emojiMd5 = msg.emojiMd5
if (!emojiUrl && !emojiMd5) { if (!emojiUrl && !emojiMd5) {
console.log('[ExportService] 表情消息缺少 url 和 md5, localId:', msg.localId, 'content:', msg.content?.substring(0, 200))
return null return null
} }
console.log('[ExportService] 导出表情:', { localId: msg.localId, emojiMd5, emojiUrl: emojiUrl?.substring(0, 100) })
const key = emojiMd5 || String(msg.localId) const key = emojiMd5 || String(msg.localId)
// 根据 URL 判断扩展名 // 根据 URL 判断扩展名
@@ -1234,6 +1395,7 @@ class ExportService {
let emojiCdnUrl: string | undefined let emojiCdnUrl: string | undefined
let emojiMd5: string | undefined let emojiMd5: string | undefined
let videoMd5: string | undefined let videoMd5: string | undefined
let chatRecordList: any[] | undefined
if (localType === 3 && content) { if (localType === 3 && content) {
// 图片消息 // 图片消息
@@ -1246,6 +1408,12 @@ class ExportService {
} else if (localType === 43 && content) { } else if (localType === 43 && content) {
// 视频消息 // 视频消息
videoMd5 = this.extractVideoMd5(content) videoMd5 = this.extractVideoMd5(content)
} else if (localType === 49 && content) {
// 检查是否是聊天记录消息type=19
const xmlType = this.extractXmlValue(content, 'type')
if (xmlType === '19') {
chatRecordList = this.parseChatHistory(content)
}
} }
rows.push({ rows.push({
@@ -1259,7 +1427,8 @@ class ExportService {
imageDatName, imageDatName,
emojiCdnUrl, emojiCdnUrl,
emojiMd5, emojiMd5,
videoMd5 videoMd5,
chatRecordList
}) })
if (firstTime === null || createTime < firstTime) firstTime = createTime if (firstTime === null || createTime < firstTime) firstTime = createTime
@@ -1444,33 +1613,10 @@ class ExportService {
const result = new Map<string, string>() const result = new Map<string, string>()
if (members.length === 0) return result if (members.length === 0) return result
// 直接使用 URL不转换为 base64与 ciphertalk 保持一致)
for (const member of members) { for (const member of members) {
const fileInfo = this.resolveAvatarFile(member.avatarUrl) if (member.avatarUrl) {
if (!fileInfo) continue result.set(member.username, member.avatarUrl)
try {
let data: Buffer | null = null
let mime = fileInfo.mime
if (fileInfo.data) {
data = fileInfo.data
} else if (fileInfo.sourcePath && fs.existsSync(fileInfo.sourcePath)) {
data = await fs.promises.readFile(fileInfo.sourcePath)
} else if (fileInfo.sourceUrl) {
const downloaded = await this.downloadToBuffer(fileInfo.sourceUrl)
if (downloaded) {
data = downloaded.data
mime = downloaded.mime || mime
}
}
if (!data) continue
// 优先使用内容检测出的 MIME 类型
const detectedMime = this.detectMimeType(data)
const finalMime = detectedMime || mime || this.inferImageMime(fileInfo.ext)
const base64 = data.toString('base64')
result.set(member.username, `data:${finalMime};base64,${base64}`)
} catch {
continue
} }
} }
@@ -1766,10 +1912,10 @@ class ExportService {
// 使用预先转写的文字 // 使用预先转写的文字
content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]' content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
} else { } else {
content = this.parseMessageContent(msg.content, msg.localType) content = this.parseMessageContent(msg.content, msg.localType, sessionId, msg.createTime)
} }
return { const message: ChatLabMessage = {
sender: msg.senderUsername, sender: msg.senderUsername,
accountName: memberInfo.accountName, accountName: memberInfo.accountName,
groupNickname: memberInfo.groupNickname, groupNickname: memberInfo.groupNickname,
@@ -1777,6 +1923,102 @@ class ExportService {
type: this.convertMessageType(msg.localType, msg.content), type: this.convertMessageType(msg.localType, msg.content),
content: content content: content
} }
// 如果有聊天记录,添加为嵌套字段
if (msg.chatRecordList && msg.chatRecordList.length > 0) {
const chatRecords: any[] = []
for (const record of msg.chatRecordList) {
// 解析时间戳 (格式: "YYYY-MM-DD HH:MM:SS")
let recordTimestamp = msg.createTime
if (record.sourcetime) {
try {
const timeParts = record.sourcetime.match(/(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})/)
if (timeParts) {
const date = new Date(
parseInt(timeParts[1]),
parseInt(timeParts[2]) - 1,
parseInt(timeParts[3]),
parseInt(timeParts[4]),
parseInt(timeParts[5]),
parseInt(timeParts[6])
)
recordTimestamp = Math.floor(date.getTime() / 1000)
}
} catch (e) {
console.error('解析聊天记录时间失败:', e)
}
}
// 转换消息类型
let recordType = 0 // TEXT
let recordContent = record.datadesc || record.datatitle || ''
switch (record.datatype) {
case 1:
recordType = 0 // TEXT
break
case 3:
recordType = 1 // IMAGE
recordContent = '[图片]'
break
case 8:
case 49:
recordType = 4 // FILE
recordContent = record.datatitle ? `[文件] ${record.datatitle}` : '[文件]'
break
case 34:
recordType = 2 // VOICE
recordContent = '[语音消息]'
break
case 43:
recordType = 3 // VIDEO
recordContent = '[视频]'
break
case 47:
recordType = 5 // EMOJI
recordContent = '[动画表情]'
break
default:
recordType = 0
recordContent = record.datadesc || record.datatitle || '[消息]'
}
const chatRecord: any = {
sender: record.sourcename || 'unknown',
accountName: record.sourcename || 'unknown',
timestamp: recordTimestamp,
type: recordType,
content: recordContent
}
// 添加头像(如果启用导出头像)
if (options.exportAvatars && record.sourceheadurl) {
chatRecord.avatar = record.sourceheadurl
}
chatRecords.push(chatRecord)
// 添加成员信息到 memberSet
if (record.sourcename && !collected.memberSet.has(record.sourcename)) {
const newMember: ChatLabMember = {
platformId: record.sourcename,
accountName: record.sourcename
}
if (options.exportAvatars && record.sourceheadurl) {
newMember.avatar = record.sourceheadurl
}
collected.memberSet.set(record.sourcename, {
member: newMember,
avatarUrl: record.sourceheadurl
})
}
}
message.chatRecords = chatRecords
}
return message
}) })
const avatarMap = options.exportAvatars const avatarMap = options.exportAvatars

View File

@@ -79,8 +79,13 @@ class GroupAnalyticsService {
if (trimmed.toLowerCase().startsWith('wxid_')) { if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i) const match = trimmed.match(/^(wxid_[^_]+)/i)
if (match) return match[1] if (match) return match[1]
return trimmed
} }
return trimmed
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
return cleaned
} }
private async ensureConnected(): Promise<{ success: boolean; error?: string }> { private async ensureConnected(): Promise<{ success: boolean; error?: string }> {

View File

@@ -380,9 +380,9 @@ export class ImageDecryptService {
} }
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
if (suffixMatch) return suffixMatch[1] const cleaned = suffixMatch ? suffixMatch[1] : trimmed
return trimmed return cleaned
} }
private async resolveDatPath( private async resolveDatPath(
@@ -415,10 +415,16 @@ export class ImageDecryptService {
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath) if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
return hardlinkPath return hardlinkPath
} }
// hardlink 找到的是缩略图,但要求高清图,直接返回 null不再搜索 // hardlink 找到的是缩略图,但要求高清图
if (!allowThumbnail && isThumb) { // 尝试在同一目录下查找高清图变体(快速查找,不遍历)
return null const hdPath = this.findHdVariantInSameDir(hardlinkPath)
if (hdPath) {
this.cacheDatPath(accountDir, imageMd5, hdPath)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath
} }
// 没找到高清图,返回 null不进行全局搜索
return null
} }
this.logInfo('[ImageDecrypt] hardlink miss (md5)', { imageMd5 }) this.logInfo('[ImageDecrypt] hardlink miss (md5)', { imageMd5 })
if (imageDatName && this.looksLikeMd5(imageDatName) && imageDatName !== imageMd5) { if (imageDatName && this.looksLikeMd5(imageDatName) && imageDatName !== imageMd5) {
@@ -431,9 +437,13 @@ export class ImageDecryptService {
this.cacheDatPath(accountDir, imageDatName, fallbackPath) this.cacheDatPath(accountDir, imageDatName, fallbackPath)
return fallbackPath return fallbackPath
} }
if (!allowThumbnail && isThumb) { // 找到缩略图但要求高清图,尝试同目录查找高清图变体
return null const hdPath = this.findHdVariantInSameDir(fallbackPath)
if (hdPath) {
this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath
} }
return null
} }
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
} }
@@ -449,10 +459,13 @@ export class ImageDecryptService {
this.cacheDatPath(accountDir, imageDatName, hardlinkPath) this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
return hardlinkPath return hardlinkPath
} }
// hardlink 找到的是缩略图,但要求高清图,直接返回 null // hardlink 找到的是缩略图,但要求高清图
if (!allowThumbnail && isThumb) { const hdPath = this.findHdVariantInSameDir(hardlinkPath)
return null if (hdPath) {
this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath
} }
return null
} }
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
} }
@@ -467,6 +480,9 @@ export class ImageDecryptService {
const cached = this.resolvedCache.get(imageDatName) const cached = this.resolvedCache.get(imageDatName)
if (cached && existsSync(cached)) { if (cached && existsSync(cached)) {
if (allowThumbnail || !this.isThumbnailPath(cached)) return cached if (allowThumbnail || !this.isThumbnailPath(cached)) return cached
// 缓存的是缩略图,尝试找高清图
const hdPath = this.findHdVariantInSameDir(cached)
if (hdPath) return hdPath
} }
} }
@@ -761,6 +777,17 @@ export class ImageDecryptService {
const root = join(accountDir, 'msg', 'attach') const root = join(accountDir, 'msg', 'attach')
if (!existsSync(root)) return null if (!existsSync(root)) return null
// 优化1快速概率性查找
// 包含1. 基于文件名的前缀猜测 (旧版)
// 2. 基于日期的最近月份扫描 (新版无索引时)
const fastHit = await this.fastProbabilisticSearch(root, datName)
if (fastHit) {
this.resolvedCache.set(key, fastHit)
return fastHit
}
// 优化2兜底扫描 (异步非阻塞)
const found = await this.walkForDatInWorker(root, datName.toLowerCase(), 8, allowThumbnail, thumbOnly) const found = await this.walkForDatInWorker(root, datName.toLowerCase(), 8, allowThumbnail, thumbOnly)
if (found) { if (found) {
this.resolvedCache.set(key, found) this.resolvedCache.set(key, found)
@@ -769,6 +796,134 @@ export class ImageDecryptService {
return null return null
} }
/**
* 基于文件名的哈希特征猜测可能的路径
* 包含1. 微信旧版结构 filename.substr(0, 2)/...
* 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename
*/
private async fastProbabilisticSearch(root: string, datName: string): Promise<string | null> {
const { promises: fs } = require('fs')
const { join } = require('path')
try {
// --- 策略 A: 旧版路径猜测 (msg/attach/xx/yy/...) ---
const lowerName = datName.toLowerCase()
let baseName = lowerName
if (baseName.endsWith('.dat')) {
baseName = baseName.slice(0, -4)
if (baseName.endsWith('_t') || baseName.endsWith('.t') || baseName.endsWith('_hd')) {
baseName = baseName.slice(0, -3)
} else if (baseName.endsWith('_thumb')) {
baseName = baseName.slice(0, -6)
}
}
const candidates: string[] = []
if (/^[a-f0-9]{32}$/.test(baseName)) {
const dir1 = baseName.substring(0, 2)
const dir2 = baseName.substring(2, 4)
candidates.push(
join(root, dir1, dir2, datName),
join(root, dir1, dir2, 'Img', datName),
join(root, dir1, dir2, 'mg', datName),
join(root, dir1, dir2, 'Image', datName)
)
}
for (const path of candidates) {
try {
await fs.access(path)
return path
} catch { }
}
// --- 策略 B: 新版 Session 哈希路径猜测 ---
try {
const entries = await fs.readdir(root, { withFileTypes: true })
const sessionDirs = entries
.filter((e: any) => e.isDirectory() && e.name.length === 32 && /^[a-f0-9]+$/i.test(e.name))
.map((e: any) => e.name)
if (sessionDirs.length === 0) return null
const now = new Date()
const months: string[] = []
for (let i = 0; i < 2; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
const mStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
months.push(mStr)
}
const targetNames = [datName]
if (baseName !== lowerName) {
targetNames.push(`${baseName}.dat`)
targetNames.push(`${baseName}_t.dat`)
targetNames.push(`${baseName}_thumb.dat`)
}
const batchSize = 20
for (let i = 0; i < sessionDirs.length; i += batchSize) {
const batch = sessionDirs.slice(i, i + batchSize)
const tasks = batch.map(async (sessDir: string) => {
for (const month of months) {
const subDirs = ['Img', 'Image']
for (const sub of subDirs) {
const dirPath = join(root, sessDir, month, sub)
try { await fs.access(dirPath) } catch { continue }
for (const name of targetNames) {
const p = join(dirPath, name)
try { await fs.access(p); return p } catch { }
}
}
}
return null
})
const results = await Promise.all(tasks)
const hit = results.find(r => r !== null)
if (hit) return hit
}
} catch { }
} catch { }
return null
}
/**
* 在同一目录下查找高清图变体
* 缩略图: xxx_t.dat -> 高清图: xxx_h.dat 或 xxx.dat
*/
private findHdVariantInSameDir(thumbPath: string): string | null {
try {
const dir = dirname(thumbPath)
const fileName = basename(thumbPath).toLowerCase()
// 提取基础名称(去掉 _t.dat 或 .t.dat
let baseName = fileName
if (baseName.endsWith('_t.dat')) {
baseName = baseName.slice(0, -6)
} else if (baseName.endsWith('.t.dat')) {
baseName = baseName.slice(0, -6)
} else {
return null
}
// 尝试查找高清图变体
const variants = [
`${baseName}_h.dat`,
`${baseName}.h.dat`,
`${baseName}.dat`
]
for (const variant of variants) {
const variantPath = join(dir, variant)
if (existsSync(variantPath)) {
return variantPath
}
}
} catch { }
return null
}
private async searchDatFileInDir( private async searchDatFileInDir(
dirPath: string, dirPath: string,
datName: string, datName: string,

View File

@@ -116,13 +116,13 @@ export class KeyService {
// 检查是否已经有本地副本,如果有就使用它 // 检查是否已经有本地副本,如果有就使用它
if (existsSync(localPath)) { if (existsSync(localPath)) {
console.log(`使用已存在的 DLL 本地副本: ${localPath}`)
return localPath return localPath
} }
console.log(`检测到网络路径 DLL正在复制到本地: ${originalPath} -> ${localPath}`)
copyFileSync(originalPath, localPath) copyFileSync(originalPath, localPath)
console.log('DLL 本地化成功')
return localPath return localPath
} catch (e) { } catch (e) {
console.error('DLL 本地化失败:', e) console.error('DLL 本地化失败:', e)
@@ -146,7 +146,7 @@ export class KeyService {
// 检查是否为网络路径,如果是则本地化 // 检查是否为网络路径,如果是则本地化
if (this.isNetworkPath(dllPath)) { if (this.isNetworkPath(dllPath)) {
console.log('检测到网络路径,将进行本地化处理')
dllPath = this.localizeNetworkDll(dllPath) dllPath = this.localizeNetworkDll(dllPath)
} }
@@ -347,7 +347,7 @@ export class KeyService {
if (pid) { if (pid) {
const runPath = await this.getProcessExecutablePath(pid) const runPath = await this.getProcessExecutablePath(pid)
if (runPath && existsSync(runPath)) { if (runPath && existsSync(runPath)) {
console.log('发现正在运行的微信进程,使用路径:', runPath)
return runPath return runPath
} }
} }

View File

@@ -57,15 +57,11 @@ class SnsService {
} }
async getTimeline(limit: number = 20, offset: number = 0, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: SnsPost[]; error?: string }> { async getTimeline(limit: number = 20, offset: number = 0, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: SnsPost[]; error?: string }> {
console.log('[SnsService] getTimeline called with:', { limit, offset, usernames, keyword, startTime, endTime })
const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime) const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime)
console.log('[SnsService] getSnsTimeline result:', {
success: result.success,
timelineCount: result.timeline?.length,
error: result.error
})
if (result.success && result.timeline) { if (result.success && result.timeline) {
const enrichedTimeline = result.timeline.map((post: any, index: number) => { const enrichedTimeline = result.timeline.map((post: any, index: number) => {
@@ -121,11 +117,11 @@ class SnsService {
} }
}) })
console.log('[SnsService] Returning enriched timeline with', enrichedTimeline.length, 'posts')
return { ...result, timeline: enrichedTimeline } return { ...result, timeline: enrichedTimeline }
} }
console.log('[SnsService] Returning result:', result)
return result return result
} }
async debugResource(url: string): Promise<{ success: boolean; status?: number; headers?: any; error?: string }> { async debugResource(url: string): Promise<{ success: boolean; status?: number; headers?: any; error?: string }> {

View File

@@ -97,7 +97,7 @@ class VideoService {
return realMd5 return realMd5
} }
} catch (e) { } catch (e) {
// Silently fail // 忽略错误
} }
} }
} }
@@ -105,10 +105,21 @@ class VideoService {
// 方法2使用 wcdbService.execQuery 查询加密的 hardlink.db // 方法2使用 wcdbService.execQuery 查询加密的 hardlink.db
if (dbPath) { if (dbPath) {
const encryptedDbPaths = [ // 检查 dbPath 是否已经包含 wxid
join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'), const dbPathLower = dbPath.toLowerCase()
join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db') const wxidLower = wxid.toLowerCase()
] const cleanedWxidLower = cleanedWxid.toLowerCase()
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
const encryptedDbPaths: string[] = []
if (dbPathContainsWxid) {
// dbPath 已包含 wxid不需要再拼接
encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db'))
} else {
// dbPath 不包含 wxid需要拼接
encryptedDbPaths.push(join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'))
encryptedDbPaths.push(join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db'))
}
for (const p of encryptedDbPaths) { for (const p of encryptedDbPaths) {
if (existsSync(p)) { if (existsSync(p)) {
@@ -129,6 +140,7 @@ class VideoService {
} }
} }
} catch (e) { } catch (e) {
// 忽略错误
} }
} }
} }
@@ -155,7 +167,6 @@ class VideoService {
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg * 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
*/ */
async getVideoInfo(videoMd5: string): Promise<VideoInfo> { async getVideoInfo(videoMd5: string): Promise<VideoInfo> {
const dbPath = this.getDbPath() const dbPath = this.getDbPath()
const wxid = this.getMyWxid() const wxid = this.getMyWxid()
@@ -166,7 +177,19 @@ class VideoService {
// 先尝试从数据库查询真正的视频文件名 // 先尝试从数据库查询真正的视频文件名
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5 const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
const videoBaseDir = join(dbPath, wxid, 'msg', 'video') // 检查 dbPath 是否已经包含 wxid避免重复拼接
const dbPathLower = dbPath.toLowerCase()
const wxidLower = wxid.toLowerCase()
const cleanedWxid = this.cleanWxid(wxid)
let videoBaseDir: string
if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) {
// dbPath 已经包含 wxid直接使用
videoBaseDir = join(dbPath, 'msg', 'video')
} else {
// dbPath 不包含 wxid需要拼接
videoBaseDir = join(dbPath, wxid, 'msg', 'video')
}
if (!existsSync(videoBaseDir)) { if (!existsSync(videoBaseDir)) {
return { exists: false } return { exists: false }
@@ -202,7 +225,7 @@ class VideoService {
} }
} }
} catch (e) { } catch (e) {
console.error('[VideoService] Error searching for video:', e) // 忽略错误
} }
return { exists: false } return { exists: false }

View File

@@ -224,12 +224,12 @@ export class VoiceTranscribeService {
let finalTranscript = '' let finalTranscript = ''
worker.on('message', (msg: any) => { worker.on('message', (msg: any) => {
console.log('[VoiceTranscribe] Worker 消息:', msg)
if (msg.type === 'partial') { if (msg.type === 'partial') {
onPartial?.(msg.text) onPartial?.(msg.text)
} else if (msg.type === 'final') { } else if (msg.type === 'final') {
finalTranscript = msg.text finalTranscript = msg.text
console.log('[VoiceTranscribe] 最终文本:', finalTranscript)
resolve({ success: true, transcript: finalTranscript }) resolve({ success: true, transcript: finalTranscript })
worker.terminate() worker.terminate()
} else if (msg.type === 'error') { } else if (msg.type === 'error') {

View File

@@ -60,6 +60,10 @@ export class WcdbCore {
private wcdbGetSnsTimeline: any = null private wcdbGetSnsTimeline: any = null
private wcdbGetSnsAnnualStats: any = null private wcdbGetSnsAnnualStats: any = null
private wcdbVerifyUser: any = null private wcdbVerifyUser: any = null
private wcdbStartMonitorPipe: any = null
private wcdbStopMonitorPipe: any = null
private monitorPipeClient: any = null
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map() private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
private readonly avatarCacheTtlMs = 10 * 60 * 1000 private readonly avatarCacheTtlMs = 10 * 60 * 1000
private logTimer: NodeJS.Timeout | null = null private logTimer: NodeJS.Timeout | null = null
@@ -79,6 +83,80 @@ export class WcdbCore {
} }
} }
// 使用命名管道 IPC
startMonitor(callback: (type: string, json: string) => void): boolean {
if (!this.wcdbStartMonitorPipe) {
this.writeLog('startMonitor: wcdbStartMonitorPipe not available')
return false
}
try {
const result = this.wcdbStartMonitorPipe()
if (result !== 0) {
this.writeLog(`startMonitor: wcdbStartMonitorPipe failed with ${result}`)
return false
}
const net = require('net')
const PIPE_PATH = '\\\\.\\pipe\\weflow_monitor'
setTimeout(() => {
this.monitorPipeClient = net.createConnection(PIPE_PATH, () => {
this.writeLog('Monitor pipe connected')
})
let buffer = ''
this.monitorPipeClient.on('data', (data: Buffer) => {
buffer += data.toString('utf8')
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.trim()) {
try {
const parsed = JSON.parse(line)
callback(parsed.action || 'update', line)
} catch {
callback('update', line)
}
}
}
})
this.monitorPipeClient.on('error', (err: Error) => {
this.writeLog(`Monitor pipe error: ${err.message}`)
})
this.monitorPipeClient.on('close', () => {
this.writeLog('Monitor pipe closed')
this.monitorPipeClient = null
})
}, 100)
this.writeLog('Monitor started via named pipe IPC')
return true
} catch (e) {
console.error('startMonitor failed:', e)
return false
}
}
stopMonitor(): void {
if (this.monitorPipeClient) {
this.monitorPipeClient.destroy()
this.monitorPipeClient = null
}
if (this.wcdbStopMonitorPipe) {
this.wcdbStopMonitorPipe()
}
}
// 保留旧方法签名以兼容
setMonitor(callback: (type: string, json: string) => void): boolean {
return this.startMonitor(callback)
}
/** /**
* 获取 DLL 路径 * 获取 DLL 路径
*/ */
@@ -113,7 +191,7 @@ export class WcdbCore {
} }
private isLogEnabled(): boolean { private isLogEnabled(): boolean {
if (process.env.WEFLOW_WORKER === '1') return false // 移除 Worker 线程的日志禁用逻辑,允许在 Worker 中记录日志
if (process.env.WCDB_LOG_ENABLED === '1') return true if (process.env.WCDB_LOG_ENABLED === '1') return true
return this.logEnabled return this.logEnabled
} }
@@ -122,7 +200,7 @@ export class WcdbCore {
if (!force && !this.isLogEnabled()) return if (!force && !this.isLogEnabled()) return
const line = `[${new Date().toISOString()}] ${message}` const line = `[${new Date().toISOString()}] ${message}`
// 同时输出到控制台和文件 // 同时输出到控制台和文件
console.log('[WCDB]', message)
try { try {
const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd() const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd()
const dir = join(base, 'logs') const dir = join(base, 'logs')
@@ -262,10 +340,10 @@ export class WcdbCore {
let protectionOk = false let protectionOk = false
for (const resPath of resourcePaths) { for (const resPath of resourcePaths) {
try { try {
// console.log(`[WCDB] 尝试 InitProtection: ${resPath}`) //
protectionOk = this.wcdbInitProtection(resPath) protectionOk = this.wcdbInitProtection(resPath)
if (protectionOk) { if (protectionOk) {
// console.log(`[WCDB] InitProtection 成功: ${resPath}`) //
break break
} }
} catch (e) { } catch (e) {
@@ -454,6 +532,17 @@ export class WcdbCore {
this.wcdbGetSnsAnnualStats = null this.wcdbGetSnsAnnualStats = null
} }
// Named pipe IPC for monitoring (replaces callback)
try {
this.wcdbStartMonitorPipe = this.lib.func('int32 wcdb_start_monitor_pipe()')
this.wcdbStopMonitorPipe = this.lib.func('void wcdb_stop_monitor_pipe()')
this.writeLog('Monitor pipe functions loaded')
} catch (e) {
console.warn('Failed to load monitor pipe functions:', e)
this.wcdbStartMonitorPipe = null
this.wcdbStopMonitorPipe = null
}
// void VerifyUser(int64_t hwnd_ptr, const char* message, char* out_result, int max_len) // void VerifyUser(int64_t hwnd_ptr, const char* message, char* out_result, int max_len)
try { try {
this.wcdbVerifyUser = this.lib.func('void VerifyUser(int64 hwnd, const char* message, _Out_ char* outResult, int maxLen)') this.wcdbVerifyUser = this.lib.func('void VerifyUser(int64 hwnd, const char* message, _Out_ char* outResult, int maxLen)')
@@ -854,6 +943,37 @@ export class WcdbCore {
} }
} }
/**
* 获取指定时间之后的新消息
*/
async getNewMessages(sessionId: string, minTime: number, limit: number = 1000): Promise<{ success: boolean; messages?: any[]; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
// 1. 打开游标 (使用 Ascending=1 从指定时间往后查)
const openRes = await this.openMessageCursorLite(sessionId, limit, true, minTime, 0)
if (!openRes.success || !openRes.cursor) {
return { success: false, error: openRes.error }
}
const cursor = openRes.cursor
try {
// 2. 获取批次
const fetchRes = await this.fetchMessageBatch(cursor)
if (!fetchRes.success) {
return { success: false, error: fetchRes.error }
}
return { success: true, messages: fetchRes.rows }
} finally {
// 3. 关闭游标
await this.closeMessageCursor(cursor)
}
} catch (e) {
return { success: false, error: String(e) }
}
}
async getMessageCount(sessionId: string): Promise<{ success: boolean; count?: number; error?: string }> { async getMessageCount(sessionId: string): Promise<{ success: boolean; count?: number; error?: string }> {
if (!this.ensureReady()) { if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' } return { success: false, error: 'WCDB 未连接' }

View File

@@ -23,6 +23,7 @@ export class WcdbService {
private resourcesPath: string | null = null private resourcesPath: string | null = null
private userDataPath: string | null = null private userDataPath: string | null = null
private logEnabled = false private logEnabled = false
private monitorListener: ((type: string, json: string) => void) | null = null
constructor() { constructor() {
this.initWorker() this.initWorker()
@@ -47,8 +48,16 @@ export class WcdbService {
try { try {
this.worker = new Worker(finalPath) this.worker = new Worker(finalPath)
this.worker.on('message', (msg: WorkerMessage) => { this.worker.on('message', (msg: any) => {
const { id, result, error } = msg const { id, result, error, type, payload } = msg
if (type === 'monitor') {
if (this.monitorListener) {
this.monitorListener(payload.type, payload.json)
}
return
}
const p = this.pending.get(id) const p = this.pending.get(id)
if (p) { if (p) {
this.pending.delete(id) this.pending.delete(id)
@@ -122,6 +131,15 @@ export class WcdbService {
this.callWorker('setLogEnabled', { enabled }).catch(() => { }) this.callWorker('setLogEnabled', { enabled }).catch(() => { })
} }
/**
* 设置数据库监控回调
*/
setMonitor(callback: (type: string, json: string) => void): void {
this.monitorListener = callback;
// Notify worker to enable monitor
this.callWorker('setMonitor').catch(() => { });
}
/** /**
* 检查服务是否就绪 * 检查服务是否就绪
*/ */
@@ -187,6 +205,13 @@ export class WcdbService {
return this.callWorker('getMessages', { sessionId, limit, offset }) return this.callWorker('getMessages', { sessionId, limit, offset })
} }
/**
* 获取新消息(增量刷新)
*/
async getNewMessages(sessionId: string, minTime: number, limit: number = 1000): Promise<{ success: boolean; messages?: any[]; error?: string }> {
return this.callWorker('getNewMessages', { sessionId, minTime, limit })
}
/** /**
* 获取消息总数 * 获取消息总数
*/ */

View File

@@ -80,17 +80,17 @@ function isLanguageAllowed(result: any, allowedLanguages: string[]): boolean {
} }
const langTag = result.lang const langTag = result.lang
console.log('[TranscribeWorker] 检测到语言标记:', langTag)
// 检查是否在允许的语言列表中 // 检查是否在允许的语言列表中
for (const lang of allowedLanguages) { for (const lang of allowedLanguages) {
if (LANGUAGE_TAGS[lang] === langTag) { if (LANGUAGE_TAGS[lang] === langTag) {
console.log('[TranscribeWorker] 语言匹配,允许:', lang)
return true return true
} }
} }
console.log('[TranscribeWorker] 语言不在白名单中,过滤掉')
return false return false
} }
@@ -117,7 +117,7 @@ async function run() {
allowedLanguages = ['zh'] allowedLanguages = ['zh']
} }
console.log('[TranscribeWorker] 使用的语言白名单:', allowedLanguages)
// 1. 初始化识别器 (SenseVoiceSmall) // 1. 初始化识别器 (SenseVoiceSmall)
const recognizerConfig = { const recognizerConfig = {
@@ -145,15 +145,15 @@ async function run() {
recognizer.decode(stream) recognizer.decode(stream)
const result = recognizer.getResult(stream) const result = recognizer.getResult(stream)
console.log('[TranscribeWorker] 识别完成 - 结果对象:', JSON.stringify(result, null, 2))
// 3. 检查语言是否在白名单中 // 3. 检查语言是否在白名单中
if (isLanguageAllowed(result, allowedLanguages)) { if (isLanguageAllowed(result, allowedLanguages)) {
const processedText = richTranscribePostProcess(result.text) const processedText = richTranscribePostProcess(result.text)
console.log('[TranscribeWorker] 语言匹配,返回文本:', processedText)
parentPort.postMessage({ type: 'final', text: processedText }) parentPort.postMessage({ type: 'final', text: processedText })
} else { } else {
console.log('[TranscribeWorker] 语言不匹配,返回空文本')
parentPort.postMessage({ type: 'final', text: '' }) parentPort.postMessage({ type: 'final', text: '' })
} }

View File

@@ -19,6 +19,16 @@ if (parentPort) {
core.setLogEnabled(payload.enabled) core.setLogEnabled(payload.enabled)
result = { success: true } result = { success: true }
break break
case 'setMonitor':
core.setMonitor((type, json) => {
parentPort!.postMessage({
id: -1,
type: 'monitor',
payload: { type, json }
})
})
result = { success: true }
break
case 'testConnection': case 'testConnection':
result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid) result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid)
break break
@@ -38,6 +48,9 @@ if (parentPort) {
case 'getMessages': case 'getMessages':
result = await core.getMessages(payload.sessionId, payload.limit, payload.offset) result = await core.getMessages(payload.sessionId, payload.limit, payload.offset)
break break
case 'getNewMessages':
result = await core.getNewMessages(payload.sessionId, payload.minTime, payload.limit)
break
case 'getMessageCount': case 'getMessageCount':
result = await core.getMessageCount(payload.sessionId) result = await core.getMessageCount(payload.sessionId)
break break

View 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.

Before

Width:  |  Height:  |  Size: 203 KiB

After

Width:  |  Height:  |  Size: 185 KiB

17
package-lock.json generated
View File

@@ -25,6 +25,7 @@
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-router-dom": "^7.1.1", "react-router-dom": "^7.1.1",
"react-virtuoso": "^4.18.1",
"sherpa-onnx-node": "^1.10.38", "sherpa-onnx-node": "^1.10.38",
"silk-wasm": "^3.7.1", "silk-wasm": "^3.7.1",
"wechat-emojis": "^1.0.2", "wechat-emojis": "^1.0.2",
@@ -7380,12 +7381,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/nan": {
"version": "2.25.0",
"resolved": "https://registry.npmmirror.com/nan/-/nan-2.25.0.tgz",
"integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==",
"license": "MIT"
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",
@@ -8050,6 +8045,16 @@
"react-dom": ">=18" "react-dom": ">=18"
} }
}, },
"node_modules/react-virtuoso": {
"version": "4.18.1",
"resolved": "https://registry.npmmirror.com/react-virtuoso/-/react-virtuoso-4.18.1.tgz",
"integrity": "sha512-KF474cDwaSb9+SJ380xruBB4P+yGWcVkcu26HtMqYNMTYlYbrNy8vqMkE+GpAApPPufJqgOLMoWMFG/3pJMXUA==",
"license": "MIT",
"peerDependencies": {
"react": ">=16 || >=17 || >= 18 || >= 19",
"react-dom": ">=16 || >=17 || >= 18 || >=19"
}
},
"node_modules/read-binary-file-arch": { "node_modules/read-binary-file-arch": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmmirror.com/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", "resolved": "https://registry.npmmirror.com/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",

View File

@@ -35,6 +35,7 @@
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-router-dom": "^7.1.1", "react-router-dom": "^7.1.1",
"react-virtuoso": "^4.18.1",
"sherpa-onnx-node": "^1.10.38", "sherpa-onnx-node": "^1.10.38",
"silk-wasm": "^3.7.1", "silk-wasm": "^3.7.1",
"wechat-emojis": "^1.0.2", "wechat-emojis": "^1.0.2",

Binary file not shown.

View File

@@ -17,9 +17,11 @@ import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
import SettingsPage from './pages/SettingsPage' import SettingsPage from './pages/SettingsPage'
import ExportPage from './pages/ExportPage' import ExportPage from './pages/ExportPage'
import VideoWindow from './pages/VideoWindow' import VideoWindow from './pages/VideoWindow'
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'
@@ -30,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,
@@ -54,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)
// 锁定状态 // 锁定状态
@@ -73,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'
@@ -99,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(() => {
@@ -172,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)
@@ -203,6 +210,18 @@ function App() {
} }
} }
const handleIgnoreUpdate = async () => {
if (!updateInfo || !updateInfo.version) return
try {
await window.electronAPI.app.ignoreUpdate(updateInfo.version)
setShowUpdateDialog(false)
setUpdateInfo(null)
} catch (e: any) {
console.error('忽略更新失败:', e)
}
}
const dismissUpdate = () => { const dismissUpdate = () => {
setUpdateInfo(null) setUpdateInfo(null)
} }
@@ -229,18 +248,18 @@ function App() {
if (!onboardingDone) { if (!onboardingDone) {
await configService.setOnboardingDone(true) await configService.setOnboardingDone(true)
} }
console.log('检测到已保存的配置,正在自动连接...')
const result = await window.electronAPI.chat.connect() const result = await window.electronAPI.chat.connect()
if (result.success) { if (result.success) {
console.log('自动连接成功')
setDbConnected(true, dbPath) setDbConnected(true, dbPath)
// 如果当前在欢迎页,跳转到首页 // 如果当前在欢迎页,跳转到首页
if (window.location.hash === '#/' || window.location.hash === '') { if (window.location.hash === '#/' || window.location.hash === '') {
navigate('/home') navigate('/home')
} }
} else { } else {
console.log('自动连接失败:', result.error)
// 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户 // 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户
// 其他错误可能需要重新配置 // 其他错误可能需要重新配置
const errorMsg = result.error || '' const errorMsg = result.error || ''
@@ -306,11 +325,22 @@ function App() {
return <VideoWindow /> return <VideoWindow />
} }
// 独立图片查看窗口
const isImageViewerWindow = location.pathname === '/image-viewer-window'
if (isImageViewerWindow) {
return <ImageWindow />
}
// 独立聊天记录窗口 // 独立聊天记录窗口
if (isChatHistoryWindow) { if (isChatHistoryWindow) {
return <ChatHistoryPage /> return <ChatHistoryPage />
} }
// 独立通知窗口
if (isNotificationWindow) {
return <NotificationWindow />
}
// 主窗口 - 完整布局 // 主窗口 - 完整布局
return ( return (
<div className="app-container"> <div className="app-container">
@@ -326,6 +356,9 @@ function App() {
{/* 全局悬浮进度胶囊 (处理:新版本提示、下载进度、错误提示) */} {/* 全局悬浮进度胶囊 (处理:新版本提示、下载进度、错误提示) */}
<UpdateProgressCapsule /> <UpdateProgressCapsule />
{/* 全局会话监听与通知 */}
<GlobalSessionMonitor />
{/* 用户协议弹窗 */} {/* 用户协议弹窗 */}
{showAgreement && !agreementLoading && ( {showAgreement && !agreementLoading && (
<div className="agreement-overlay"> <div className="agreement-overlay">
@@ -383,6 +416,7 @@ function App() {
updateInfo={updateInfo} updateInfo={updateInfo}
onClose={() => setShowUpdateDialog(false)} onClose={() => setShowUpdateDialog(false)}
onUpdate={handleUpdateNow} onUpdate={handleUpdateNow}
onIgnore={handleIgnoreUpdate}
isDownloading={isDownloading} isDownloading={isDownloading}
progress={downloadProgress} progress={downloadProgress}
/> />

View 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
}

View 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;
}
}

View 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)
}

View File

@@ -171,6 +171,29 @@
.actions { .actions {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 12px;
.btn-ignore {
background: transparent;
color: #666666;
border: 1px solid #d0d0d0;
padding: 16px 32px;
border-radius: 20px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #f5f5f5;
border-color: #999999;
color: #333333;
}
&:active {
transform: scale(0.98);
}
}
.btn-update { .btn-update {
background: #000000; background: #000000;

View File

@@ -12,6 +12,7 @@ interface UpdateDialogProps {
updateInfo: UpdateInfo | null updateInfo: UpdateInfo | null
onClose: () => void onClose: () => void
onUpdate: () => void onUpdate: () => void
onIgnore?: () => void
isDownloading: boolean isDownloading: boolean
progress: number | { progress: number | {
percent: number percent: number
@@ -27,6 +28,7 @@ const UpdateDialog: React.FC<UpdateDialogProps> = ({
updateInfo, updateInfo,
onClose, onClose,
onUpdate, onUpdate,
onIgnore,
isDownloading, isDownloading,
progress progress
}) => { }) => {
@@ -118,6 +120,11 @@ const UpdateDialog: React.FC<UpdateDialogProps> = ({
</div> </div>
) : ( ) : (
<div className="actions"> <div className="actions">
{onIgnore && (
<button className="btn-ignore" onClick={onIgnore}>
</button>
)}
<button className="btn-update" onClick={onUpdate}> <button className="btn-update" onClick={onUpdate}>
</button> </button>

View File

@@ -91,7 +91,7 @@ function AnnualReportPage() {
<div className="annual-report-page"> <div className="annual-report-page">
<Sparkles size={32} className="header-icon" /> <Sparkles size={32} className="header-icon" />
<h1 className="page-title"></h1> <h1 className="page-title"></h1>
<p className="page-desc"></p> <p className="page-desc"></p>
<div className="report-sections"> <div className="report-sections">
<section className="report-section"> <section className="report-section">

View File

@@ -917,7 +917,7 @@ function AnnualReportWindow() {
<Avatar url={selfAvatarUrl} name="我" size="lg" /> <Avatar url={selfAvatarUrl} name="我" size="lg" />
</div> </div>
</div> </div>
<p className="hero-desc"><br /></p> <p className="hero-desc"><br /></p>
</section> </section>
{/* 双向奔赴 */} {/* 双向奔赴 */}
@@ -1017,15 +1017,15 @@ 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>
)} )}

View File

@@ -2146,8 +2146,7 @@
} }
.video-placeholder, .video-placeholder,
.video-loading, .video-loading {
.video-unavailable {
min-width: 120px; min-width: 120px;
min-height: 80px; min-height: 80px;
display: flex; display: flex;
@@ -2167,6 +2166,46 @@
} }
} }
.video-unavailable {
min-width: 160px;
min-height: 120px;
border-radius: 12px;
background: var(--bg-tertiary);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
color: var(--text-tertiary);
font-size: 12px;
border: none;
cursor: pointer;
text-align: center;
-webkit-app-region: no-drag;
transition: transform 0.15s ease, box-shadow 0.15s ease;
svg {
width: 24px;
height: 24px;
opacity: 0.6;
}
&.clicked {
transform: scale(0.98);
box-shadow: 0 0 0 2px var(--primary-light);
}
&:disabled {
cursor: default;
opacity: 0.7;
}
}
.video-action {
font-size: 11px;
color: var(--text-quaternary);
}
.video-loading { .video-loading {
.spin { .spin {
animation: spin 1s linear infinite; animation: spin 1s linear infinite;

View File

@@ -4,7 +4,6 @@ import { createPortal } from 'react-dom'
import { useChatStore } from '../stores/chatStore' import { useChatStore } from '../stores/chatStore'
import type { ChatSession, Message } from '../types/models' import type { ChatSession, Message } from '../types/models'
import { getEmojiPath } from 'wechat-emojis' import { getEmojiPath } from 'wechat-emojis'
import { ImagePreview } from '../components/ImagePreview'
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog' import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
import { AnimatedStreamingText } from '../components/AnimatedStreamingText' import { AnimatedStreamingText } from '../components/AnimatedStreamingText'
import JumpToDateDialog from '../components/JumpToDateDialog' import JumpToDateDialog from '../components/JumpToDateDialog'
@@ -192,6 +191,7 @@ function ChatPage(_props: ChatPageProps) {
const isLoadingMessagesRef = useRef(false) const isLoadingMessagesRef = useRef(false)
const isLoadingMoreRef = useRef(false) const isLoadingMoreRef = useRef(false)
const isConnectedRef = useRef(false) const isConnectedRef = useRef(false)
const isRefreshingRef = useRef(false)
const searchKeywordRef = useRef('') const searchKeywordRef = useRef('')
const preloadImageKeysRef = useRef<Set<string>>(new Set()) const preloadImageKeysRef = useRef<Set<string>>(new Set())
const lastPreloadSessionRef = useRef<string | null>(null) const lastPreloadSessionRef = useRef<string | null>(null)
@@ -286,6 +286,11 @@ function ChatPage(_props: ChatPageProps) {
setSessions setSessions
]) ])
// 同步 currentSessionId 到 ref
useEffect(() => {
currentSessionRef.current = currentSessionId
}, [currentSessionId])
// 加载会话列表(优化:先返回基础数据,异步加载联系人信息) // 加载会话列表(优化:先返回基础数据,异步加载联系人信息)
const loadSessions = async (options?: { silent?: boolean }) => { const loadSessions = async (options?: { silent?: boolean }) => {
if (options?.silent) { if (options?.silent) {
@@ -301,7 +306,10 @@ 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)) {
setSessions(nextSessions) setSessions(nextSessions)
sessionsRef.current = nextSessions
// 立即启动联系人信息加载,不再延迟 500ms // 立即启动联系人信息加载,不再延迟 500ms
void enrichSessionsContactInfo(nextSessions) void enrichSessionsContactInfo(nextSessions)
} else { } else {
@@ -330,14 +338,14 @@ function ChatPage(_props: ChatPageProps) {
// 防止重复加载 // 防止重复加载
if (isEnrichingRef.current) { if (isEnrichingRef.current) {
console.log('[性能监控] 联系人信息正在加载中,跳过重复请求')
return return
} }
isEnrichingRef.current = true isEnrichingRef.current = true
enrichCancelledRef.current = false enrichCancelledRef.current = false
console.log(`[性能监控] 开始加载联系人信息,会话数: ${sessions.length}`)
const totalStart = performance.now() const totalStart = performance.now()
// 移除初始 500ms 延迟,让后台加载与 UI 渲染并行 // 移除初始 500ms 延迟,让后台加载与 UI 渲染并行
@@ -352,12 +360,12 @@ function ChatPage(_props: ChatPageProps) {
// 找出需要加载联系人信息的会话(没有头像或者没有显示名称的) // 找出需要加载联系人信息的会话(没有头像或者没有显示名称的)
const needEnrich = sessions.filter(s => !s.avatarUrl || !s.displayName || s.displayName === s.username) const needEnrich = sessions.filter(s => !s.avatarUrl || !s.displayName || s.displayName === s.username)
if (needEnrich.length === 0) { if (needEnrich.length === 0) {
console.log('[性能监控] 所有联系人信息已缓存,跳过加载')
isEnrichingRef.current = false isEnrichingRef.current = false
return return
} }
console.log(`[性能监控] 需要加载的联系人信息: ${needEnrich.length}`)
// 进一步减少批次大小每批3个避免DLL调用阻塞 // 进一步减少批次大小每批3个避免DLL调用阻塞
const batchSize = 3 const batchSize = 3
@@ -366,7 +374,7 @@ function ChatPage(_props: ChatPageProps) {
for (let i = 0; i < needEnrich.length; i += batchSize) { for (let i = 0; i < needEnrich.length; i += batchSize) {
// 如果正在滚动,暂停加载 // 如果正在滚动,暂停加载
if (isScrollingRef.current) { if (isScrollingRef.current) {
console.log('[性能监控] 检测到滚动,暂停加载联系人信息')
// 等待滚动结束 // 等待滚动结束
while (isScrollingRef.current && !enrichCancelledRef.current) { while (isScrollingRef.current && !enrichCancelledRef.current) {
await new Promise(resolve => setTimeout(resolve, 200)) await new Promise(resolve => setTimeout(resolve, 200))
@@ -410,9 +418,9 @@ function ChatPage(_props: ChatPageProps) {
const totalTime = performance.now() - totalStart const totalTime = performance.now() - totalStart
if (!enrichCancelledRef.current) { if (!enrichCancelledRef.current) {
console.log(`[性能监控] 联系人信息加载完成,总耗时: ${totalTime.toFixed(2)}ms, 已加载: ${loadedCount}/${needEnrich.length}`)
} else { } else {
console.log(`[性能监控] 联系人信息加载被取消,已加载: ${loadedCount}/${needEnrich.length}`)
} }
} catch (e) { } catch (e) {
console.error('加载联系人信息失败:', e) console.error('加载联系人信息失败:', e)
@@ -514,7 +522,7 @@ function ChatPage(_props: ChatPageProps) {
// 如果是自己的信息且当前个人头像为空,同步更新 // 如果是自己的信息且当前个人头像为空,同步更新
if (myWxid && username === myWxid && contact.avatarUrl && !myAvatarUrl) { if (myWxid && username === myWxid && contact.avatarUrl && !myAvatarUrl) {
console.log('[ChatPage] 从联系人同步获取到个人头像')
setMyAvatarUrl(contact.avatarUrl) setMyAvatarUrl(contact.avatarUrl)
} }
@@ -542,12 +550,61 @@ function ChatPage(_props: ChatPageProps) {
// 刷新当前会话消息(增量更新新消息) // 刷新当前会话消息(增量更新新消息)
const [isRefreshingMessages, setIsRefreshingMessages] = useState(false) const [isRefreshingMessages, setIsRefreshingMessages] = useState(false)
/**
* 极速增量刷新:基于最后一条消息时间戳,获取后续新消息
* (由用户建议:记住上一条消息时间,自动取之后的并渲染,然后后台兜底全量同步)
*/
const handleIncrementalRefresh = async () => {
if (!currentSessionId || isRefreshingRef.current) return
isRefreshingRef.current = true
setIsRefreshingMessages(true)
// 找出当前已渲染消息中的最大时间戳(使用 getState 获取最新状态,避免闭包过时导致重复)
const currentMessages = useChatStore.getState().messages
const lastMsg = currentMessages[currentMessages.length - 1]
const minTime = lastMsg?.createTime || 0
// 1. 优先执行增量查询并渲染(第一步)
try {
const result = await (window.electronAPI.chat as any).getNewMessages(currentSessionId, minTime) as {
success: boolean;
messages?: Message[];
error?: string
}
if (result.success && result.messages && result.messages.length > 0) {
// 过滤去重:必须对比实时的状态,防止在 handleRefreshMessages 运行期间导致的冲突
const latestMessages = useChatStore.getState().messages
const existingKeys = new Set(latestMessages.map(getMessageKey))
const newOnes = result.messages.filter(m => !existingKeys.has(getMessageKey(m)))
if (newOnes.length > 0) {
appendMessages(newOnes, false)
flashNewMessages(newOnes.map(getMessageKey))
// 滚动到底部
requestAnimationFrame(() => {
if (messageListRef.current) {
messageListRef.current.scrollTop = messageListRef.current.scrollHeight
}
})
}
}
} catch (e) {
console.warn('[IncrementalRefresh] 失败,将依赖全量同步兜底:', e)
} finally {
isRefreshingRef.current = false
setIsRefreshingMessages(false)
}
}
const handleRefreshMessages = async () => { const handleRefreshMessages = async () => {
if (!currentSessionId || isRefreshingMessages) return if (!currentSessionId || isRefreshingRef.current) return
setJumpStartTime(0) setJumpStartTime(0)
setJumpEndTime(0) setJumpEndTime(0)
setHasMoreLater(false) setHasMoreLater(false)
setIsRefreshingMessages(true) setIsRefreshingMessages(true)
isRefreshingRef.current = true
try { try {
// 获取最新消息并增量添加 // 获取最新消息并增量添加
const result = await window.electronAPI.chat.getLatestMessages(currentSessionId, 50) as { const result = await window.electronAPI.chat.getLatestMessages(currentSessionId, 50) as {
@@ -558,13 +615,17 @@ function ChatPage(_props: ChatPageProps) {
if (!result.success || !result.messages) { if (!result.success || !result.messages) {
return return
} }
const existing = new Set(messages.map(getMessageKey)) // 使用实时状态进行去重对比
const lastMsg = messages[messages.length - 1] const latestMessages = useChatStore.getState().messages
const existing = new Set(latestMessages.map(getMessageKey))
const lastMsg = latestMessages[latestMessages.length - 1]
const lastTime = lastMsg?.createTime ?? 0 const lastTime = lastMsg?.createTime ?? 0
const newMessages = result.messages.filter((msg) => { const newMessages = result.messages.filter((msg) => {
const key = getMessageKey(msg) const key = getMessageKey(msg)
if (existing.has(key)) return false if (existing.has(key)) return false
if (lastTime > 0 && msg.createTime < lastTime) return false // 这里的 lastTime 仅作参考过滤,主要的去重靠 key
if (lastTime > 0 && msg.createTime < lastTime - 3600) return false // 仅过滤 1 小时之前的冗余请求
return true return true
}) })
if (newMessages.length > 0) { if (newMessages.length > 0) {
@@ -580,10 +641,13 @@ function ChatPage(_props: ChatPageProps) {
} catch (e) { } catch (e) {
console.error('刷新消息失败:', e) console.error('刷新消息失败:', e)
} finally { } finally {
isRefreshingRef.current = false
setIsRefreshingMessages(false) setIsRefreshingMessages(false)
} }
} }
// 加载消息 // 加载消息
const loadMessages = async (sessionId: string, offset = 0, startTime = 0, endTime = 0) => { const loadMessages = async (sessionId: string, offset = 0, startTime = 0, endTime = 0) => {
const listEl = messageListRef.current const listEl = messageListRef.current
@@ -621,7 +685,7 @@ function ChatPage(_props: ChatPageProps) {
.map(m => m.senderUsername as string) .map(m => m.senderUsername as string)
)] )]
if (unknownSenders.length > 0) { if (unknownSenders.length > 0) {
console.log(`[性能监控] 预取消息发送者信息: ${unknownSenders.length}`)
// 在批量请求前,先将这些发送者标记为加载中,防止 MessageBubble 触发重复请求 // 在批量请求前,先将这些发送者标记为加载中,防止 MessageBubble 触发重复请求
const batchPromise = loadContactInfoBatch(unknownSenders) const batchPromise = loadContactInfoBatch(unknownSenders)
unknownSenders.forEach(username => { unknownSenders.forEach(username => {
@@ -1531,7 +1595,6 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
const [voiceTranscriptLoading, setVoiceTranscriptLoading] = useState(false) const [voiceTranscriptLoading, setVoiceTranscriptLoading] = useState(false)
const [voiceTranscriptError, setVoiceTranscriptError] = useState(false) const [voiceTranscriptError, setVoiceTranscriptError] = useState(false)
const voiceTranscriptRequestedRef = useRef(false) const voiceTranscriptRequestedRef = useRef(false)
const [showImagePreview, setShowImagePreview] = useState(false)
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(true) const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(true)
const [voiceCurrentTime, setVoiceCurrentTime] = useState(0) const [voiceCurrentTime, setVoiceCurrentTime] = useState(0)
const [voiceDuration, setVoiceDuration] = useState(0) const [voiceDuration, setVoiceDuration] = useState(0)
@@ -1549,23 +1612,13 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
useEffect(() => { useEffect(() => {
if (!isVideo) return if (!isVideo) return
console.log('[Video Debug] Full message object:', JSON.stringify(message, null, 2))
console.log('[Video Debug] Message keys:', Object.keys(message))
console.log('[Video Debug] Message:', {
localId: message.localId,
localType: message.localType,
hasVideoMd5: !!message.videoMd5,
hasContent: !!message.content,
hasParsedContent: !!message.parsedContent,
hasRawContent: !!(message as any).rawContent,
contentPreview: message.content?.substring(0, 200),
parsedContentPreview: message.parsedContent?.substring(0, 200),
rawContentPreview: (message as any).rawContent?.substring(0, 200)
})
// 优先使用数据库中的 videoMd5 // 优先使用数据库中的 videoMd5
if (message.videoMd5) { if (message.videoMd5) {
console.log('[Video Debug] Using videoMd5 from message:', message.videoMd5)
setVideoMd5(message.videoMd5) setVideoMd5(message.videoMd5)
return return
} }
@@ -1573,11 +1626,11 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
// 尝试从多个可能的字段获取原始内容 // 尝试从多个可能的字段获取原始内容
const contentToUse = message.content || (message as any).rawContent || message.parsedContent const contentToUse = message.content || (message as any).rawContent || message.parsedContent
if (contentToUse) { if (contentToUse) {
console.log('[Video Debug] Parsing MD5 from content, length:', contentToUse.length)
window.electronAPI.video.parseVideoMd5(contentToUse).then((result: { success: boolean; md5?: string; error?: string }) => { window.electronAPI.video.parseVideoMd5(contentToUse).then((result: { success: boolean; md5?: string; error?: string }) => {
console.log('[Video Debug] Parse result:', result)
if (result && result.success && result.md5) { if (result && result.success && result.md5) {
console.log('[Video Debug] Parsed MD5:', result.md5)
setVideoMd5(result.md5) setVideoMd5(result.md5)
} else { } else {
console.error('[Video Debug] Failed to parse MD5:', result) console.error('[Video Debug] Failed to parse MD5:', result)
@@ -1907,11 +1960,11 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
}, [isImage, imageHasUpdate, imageInView, imageCacheKey, triggerForceHd]) }, [isImage, imageHasUpdate, imageInView, imageCacheKey, triggerForceHd])
useEffect(() => { useEffect(() => {
if (!isImage || !showImagePreview || !imageHasUpdate) return if (!isImage || !imageHasUpdate) return
if (imageAutoHdTriggered.current === imageCacheKey) return if (imageAutoHdTriggered.current === imageCacheKey) return
imageAutoHdTriggered.current = imageCacheKey imageAutoHdTriggered.current = imageCacheKey
triggerForceHd() triggerForceHd()
}, [isImage, showImagePreview, imageHasUpdate, imageCacheKey, triggerForceHd]) }, [isImage, imageHasUpdate, imageCacheKey, triggerForceHd])
// 更激进:进入视野/打开预览时,无论 hasUpdate 与否都尝试强制高清 // 更激进:进入视野/打开预览时,无论 hasUpdate 与否都尝试强制高清
useEffect(() => { useEffect(() => {
@@ -1919,11 +1972,6 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
triggerForceHd() triggerForceHd()
}, [isImage, imageInView, triggerForceHd]) }, [isImage, imageInView, triggerForceHd])
useEffect(() => {
if (!isImage || !showImagePreview) return
triggerForceHd()
}, [isImage, showImagePreview, triggerForceHd])
useEffect(() => { useEffect(() => {
if (!isVoice) return if (!isVoice) return
@@ -2061,11 +2109,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
String(message.localId), String(message.localId),
message.createTime message.createTime
) )
console.log('[ChatPage] 调用转写:', {
sessionId: session.username,
msgId: message.localId,
createTime: message.createTime
})
if (result.success) { if (result.success) {
const transcriptText = (result.transcript || '').trim() const transcriptText = (result.transcript || '').trim()
voiceTranscriptCache.set(voiceTranscriptCacheKey, transcriptText) voiceTranscriptCache.set(voiceTranscriptCacheKey, transcriptText)
@@ -2111,6 +2155,9 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
}, [isVoice, message.localId, requestVoiceTranscript]) }, [isVoice, message.localId, requestVoiceTranscript])
// 视频懒加载 // 视频懒加载
const videoAutoLoadTriggered = useRef(false)
const [videoClicked, setVideoClicked] = useState(false)
useEffect(() => { useEffect(() => {
if (!isVideo || !videoContainerRef.current) return if (!isVideo || !videoContainerRef.current) return
@@ -2134,19 +2181,18 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
return () => observer.disconnect() return () => observer.disconnect()
}, [isVideo]) }, [isVideo])
// 加载视频信息 // 视频加载中状态引用,避免依赖问题
useEffect(() => { const videoLoadingRef = useRef(false)
if (!isVideo || !isVideoVisible || videoInfo || videoLoading) return
if (!videoMd5) {
console.log('[Video Debug] No videoMd5 available yet')
return
}
console.log('[Video Debug] Loading video info for MD5:', videoMd5) // 加载视频信息(添加重试机制)
const requestVideoInfo = useCallback(async () => {
if (!videoMd5 || videoLoadingRef.current) return
videoLoadingRef.current = true
setVideoLoading(true) setVideoLoading(true)
window.electronAPI.video.getVideoInfo(videoMd5).then((result: { success: boolean; exists: boolean; videoUrl?: string; coverUrl?: string; thumbUrl?: string; error?: string }) => { try {
console.log('[Video Debug] getVideoInfo result:', result) const result = await window.electronAPI.video.getVideoInfo(videoMd5)
if (result && result.success) { if (result && result.success && result.exists) {
setVideoInfo({ setVideoInfo({
exists: result.exists, exists: result.exists,
videoUrl: result.videoUrl, videoUrl: result.videoUrl,
@@ -2154,16 +2200,25 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
thumbUrl: result.thumbUrl thumbUrl: result.thumbUrl
}) })
} else { } else {
console.error('[Video Debug] Video info failed:', result)
setVideoInfo({ exists: false }) setVideoInfo({ exists: false })
} }
}).catch((err: unknown) => { } catch (err) {
console.error('[Video Debug] getVideoInfo error:', err)
setVideoInfo({ exists: false }) setVideoInfo({ exists: false })
}).finally(() => { } finally {
videoLoadingRef.current = false
setVideoLoading(false) setVideoLoading(false)
}) }
}, [isVideo, isVideoVisible, videoInfo, videoLoading, videoMd5]) }, [videoMd5])
// 视频进入视野时自动加载
useEffect(() => {
if (!isVideo || !isVideoVisible) return
if (videoInfo?.exists) return // 已成功加载,不需要重试
if (videoAutoLoadTriggered.current) return
videoAutoLoadTriggered.current = true
void requestVideoInfo()
}, [isVideo, isVideoVisible, videoInfo, requestVideoInfo])
// 根据设置决定是否自动转写 // 根据设置决定是否自动转写
@@ -2278,15 +2333,12 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
if (imageHasUpdate) { if (imageHasUpdate) {
void requestImageDecrypt(true, true) void requestImageDecrypt(true, true)
} }
setShowImagePreview(true) void window.electronAPI.window.openImageViewerWindow(imageLocalPath)
}} }}
onLoad={() => setImageError(false)} onLoad={() => setImageError(false)}
onError={() => setImageError(true)} onError={() => setImageError(true)}
/> />
</div> </div>
{showImagePreview && (
<ImagePreview src={imageLocalPath} onClose={() => setShowImagePreview(false)} />
)}
</> </>
)} )}
</div> </div>
@@ -2325,16 +2377,27 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
) )
} }
// 视频不存在 // 视频不存在 - 添加点击重试功能
if (!videoInfo?.exists || !videoInfo.videoUrl) { if (!videoInfo?.exists || !videoInfo.videoUrl) {
return ( return (
<div className="video-unavailable" ref={videoContainerRef}> <button
className={`video-unavailable ${videoClicked ? 'clicked' : ''}`}
ref={videoContainerRef}
onClick={() => {
setVideoClicked(true)
setTimeout(() => setVideoClicked(false), 800)
videoAutoLoadTriggered.current = false
void requestVideoInfo()
}}
type="button"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="23 7 16 12 23 17 23 7"></polygon> <polygon points="23 7 16 12 23 17 23 7"></polygon>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect> <rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
</svg> </svg>
<span></span> <span></span>
</div> <span className="video-action">{videoClicked ? '已点击…' : '点击重试'}</span>
</button>
) )
} }
@@ -2684,7 +2747,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
const content = message.rawContent || message.content || message.parsedContent || '' const content = message.rawContent || message.content || message.parsedContent || ''
// 添加调试日志 // 添加调试日志
console.log('[Transfer Debug] Raw content:', content.substring(0, 500))
const parser = new DOMParser() const parser = new DOMParser()
const doc = parser.parseFromString(content, 'text/xml') const doc = parser.parseFromString(content, 'text/xml')
@@ -2693,7 +2756,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
const payMemo = doc.querySelector('pay_memo')?.textContent || '' const payMemo = doc.querySelector('pay_memo')?.textContent || ''
const paysubtype = doc.querySelector('paysubtype')?.textContent || '1' const paysubtype = doc.querySelector('paysubtype')?.textContent || '1'
console.log('[Transfer Debug] Parsed:', { feedesc, payMemo, paysubtype, title })
// paysubtype: 1=待收款, 3=已收款 // paysubtype: 1=待收款, 3=已收款
const isReceived = paysubtype === '3' const isReceived = paysubtype === '3'
@@ -2743,7 +2806,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
<div className="miniapp-message"> <div className="miniapp-message">
<div className="miniapp-icon"> <div className="miniapp-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/> <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
</svg> </svg>
</div> </div>
<div className="miniapp-info"> <div className="miniapp-info">

View File

@@ -41,15 +41,10 @@ function ContactsPage() {
return return
} }
const contactsResult = await window.electronAPI.chat.getContacts() const contactsResult = await window.electronAPI.chat.getContacts()
console.log('📞 getContacts结果:', contactsResult)
if (contactsResult.success && contactsResult.contacts) { if (contactsResult.success && contactsResult.contacts) {
console.log('📊 总联系人数:', contactsResult.contacts.length)
console.log('📊 按类型统计:', {
friends: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'friend').length,
groups: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'group').length,
officials: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'official').length,
other: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'other').length
})
// 获取头像URL // 获取头像URL
const usernames = contactsResult.contacts.map((c: ContactInfo) => c.username) const usernames = contactsResult.contacts.map((c: ContactInfo) => c.username)

View File

@@ -0,0 +1,99 @@
.image-window-container {
width: 100vw;
height: 100vh;
background-color: var(--bg-primary);
display: flex;
flex-direction: column;
overflow: hidden;
user-select: none;
.title-bar {
height: 40px;
min-height: 40px;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
padding-right: 140px; // 为原生窗口控件留出空间
.window-drag-area {
flex: 1;
height: 100%;
-webkit-app-region: drag;
}
.title-bar-controls {
display: flex;
align-items: center;
gap: 8px;
-webkit-app-region: no-drag;
margin-right: 16px;
button {
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 6px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
}
.scale-text {
min-width: 50px;
text-align: center;
color: var(--text-secondary);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.divider {
width: 1px;
height: 14px;
background: var(--border-color);
margin: 0 4px;
}
}
}
.image-viewport {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
cursor: grab;
&:active {
cursor: grabbing;
}
img {
max-width: none;
max-height: none;
object-fit: contain;
will-change: transform;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
pointer-events: auto;
}
}
}
.image-window-empty {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
background-color: var(--bg-primary);
}

162
src/pages/ImageWindow.tsx Normal file
View File

@@ -0,0 +1,162 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom'
import { ZoomIn, ZoomOut, RotateCw, RotateCcw } from 'lucide-react'
import './ImageWindow.scss'
export default function ImageWindow() {
const [searchParams] = useSearchParams()
const imagePath = searchParams.get('imagePath')
const [scale, setScale] = useState(1)
const [rotation, setRotation] = useState(0)
const [position, setPosition] = useState({ x: 0, y: 0 })
const [initialScale, setInitialScale] = useState(1)
const viewportRef = useRef<HTMLDivElement>(null)
// 使用 ref 存储拖动状态,避免闭包问题
const dragStateRef = useRef({
isDragging: false,
startX: 0,
startY: 0,
startPosX: 0,
startPosY: 0
})
const handleZoomIn = () => setScale(prev => Math.min(prev + 0.25, 10))
const handleZoomOut = () => setScale(prev => Math.max(prev - 0.25, 0.1))
const handleRotate = () => setRotation(prev => (prev + 90) % 360)
const handleRotateCcw = () => setRotation(prev => (prev - 90 + 360) % 360)
// 重置视图
const handleReset = useCallback(() => {
setScale(1)
setRotation(0)
setPosition({ x: 0, y: 0 })
}, [])
// 图片加载完成后计算初始缩放
const handleImageLoad = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget
const naturalWidth = img.naturalWidth
const naturalHeight = img.naturalHeight
if (viewportRef.current) {
const viewportWidth = viewportRef.current.clientWidth * 0.9
const viewportHeight = viewportRef.current.clientHeight * 0.9
const scaleX = viewportWidth / naturalWidth
const scaleY = viewportHeight / naturalHeight
const fitScale = Math.min(scaleX, scaleY, 1)
setInitialScale(fitScale)
setScale(1)
}
}, [])
// 使用原生事件监听器处理拖动
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!dragStateRef.current.isDragging) return
const dx = e.clientX - dragStateRef.current.startX
const dy = e.clientY - dragStateRef.current.startY
setPosition({
x: dragStateRef.current.startPosX + dx,
y: dragStateRef.current.startPosY + dy
})
}
const handleMouseUp = () => {
dragStateRef.current.isDragging = false
document.body.style.cursor = ''
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
}, [])
const handleMouseDown = (e: React.MouseEvent) => {
if (e.button !== 0) return
e.preventDefault()
dragStateRef.current = {
isDragging: true,
startX: e.clientX,
startY: e.clientY,
startPosX: position.x,
startPosY: position.y
}
document.body.style.cursor = 'grabbing'
}
const handleWheel = useCallback((e: React.WheelEvent) => {
const delta = -Math.sign(e.deltaY) * 0.15
setScale(prev => Math.min(Math.max(prev + delta, 0.1), 10))
}, [])
// 双击重置
const handleDoubleClick = useCallback(() => {
handleReset()
}, [handleReset])
// 快捷键支持
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') window.electronAPI.window.close()
if (e.key === '=' || e.key === '+') handleZoomIn()
if (e.key === '-') handleZoomOut()
if (e.key === 'r' || e.key === 'R') handleRotate()
if (e.key === '0') handleReset()
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleReset])
if (!imagePath) {
return (
<div className="image-window-empty">
<span></span>
</div>
)
}
const displayScale = initialScale * scale
return (
<div className="image-window-container">
<div className="title-bar">
<div className="window-drag-area"></div>
<div className="title-bar-controls">
<button onClick={handleZoomOut} title="缩小 (-)"><ZoomOut size={16} /></button>
<span className="scale-text">{Math.round(displayScale * 100)}%</span>
<button onClick={handleZoomIn} title="放大 (+)"><ZoomIn size={16} /></button>
<div className="divider"></div>
<button onClick={handleRotateCcw} title="逆时针旋转"><RotateCcw size={16} /></button>
<button onClick={handleRotate} title="顺时针旋转 (R)"><RotateCw size={16} /></button>
</div>
</div>
<div
className="image-viewport"
ref={viewportRef}
onWheel={handleWheel}
onDoubleClick={handleDoubleClick}
onMouseDown={handleMouseDown}
>
<img
src={imagePath}
alt="Preview"
style={{
transform: `translate(${position.x}px, ${position.y}px) scale(${displayScale}) rotate(${rotation}deg)`
}}
onLoad={handleImageLoad}
draggable={false}
/>
</div>
</div>
)
}

View 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;
}

View 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>
)
}

View File

@@ -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);
}

View File

@@ -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']
@@ -316,6 +359,19 @@ function SettingsPage() {
} }
} }
const handleIgnoreUpdate = async () => {
if (!updateInfo || !updateInfo.version) return
try {
await window.electronAPI.app.ignoreUpdate(updateInfo.version)
setShowUpdateDialog(false)
setUpdateInfo(null)
showMessage(`已忽略版本 ${updateInfo.version}`, true)
} catch (e: any) {
showMessage(`操作失败: ${e}`, false)
}
}
const showMessage = (text: string, success: boolean) => { const showMessage = (text: string, success: boolean) => {
@@ -829,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">
@@ -1661,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()}

View File

@@ -149,7 +149,7 @@ export default function SnsPage() {
const currentPosts = postsRef.current const currentPosts = postsRef.current
if (currentPosts.length > 0) { if (currentPosts.length > 0) {
const topTs = currentPosts[0].createTime const topTs = currentPosts[0].createTime
console.log('[SnsPage] Fetching newer posts starts from:', topTs + 1);
const result = await window.electronAPI.sns.getTimeline( const result = await window.electronAPI.sns.getTimeline(
limit, limit,
@@ -281,10 +281,10 @@ export default function SnsPage() {
const checkSchema = async () => { const checkSchema = async () => {
try { try {
const schema = await window.electronAPI.chat.execQuery('sns', null, "PRAGMA table_info(SnsTimeLine)"); const schema = await window.electronAPI.chat.execQuery('sns', null, "PRAGMA table_info(SnsTimeLine)");
console.log('[SnsPage] SnsTimeLine Schema:', schema);
if (schema.success && schema.rows) { if (schema.success && schema.rows) {
const columns = schema.rows.map((r: any) => r.name); const columns = schema.rows.map((r: any) => r.name);
console.log('[SnsPage] Available columns:', columns);
} }
} catch (e) { } catch (e) {
console.error('[SnsPage] Failed to check schema:', e); console.error('[SnsPage] Failed to check schema:', e);
@@ -335,7 +335,7 @@ export default function SnsPage() {
// deltaY < 0 表示向上滚scrollTop === 0 表示已经在最顶端 // deltaY < 0 表示向上滚scrollTop === 0 表示已经在最顶端
if (e.deltaY < -20 && container.scrollTop <= 0 && hasNewer && !loading && !loadingNewer) { if (e.deltaY < -20 && container.scrollTop <= 0 && hasNewer && !loading && !loadingNewer) {
console.log('[SnsPage] Wheel-up detected at top, loading newer posts...');
loadPosts({ direction: 'newer' }) loadPosts({ direction: 'newer' })
} }
} }

View File

@@ -35,7 +35,16 @@ export const CONFIG_KEYS = {
// 安全 // 安全
AUTH_ENABLED: 'authEnabled', AUTH_ENABLED: 'authEnabled',
AUTH_PASSWORD: 'authPassword', AUTH_PASSWORD: 'authPassword',
AUTH_USE_HELLO: 'authUseHello' AUTH_USE_HELLO: 'authUseHello',
// 更新
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 {
@@ -399,3 +408,60 @@ export async function getAuthUseHello(): Promise<boolean> {
export async function setAuthUseHello(useHello: boolean): Promise<void> { export async function setAuthUseHello(useHello: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AUTH_USE_HELLO, useHello) await config.set(CONFIG_KEYS.AUTH_USE_HELLO, useHello)
} }
// === 更新相关 ===
// 获取被忽略的更新版本
export async function getIgnoredUpdateVersion(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.IGNORED_UPDATE_VERSION)
return (value as string) || null
}
// 设置被忽略的更新版本
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)
}

View File

@@ -80,11 +80,23 @@ export const useChatStore = create<ChatState>((set, get) => ({
setMessages: (messages) => set({ messages }), setMessages: (messages) => set({ messages }),
appendMessages: (newMessages, prepend = false) => set((state) => ({ appendMessages: (newMessages, prepend = false) => set((state) => {
messages: prepend // 强制去重逻辑
? [...newMessages, ...state.messages] const getMsgKey = (m: Message) => {
: [...state.messages, ...newMessages] if (m.localId && m.localId > 0) return `l:${m.localId}`
})), return `t:${m.createTime}:${m.sortSeq || 0}:${m.serverId || 0}`
}
const existingKeys = new Set(state.messages.map(getMsgKey))
const filtered = newMessages.filter(m => !existingKeys.has(getMsgKey(m)))
if (filtered.length === 0) return state
return {
messages: prepend
? [...filtered, ...state.messages]
: [...state.messages, ...filtered]
}
}),
setLoadingMessages: (loading) => set({ isLoadingMessages: loading }), setLoadingMessages: (loading) => set({ isLoadingMessages: loading }),
setLoadingMore: (loading) => set({ isLoadingMore: loading }), setLoadingMore: (loading) => set({ isLoadingMore: loading }),

View File

@@ -11,6 +11,7 @@ export interface ElectronAPI {
setTitleBarOverlay: (options: { symbolColor: string }) => void setTitleBarOverlay: (options: { symbolColor: string }) => void
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise<void> openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise<void>
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void> resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
openImageViewerWindow: (imagePath: string) => Promise<void>
openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean> openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean>
} }
config: { config: {
@@ -32,6 +33,7 @@ export interface ElectronAPI {
getVersion: () => Promise<string> getVersion: () => Promise<string>
checkForUpdates: () => Promise<{ hasUpdate: boolean; version?: string; releaseNotes?: string }> checkForUpdates: () => Promise<{ hasUpdate: boolean; version?: string; releaseNotes?: string }>
downloadAndInstall: () => Promise<void> downloadAndInstall: () => Promise<void>
ignoreUpdate: (version: string) => Promise<{ success: boolean }>
onDownloadProgress: (callback: (progress: number) => void) => () => void onDownloadProgress: (callback: (progress: number) => void) => () => void
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void
} }
@@ -76,6 +78,11 @@ export interface ElectronAPI {
messages?: Message[] messages?: Message[]
error?: string error?: string
}> }>
getNewMessages: (sessionId: string, minTime: number, limit?: number) => Promise<{
success: boolean
messages?: Message[]
error?: string
}>
getContact: (username: string) => Promise<Contact | null> getContact: (username: string) => Promise<Contact | null>
getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null> getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null>
getContacts: () => Promise<{ getContacts: () => Promise<{
@@ -109,6 +116,7 @@ export interface ElectronAPI {
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
execQuery: (kind: string, path: string | null, sql: string) => Promise<{ success: boolean; rows?: any[]; error?: string }> execQuery: (kind: string, path: string | null, sql: string) => Promise<{ success: boolean; rows?: any[]; error?: string }>
getMessage: (sessionId: string, localId: number) => Promise<{ success: boolean; message?: Message; error?: string }> getMessage: (sessionId: string, localId: number) => Promise<{ success: boolean; message?: Message; error?: string }>
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => () => void
} }
image: { image: {

View File

@@ -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
} }
// 联系人 // 联系人