mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-26 07:35:50 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9216aabad | ||
|
|
79d6aef480 | ||
|
|
8134d62056 | ||
|
|
8664ebf6f5 | ||
|
|
7b832ac2ef | ||
|
|
5934fc33ce | ||
|
|
b6d10f79de | ||
|
|
f90822694f | ||
|
|
123a088a39 | ||
|
|
cb37f534ac | ||
|
|
50903b35cf |
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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: []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
}
|
||||||
|
|
||||||
private normalizeAppMessageContent(content: string): string {
|
private normalizeAppMessageContent(content: string): string {
|
||||||
if (!content) return ''
|
if (!content) return ''
|
||||||
if (content.includes('<') && content.includes('>')) {
|
if (content.includes('<') && content.includes('>')) {
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }> {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }> {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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 未连接' }
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取消息总数
|
* 获取消息总数
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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: '' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
200
electron/windows/notificationWindow.ts
Normal file
200
electron/windows/notificationWindow.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import { BrowserWindow, ipcMain, screen } from 'electron'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { ConfigService } from '../services/config'
|
||||||
|
|
||||||
|
let notificationWindow: BrowserWindow | null = null
|
||||||
|
let closeTimer: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
|
export function createNotificationWindow() {
|
||||||
|
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||||
|
return notificationWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||||
|
const iconPath = isDev
|
||||||
|
? join(__dirname, '../../public/icon.ico')
|
||||||
|
: join(process.resourcesPath, 'icon.ico')
|
||||||
|
|
||||||
|
console.log('[NotificationWindow] Creating window...')
|
||||||
|
const width = 344
|
||||||
|
const height = 114
|
||||||
|
|
||||||
|
// Update default creation size
|
||||||
|
notificationWindow = new BrowserWindow({
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
type: 'toolbar', // 有助于在某些操作系统上保持置顶
|
||||||
|
frame: false,
|
||||||
|
transparent: true,
|
||||||
|
resizable: false,
|
||||||
|
show: false,
|
||||||
|
alwaysOnTop: true,
|
||||||
|
skipTaskbar: true,
|
||||||
|
focusable: false, // 不抢占焦点
|
||||||
|
icon: iconPath,
|
||||||
|
webPreferences: {
|
||||||
|
preload: join(__dirname, 'preload.js'), // FIX: Use correct relative path (same dir in dist)
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
// devTools: true // Enable DevTools
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// notificationWindow.webContents.openDevTools({ mode: 'detach' }) // DEBUG: Force Open DevTools
|
||||||
|
notificationWindow.setIgnoreMouseEvents(true, { forward: true }) // 初始点击穿透
|
||||||
|
|
||||||
|
// 处理鼠标事件 (如果需要从渲染进程转发,但目前特定区域处理?)
|
||||||
|
// 实际上,我们希望窗口可点击。
|
||||||
|
// 我们将在显示时将忽略鼠标事件设为 false。
|
||||||
|
|
||||||
|
const loadUrl = isDev
|
||||||
|
? `${process.env.VITE_DEV_SERVER_URL}#/notification-window`
|
||||||
|
: `file://${join(__dirname, '../dist/index.html')}#/notification-window`
|
||||||
|
|
||||||
|
console.log('[NotificationWindow] Loading URL:', loadUrl)
|
||||||
|
notificationWindow.loadURL(loadUrl)
|
||||||
|
|
||||||
|
notificationWindow.on('closed', () => {
|
||||||
|
notificationWindow = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return notificationWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function showNotification(data: any) {
|
||||||
|
// 先检查配置
|
||||||
|
const config = ConfigService.getInstance()
|
||||||
|
const enabled = await config.get('notificationEnabled')
|
||||||
|
if (enabled === false) return // 默认为 true
|
||||||
|
|
||||||
|
// 检查会话过滤
|
||||||
|
const filterMode = config.get('notificationFilterMode') || 'all'
|
||||||
|
const filterList = config.get('notificationFilterList') || []
|
||||||
|
const sessionId = data.sessionId
|
||||||
|
|
||||||
|
if (sessionId && filterMode !== 'all' && filterList.length > 0) {
|
||||||
|
const isInList = filterList.includes(sessionId)
|
||||||
|
if (filterMode === 'whitelist' && !isInList) {
|
||||||
|
// 白名单模式:不在列表中则不显示
|
||||||
|
console.log('[NotificationWindow] Filtered by whitelist:', sessionId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (filterMode === 'blacklist' && isInList) {
|
||||||
|
// 黑名单模式:在列表中则不显示
|
||||||
|
console.log('[NotificationWindow] Filtered by blacklist:', sessionId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let win = notificationWindow
|
||||||
|
if (!win || win.isDestroyed()) {
|
||||||
|
win = createNotificationWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!win) return
|
||||||
|
|
||||||
|
// 确保加载完成
|
||||||
|
if (win.webContents.isLoading()) {
|
||||||
|
win.once('ready-to-show', () => {
|
||||||
|
showAndSend(win!, data)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
showAndSend(win, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastNotificationData: any = null
|
||||||
|
|
||||||
|
async function showAndSend(win: BrowserWindow, data: any) {
|
||||||
|
lastNotificationData = data
|
||||||
|
const config = ConfigService.getInstance()
|
||||||
|
const position = (await config.get('notificationPosition')) || 'top-right'
|
||||||
|
|
||||||
|
// 更新位置
|
||||||
|
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize
|
||||||
|
const winWidth = 344
|
||||||
|
const winHeight = 114
|
||||||
|
const padding = 20
|
||||||
|
|
||||||
|
let x = 0
|
||||||
|
let y = 0
|
||||||
|
|
||||||
|
switch (position) {
|
||||||
|
case 'top-right':
|
||||||
|
x = screenWidth - winWidth - padding
|
||||||
|
y = padding
|
||||||
|
break
|
||||||
|
case 'bottom-right':
|
||||||
|
x = screenWidth - winWidth - padding
|
||||||
|
y = screenHeight - winHeight - padding
|
||||||
|
break
|
||||||
|
case 'top-left':
|
||||||
|
x = padding
|
||||||
|
y = padding
|
||||||
|
break
|
||||||
|
case 'bottom-left':
|
||||||
|
x = padding
|
||||||
|
y = screenHeight - winHeight - padding
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
win.setPosition(Math.floor(x), Math.floor(y))
|
||||||
|
win.setSize(winWidth, winHeight) // 确保尺寸
|
||||||
|
|
||||||
|
// 设为可交互
|
||||||
|
win.setIgnoreMouseEvents(false)
|
||||||
|
win.showInactive() // 显示但不聚焦
|
||||||
|
win.setAlwaysOnTop(true, 'screen-saver') // 最高层级
|
||||||
|
|
||||||
|
win.webContents.send('notification:show', data)
|
||||||
|
|
||||||
|
// 自动关闭计时器通常由渲染进程管理
|
||||||
|
// 渲染进程发送 'notification:close' 来隐藏窗口
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerNotificationHandlers() {
|
||||||
|
ipcMain.handle('notification:show', (_, data) => {
|
||||||
|
showNotification(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('notification:close', () => {
|
||||||
|
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||||
|
notificationWindow.hide()
|
||||||
|
notificationWindow.setIgnoreMouseEvents(true, { forward: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle renderer ready event (fix race condition)
|
||||||
|
ipcMain.on('notification:ready', (event) => {
|
||||||
|
console.log('[NotificationWindow] Renderer ready, checking cached data')
|
||||||
|
if (lastNotificationData && notificationWindow && !notificationWindow.isDestroyed()) {
|
||||||
|
console.log('[NotificationWindow] Re-sending cached data')
|
||||||
|
notificationWindow.webContents.send('notification:show', lastNotificationData)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle resize request from renderer
|
||||||
|
ipcMain.on('notification:resize', (event, { width, height }) => {
|
||||||
|
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||||
|
// Enforce max-height if needed, or trust renderer
|
||||||
|
// Ensure it doesn't go off screen bottom?
|
||||||
|
// Logic in showAndSend handles position, but we need to keep anchor point (top-right usually).
|
||||||
|
// If we resize, we should re-calculate position to keep it anchored?
|
||||||
|
// Actually, setSize changes size. If it's top-right, x/y stays same -> window grows down. That's fine for top-right.
|
||||||
|
// If bottom-right, growing down pushes it off screen.
|
||||||
|
|
||||||
|
// Simple version: just setSize. For V1 we assume Top-Right.
|
||||||
|
// But wait, the config supports bottom-right.
|
||||||
|
// We can re-call setPosition or just let it be.
|
||||||
|
// If bottom-right, y needs to prevent overflow.
|
||||||
|
|
||||||
|
// Ideally we get current config position
|
||||||
|
const bounds = notificationWindow.getBounds()
|
||||||
|
// Check if we need to adjust Y?
|
||||||
|
// For now, let's just set the size as requested.
|
||||||
|
notificationWindow.setSize(Math.round(width), Math.round(height))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 'notification-clicked' 在 main.ts 中处理 (导航)
|
||||||
|
}
|
||||||
BIN
mdassets/us.png
BIN
mdassets/us.png
Binary file not shown.
|
Before Width: | Height: | Size: 203 KiB After Width: | Height: | Size: 185 KiB |
17
package-lock.json
generated
17
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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.
52
src/App.tsx
52
src/App.tsx
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
258
src/components/GlobalSessionMonitor.tsx
Normal file
258
src/components/GlobalSessionMonitor.tsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { useChatStore } from '../stores/chatStore'
|
||||||
|
import type { ChatSession } from '../types/models'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
export function GlobalSessionMonitor() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const {
|
||||||
|
sessions,
|
||||||
|
setSessions,
|
||||||
|
currentSessionId,
|
||||||
|
appendMessages,
|
||||||
|
messages
|
||||||
|
} = useChatStore()
|
||||||
|
|
||||||
|
const sessionsRef = useRef(sessions)
|
||||||
|
|
||||||
|
// 保持 ref 同步
|
||||||
|
useEffect(() => {
|
||||||
|
sessionsRef.current = sessions
|
||||||
|
}, [sessions])
|
||||||
|
|
||||||
|
// 去重辅助函数:获取消息 key
|
||||||
|
const getMessageKey = (msg: any) => {
|
||||||
|
if (msg.localId && msg.localId > 0) return `l:${msg.localId}`
|
||||||
|
return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理数据库变更
|
||||||
|
useEffect(() => {
|
||||||
|
const handleDbChange = (_event: any, data: { type: string; json: string }) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(data.json)
|
||||||
|
const tableName = payload.table
|
||||||
|
|
||||||
|
// 只关注 Session 表
|
||||||
|
if (tableName === 'Session' || tableName === 'session') {
|
||||||
|
refreshSessions()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('解析数据库变更失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.electronAPI.chat.onWcdbChange) {
|
||||||
|
const removeListener = window.electronAPI.chat.onWcdbChange(handleDbChange)
|
||||||
|
return () => {
|
||||||
|
removeListener()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return () => { }
|
||||||
|
}, []) // 空依赖数组 - 主要是静态的
|
||||||
|
|
||||||
|
const refreshSessions = async () => {
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.chat.getSessions()
|
||||||
|
if (result.success && result.sessions && Array.isArray(result.sessions)) {
|
||||||
|
const newSessions = result.sessions as ChatSession[]
|
||||||
|
const oldSessions = sessionsRef.current
|
||||||
|
|
||||||
|
// 1. 检测变更并通知
|
||||||
|
checkForNewMessages(oldSessions, newSessions)
|
||||||
|
|
||||||
|
// 2. 更新 store
|
||||||
|
setSessions(newSessions)
|
||||||
|
|
||||||
|
// 3. 如果在活跃会话中,增量刷新消息
|
||||||
|
const currentId = useChatStore.getState().currentSessionId
|
||||||
|
if (currentId) {
|
||||||
|
const currentSessionNew = newSessions.find(s => s.username === currentId)
|
||||||
|
const currentSessionOld = oldSessions.find(s => s.username === currentId)
|
||||||
|
|
||||||
|
if (currentSessionNew && (!currentSessionOld || currentSessionNew.lastTimestamp > currentSessionOld.lastTimestamp)) {
|
||||||
|
void handleActiveSessionRefresh(currentId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('全局会话刷新失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkForNewMessages = async (oldSessions: ChatSession[], newSessions: ChatSession[]) => {
|
||||||
|
const oldMap = new Map(oldSessions.map(s => [s.username, s]))
|
||||||
|
|
||||||
|
for (const newSession of newSessions) {
|
||||||
|
const oldSession = oldMap.get(newSession.username)
|
||||||
|
|
||||||
|
// 条件: 新会话或时间戳更新
|
||||||
|
const isCurrentSession = newSession.username === useChatStore.getState().currentSessionId
|
||||||
|
|
||||||
|
if (!isCurrentSession && (!oldSession || newSession.lastTimestamp > oldSession.lastTimestamp)) {
|
||||||
|
// 这是新消息事件
|
||||||
|
|
||||||
|
// 1. 群聊过滤自己发送的消息
|
||||||
|
if (newSession.username.includes('@chatroom')) {
|
||||||
|
// 如果是自己发的消息,不弹通知
|
||||||
|
// 注意:lastMsgSender 需要后端支持返回
|
||||||
|
// 使用宽松比较以处理 wxid_ 前缀差异
|
||||||
|
if (newSession.lastMsgSender && newSession.selfWxid) {
|
||||||
|
const sender = newSession.lastMsgSender.replace(/^wxid_/, '');
|
||||||
|
const self = newSession.selfWxid.replace(/^wxid_/, '');
|
||||||
|
|
||||||
|
// 使用主进程日志打印,方便用户查看
|
||||||
|
const debugInfo = {
|
||||||
|
type: 'NotificationFilter',
|
||||||
|
username: newSession.username,
|
||||||
|
lastMsgSender: newSession.lastMsgSender,
|
||||||
|
selfWxid: newSession.selfWxid,
|
||||||
|
senderClean: sender,
|
||||||
|
selfClean: self,
|
||||||
|
match: sender === self
|
||||||
|
};
|
||||||
|
|
||||||
|
if (window.electronAPI.log?.debug) {
|
||||||
|
window.electronAPI.log.debug(debugInfo);
|
||||||
|
} else {
|
||||||
|
console.log('[NotificationFilter]', debugInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sender === self) {
|
||||||
|
if (window.electronAPI.log?.debug) {
|
||||||
|
window.electronAPI.log.debug('[NotificationFilter] Filtered own message');
|
||||||
|
} else {
|
||||||
|
console.log('[NotificationFilter] Filtered own message');
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const missingInfo = {
|
||||||
|
type: 'NotificationFilter Missing info',
|
||||||
|
lastMsgSender: newSession.lastMsgSender,
|
||||||
|
selfWxid: newSession.selfWxid
|
||||||
|
};
|
||||||
|
if (window.electronAPI.log?.debug) {
|
||||||
|
window.electronAPI.log.debug(missingInfo);
|
||||||
|
} else {
|
||||||
|
console.log('[NotificationFilter] Missing info:', missingInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = newSession.displayName || newSession.username
|
||||||
|
let avatarUrl = newSession.avatarUrl
|
||||||
|
let content = newSession.summary || '[新消息]'
|
||||||
|
|
||||||
|
if (newSession.username.includes('@chatroom')) {
|
||||||
|
// 1. 群聊过滤自己发送的消息
|
||||||
|
// 辅助函数:清理 wxid 后缀 (如 _8602)
|
||||||
|
const cleanWxid = (id: string) => {
|
||||||
|
if (!id) return '';
|
||||||
|
const trimmed = id.trim();
|
||||||
|
// 仅移除末尾的 _xxxx (4位字母数字)
|
||||||
|
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/);
|
||||||
|
return suffixMatch ? suffixMatch[1] : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newSession.lastMsgSender && newSession.selfWxid) {
|
||||||
|
const senderClean = cleanWxid(newSession.lastMsgSender);
|
||||||
|
const selfClean = cleanWxid(newSession.selfWxid);
|
||||||
|
const match = senderClean === selfClean;
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 群聊显示发送者名字 (放在内容中: "Name: Message")
|
||||||
|
// 标题保持为群聊名称 (title 变量)
|
||||||
|
if (newSession.lastSenderDisplayName) {
|
||||||
|
content = `${newSession.lastSenderDisplayName}: ${content}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修复 "Random User" 的逻辑 (缺少具体信息)
|
||||||
|
// 如果标题看起来像 wxid 或没有头像,尝试获取信息
|
||||||
|
const needsEnrichment = !newSession.displayName || !newSession.avatarUrl || newSession.displayName === newSession.username
|
||||||
|
|
||||||
|
if (needsEnrichment && newSession.username) {
|
||||||
|
try {
|
||||||
|
// 尝试丰富或获取联系人详情
|
||||||
|
const contact = await window.electronAPI.chat.getContact(newSession.username)
|
||||||
|
if (contact) {
|
||||||
|
if (contact.remark || contact.nickname) {
|
||||||
|
title = contact.remark || contact.nickname
|
||||||
|
}
|
||||||
|
if (contact.avatarUrl) {
|
||||||
|
avatarUrl = contact.avatarUrl
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果不在缓存/数据库中
|
||||||
|
const enrichResult = await window.electronAPI.chat.enrichSessionsContactInfo([newSession.username])
|
||||||
|
if (enrichResult.success && enrichResult.contacts) {
|
||||||
|
const enrichedContact = enrichResult.contacts[newSession.username]
|
||||||
|
if (enrichedContact) {
|
||||||
|
if (enrichedContact.displayName) {
|
||||||
|
title = enrichedContact.displayName
|
||||||
|
}
|
||||||
|
if (enrichedContact.avatarUrl) {
|
||||||
|
avatarUrl = enrichedContact.avatarUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 如果仍然没有有效名称,再尝试一次获取
|
||||||
|
if (title === newSession.username || title.startsWith('wxid_')) {
|
||||||
|
const retried = await window.electronAPI.chat.getContact(newSession.username)
|
||||||
|
if (retried) {
|
||||||
|
title = retried.remark || retried.nickname || title
|
||||||
|
avatarUrl = retried.avatarUrl || avatarUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('获取通知的联系人信息失败', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最终检查:如果标题仍是 wxid 格式,则跳过通知(避免显示乱跳用户)
|
||||||
|
// 群聊例外,因为群聊 username 包含 @chatroom
|
||||||
|
const isGroupChat = newSession.username.includes('@chatroom')
|
||||||
|
const isWxidTitle = title.startsWith('wxid_') && title === newSession.username
|
||||||
|
if (isWxidTitle && !isGroupChat) {
|
||||||
|
console.warn('[NotificationFilter] 跳过无法识别的用户通知:', newSession.username)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用 IPC 以显示独立窗口通知
|
||||||
|
window.electronAPI.notification?.show({
|
||||||
|
title: title,
|
||||||
|
content: content,
|
||||||
|
avatarUrl: avatarUrl,
|
||||||
|
sessionId: newSession.username
|
||||||
|
})
|
||||||
|
|
||||||
|
// 我们不再为 Toast 设置本地状态
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleActiveSessionRefresh = async (sessionId: string) => {
|
||||||
|
// 从 ChatPage 复制/调整的逻辑,以保持集中
|
||||||
|
const state = useChatStore.getState()
|
||||||
|
const lastMsg = state.messages[state.messages.length - 1]
|
||||||
|
const minTime = lastMsg?.createTime || 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await (window.electronAPI.chat as any).getNewMessages(sessionId, minTime)
|
||||||
|
if (result.success && result.messages && result.messages.length > 0) {
|
||||||
|
appendMessages(result.messages, false) // 追加到末尾
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('后台活跃会话刷新失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 此组件不再渲染 UI
|
||||||
|
return null
|
||||||
|
}
|
||||||
200
src/components/NotificationToast.scss
Normal file
200
src/components/NotificationToast.scss
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
.notification-toast-container {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
width: 320px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||||
|
padding: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
pointer-events: none; // Allow clicking through when hidden
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.static {
|
||||||
|
position: relative !important;
|
||||||
|
width: calc(100% - 4px) !important; // Leave 2px margin for anti-aliasing saftey
|
||||||
|
height: auto !important; // Fits content
|
||||||
|
min-height: 0;
|
||||||
|
top: 0 !important;
|
||||||
|
bottom: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
|
transform: none !important;
|
||||||
|
margin: 2px !important; // 2px centered margin
|
||||||
|
border-radius: 12px !important; // Rounded corners
|
||||||
|
|
||||||
|
|
||||||
|
// Disable backdrop filter
|
||||||
|
backdrop-filter: none !important;
|
||||||
|
-webkit-backdrop-filter: none !important;
|
||||||
|
|
||||||
|
// Ensure background is solid
|
||||||
|
background: var(--bg-secondary, #2c2c2c);
|
||||||
|
color: var(--text-primary, #ffffff);
|
||||||
|
|
||||||
|
box-shadow: none !important; // NO SHADOW
|
||||||
|
border: 1px solid var(--border-light, rgba(255, 255, 255, 0.1));
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
padding: 16px;
|
||||||
|
padding-right: 32px; // Make space for close button
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
// Force close button to be visible but transparent background
|
||||||
|
.notification-close {
|
||||||
|
opacity: 1 !important;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
background: transparent !important; // Transparent per user request
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: rgba(255, 255, 255, 0.1) !important; // Subtle hover effect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-time {
|
||||||
|
top: 24px; // Match padding
|
||||||
|
right: 40px; // Left of close button (12px + 20px + 8px)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position variants
|
||||||
|
&.bottom-right {
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
transform: translate(0, 20px) scale(0.95);
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.top-right {
|
||||||
|
top: 24px;
|
||||||
|
right: 24px;
|
||||||
|
transform: translate(0, -20px) scale(0.95);
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bottom-left {
|
||||||
|
bottom: 24px;
|
||||||
|
left: 24px;
|
||||||
|
transform: translate(0, 20px) scale(0.95);
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.top-left {
|
||||||
|
top: 24px;
|
||||||
|
left: 24px;
|
||||||
|
transform: translate(0, -20px) scale(0.95);
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.16) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.notification-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
|
||||||
|
.notification-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100%; // 允许缩放
|
||||||
|
flex: 1; // 占据剩余空间
|
||||||
|
min-width: 0; // 关键:允许 flex 子项收缩到内容以下
|
||||||
|
margin-right: 60px; // Make space for absolute time + close button
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 36px; // Left of close button (8px + 20px + 8px)
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-body {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .notification-close {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
108
src/components/NotificationToast.tsx
Normal file
108
src/components/NotificationToast.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { Avatar } from './Avatar'
|
||||||
|
import './NotificationToast.scss'
|
||||||
|
|
||||||
|
export interface NotificationData {
|
||||||
|
id: string
|
||||||
|
sessionId: string
|
||||||
|
avatarUrl?: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationToastProps {
|
||||||
|
data: NotificationData | null
|
||||||
|
onClose: () => void
|
||||||
|
onClick: (sessionId: string) => void
|
||||||
|
duration?: number
|
||||||
|
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
|
||||||
|
isStatic?: boolean
|
||||||
|
initialVisible?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationToast({
|
||||||
|
data,
|
||||||
|
onClose,
|
||||||
|
onClick,
|
||||||
|
duration = 5000,
|
||||||
|
position = 'top-right',
|
||||||
|
isStatic = false,
|
||||||
|
initialVisible = false
|
||||||
|
}: NotificationToastProps) {
|
||||||
|
const [isVisible, setIsVisible] = useState(initialVisible)
|
||||||
|
const [currentData, setCurrentData] = useState<NotificationData | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
setCurrentData(data)
|
||||||
|
setIsVisible(true)
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsVisible(false)
|
||||||
|
// clean up data after animation
|
||||||
|
setTimeout(onClose, 300)
|
||||||
|
}, duration)
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
} else {
|
||||||
|
setIsVisible(false)
|
||||||
|
}
|
||||||
|
}, [data, duration, onClose])
|
||||||
|
|
||||||
|
if (!currentData) return null
|
||||||
|
|
||||||
|
const handleClose = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsVisible(false)
|
||||||
|
setTimeout(onClose, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
setIsVisible(false)
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose()
|
||||||
|
onClick(currentData.sessionId)
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div
|
||||||
|
className={`notification-toast-container ${position} ${isVisible ? 'visible' : ''} ${isStatic ? 'static' : ''}`}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<div className="notification-content">
|
||||||
|
<div className="notification-avatar">
|
||||||
|
<Avatar
|
||||||
|
src={currentData.avatarUrl}
|
||||||
|
name={currentData.title}
|
||||||
|
size={40}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="notification-text">
|
||||||
|
<div className="notification-header">
|
||||||
|
<span className="notification-title">{currentData.title}</span>
|
||||||
|
<span className="notification-time">
|
||||||
|
{new Date(currentData.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="notification-body">
|
||||||
|
{currentData.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="notification-close" onClick={handleClose}>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isStatic) {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
// Portal to document.body to ensure it's on top
|
||||||
|
return createPortal(content, document.body)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
99
src/pages/ImageWindow.scss
Normal file
99
src/pages/ImageWindow.scss
Normal 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
162
src/pages/ImageWindow.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
54
src/pages/NotificationWindow.scss
Normal file
54
src/pages/NotificationWindow.scss
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
@keyframes noti-enter {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px) scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes noti-exit {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
filter: blur(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.92) translateY(4px);
|
||||||
|
filter: blur(2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
// Ensure the body background is transparent to let the rounded corners show
|
||||||
|
background: transparent;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#notification-root {
|
||||||
|
// Ensure the container allows 3D transforms
|
||||||
|
perspective: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#notification-current {
|
||||||
|
// New notification slides in
|
||||||
|
animation: noti-enter 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
#notification-prev {
|
||||||
|
// Old notification scales out
|
||||||
|
animation: noti-exit 0.35s cubic-bezier(0.33, 1, 0.68, 1) forwards;
|
||||||
|
transform-origin: center top;
|
||||||
|
will-change: transform, opacity, filter;
|
||||||
|
|
||||||
|
// Ensure it stays behind
|
||||||
|
z-index: 0 !important;
|
||||||
|
}
|
||||||
165
src/pages/NotificationWindow.tsx
Normal file
165
src/pages/NotificationWindow.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { useEffect, useState, useRef } from 'react'
|
||||||
|
import { NotificationToast, type NotificationData } from '../components/NotificationToast'
|
||||||
|
import '../components/NotificationToast.scss'
|
||||||
|
import './NotificationWindow.scss'
|
||||||
|
|
||||||
|
export default function NotificationWindow() {
|
||||||
|
const [notification, setNotification] = useState<NotificationData | null>(null)
|
||||||
|
const [prevNotification, setPrevNotification] = useState<NotificationData | null>(null)
|
||||||
|
|
||||||
|
// We need a ref to access the current notification inside the callback
|
||||||
|
// without satisfying the dependency array which would recreate the listener
|
||||||
|
// Actually, setNotification(prev => ...) pattern is better, but we need the VALUE of current to set as prev.
|
||||||
|
// So we use setNotification callback: setNotification(current => { ... return newNode })
|
||||||
|
// But we need to update TWO states.
|
||||||
|
// So we use a ref to track "current displayed" for the event handler.
|
||||||
|
// Or just use functional updates, but we need to setPrev(current).
|
||||||
|
|
||||||
|
const notificationRef = useRef<NotificationData | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
notificationRef.current = notification
|
||||||
|
}, [notification])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleShow = (_event: any, data: any) => {
|
||||||
|
// data: { title, content, avatarUrl, sessionId }
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000)
|
||||||
|
const newNoti: NotificationData = {
|
||||||
|
id: `noti_${timestamp}_${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
title: data.title,
|
||||||
|
content: data.content,
|
||||||
|
timestamp: timestamp,
|
||||||
|
avatarUrl: data.avatarUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set previous to current (ref)
|
||||||
|
if (notificationRef.current) {
|
||||||
|
setPrevNotification(notificationRef.current)
|
||||||
|
}
|
||||||
|
setNotification(newNoti)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.electronAPI) {
|
||||||
|
const remove = window.electronAPI.notification?.onShow?.(handleShow)
|
||||||
|
window.electronAPI.notification?.ready?.()
|
||||||
|
return () => remove?.()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Clean up prevNotification after transition
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevNotification) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setPrevNotification(null)
|
||||||
|
}, 400)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [prevNotification])
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setNotification(null)
|
||||||
|
setPrevNotification(null)
|
||||||
|
window.electronAPI.notification?.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = (sessionId: string) => {
|
||||||
|
window.electronAPI.notification?.click(sessionId)
|
||||||
|
setNotification(null)
|
||||||
|
setPrevNotification(null)
|
||||||
|
// Main process handles window hide/close
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Measure only if we have a notification (current or prev)
|
||||||
|
if (!notification && !prevNotification) return
|
||||||
|
|
||||||
|
// Prefer measuring the NEW one
|
||||||
|
const targetId = notification ? 'notification-current' : 'notification-prev'
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
// Find the wrapper of the content
|
||||||
|
// Since we wrap them, we should measure the content inside
|
||||||
|
// But getting root is easier if size is set by relative child
|
||||||
|
const root = document.getElementById('notification-root')
|
||||||
|
if (root) {
|
||||||
|
const height = root.offsetHeight
|
||||||
|
const width = 344
|
||||||
|
if (window.electronAPI?.notification?.resize) {
|
||||||
|
const finalHeight = Math.min(height + 4, 300)
|
||||||
|
window.electronAPI.notification.resize(width, finalHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 50)
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [notification, prevNotification])
|
||||||
|
|
||||||
|
if (!notification && !prevNotification) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id="notification-root"
|
||||||
|
style={{
|
||||||
|
width: '100vw',
|
||||||
|
height: 'auto',
|
||||||
|
minHeight: '10px',
|
||||||
|
background: 'transparent',
|
||||||
|
position: 'relative', // Context for absolute children
|
||||||
|
overflow: 'hidden', // Prevent scrollbars during transition
|
||||||
|
padding: '2px', // Margin safe
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}}>
|
||||||
|
|
||||||
|
{/* Previous Notification (Background / Fading Out) */}
|
||||||
|
{prevNotification && (
|
||||||
|
<div
|
||||||
|
id="notification-prev"
|
||||||
|
key={prevNotification.id}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 2, // Match padding
|
||||||
|
left: 2,
|
||||||
|
width: 'calc(100% - 4px)', // Match width logic
|
||||||
|
zIndex: 1,
|
||||||
|
pointerEvents: 'none' // Disable interaction on old one
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NotificationToast
|
||||||
|
key={prevNotification.id}
|
||||||
|
data={prevNotification}
|
||||||
|
onClose={() => { }} // No-op for background item
|
||||||
|
onClick={() => { }}
|
||||||
|
position="top-right"
|
||||||
|
isStatic={true}
|
||||||
|
initialVisible={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current Notification (Foreground / Fading In) */}
|
||||||
|
{notification && (
|
||||||
|
<div
|
||||||
|
id="notification-current"
|
||||||
|
key={notification.id}
|
||||||
|
style={{
|
||||||
|
position: 'relative', // Takes up space
|
||||||
|
zIndex: 2,
|
||||||
|
width: '100%'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NotificationToast
|
||||||
|
key={notification.id} // Ensure remount for animation
|
||||||
|
data={notification}
|
||||||
|
onClose={handleClose}
|
||||||
|
onClick={handleClick}
|
||||||
|
position="top-right"
|
||||||
|
isStatic={true}
|
||||||
|
initialVisible={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -180,7 +180,7 @@
|
|||||||
animation: pulse 1.5s ease-in-out infinite;
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input:not(.filter-search-box input) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -207,6 +207,7 @@
|
|||||||
select {
|
select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
|
padding-right: 36px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -214,6 +215,9 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -221,6 +225,124 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.select-wrapper {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
select {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
>svg {
|
||||||
|
position: absolute;
|
||||||
|
right: 14px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自定义下拉选择框
|
||||||
|
.custom-select {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-select-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-select-value {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-select-arrow {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
|
&.rotate {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-select-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 90%, var(--bg-secondary));
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 100;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
|
||||||
|
// 展开收起动画
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateY(-8px) scaleY(0.95);
|
||||||
|
transform-origin: top center;
|
||||||
|
transition: all 0.2s cubic-bezier(0.2, 0, 0.2, 1);
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(0) scaleY(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-select-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.select-field {
|
.select-field {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
@@ -1265,3 +1387,172 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 通知过滤双列表容器
|
||||||
|
.notification-filter-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
>span {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-search-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 140px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel-count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
background: var(--primary);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel-list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 200px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
|
||||||
|
.filter-item-action {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary));
|
||||||
|
border: 1px solid color-mix(in srgb, var(--primary) 20%, transparent);
|
||||||
|
|
||||||
|
&:hover .filter-item-action {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-item-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-item-action {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 100px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
@@ -9,14 +9,16 @@ import {
|
|||||||
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
||||||
RotateCcw, Trash2, Plug, Check, Sun, Moon,
|
RotateCcw, Trash2, Plug, Check, Sun, Moon,
|
||||||
Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic,
|
Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic,
|
||||||
ShieldCheck, Fingerprint, Lock, KeyRound
|
ShieldCheck, Fingerprint, Lock, KeyRound, Bell
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { Avatar } from '../components/Avatar'
|
||||||
import './SettingsPage.scss'
|
import './SettingsPage.scss'
|
||||||
|
|
||||||
type SettingsTab = 'appearance' | 'database' | 'whisper' | 'export' | 'cache' | 'security' | 'about'
|
type SettingsTab = 'appearance' | 'notification' | 'database' | 'whisper' | 'export' | 'cache' | 'security' | 'about'
|
||||||
|
|
||||||
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
||||||
{ id: 'appearance', label: '外观', icon: Palette },
|
{ id: 'appearance', label: '外观', icon: Palette },
|
||||||
|
{ id: 'notification', label: '通知', icon: Bell },
|
||||||
{ id: 'database', label: '数据库连接', icon: Database },
|
{ id: 'database', label: '数据库连接', icon: Database },
|
||||||
{ id: 'whisper', label: '语音识别模型', icon: Mic },
|
{ id: 'whisper', label: '语音识别模型', icon: Mic },
|
||||||
{ id: 'export', label: '导出', icon: Download },
|
{ id: 'export', label: '导出', icon: Download },
|
||||||
@@ -25,6 +27,7 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
|||||||
{ id: 'about', label: '关于', icon: Info }
|
{ id: 'about', label: '关于', icon: Info }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
interface WxidOption {
|
interface WxidOption {
|
||||||
wxid: string
|
wxid: string
|
||||||
modifiedTime: number
|
modifiedTime: number
|
||||||
@@ -83,6 +86,18 @@ function SettingsPage() {
|
|||||||
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
||||||
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
|
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
|
||||||
|
|
||||||
|
const [notificationEnabled, setNotificationEnabled] = useState(true)
|
||||||
|
const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'>('top-right')
|
||||||
|
const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all')
|
||||||
|
const [notificationFilterList, setNotificationFilterList] = useState<string[]>([])
|
||||||
|
const [filterSearchKeyword, setFilterSearchKeyword] = useState('')
|
||||||
|
const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false)
|
||||||
|
const [positionDropdownOpen, setPositionDropdownOpen] = useState(false)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const [isLoading, setIsLoadingState] = useState(false)
|
const [isLoading, setIsLoadingState] = useState(false)
|
||||||
const [isTesting, setIsTesting] = useState(false)
|
const [isTesting, setIsTesting] = useState(false)
|
||||||
const [isDetectingPath, setIsDetectingPath] = useState(false)
|
const [isDetectingPath, setIsDetectingPath] = useState(false)
|
||||||
@@ -167,6 +182,24 @@ function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// 点击外部关闭自定义下拉框
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (!target.closest('.custom-select')) {
|
||||||
|
setFilterModeDropdownOpen(false)
|
||||||
|
setPositionDropdownOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filterModeDropdownOpen || positionDropdownOpen) {
|
||||||
|
document.addEventListener('click', handleClickOutside)
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', handleClickOutside)
|
||||||
|
}
|
||||||
|
}, [filterModeDropdownOpen, positionDropdownOpen])
|
||||||
|
|
||||||
|
|
||||||
const loadConfig = async () => {
|
const loadConfig = async () => {
|
||||||
try {
|
try {
|
||||||
const savedKey = await configService.getDecryptKey()
|
const savedKey = await configService.getDecryptKey()
|
||||||
@@ -188,6 +221,11 @@ function SettingsPage() {
|
|||||||
const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns()
|
const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns()
|
||||||
const savedExportDefaultConcurrency = await configService.getExportDefaultConcurrency()
|
const savedExportDefaultConcurrency = await configService.getExportDefaultConcurrency()
|
||||||
|
|
||||||
|
const savedNotificationEnabled = await configService.getNotificationEnabled()
|
||||||
|
const savedNotificationPosition = await configService.getNotificationPosition()
|
||||||
|
const savedNotificationFilterMode = await configService.getNotificationFilterMode()
|
||||||
|
const savedNotificationFilterList = await configService.getNotificationFilterList()
|
||||||
|
|
||||||
const savedAuthEnabled = await configService.getAuthEnabled()
|
const savedAuthEnabled = await configService.getAuthEnabled()
|
||||||
const savedAuthUseHello = await configService.getAuthUseHello()
|
const savedAuthUseHello = await configService.getAuthUseHello()
|
||||||
setAuthEnabled(savedAuthEnabled)
|
setAuthEnabled(savedAuthEnabled)
|
||||||
@@ -221,6 +259,11 @@ function SettingsPage() {
|
|||||||
setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true)
|
setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true)
|
||||||
setExportDefaultConcurrency(savedExportDefaultConcurrency ?? 2)
|
setExportDefaultConcurrency(savedExportDefaultConcurrency ?? 2)
|
||||||
|
|
||||||
|
setNotificationEnabled(savedNotificationEnabled)
|
||||||
|
setNotificationPosition(savedNotificationPosition)
|
||||||
|
setNotificationFilterMode(savedNotificationFilterMode)
|
||||||
|
setNotificationFilterList(savedNotificationFilterList)
|
||||||
|
|
||||||
// 如果语言列表为空,保存默认值
|
// 如果语言列表为空,保存默认值
|
||||||
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
|
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
|
||||||
const defaultLanguages = ['zh']
|
const defaultLanguages = ['zh']
|
||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
8
src/types/electron.d.ts
vendored
8
src/types/electron.d.ts
vendored
@@ -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: {
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ export interface ChatSession {
|
|||||||
lastMsgType: number
|
lastMsgType: number
|
||||||
displayName?: string
|
displayName?: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
|
lastMsgSender?: string
|
||||||
|
lastSenderDisplayName?: string
|
||||||
|
selfWxid?: string // Helper field to avoid extra API calls
|
||||||
}
|
}
|
||||||
|
|
||||||
// 联系人
|
// 联系人
|
||||||
|
|||||||
Reference in New Issue
Block a user