Compare commits

..

25 Commits

Author SHA1 Message Date
cc
7365831ec1 Merge pull request #321 from hicccc77/dev
新增启动页面;修复转发表情包无法索引的问题;修复群回复中消息溢出错误;修复群消息中消息类型判定错误
2026-02-26 19:43:12 +08:00
cc
4a09b682b2 新增启动页面;修复转发表情包无法索引的问题;修复群回复中消息溢出错误;修复群消息中消息类型判定错误 2026-02-26 19:40:26 +08:00
cc
afbd52a91e Merge pull request #314 from hicccc77/dev
修复图片密钥在部分情况下无法索引到正确数据的问题
2026-02-26 11:13:45 +08:00
cc
1c6e14acb4 修复图片密钥在部分情况下无法索引到正确数据的问题 2026-02-26 11:13:16 +08:00
xuncha
6968936c8f Merge pull request #312 from hicccc77/dev
Dev
2026-02-25 19:52:23 +08:00
xuncha
a571278145 Merge pull request #311 from xunchahaha:dev
修复引用消息错误的问题
2026-02-25 19:23:18 +08:00
xuncha
e4e25394e2 修复引用消息错误的问题 2026-02-25 19:22:53 +08:00
xuncha
fe47d7b9e3 Merge pull request #310 from hicccc77/dev
Dev
2026-02-25 18:03:32 +08:00
xuncha
4bb5bc6e32 Merge pull request #309 from xunchahaha:dev
Dev
2026-02-25 18:03:07 +08:00
xuncha
49d951e96a 1 2026-02-25 18:01:27 +08:00
xuncha
9585a02959 修复透明卡片问题 2026-02-25 17:59:42 +08:00
xuncha
a51fa5e4a2 修复 2026-02-25 17:26:45 +08:00
xuncha
bc0671440c 更新消息类型适配 2026-02-25 17:07:47 +08:00
xuncha
1a07c3970f 简单优化图片解密 2026-02-25 14:54:08 +08:00
xuncha
83c07b27f9 图片批量解密 图片解密优化 2026-02-25 14:23:22 +08:00
xuncha
fbcf7d2fc3 实况播放更加丝滑 2026-02-25 13:54:06 +08:00
cc
b547ac1aed 重要安全更新 2026-02-25 13:25:25 +08:00
cc
411f8a8d61 修复朋友圈封面信息被错误解析的问题;解决了一些安全问题 2026-02-25 12:12:08 +08:00
cc
b3741a5cf4 Merge pull request #299 from hicccc77/main
main
2026-02-23 10:32:18 +08:00
cc
b1cf524612 Merge pull request #298 from hicccc77/dev
聊天页面支持实况解析;朋友圈页面优化
2026-02-23 10:31:37 +08:00
cc
364c920fff Merge pull request #297 from Leoluis0705/fix-issues-clean
fix: 修复更新弹窗无响应、内存泄漏、SQL注入、文件句柄泄漏及并发安全问题;优化导出功能
2026-02-23 10:30:55 +08:00
你的名字
e89ccee5f4 refactor: 响应Codex代码评审建议 2026-02-23 10:28:51 +08:00
你的名字
6a86e69cd4 fix: 修复聊天记录加载问题 2026-02-23 10:28:45 +08:00
你的名字
ab2c086e93 fix: 修复更新弹窗无响应、内存泄漏、SQL注入、文件句柄泄漏及并发安全问题;优化导出功能 2026-02-23 09:55:33 +08:00
cc
b9c65e634c 聊天页面支持实况解析;朋友圈页面优化 2026-02-22 21:39:11 +08:00
42 changed files with 4211 additions and 275 deletions

4
.gitignore vendored
View File

@@ -61,4 +61,6 @@ wcdb/
chatlab-format.md
*.bak
AGENTS.md
.claude/
.claude/
.agents/
resources/wx_send

View File

@@ -82,11 +82,18 @@ let configService: ConfigService | null = null
// 协议窗口实例
let agreementWindow: BrowserWindow | null = null
let onboardingWindow: BrowserWindow | null = null
// Splash 启动窗口
let splashWindow: BrowserWindow | null = null
const keyService = new KeyService()
let mainWindowReady = false
let shouldShowMain = true
// 更新下载状态管理Issue #294 修复)
let isDownloadInProgress = false
let downloadProgressHandler: ((progress: any) => void) | null = null
let downloadedHandler: (() => void) | null = null
function createWindow(options: { autoShow?: boolean } = {}) {
// 获取图标路径 - 打包后在 resources 目录
const { autoShow = true } = options
@@ -117,9 +124,10 @@ function createWindow(options: { autoShow?: boolean } = {}) {
})
// 窗口准备好后显示
// Splash 模式下不在这里 show由启动流程统一控制
win.once('ready-to-show', () => {
mainWindowReady = true
if (autoShow || shouldShowMain) {
if (autoShow && !splashWindow) {
win.show()
}
})
@@ -245,6 +253,73 @@ function createAgreementWindow() {
return agreementWindow
}
/**
* 创建 Splash 启动窗口
* 使用纯 HTML 页面,不依赖 React确保极速显示
*/
function createSplashWindow(): BrowserWindow {
const isDev = !!process.env.VITE_DEV_SERVER_URL
const iconPath = isDev
? join(__dirname, '../public/icon.ico')
: join(process.resourcesPath, 'icon.ico')
splashWindow = new BrowserWindow({
width: 760,
height: 460,
resizable: false,
frame: false,
transparent: true,
backgroundColor: '#00000000',
hasShadow: false,
center: true,
skipTaskbar: false,
icon: iconPath,
webPreferences: {
contextIsolation: true,
nodeIntegration: false
// 不需要 preload —— 通过 executeJavaScript 单向推送进度
},
show: false
})
if (isDev) {
splashWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}splash.html`)
} else {
splashWindow.loadFile(join(__dirname, '../dist/splash.html'))
}
splashWindow.once('ready-to-show', () => {
splashWindow?.show()
})
splashWindow.on('closed', () => {
splashWindow = null
})
return splashWindow
}
/**
* 向 Splash 窗口发送进度更新
*/
function updateSplashProgress(percent: number, text: string, indeterminate = false) {
if (splashWindow && !splashWindow.isDestroyed()) {
splashWindow.webContents
.executeJavaScript(`updateProgress(${percent}, ${JSON.stringify(text)}, ${indeterminate})`)
.catch(() => {})
}
}
/**
* 关闭 Splash 窗口
*/
function closeSplash() {
if (splashWindow && !splashWindow.isDestroyed()) {
splashWindow.close()
splashWindow = null
}
}
/**
* 创建首次引导窗口
*/
@@ -397,7 +472,7 @@ function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHe
/**
* 创建独立的图片查看窗口
*/
function createImageViewerWindow(imagePath: string) {
function createImageViewerWindow(imagePath: string, liveVideoPath?: string) {
const isDev = !!process.env.VITE_DEV_SERVER_URL
const iconPath = isDev
? join(__dirname, '../public/icon.ico')
@@ -430,7 +505,8 @@ function createImageViewerWindow(imagePath: string) {
win.show()
})
const imageParam = `imagePath=${encodeURIComponent(imagePath)}`
let imageParam = `imagePath=${encodeURIComponent(imagePath)}`
if (liveVideoPath) imageParam += `&liveVideoPath=${encodeURIComponent(liveVideoPath)}`
if (process.env.VITE_DEV_SERVER_URL) {
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/image-viewer-window?${imageParam}`)
@@ -617,22 +693,61 @@ function registerIpcHandlers() {
if (!AUTO_UPDATE_ENABLED) {
throw new Error('自动更新已暂时禁用')
}
// 防止重复下载Issue #294 修复)
if (isDownloadInProgress) {
throw new Error('更新正在下载中,请稍候')
}
isDownloadInProgress = true
const win = BrowserWindow.fromWebContents(event.sender)
// 监听下载进度
autoUpdater.on('download-progress', (progress) => {
win?.webContents.send('app:downloadProgress', progress)
})
// 清理旧的监听器Issue #294 修复:防止监听器泄漏)
if (downloadProgressHandler) {
autoUpdater.removeListener('download-progress', downloadProgressHandler)
downloadProgressHandler = null
}
if (downloadedHandler) {
autoUpdater.removeListener('update-downloaded', downloadedHandler)
downloadedHandler = null
}
// 下载完成后自动安装
autoUpdater.on('update-downloaded', () => {
// 创建新的监听器并保存引用
downloadProgressHandler = (progress) => {
if (win && !win.isDestroyed()) {
win.webContents.send('app:downloadProgress', progress)
}
}
downloadedHandler = () => {
console.log('[Update] 更新下载完成,准备安装')
if (downloadProgressHandler) {
autoUpdater.removeListener('download-progress', downloadProgressHandler)
downloadProgressHandler = null
}
downloadedHandler = null
isDownloadInProgress = false
autoUpdater.quitAndInstall(false, true)
})
}
autoUpdater.on('download-progress', downloadProgressHandler)
autoUpdater.once('update-downloaded', downloadedHandler)
try {
console.log('[Update] 开始下载更新...')
await autoUpdater.downloadUpdate()
} catch (error) {
console.error('下载更新失败:', error)
console.error('[Update] 下载更新失败:', error)
// 失败时清理状态和监听器
isDownloadInProgress = false
if (downloadProgressHandler) {
autoUpdater.removeListener('download-progress', downloadProgressHandler)
downloadProgressHandler = null
}
if (downloadedHandler) {
autoUpdater.removeListener('update-downloaded', downloadedHandler)
downloadedHandler = null
}
throw error
}
})
@@ -869,6 +984,9 @@ function registerIpcHandlers() {
ipcMain.handle('chat:getAllVoiceMessages', async (_, sessionId: string) => {
return chatService.getAllVoiceMessages(sessionId)
})
ipcMain.handle('chat:getAllImageMessages', async (_, sessionId: string) => {
return chatService.getAllImageMessages(sessionId)
})
ipcMain.handle('chat:getMessageDates', async (_, sessionId: string) => {
return chatService.getMessageDates(sessionId)
})
@@ -986,7 +1104,65 @@ function registerIpcHandlers() {
? mainWindow
: (BrowserWindow.fromWebContents(event.sender) || undefined)
return windowsHelloService.verify(message, targetWin)
const result = await windowsHelloService.verify(message, targetWin)
// Hello 验证成功后,自动用 authHelloSecret 中的密码解锁密钥
if (result && configService) {
const secret = configService.getHelloSecret()
if (secret && configService.isLockMode()) {
configService.unlock(secret)
}
}
return result
})
// 验证应用锁状态(检测 lock: 前缀,防篡改)
ipcMain.handle('auth:verifyEnabled', async () => {
return configService?.verifyAuthEnabled() ?? false
})
// 密码解锁(验证 + 解密密钥到内存)
ipcMain.handle('auth:unlock', async (_event, password: string) => {
if (!configService) return { success: false, error: '配置服务未初始化' }
return configService.unlock(password)
})
// 开启应用锁
ipcMain.handle('auth:enableLock', async (_event, password: string) => {
if (!configService) return { success: false, error: '配置服务未初始化' }
return configService.enableLock(password)
})
// 关闭应用锁
ipcMain.handle('auth:disableLock', async (_event, password: string) => {
if (!configService) return { success: false, error: '配置服务未初始化' }
return configService.disableLock(password)
})
// 修改密码
ipcMain.handle('auth:changePassword', async (_event, oldPassword: string, newPassword: string) => {
if (!configService) return { success: false, error: '配置服务未初始化' }
return configService.changePassword(oldPassword, newPassword)
})
// 设置 Hello Secret
ipcMain.handle('auth:setHelloSecret', async (_event, password: string) => {
if (!configService) return { success: false }
configService.setHelloSecret(password)
return { success: true }
})
// 清除 Hello Secret
ipcMain.handle('auth:clearHelloSecret', async () => {
if (!configService) return { success: false }
configService.clearHelloSecret()
return { success: true }
})
// 检查是否处于 lock: 模式
ipcMain.handle('auth:isLockMode', async () => {
return configService?.isLockMode() ?? false
})
// 导出相关
@@ -1119,8 +1295,18 @@ function registerIpcHandlers() {
})
// 打开图片查看窗口
ipcMain.handle('window:openImageViewerWindow', (_, imagePath: string) => {
createImageViewerWindow(imagePath)
ipcMain.handle('window:openImageViewerWindow', async (_, imagePath: string, liveVideoPath?: string) => {
// 如果是 dataUrl写入临时文件
if (imagePath.startsWith('data:')) {
const commaIdx = imagePath.indexOf(',')
const meta = imagePath.slice(5, commaIdx) // e.g. "image/jpeg;base64"
const ext = meta.split('/')[1]?.split(';')[0] || 'jpg'
const tmpPath = join(app.getPath('temp'), `weflow_preview_${Date.now()}.${ext}`)
await writeFile(tmpPath, Buffer.from(imagePath.slice(commaIdx + 1), 'base64'))
createImageViewerWindow(`file://${tmpPath.replace(/\\/g, '/')}`, liveVideoPath)
} else {
createImageViewerWindow(imagePath, liveVideoPath)
}
})
// 完成引导,关闭引导窗口并显示主窗口
@@ -1392,26 +1578,70 @@ function checkForUpdatesOnStartup() {
}, 3000)
}
app.whenReady().then(() => {
app.whenReady().then(async () => {
// 立即创建 Splash 窗口,确保用户尽快看到反馈
createSplashWindow()
// 等待 Splash 页面加载完成后再推送进度
if (splashWindow) {
await new Promise<void>((resolve) => {
if (splashWindow!.webContents.isLoading()) {
splashWindow!.webContents.once('did-finish-load', () => resolve())
} else {
resolve()
}
})
splashWindow.webContents
.executeJavaScript(`setVersion(${JSON.stringify(app.getVersion())})`)
.catch(() => {})
}
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
// 初始化配置服务
updateSplashProgress(5, '正在加载配置...')
configService = new ConfigService()
// 将用户主题配置推送给 Splash 窗口
if (splashWindow && !splashWindow.isDestroyed()) {
const themeId = configService.get('themeId') || 'cloud-dancer'
const themeMode = configService.get('theme') || 'system'
splashWindow.webContents
.executeJavaScript(`applyTheme(${JSON.stringify(themeId)}, ${JSON.stringify(themeMode)})`)
.catch(() => {})
}
await delay(200)
// 设置资源路径
updateSplashProgress(10, '正在初始化...')
const candidateResources = app.isPackaged
? join(process.resourcesPath, 'resources')
: join(app.getAppPath(), 'resources')
const fallbackResources = join(process.cwd(), 'resources')
const resourcesPath = existsSync(candidateResources) ? candidateResources : fallbackResources
const userDataPath = app.getPath('userData')
await delay(200)
// 初始化数据库服务
updateSplashProgress(18, '正在初始化...')
wcdbService.setPaths(resourcesPath, userDataPath)
wcdbService.setLogEnabled(configService.get('logEnabled') === true)
await delay(200)
// 注册 IPC 处理器
updateSplashProgress(25, '正在初始化...')
registerIpcHandlers()
await delay(200)
// 检查配置状态
const onboardingDone = configService.get('onboardingDone')
shouldShowMain = onboardingDone === true
mainWindow = createWindow({ autoShow: shouldShowMain })
if (!onboardingDone) {
createOnboardingWindow()
}
// 创建主窗口(不显示,由启动流程统一控制)
updateSplashProgress(30, '正在加载界面...')
mainWindow = createWindow({ autoShow: false })
// 解决朋友圈图片无法加载问题(添加 Referer
// 配置网络服务
session.defaultSession.webRequest.onBeforeSendHeaders(
{
urls: ['*://*.qpic.cn/*', '*://*.wx.qq.com/*']
@@ -1422,7 +1652,31 @@ app.whenReady().then(() => {
}
)
// 启动时检测更新
// 等待主窗口加载完成(真正耗时阶段,进度条末端呼吸光点)
updateSplashProgress(30, '正在加载界面...', true)
await new Promise<void>((resolve) => {
if (mainWindowReady) {
resolve()
} else {
mainWindow!.once('ready-to-show', () => {
mainWindowReady = true
resolve()
})
}
})
// 加载完成,收尾
updateSplashProgress(100, '启动完成')
await new Promise((resolve) => setTimeout(resolve, 250))
closeSplash()
if (!onboardingDone) {
createOnboardingWindow()
} else {
mainWindow?.show()
}
// 启动时检测更新(不阻塞启动)
checkForUpdatesOnStartup()
app.on('activate', () => {

View File

@@ -24,7 +24,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
// 认证
auth: {
hello: (message?: string) => ipcRenderer.invoke('auth:hello', message)
hello: (message?: string) => ipcRenderer.invoke('auth:hello', message),
verifyEnabled: () => ipcRenderer.invoke('auth:verifyEnabled'),
unlock: (password: string) => ipcRenderer.invoke('auth:unlock', password),
enableLock: (password: string) => ipcRenderer.invoke('auth:enableLock', password),
disableLock: (password: string) => ipcRenderer.invoke('auth:disableLock', password),
changePassword: (oldPassword: string, newPassword: string) => ipcRenderer.invoke('auth:changePassword', oldPassword, newPassword),
setHelloSecret: (password: string) => ipcRenderer.invoke('auth:setHelloSecret', password),
clearHelloSecret: () => ipcRenderer.invoke('auth:clearHelloSecret'),
isLockMode: () => ipcRenderer.invoke('auth:isLockMode')
},
@@ -78,8 +86,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
resizeToFitVideo: (videoWidth: number, videoHeight: number) =>
ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight),
openImageViewerWindow: (imagePath: string) =>
ipcRenderer.invoke('window:openImageViewerWindow', imagePath),
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) =>
ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
openChatHistoryWindow: (sessionId: string, messageId: number) =>
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId)
},
@@ -146,6 +154,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),
getAllVoiceMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId),
getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId),
getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId),
resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId),
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),

View File

@@ -79,14 +79,14 @@ class AnalyticsService {
const chunkSize = 200
for (let i = 0; i < usernames.length; i += chunkSize) {
const chunk = usernames.slice(i, i + chunkSize)
const inList = chunk.map((u) => `'${this.escapeSqlValue(u)}'`).join(',')
if (!inList) continue
// 使用参数化查询防止SQL注入
const placeholders = chunk.map(() => '?').join(',')
const sql = `
SELECT username, alias
FROM contact
WHERE username IN (${inList})
WHERE username IN (${placeholders})
`
const result = await wcdbService.execQuery('contact', null, sql)
const result = await wcdbService.execQuery('contact', null, sql, chunk)
if (!result.success || !result.rows) continue
for (const row of result.rows as Record<string, any>[]) {
const username = row.username || ''

View File

@@ -1,4 +1,4 @@
import { join, dirname, basename, extname } from 'path'
import { join, dirname, basename, extname } from 'path'
import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, watch } from 'fs'
import * as path from 'path'
import * as fs from 'fs'
@@ -13,6 +13,7 @@ import { wcdbService } from './wcdbService'
import { MessageCacheService } from './messageCacheService'
import { ContactCacheService, ContactCacheEntry } from './contactCacheService'
import { voiceTranscribeService } from './voiceTranscribeService'
import { LRUCache } from '../utils/LRUCache.js'
type HardlinkState = {
db: Database.Database
@@ -72,9 +73,36 @@ export interface Message {
fileSize?: number // 文件大小
fileExt?: string // 文件扩展名
xmlType?: string // XML 中的 type 字段
appMsgKind?: string // 归一化 appmsg 类型
appMsgDesc?: string
appMsgAppName?: string
appMsgSourceName?: string
appMsgSourceUsername?: string
appMsgThumbUrl?: string
appMsgMusicUrl?: string
appMsgDataUrl?: string
appMsgLocationLabel?: string
finderNickname?: string
finderUsername?: string
finderCoverUrl?: string
finderAvatar?: string
finderDuration?: number
// 位置消息
locationLat?: number
locationLng?: number
locationPoiname?: string
locationLabel?: string
// 音乐消息
musicAlbumUrl?: string
musicUrl?: string
// 礼物消息
giftImageUrl?: string
giftWish?: string
giftPrice?: string
// 名片消息
cardUsername?: string // 名片的微信ID
cardNickname?: string // 名片的昵称
cardAvatarUrl?: string // 名片头像 URL
// 转账消息
transferPayerUsername?: string // 转账付款人
transferReceiverUsername?: string // 转账收款人
@@ -114,6 +142,7 @@ class ChatService {
private configService: ConfigService
private connected = false
private messageCursors: Map<string, { cursor: number; fetched: number; batchSize: number; startTime?: number; endTime?: number; ascending?: boolean; bufferedMessages?: any[] }> = new Map()
private messageCursorMutex: boolean = false
private readonly messageBatchDefault = 50
private avatarCache: Map<string, ContactCacheEntry>
private readonly avatarCacheTtlMs = 10 * 60 * 1000
@@ -121,8 +150,8 @@ class ChatService {
private hardlinkCache = new Map<string, HardlinkState>()
private readonly contactCacheService: ContactCacheService
private readonly messageCacheService: MessageCacheService
private voiceWavCache = new Map<string, Buffer>()
private voiceTranscriptCache = new Map<string, string>()
private voiceWavCache: LRUCache<string, Buffer>
private voiceTranscriptCache: LRUCache<string, string>
private voiceTranscriptPending = new Map<string, Promise<{ success: boolean; transcript?: string; error?: string }>>()
private transcriptCacheLoaded = false
private transcriptCacheDirty = false
@@ -149,6 +178,9 @@ class ChatService {
const persisted = this.contactCacheService.getAllEntries()
this.avatarCache = new Map(Object.entries(persisted))
this.messageCacheService = new MessageCacheService(this.configService.getCacheBasePath())
// 初始化LRU缓存限制大小防止内存泄漏
this.voiceWavCache = new LRUCache(this.voiceWavCacheMaxEntries)
this.voiceTranscriptCache = new LRUCache(1000) // 最多缓存1000条转写记录
}
/**
@@ -728,6 +760,13 @@ class ChatService {
}
const batchSize = Math.max(1, limit || this.messageBatchDefault)
// 使用互斥锁保护游标状态访问
while (this.messageCursorMutex) {
await new Promise(resolve => setTimeout(resolve, 1))
}
this.messageCursorMutex = true
let state = this.messageCursors.get(sessionId)
// 只在以下情况重新创建游标:
@@ -765,6 +804,7 @@ class ChatService {
state = { cursor: cursorResult.cursor, fetched: 0, batchSize, startTime, endTime, ascending }
this.messageCursors.set(sessionId, state)
this.messageCursorMutex = false
// 如果需要跳过消息(offset > 0),逐批获取但不返回
// 注意:仅在 offset === 0 时重建游标最安全;
@@ -825,6 +865,10 @@ class ChatService {
let rows: any[] = state.bufferedMessages || []
state.bufferedMessages = undefined // Clear buffer after use
// Track actual hasMore status from C++ layer
// If we have buffered messages, we need to check if there's more data
let actualHasMore = rows.length > 0 // If buffer exists, assume there might be more
// If buffer is not enough to fill a batch, try to fetch more
// Or if buffer is empty, fetch a batch
if (rows.length < batchSize) {
@@ -832,6 +876,7 @@ class ChatService {
if (nextBatch.success && nextBatch.rows) {
rows = rows.concat(nextBatch.rows)
state.fetched += nextBatch.rows.length
actualHasMore = nextBatch.hasMore === true
} else if (!nextBatch.success) {
console.error('[ChatService] 获取消息批次失败:', nextBatch.error)
// If we have some buffered rows, we can still return them?
@@ -839,6 +884,7 @@ class ChatService {
if (rows.length === 0) {
return { success: false, error: nextBatch.error || '获取消息失败' }
}
actualHasMore = false
}
}
@@ -849,13 +895,43 @@ class ChatService {
// Next time offset will catch up or mismatch trigger reset.
}
const hasMore = rows.length > 0 // Simplified hasMore check for now, can be improved
// Use actual hasMore from C++ layer, not simplified row count check
const hasMore = actualHasMore
const normalized = this.normalizeMessageOrder(this.mapRowsToMessages(rows))
// 🔒 安全验证:过滤掉不属于当前 sessionId 的消息(防止 C++ 层或缓存错误)
const filtered = normalized.filter(msg => {
// 检查消息的 senderUsername 或 rawContent 中的 talker
// 群聊消息senderUsername 是群成员,需要检查 _db_path 或上下文
// 单聊消息senderUsername 应该是 sessionId 或自己
const isGroupChat = sessionId.includes('@chatroom')
if (isGroupChat) {
// 群聊消息暂不验证(因为 senderUsername 是群成员,不是 sessionId
return true
} else {
// 单聊消息senderUsername 应该是 sessionId对方或为空/null自己
if (!msg.senderUsername || msg.senderUsername === sessionId) {
return true
}
// 如果 isSend 为 1说明是自己发的允许通过
if (msg.isSend === 1) {
return true
}
// 其他情况:可能是错误的消息
console.warn(`[ChatService] 检测到异常消息: sessionId=${sessionId}, senderUsername=${msg.senderUsername}, localId=${msg.localId}`)
return false
}
})
if (filtered.length < normalized.length) {
console.warn(`[ChatService] 过滤了 ${normalized.length - filtered.length} 条异常消息`)
}
// 并发检查并修复缺失 CDN URL 的表情包
const fixPromises: Promise<void>[] = []
for (const msg of normalized) {
for (const msg of filtered) {
if (msg.localType === 47 && !msg.emojiCdnUrl && msg.emojiMd5) {
fixPromises.push(this.fallbackEmoticon(msg))
}
@@ -866,9 +942,12 @@ class ChatService {
}
state.fetched += rows.length
this.messageCacheService.set(sessionId, normalized)
return { success: true, messages: normalized, hasMore }
this.messageCursorMutex = false
this.messageCacheService.set(sessionId, filtered)
return { success: true, messages: filtered, hasMore }
} catch (e) {
this.messageCursorMutex = false
console.error('ChatService: 获取消息失败:', e)
return { success: false, error: String(e) }
}
@@ -912,12 +991,34 @@ class ChatService {
}
console.warn(`[ChatService] 表情包数据库未命中: md5=${msg.emojiMd5}, db=${dbPath}`)
// 数据库未命中时,尝试从本地 emoji 缓存目录查找(转发的表情包只有 md5无 CDN URL
this.findEmojiInLocalCache(msg)
} catch (e) {
console.error(`[ChatService] 恢复表情包失败: md5=${msg.emojiMd5}`, e)
}
}
/**
* 从本地 WeFlow emoji 缓存目录按 md5 查找文件
*/
private findEmojiInLocalCache(msg: Message): void {
if (!msg.emojiMd5) return
const cacheDir = this.getEmojiCacheDir()
if (!existsSync(cacheDir)) return
const extensions = ['.gif', '.png', '.webp', '.jpg', '.jpeg']
for (const ext of extensions) {
const filePath = join(cacheDir, `${msg.emojiMd5}${ext}`)
if (existsSync(filePath)) {
msg.emojiLocalPath = filePath
// 同步写入内存缓存,避免重复查找
emojiCache.set(msg.emojiMd5, filePath)
return
}
}
}
/**
* 查找 emoticon.db 路径
*/
@@ -1172,9 +1273,33 @@ class ChatService {
let fileSize: number | undefined
let fileExt: string | undefined
let xmlType: string | undefined
let appMsgKind: string | undefined
let appMsgDesc: string | undefined
let appMsgAppName: string | undefined
let appMsgSourceName: string | undefined
let appMsgSourceUsername: string | undefined
let appMsgThumbUrl: string | undefined
let appMsgMusicUrl: string | undefined
let appMsgDataUrl: string | undefined
let appMsgLocationLabel: string | undefined
let finderNickname: string | undefined
let finderUsername: string | undefined
let finderCoverUrl: string | undefined
let finderAvatar: string | undefined
let finderDuration: number | undefined
let locationLat: number | undefined
let locationLng: number | undefined
let locationPoiname: string | undefined
let locationLabel: string | undefined
let musicAlbumUrl: string | undefined
let musicUrl: string | undefined
let giftImageUrl: string | undefined
let giftWish: string | undefined
let giftPrice: string | undefined
// 名片消息
let cardUsername: string | undefined
let cardNickname: string | undefined
let cardAvatarUrl: string | undefined
// 转账消息
let transferPayerUsername: string | undefined
let transferReceiverUsername: string | undefined
@@ -1212,6 +1337,15 @@ class ChatService {
const cardInfo = this.parseCardInfo(content)
cardUsername = cardInfo.username
cardNickname = cardInfo.nickname
cardAvatarUrl = cardInfo.avatarUrl
} else if (localType === 48 && content) {
// 位置消息
const latStr = this.extractXmlAttribute(content, 'location', 'x') || this.extractXmlAttribute(content, 'location', 'latitude')
const lngStr = this.extractXmlAttribute(content, 'location', 'y') || this.extractXmlAttribute(content, 'location', 'longitude')
if (latStr) { const v = parseFloat(latStr); if (Number.isFinite(v)) locationLat = v }
if (lngStr) { const v = parseFloat(lngStr); if (Number.isFinite(v)) locationLng = v }
locationLabel = this.extractXmlAttribute(content, 'location', 'label') || this.extractXmlValue(content, 'label') || undefined
locationPoiname = this.extractXmlAttribute(content, 'location', 'poiname') || this.extractXmlValue(content, 'poiname') || undefined
} else if ((localType === 49 || localType === 8589934592049) && content) {
// Type 49 消息链接、文件、小程序、转账等8589934592049 也是转账类型
const type49Info = this.parseType49Message(content)
@@ -1226,12 +1360,56 @@ class ChatService {
chatRecordList = type49Info.chatRecordList
transferPayerUsername = type49Info.transferPayerUsername
transferReceiverUsername = type49Info.transferReceiverUsername
// 引用消息appmsg type=57的 quotedContent/quotedSender
if (type49Info.quotedContent !== undefined) quotedContent = type49Info.quotedContent
if (type49Info.quotedSender !== undefined) quotedSender = type49Info.quotedSender
} else if (localType === 244813135921 || (content && content.includes('<type>57</type>'))) {
const quoteInfo = this.parseQuoteMessage(content)
quotedContent = quoteInfo.content
quotedSender = quoteInfo.sender
}
const looksLikeAppMsg = Boolean(content && (content.includes('<appmsg') || content.includes('&lt;appmsg')))
if (looksLikeAppMsg) {
const type49Info = this.parseType49Message(content)
xmlType = xmlType || type49Info.xmlType
linkTitle = linkTitle || type49Info.linkTitle
linkUrl = linkUrl || type49Info.linkUrl
linkThumb = linkThumb || type49Info.linkThumb
fileName = fileName || type49Info.fileName
fileSize = fileSize ?? type49Info.fileSize
fileExt = fileExt || type49Info.fileExt
appMsgKind = appMsgKind || type49Info.appMsgKind
appMsgDesc = appMsgDesc || type49Info.appMsgDesc
appMsgAppName = appMsgAppName || type49Info.appMsgAppName
appMsgSourceName = appMsgSourceName || type49Info.appMsgSourceName
appMsgSourceUsername = appMsgSourceUsername || type49Info.appMsgSourceUsername
appMsgThumbUrl = appMsgThumbUrl || type49Info.appMsgThumbUrl
appMsgMusicUrl = appMsgMusicUrl || type49Info.appMsgMusicUrl
appMsgDataUrl = appMsgDataUrl || type49Info.appMsgDataUrl
appMsgLocationLabel = appMsgLocationLabel || type49Info.appMsgLocationLabel
finderNickname = finderNickname || type49Info.finderNickname
finderUsername = finderUsername || type49Info.finderUsername
finderCoverUrl = finderCoverUrl || type49Info.finderCoverUrl
finderAvatar = finderAvatar || type49Info.finderAvatar
finderDuration = finderDuration ?? type49Info.finderDuration
locationLat = locationLat ?? type49Info.locationLat
locationLng = locationLng ?? type49Info.locationLng
locationPoiname = locationPoiname || type49Info.locationPoiname
locationLabel = locationLabel || type49Info.locationLabel
musicAlbumUrl = musicAlbumUrl || type49Info.musicAlbumUrl
musicUrl = musicUrl || type49Info.musicUrl
giftImageUrl = giftImageUrl || type49Info.giftImageUrl
giftWish = giftWish || type49Info.giftWish
giftPrice = giftPrice || type49Info.giftPrice
chatRecordTitle = chatRecordTitle || type49Info.chatRecordTitle
chatRecordList = chatRecordList || type49Info.chatRecordList
transferPayerUsername = transferPayerUsername || type49Info.transferPayerUsername
transferReceiverUsername = transferReceiverUsername || type49Info.transferReceiverUsername
if (!quotedContent && type49Info.quotedContent !== undefined) quotedContent = type49Info.quotedContent
if (!quotedSender && type49Info.quotedSender !== undefined) quotedSender = type49Info.quotedSender
}
messages.push({
localId: this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0),
serverId: this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0),
@@ -1260,8 +1438,32 @@ class ChatService {
fileSize,
fileExt,
xmlType,
appMsgKind,
appMsgDesc,
appMsgAppName,
appMsgSourceName,
appMsgSourceUsername,
appMsgThumbUrl,
appMsgMusicUrl,
appMsgDataUrl,
appMsgLocationLabel,
finderNickname,
finderUsername,
finderCoverUrl,
finderAvatar,
finderDuration,
locationLat,
locationLng,
locationPoiname,
locationLabel,
musicAlbumUrl,
musicUrl,
giftImageUrl,
giftWish,
giftPrice,
cardUsername,
cardNickname,
cardAvatarUrl,
transferPayerUsername,
transferReceiverUsername,
chatRecordTitle,
@@ -1298,6 +1500,7 @@ class ChatService {
// 检查 XML type用于识别引用消息等
const xmlType = this.extractXmlValue(content, 'type')
const looksLikeAppMsg = content.includes('<appmsg') || content.includes('&lt;appmsg')
switch (localType) {
case 1:
@@ -1312,8 +1515,14 @@ class ChatService {
return '[视频]'
case 47:
return '[动画表情]'
case 48:
return '[位置]'
case 48: {
const label =
this.extractXmlAttribute(content, 'location', 'label') ||
this.extractXmlAttribute(content, 'location', 'poiname') ||
this.extractXmlValue(content, 'label') ||
this.extractXmlValue(content, 'poiname')
return label ? `[位置] ${label}` : '[位置]'
}
case 49:
return this.parseType49(content)
case 50:
@@ -1348,6 +1557,10 @@ class ChatService {
return title || '[引用消息]'
}
if (looksLikeAppMsg) {
return this.parseType49(content)
}
// 尝试从 XML 提取通用 title
const genericTitle = this.extractXmlValue(content, 'title')
if (genericTitle && genericTitle.length > 0 && genericTitle.length < 100) {
@@ -1363,7 +1576,34 @@ class ChatService {
private parseType49(content: string): string {
const title = this.extractXmlValue(content, 'title')
const type = this.extractXmlValue(content, 'type')
// 从 appmsg 直接子节点提取 type避免匹配到 refermsg 内部的 <type>
let type = ''
const appmsgMatch = /<appmsg[\s\S]*?>([\s\S]*?)<\/appmsg>/i.exec(content)
if (appmsgMatch) {
const inner = appmsgMatch[1]
.replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '')
.replace(/<patMsg[\s\S]*?<\/patMsg>/gi, '')
const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(inner)
if (typeMatch) type = typeMatch[1].trim()
}
if (!type) type = this.extractXmlValue(content, 'type')
const normalized = content.toLowerCase()
const locationLabel =
this.extractXmlAttribute(content, 'location', 'label') ||
this.extractXmlAttribute(content, 'location', 'poiname') ||
this.extractXmlValue(content, 'label') ||
this.extractXmlValue(content, 'poiname')
const isFinder =
type === '51' ||
normalized.includes('<finder') ||
normalized.includes('finderusername') ||
normalized.includes('finderobjectid')
const isRedPacket = type === '2001' || normalized.includes('hongbao')
const isMusic =
type === '3' ||
normalized.includes('<musicurl>') ||
normalized.includes('<playurl>') ||
normalized.includes('<dataurl>')
// 群公告消息type 87特殊处理
if (type === '87') {
@@ -1374,6 +1614,19 @@ class ChatService {
return '[群公告]'
}
if (isFinder) {
return title ? `[视频号] ${title}` : '[视频号]'
}
if (isRedPacket) {
return title ? `[红包] ${title}` : '[红包]'
}
if (locationLabel) {
return `[位置] ${locationLabel}`
}
if (isMusic) {
return title ? `[音乐] ${title}` : '[音乐]'
}
if (title) {
switch (type) {
case '5':
@@ -1391,6 +1644,8 @@ class ChatService {
return title
case '2000':
return `[转账] ${title}`
case '2001':
return `[红包] ${title}`
default:
return title
}
@@ -1407,6 +1662,13 @@ class ChatService {
return '[小程序]'
case '2000':
return '[转账]'
case '2001':
return '[红包]'
case '3':
return '[音乐]'
case '5':
case '49':
return '[链接]'
case '87':
return '[群公告]'
default:
@@ -1712,7 +1974,7 @@ class ChatService {
* 解析名片消息
* 格式: <msg username="wxid_xxx" nickname="昵称" ... />
*/
private parseCardInfo(content: string): { username?: string; nickname?: string } {
private parseCardInfo(content: string): { username?: string; nickname?: string; avatarUrl?: string } {
try {
if (!content) return {}
@@ -1722,7 +1984,11 @@ class ChatService {
// 提取 nickname
const nickname = this.extractXmlAttribute(content, 'msg', 'nickname') || undefined
return { username, nickname }
// 提取头像
const avatarUrl = this.extractXmlAttribute(content, 'msg', 'bigheadimgurl') ||
this.extractXmlAttribute(content, 'msg', 'smallheadimgurl') || undefined
return { username, nickname, avatarUrl }
} catch (e) {
console.error('[ChatService] 名片解析失败:', e)
return {}
@@ -1735,9 +2001,35 @@ class ChatService {
*/
private parseType49Message(content: string): {
xmlType?: string
quotedContent?: string
quotedSender?: string
linkTitle?: string
linkUrl?: string
linkThumb?: string
appMsgKind?: string
appMsgDesc?: string
appMsgAppName?: string
appMsgSourceName?: string
appMsgSourceUsername?: string
appMsgThumbUrl?: string
appMsgMusicUrl?: string
appMsgDataUrl?: string
appMsgLocationLabel?: string
finderNickname?: string
finderUsername?: string
finderCoverUrl?: string
finderAvatar?: string
finderDuration?: number
locationLat?: number
locationLng?: number
locationPoiname?: string
locationLabel?: string
musicAlbumUrl?: string
musicUrl?: string
giftImageUrl?: string
giftWish?: string
giftPrice?: string
cardAvatarUrl?: string
fileName?: string
fileSize?: number
fileExt?: string
@@ -1755,8 +2047,20 @@ class ChatService {
try {
if (!content) return {}
// 提取 appmsg 的 type
const xmlType = this.extractXmlValue(content, 'type')
// 提取 appmsg 直接子节点的 type,避免匹配到 refermsg 内部的 <type>
// 先尝试从 <appmsg>...</appmsg> 块内提取,再用正则跳过嵌套标签
let xmlType = ''
const appmsgMatch = /<appmsg[\s\S]*?>([\s\S]*?)<\/appmsg>/i.exec(content)
if (appmsgMatch) {
// 在 appmsg 内容中,找第一个 <type> 但跳过在子元素内部的(如 refermsg > type
// 策略去掉所有嵌套块refermsg、patMsg 等),再提取 type
const appmsgInner = appmsgMatch[1]
.replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '')
.replace(/<patMsg[\s\S]*?<\/patMsg>/gi, '')
const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(appmsgInner)
if (typeMatch) xmlType = typeMatch[1].trim()
}
if (!xmlType) xmlType = this.extractXmlValue(content, 'type')
if (!xmlType) return {}
const result: any = { xmlType }
@@ -1764,6 +2068,128 @@ class ChatService {
// 提取通用字段
const title = this.extractXmlValue(content, 'title')
const url = this.extractXmlValue(content, 'url')
const desc = this.extractXmlValue(content, 'des') || this.extractXmlValue(content, 'description')
const appName = this.extractXmlValue(content, 'appname')
const sourceName = this.extractXmlValue(content, 'sourcename')
const sourceUsername = this.extractXmlValue(content, 'sourceusername')
const thumbUrl =
this.extractXmlValue(content, 'thumburl') ||
this.extractXmlValue(content, 'cdnthumburl') ||
this.extractXmlValue(content, 'cover') ||
this.extractXmlValue(content, 'coverurl') ||
this.extractXmlValue(content, 'thumb_url')
const musicUrl =
this.extractXmlValue(content, 'musicurl') ||
this.extractXmlValue(content, 'playurl') ||
this.extractXmlValue(content, 'songalbumurl')
const dataUrl = this.extractXmlValue(content, 'dataurl') || this.extractXmlValue(content, 'lowurl')
const locationLabel =
this.extractXmlAttribute(content, 'location', 'label') ||
this.extractXmlAttribute(content, 'location', 'poiname') ||
this.extractXmlValue(content, 'label') ||
this.extractXmlValue(content, 'poiname')
const finderUsername =
this.extractXmlValue(content, 'finderusername') ||
this.extractXmlValue(content, 'finder_username') ||
this.extractXmlValue(content, 'finderuser')
const finderNickname =
this.extractXmlValue(content, 'findernickname') ||
this.extractXmlValue(content, 'finder_nickname')
const normalized = content.toLowerCase()
const isFinder = xmlType === '51'
const isRedPacket = xmlType === '2001'
const isMusic = xmlType === '3'
const isLocation = Boolean(locationLabel)
result.linkTitle = title || undefined
result.linkUrl = url || undefined
result.linkThumb = thumbUrl || undefined
result.appMsgDesc = desc || undefined
result.appMsgAppName = appName || undefined
result.appMsgSourceName = sourceName || undefined
result.appMsgSourceUsername = sourceUsername || undefined
result.appMsgThumbUrl = thumbUrl || undefined
result.appMsgMusicUrl = musicUrl || undefined
result.appMsgDataUrl = dataUrl || undefined
result.appMsgLocationLabel = locationLabel || undefined
result.finderUsername = finderUsername || undefined
result.finderNickname = finderNickname || undefined
// 视频号封面/头像/时长
if (isFinder) {
const finderCover =
this.extractXmlValue(content, 'thumbUrl') ||
this.extractXmlValue(content, 'coverUrl') ||
this.extractXmlValue(content, 'thumburl') ||
this.extractXmlValue(content, 'coverurl')
if (finderCover) result.finderCoverUrl = finderCover
const finderAvatar = this.extractXmlValue(content, 'avatar')
if (finderAvatar) result.finderAvatar = finderAvatar
const durationStr = this.extractXmlValue(content, 'videoPlayDuration') || this.extractXmlValue(content, 'duration')
if (durationStr) {
const d = parseInt(durationStr, 10)
if (Number.isFinite(d) && d > 0) result.finderDuration = d
}
}
// 位置经纬度
if (isLocation) {
const latAttr = this.extractXmlAttribute(content, 'location', 'x') || this.extractXmlAttribute(content, 'location', 'latitude')
const lngAttr = this.extractXmlAttribute(content, 'location', 'y') || this.extractXmlAttribute(content, 'location', 'longitude')
if (latAttr) { const v = parseFloat(latAttr); if (Number.isFinite(v)) result.locationLat = v }
if (lngAttr) { const v = parseFloat(lngAttr); if (Number.isFinite(v)) result.locationLng = v }
result.locationPoiname = this.extractXmlAttribute(content, 'location', 'poiname') || locationLabel || undefined
result.locationLabel = this.extractXmlAttribute(content, 'location', 'label') || undefined
}
// 音乐专辑封面
if (isMusic) {
const albumUrl = this.extractXmlValue(content, 'songalbumurl')
if (albumUrl) result.musicAlbumUrl = albumUrl
result.musicUrl = musicUrl || dataUrl || url || undefined
}
// 礼物消息
const isGift = xmlType === '115'
if (isGift) {
result.giftWish = this.extractXmlValue(content, 'wishmessage') || undefined
result.giftImageUrl = this.extractXmlValue(content, 'skuimgurl') || undefined
result.giftPrice = this.extractXmlValue(content, 'skuprice') || undefined
}
if (isFinder) {
result.appMsgKind = 'finder'
} else if (isRedPacket) {
result.appMsgKind = 'red-packet'
} else if (isGift) {
result.appMsgKind = 'gift'
} else if (isLocation) {
result.appMsgKind = 'location'
} else if (isMusic) {
result.appMsgKind = 'music'
} else if (xmlType === '33' || xmlType === '36') {
result.appMsgKind = 'miniapp'
} else if (xmlType === '6') {
result.appMsgKind = 'file'
} else if (xmlType === '19') {
result.appMsgKind = 'chat-record'
} else if (xmlType === '2000') {
result.appMsgKind = 'transfer'
} else if (xmlType === '87') {
result.appMsgKind = 'announcement'
} else if (xmlType === '57') {
// 引用回复消息,解析 refermsg
result.appMsgKind = 'quote'
const quoteInfo = this.parseQuoteMessage(content)
result.quotedContent = quoteInfo.content
result.quotedSender = quoteInfo.sender
} else if ((xmlType === '5' || xmlType === '49') && (sourceUsername?.startsWith('gh_') || appName?.includes('公众号') || sourceName)) {
result.appMsgKind = 'official-link'
} else if (url) {
result.appMsgKind = 'link'
} else {
result.appMsgKind = 'card'
}
switch (xmlType) {
case '6': {
@@ -3698,10 +4124,7 @@ class ChatService {
private cacheVoiceWav(cacheKey: string, wavData: Buffer): void {
this.voiceWavCache.set(cacheKey, wavData)
if (this.voiceWavCache.size > this.voiceWavCacheMaxEntries) {
const oldestKey = this.voiceWavCache.keys().next().value
if (oldestKey) this.voiceWavCache.delete(oldestKey)
}
// LRU缓存会自动处理大小限制无需手动清理
}
/** 获取持久化转写缓存文件路径 */
@@ -3835,6 +4258,74 @@ class ChatService {
* 获取某会话中有消息的日期列表
* 返回 YYYY-MM-DD 格式的日期字符串数组
*/
/**
* 获取某会话的全部图片消息(用于聊天页批量图片解密)
*/
async getAllImageMessages(
sessionId: string
): Promise<{ success: boolean; images?: { imageMd5?: string; imageDatName?: string; createTime?: number }[]; error?: string }> {
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) {
return { success: false, error: connectResult.error || '数据库未连接' }
}
let tables = this.sessionTablesCache.get(sessionId)
if (!tables) {
const tableStats = await wcdbService.getMessageTableStats(sessionId)
if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) {
return { success: false, error: '未找到会话消息表' }
}
tables = tableStats.tables
.map(t => ({ tableName: t.table_name || t.name, dbPath: t.db_path }))
.filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }>
if (tables.length > 0) {
this.sessionTablesCache.set(sessionId, tables)
setTimeout(() => { this.sessionTablesCache.delete(sessionId) }, this.sessionTablesCacheTtl)
}
}
let allImages: Array<{ imageMd5?: string; imageDatName?: string; createTime?: number }> = []
for (const { tableName, dbPath } of tables) {
try {
const sql = `SELECT * FROM ${tableName} WHERE local_type = 3 ORDER BY create_time DESC`
const result = await wcdbService.execQuery('message', dbPath, sql)
if (result.success && result.rows && result.rows.length > 0) {
const mapped = this.mapRowsToMessages(result.rows as Record<string, any>[])
const images = mapped
.filter(msg => msg.localType === 3)
.map(msg => ({
imageMd5: msg.imageMd5 || undefined,
imageDatName: msg.imageDatName || undefined,
createTime: msg.createTime || undefined
}))
.filter(img => Boolean(img.imageMd5 || img.imageDatName))
allImages.push(...images)
}
} catch (e) {
console.error(`[ChatService] 查询图片消息失败 (${dbPath}):`, e)
}
}
allImages.sort((a, b) => (b.createTime || 0) - (a.createTime || 0))
const seen = new Set<string>()
allImages = allImages.filter(img => {
const key = img.imageMd5 || img.imageDatName || ''
if (!key || seen.has(key)) return false
seen.add(key)
return true
})
console.log(`[ChatService] 共找到 ${allImages.length} 条图片消息(去重后)`)
return { success: true, images: allImages }
} catch (e) {
console.error('[ChatService] 获取全部图片消息失败:', e)
return { success: false, error: String(e) }
}
}
async getMessageDates(sessionId: string): Promise<{ success: boolean; dates?: string[]; error?: string }> {
try {
const connectResult = await this.ensureConnected()
@@ -3968,6 +4459,15 @@ class ChatService {
msg.emojiThumbUrl = emojiInfo.thumbUrl
msg.emojiEncryptUrl = emojiInfo.encryptUrl
msg.emojiAesKey = emojiInfo.aesKey
} else if (msg.localType === 42) {
const cardInfo = this.parseCardInfo(rawContent)
msg.cardUsername = cardInfo.username
msg.cardNickname = cardInfo.nickname
msg.cardAvatarUrl = cardInfo.avatarUrl
}
if (rawContent && (rawContent.includes('<appmsg') || rawContent.includes('&lt;appmsg'))) {
Object.assign(msg, this.parseType49Message(rawContent))
}
return msg

View File

@@ -1,12 +1,17 @@
import { join } from 'path'
import { app } from 'electron'
import { app, safeStorage } from 'electron'
import crypto from 'crypto'
import Store from 'electron-store'
// 加密前缀标记
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
const LOCK_PREFIX = 'lock:' // 密码派生密钥加密(锁定模式)
interface ConfigSchema {
// 数据库相关
dbPath: string // 数据库根目录 (xwechat_files)
decryptKey: string // 解密密钥
myWxid: string // 当前用户 wxid
dbPath: string
decryptKey: string
myWxid: string
onboardingDone: boolean
imageXorKey: number
imageAesKey: string
@@ -14,7 +19,6 @@ interface ConfigSchema {
// 缓存相关
cachePath: string
lastOpenedDb: string
lastSession: string
@@ -34,8 +38,9 @@ interface ConfigSchema {
// 安全相关
authEnabled: boolean
authPassword: string // SHA-256 hash
authPassword: string // SHA-256 hashsafeStorage 加密)
authUseHello: boolean
authHelloSecret: string // 原始密码safeStorage 加密Hello 解锁时使用)
// 更新相关
ignoredUpdateVersion: string
@@ -48,10 +53,23 @@ interface ConfigSchema {
wordCloudExcludeWords: string[]
}
// 需要 safeStorage 加密的字段(普通模式)
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword'])
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
// 需要与密码绑定的敏感密钥字段(锁定模式时用 lock: 加密)
const LOCKABLE_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey'])
const LOCKABLE_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
export class ConfigService {
private static instance: ConfigService
private store!: Store<ConfigSchema>
// 锁定模式运行时状态
private unlockedKeys: Map<string, any> = new Map()
private unlockPassword: string | null = null
static getInstance(): ConfigService {
if (!ConfigService.instance) {
ConfigService.instance = new ConfigService()
@@ -75,7 +93,6 @@ export class ConfigService {
imageAesKey: '',
wxidConfigs: {},
cachePath: '',
lastOpenedDb: '',
lastSession: '',
theme: 'system',
@@ -90,11 +107,10 @@ export class ConfigService {
transcribeLanguages: ['zh'],
exportDefaultConcurrency: 2,
analyticsExcludedUsernames: [],
authEnabled: false,
authPassword: '',
authUseHello: false,
authHelloSecret: '',
ignoredUpdateVersion: '',
notificationEnabled: true,
notificationPosition: 'top-right',
@@ -103,29 +119,535 @@ export class ConfigService {
wordCloudExcludeWords: []
}
})
this.migrateAuthFields()
}
// === 状态查询 ===
isLockMode(): boolean {
const raw: any = this.store.get('decryptKey')
return typeof raw === 'string' && raw.startsWith(LOCK_PREFIX)
}
isUnlocked(): boolean {
return !this.isLockMode() || this.unlockedKeys.size > 0
}
// === get / set ===
get<K extends keyof ConfigSchema>(key: K): ConfigSchema[K] {
return this.store.get(key)
const raw = this.store.get(key)
if (ENCRYPTED_BOOL_KEYS.has(key)) {
const str = typeof raw === 'string' ? raw : ''
if (!str || !str.startsWith(SAFE_PREFIX)) return raw
return (this.safeDecrypt(str) === 'true') as ConfigSchema[K]
}
if (ENCRYPTED_NUMBER_KEYS.has(key)) {
const str = typeof raw === 'string' ? raw : ''
if (!str) return raw
if (str.startsWith(LOCK_PREFIX)) {
const cached = this.unlockedKeys.get(key as string)
return (cached !== undefined ? cached : 0) as ConfigSchema[K]
}
if (!str.startsWith(SAFE_PREFIX)) return raw
const num = Number(this.safeDecrypt(str))
return (Number.isFinite(num) ? num : 0) as ConfigSchema[K]
}
if (ENCRYPTED_STRING_KEYS.has(key) && typeof raw === 'string') {
if (key === 'authPassword') return this.safeDecrypt(raw) as ConfigSchema[K]
if (raw.startsWith(LOCK_PREFIX)) {
const cached = this.unlockedKeys.get(key as string)
return (cached !== undefined ? cached : '') as ConfigSchema[K]
}
return this.safeDecrypt(raw) as ConfigSchema[K]
}
if (key === 'wxidConfigs' && raw && typeof raw === 'object') {
return this.decryptWxidConfigs(raw as any) as ConfigSchema[K]
}
return raw
}
set<K extends keyof ConfigSchema>(key: K, value: ConfigSchema[K]): void {
this.store.set(key, value)
let toStore = value
const inLockMode = this.isLockMode() && this.unlockPassword
if (ENCRYPTED_BOOL_KEYS.has(key)) {
toStore = this.safeEncrypt(String(value)) as ConfigSchema[K]
} else if (ENCRYPTED_NUMBER_KEYS.has(key)) {
if (inLockMode && LOCKABLE_NUMBER_KEYS.has(key)) {
toStore = this.lockEncrypt(String(value), this.unlockPassword!) as ConfigSchema[K]
this.unlockedKeys.set(key as string, value)
} else {
toStore = this.safeEncrypt(String(value)) as ConfigSchema[K]
}
} else if (ENCRYPTED_STRING_KEYS.has(key) && typeof value === 'string') {
if (key === 'authPassword') {
toStore = this.safeEncrypt(value) as ConfigSchema[K]
} else if (inLockMode && LOCKABLE_STRING_KEYS.has(key)) {
toStore = this.lockEncrypt(value, this.unlockPassword!) as ConfigSchema[K]
this.unlockedKeys.set(key as string, value)
} else {
toStore = this.safeEncrypt(value) as ConfigSchema[K]
}
} else if (key === 'wxidConfigs' && value && typeof value === 'object') {
if (inLockMode) {
toStore = this.lockEncryptWxidConfigs(value as any) as ConfigSchema[K]
} else {
toStore = this.encryptWxidConfigs(value as any) as ConfigSchema[K]
}
}
this.store.set(key, toStore)
}
// === 加密/解密工具 ===
private safeEncrypt(plaintext: string): string {
if (!plaintext) return ''
if (plaintext.startsWith(SAFE_PREFIX)) return plaintext
if (!safeStorage.isEncryptionAvailable()) return plaintext
const encrypted = safeStorage.encryptString(plaintext)
return SAFE_PREFIX + encrypted.toString('base64')
}
private safeDecrypt(stored: string): string {
if (!stored) return ''
if (!stored.startsWith(SAFE_PREFIX)) return stored
if (!safeStorage.isEncryptionAvailable()) return ''
try {
const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64')
return safeStorage.decryptString(buf)
} catch {
return ''
}
}
private lockEncrypt(plaintext: string, password: string): string {
if (!plaintext) return ''
const salt = crypto.randomBytes(16)
const iv = crypto.randomBytes(12)
const derivedKey = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256')
const cipher = crypto.createCipheriv('aes-256-gcm', derivedKey, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
const authTag = cipher.getAuthTag()
const combined = Buffer.concat([salt, iv, authTag, encrypted])
return LOCK_PREFIX + combined.toString('base64')
}
private lockDecrypt(stored: string, password: string): string | null {
if (!stored || !stored.startsWith(LOCK_PREFIX)) return null
try {
const combined = Buffer.from(stored.slice(LOCK_PREFIX.length), 'base64')
const salt = combined.subarray(0, 16)
const iv = combined.subarray(16, 28)
const authTag = combined.subarray(28, 44)
const ciphertext = combined.subarray(44)
const derivedKey = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256')
const decipher = crypto.createDecipheriv('aes-256-gcm', derivedKey, iv)
decipher.setAuthTag(authTag)
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
return decrypted.toString('utf8')
} catch {
return null
}
}
// 通过尝试解密 lock: 字段来验证密码是否正确(当 authPassword 被删除时使用)
private verifyPasswordByDecrypt(password: string): boolean {
// 依次尝试解密任意一个 lock: 字段GCM authTag 会验证密码正确性
const lockFields = ['decryptKey', 'imageAesKey', 'imageXorKey'] as const
for (const key of lockFields) {
const raw: any = this.store.get(key as any)
if (typeof raw === 'string' && raw.startsWith(LOCK_PREFIX)) {
const result = this.lockDecrypt(raw, password)
// lockDecrypt 返回 null 表示解密失败(密码错误),非 null 表示成功
return result !== null
}
}
return false
}
// === wxidConfigs 加密/解密 ===
private encryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] {
const result: ConfigSchema['wxidConfigs'] = {}
for (const [wxid, cfg] of Object.entries(configs)) {
result[wxid] = { ...cfg }
if (cfg.decryptKey) result[wxid].decryptKey = this.safeEncrypt(cfg.decryptKey)
if (cfg.imageAesKey) result[wxid].imageAesKey = this.safeEncrypt(cfg.imageAesKey)
if (cfg.imageXorKey !== undefined) {
(result[wxid] as any).imageXorKey = this.safeEncrypt(String(cfg.imageXorKey))
}
}
return result
}
private decryptLockedWxidConfigs(password: string): void {
const wxidConfigs = this.store.get('wxidConfigs')
if (!wxidConfigs || typeof wxidConfigs !== 'object') return
for (const [wxid, cfg] of Object.entries(wxidConfigs) as [string, any][]) {
if (cfg.decryptKey && typeof cfg.decryptKey === 'string' && cfg.decryptKey.startsWith(LOCK_PREFIX)) {
const d = this.lockDecrypt(cfg.decryptKey, password)
if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:decryptKey`, d)
}
if (cfg.imageAesKey && typeof cfg.imageAesKey === 'string' && cfg.imageAesKey.startsWith(LOCK_PREFIX)) {
const d = this.lockDecrypt(cfg.imageAesKey, password)
if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:imageAesKey`, d)
}
if (cfg.imageXorKey && typeof cfg.imageXorKey === 'string' && cfg.imageXorKey.startsWith(LOCK_PREFIX)) {
const d = this.lockDecrypt(cfg.imageXorKey, password)
if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:imageXorKey`, Number(d))
}
}
}
private decryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] {
const result: ConfigSchema['wxidConfigs'] = {}
for (const [wxid, cfg] of Object.entries(configs) as [string, any][]) {
result[wxid] = { ...cfg, updatedAt: cfg.updatedAt }
// decryptKey
if (typeof cfg.decryptKey === 'string') {
if (cfg.decryptKey.startsWith(LOCK_PREFIX)) {
result[wxid].decryptKey = this.unlockedKeys.get(`wxid:${wxid}:decryptKey`) ?? ''
} else {
result[wxid].decryptKey = this.safeDecrypt(cfg.decryptKey)
}
}
// imageAesKey
if (typeof cfg.imageAesKey === 'string') {
if (cfg.imageAesKey.startsWith(LOCK_PREFIX)) {
result[wxid].imageAesKey = this.unlockedKeys.get(`wxid:${wxid}:imageAesKey`) ?? ''
} else {
result[wxid].imageAesKey = this.safeDecrypt(cfg.imageAesKey)
}
}
// imageXorKey
if (typeof cfg.imageXorKey === 'string') {
if (cfg.imageXorKey.startsWith(LOCK_PREFIX)) {
result[wxid].imageXorKey = this.unlockedKeys.get(`wxid:${wxid}:imageXorKey`) ?? 0
} else if (cfg.imageXorKey.startsWith(SAFE_PREFIX)) {
const num = Number(this.safeDecrypt(cfg.imageXorKey))
result[wxid].imageXorKey = Number.isFinite(num) ? num : 0
}
}
}
return result
}
private lockEncryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] {
const result: ConfigSchema['wxidConfigs'] = {}
for (const [wxid, cfg] of Object.entries(configs)) {
result[wxid] = { ...cfg }
if (cfg.decryptKey) result[wxid].decryptKey = this.lockEncrypt(cfg.decryptKey, this.unlockPassword!) as any
if (cfg.imageAesKey) result[wxid].imageAesKey = this.lockEncrypt(cfg.imageAesKey, this.unlockPassword!) as any
if (cfg.imageXorKey !== undefined) {
(result[wxid] as any).imageXorKey = this.lockEncrypt(String(cfg.imageXorKey), this.unlockPassword!)
}
}
return result
}
// === 业务方法 ===
enableLock(password: string): { success: boolean; error?: string } {
try {
// 先读取当前所有明文密钥
const decryptKey = this.get('decryptKey')
const imageAesKey = this.get('imageAesKey')
const imageXorKey = this.get('imageXorKey')
const wxidConfigs = this.get('wxidConfigs')
// 存储密码 hashsafeStorage 加密)
const passwordHash = crypto.createHash('sha256').update(password).digest('hex')
this.store.set('authPassword', this.safeEncrypt(passwordHash) as any)
this.store.set('authEnabled', this.safeEncrypt('true') as any)
// 设置运行时状态
this.unlockPassword = password
this.unlockedKeys.set('decryptKey', decryptKey)
this.unlockedKeys.set('imageAesKey', imageAesKey)
this.unlockedKeys.set('imageXorKey', imageXorKey)
// 用密码派生密钥重新加密所有敏感字段
if (decryptKey) this.store.set('decryptKey', this.lockEncrypt(String(decryptKey), password) as any)
if (imageAesKey) this.store.set('imageAesKey', this.lockEncrypt(String(imageAesKey), password) as any)
if (imageXorKey !== undefined) this.store.set('imageXorKey', this.lockEncrypt(String(imageXorKey), password) as any)
// 处理 wxidConfigs 中的嵌套密钥
if (wxidConfigs && Object.keys(wxidConfigs).length > 0) {
const lockedConfigs = this.lockEncryptWxidConfigs(wxidConfigs)
this.store.set('wxidConfigs', lockedConfigs)
for (const [wxid, cfg] of Object.entries(wxidConfigs)) {
if (cfg.decryptKey) this.unlockedKeys.set(`wxid:${wxid}:decryptKey`, cfg.decryptKey)
if (cfg.imageAesKey) this.unlockedKeys.set(`wxid:${wxid}:imageAesKey`, cfg.imageAesKey)
if (cfg.imageXorKey !== undefined) this.unlockedKeys.set(`wxid:${wxid}:imageXorKey`, cfg.imageXorKey)
}
}
return { success: true }
} catch (e: any) {
return { success: false, error: e.message }
}
}
unlock(password: string): { success: boolean; error?: string } {
try {
// 验证密码
const storedHash = this.safeDecrypt(this.store.get('authPassword') as any)
const inputHash = crypto.createHash('sha256').update(password).digest('hex')
if (storedHash && storedHash !== inputHash) {
// authPassword 存在但密码不匹配
return { success: false, error: '密码错误' }
}
if (!storedHash) {
// authPassword 被删除/损坏,尝试用密码直接解密 lock: 字段来验证
const verified = this.verifyPasswordByDecrypt(password)
if (!verified) {
return { success: false, error: '密码错误' }
}
// 密码正确,自愈 authPassword
const newHash = crypto.createHash('sha256').update(password).digest('hex')
this.store.set('authPassword', this.safeEncrypt(newHash) as any)
this.store.set('authEnabled', this.safeEncrypt('true') as any)
}
// 解密所有 lock: 字段到内存缓存
const rawDecryptKey: any = this.store.get('decryptKey')
if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) {
const d = this.lockDecrypt(rawDecryptKey, password)
if (d !== null) this.unlockedKeys.set('decryptKey', d)
}
const rawImageAesKey: any = this.store.get('imageAesKey')
if (typeof rawImageAesKey === 'string' && rawImageAesKey.startsWith(LOCK_PREFIX)) {
const d = this.lockDecrypt(rawImageAesKey, password)
if (d !== null) this.unlockedKeys.set('imageAesKey', d)
}
const rawImageXorKey: any = this.store.get('imageXorKey')
if (typeof rawImageXorKey === 'string' && rawImageXorKey.startsWith(LOCK_PREFIX)) {
const d = this.lockDecrypt(rawImageXorKey, password)
if (d !== null) this.unlockedKeys.set('imageXorKey', Number(d))
}
// 解密 wxidConfigs 嵌套密钥
this.decryptLockedWxidConfigs(password)
// 保留密码供 set() 使用
this.unlockPassword = password
return { success: true }
} catch (e: any) {
return { success: false, error: e.message }
}
}
disableLock(password: string): { success: boolean; error?: string } {
try {
// 验证密码
const storedHash = this.safeDecrypt(this.store.get('authPassword') as any)
const inputHash = crypto.createHash('sha256').update(password).digest('hex')
if (storedHash !== inputHash) {
return { success: false, error: '密码错误' }
}
// 先解密所有 lock: 字段
if (this.unlockedKeys.size === 0) {
this.unlock(password)
}
// 将所有密钥转回 safe: 格式
const decryptKey = this.unlockedKeys.get('decryptKey')
const imageAesKey = this.unlockedKeys.get('imageAesKey')
const imageXorKey = this.unlockedKeys.get('imageXorKey')
if (decryptKey) this.store.set('decryptKey', this.safeEncrypt(String(decryptKey)) as any)
if (imageAesKey) this.store.set('imageAesKey', this.safeEncrypt(String(imageAesKey)) as any)
if (imageXorKey !== undefined) this.store.set('imageXorKey', this.safeEncrypt(String(imageXorKey)) as any)
// 转换 wxidConfigs
const wxidConfigs = this.get('wxidConfigs')
if (wxidConfigs && Object.keys(wxidConfigs).length > 0) {
const safeConfigs = this.encryptWxidConfigs(wxidConfigs)
this.store.set('wxidConfigs', safeConfigs)
}
// 清除 auth 字段
this.store.set('authEnabled', false as any)
this.store.set('authPassword', '' as any)
this.store.set('authUseHello', false as any)
this.store.set('authHelloSecret', '' as any)
// 清除运行时状态
this.unlockedKeys.clear()
this.unlockPassword = null
return { success: true }
} catch (e: any) {
return { success: false, error: e.message }
}
}
changePassword(oldPassword: string, newPassword: string): { success: boolean; error?: string } {
try {
// 验证旧密码
const storedHash = this.safeDecrypt(this.store.get('authPassword') as any)
const oldHash = crypto.createHash('sha256').update(oldPassword).digest('hex')
if (storedHash !== oldHash) {
return { success: false, error: '旧密码错误' }
}
// 确保已解锁
if (this.unlockedKeys.size === 0) {
this.unlock(oldPassword)
}
// 用新密码重新加密所有密钥
const decryptKey = this.unlockedKeys.get('decryptKey')
const imageAesKey = this.unlockedKeys.get('imageAesKey')
const imageXorKey = this.unlockedKeys.get('imageXorKey')
if (decryptKey) this.store.set('decryptKey', this.lockEncrypt(String(decryptKey), newPassword) as any)
if (imageAesKey) this.store.set('imageAesKey', this.lockEncrypt(String(imageAesKey), newPassword) as any)
if (imageXorKey !== undefined) this.store.set('imageXorKey', this.lockEncrypt(String(imageXorKey), newPassword) as any)
// 重新加密 wxidConfigs
const wxidConfigs = this.get('wxidConfigs')
if (wxidConfigs && Object.keys(wxidConfigs).length > 0) {
this.unlockPassword = newPassword
const lockedConfigs = this.lockEncryptWxidConfigs(wxidConfigs)
this.store.set('wxidConfigs', lockedConfigs)
}
// 更新密码 hash
const newHash = crypto.createHash('sha256').update(newPassword).digest('hex')
this.store.set('authPassword', this.safeEncrypt(newHash) as any)
// 更新 Hello secret如果启用了 Hello
const useHello = this.get('authUseHello')
if (useHello) {
this.store.set('authHelloSecret', this.safeEncrypt(newPassword) as any)
}
this.unlockPassword = newPassword
return { success: true }
} catch (e: any) {
return { success: false, error: e.message }
}
}
// === Hello 相关 ===
setHelloSecret(password: string): void {
this.store.set('authHelloSecret', this.safeEncrypt(password) as any)
this.store.set('authUseHello', this.safeEncrypt('true') as any)
}
getHelloSecret(): string {
const raw: any = this.store.get('authHelloSecret')
if (!raw || typeof raw !== 'string') return ''
return this.safeDecrypt(raw)
}
clearHelloSecret(): void {
this.store.set('authHelloSecret', '' as any)
this.store.set('authUseHello', this.safeEncrypt('false') as any)
}
// === 迁移 ===
private migrateAuthFields(): void {
// 将旧版明文 auth 字段迁移为 safeStorage 加密格式
// 如果已经是 safe: 或 lock: 前缀则跳过
const rawEnabled: any = this.store.get('authEnabled')
if (typeof rawEnabled === 'boolean') {
this.store.set('authEnabled', this.safeEncrypt(String(rawEnabled)) as any)
}
const rawUseHello: any = this.store.get('authUseHello')
if (typeof rawUseHello === 'boolean') {
this.store.set('authUseHello', this.safeEncrypt(String(rawUseHello)) as any)
}
const rawPassword: any = this.store.get('authPassword')
if (typeof rawPassword === 'string' && rawPassword && !rawPassword.startsWith(SAFE_PREFIX)) {
this.store.set('authPassword', this.safeEncrypt(rawPassword) as any)
}
// 迁移敏感密钥字段(明文 → safe:
for (const key of LOCKABLE_STRING_KEYS) {
const raw: any = this.store.get(key as any)
if (typeof raw === 'string' && raw && !raw.startsWith(SAFE_PREFIX) && !raw.startsWith(LOCK_PREFIX)) {
this.store.set(key as any, this.safeEncrypt(raw) as any)
}
}
// imageXorKey: 数字 → safe:
const rawXor: any = this.store.get('imageXorKey')
if (typeof rawXor === 'number' && rawXor !== 0) {
this.store.set('imageXorKey', this.safeEncrypt(String(rawXor)) as any)
}
// wxidConfigs 中的嵌套密钥
const wxidConfigs: any = this.store.get('wxidConfigs')
if (wxidConfigs && typeof wxidConfigs === 'object') {
let changed = false
for (const [_wxid, cfg] of Object.entries(wxidConfigs) as [string, any][]) {
if (cfg.decryptKey && typeof cfg.decryptKey === 'string' && !cfg.decryptKey.startsWith(SAFE_PREFIX) && !cfg.decryptKey.startsWith(LOCK_PREFIX)) {
cfg.decryptKey = this.safeEncrypt(cfg.decryptKey)
changed = true
}
if (cfg.imageAesKey && typeof cfg.imageAesKey === 'string' && !cfg.imageAesKey.startsWith(SAFE_PREFIX) && !cfg.imageAesKey.startsWith(LOCK_PREFIX)) {
cfg.imageAesKey = this.safeEncrypt(cfg.imageAesKey)
changed = true
}
if (typeof cfg.imageXorKey === 'number' && cfg.imageXorKey !== 0) {
cfg.imageXorKey = this.safeEncrypt(String(cfg.imageXorKey))
changed = true
}
}
if (changed) {
this.store.set('wxidConfigs', wxidConfigs)
}
}
}
// === 验证 ===
verifyAuthEnabled(): boolean {
// 先检查 authEnabled 字段
const rawEnabled: any = this.store.get('authEnabled')
if (typeof rawEnabled === 'string' && rawEnabled.startsWith(SAFE_PREFIX)) {
if (this.safeDecrypt(rawEnabled) === 'true') return true
}
// 即使 authEnabled 被删除/篡改,如果密钥是 lock: 格式,说明曾开启过应用锁
const rawDecryptKey: any = this.store.get('decryptKey')
if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) {
return true
}
return false
}
// === 工具方法 ===
getCacheBasePath(): string {
const configured = this.get('cachePath')
if (configured && configured.trim().length > 0) {
return configured
}
return join(app.getPath('documents'), 'WeFlow')
return join(app.getPath('userData'), 'cache')
}
getAll(): ConfigSchema {
getAll(): Partial<ConfigSchema> {
return this.store.store
}
clear(): void {
this.store.clear()
this.unlockedKeys.clear()
this.unlockPassword = null
}
}
}

View File

@@ -12,6 +12,7 @@ import { chatService } from './chatService'
import { videoService } from './videoService'
import { voiceTranscribeService } from './voiceTranscribeService'
import { EXPORT_HTML_STYLES } from './exportHtmlStyles'
import { LRUCache } from '../utils/LRUCache.js'
// ChatLab 格式类型定义
interface ChatLabHeader {
@@ -140,12 +141,15 @@ async function parallelLimit<T, R>(
class ExportService {
private configService: ConfigService
private contactCache: Map<string, { displayName: string; avatarUrl?: string }> = new Map()
private inlineEmojiCache: Map<string, string> = new Map()
private contactCache: LRUCache<string, { displayName: string; avatarUrl?: string }>
private inlineEmojiCache: LRUCache<string, string>
private htmlStyleCache: string | null = null
constructor() {
this.configService = new ConfigService()
// 限制缓存大小,防止内存泄漏
this.contactCache = new LRUCache(500) // 最多缓存500个联系人
this.inlineEmojiCache = new LRUCache(100) // 最多缓存100个表情
}
private getClampedConcurrency(value: number | undefined, fallback = 2, max = 6): number {
@@ -219,9 +223,9 @@ class ExportService {
*/
async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
try {
const escapedChatroomId = chatroomId.replace(/'/g, "''")
const sql = `SELECT ext_buffer FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1`
const result = await wcdbService.execQuery('contact', null, sql)
// 使用参数化查询防止SQL注入
const sql = 'SELECT ext_buffer FROM chat_room WHERE username = ? LIMIT 1'
const result = await wcdbService.execQuery('contact', null, sql, [chatroomId])
if (!result.success || !result.rows || result.rows.length === 0) {
return new Map<string, string>()
}
@@ -1467,6 +1471,7 @@ class ExportService {
})
if (!result.success || !result.localPath) {
console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`)
// 尝试获取缩略图
const thumbResult = await imageDecryptService.resolveCachedImage({
sessionId,
@@ -1474,8 +1479,10 @@ class ExportService {
imageDatName
})
if (!thumbResult.success || !thumbResult.localPath) {
console.log(`[Export] 缩略图也获取失败 (localId=${msg.localId}): error=${thumbResult.error || '未知'} → 将显示 [图片] 占位符`)
return null
}
console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`)
result.localPath = thumbResult.localPath
}
@@ -1503,7 +1510,10 @@ class ExportService {
}
// 复制文件
if (!fs.existsSync(sourcePath)) return null
if (!fs.existsSync(sourcePath)) {
console.log(`[Export] 源图片文件不存在 (localId=${msg.localId}): ${sourcePath} → 将显示 [图片] 占位符`)
return null
}
const ext = path.extname(sourcePath) || '.jpg'
const fileName = `${messageId}_${imageKey}${ext}`
const destPath = path.join(imagesDir, fileName)
@@ -1517,6 +1527,7 @@ class ExportService {
kind: 'image'
}
} catch (e) {
console.error(`[Export] 导出图片异常 (localId=${msg.localId}):`, e, `→ 将显示 [图片] 占位符`)
return null
}
}
@@ -1785,7 +1796,14 @@ class ExportService {
fileStream.close()
resolve(true)
})
fileStream.on('error', () => {
fileStream.on('error', (err) => {
// 确保在错误情况下销毁流,释放文件句柄
fileStream.destroy()
resolve(false)
})
response.on('error', (err) => {
// 确保在响应错误时也关闭文件句柄
fileStream.destroy()
resolve(false)
})
})
@@ -1812,22 +1830,43 @@ class ExportService {
let firstTime: number | null = null
let lastTime: number | null = null
// 修复时间范围0 表示不限制,而不是时间戳 0
const beginTime = dateRange?.start || 0
const endTime = dateRange?.end && dateRange.end > 0 ? dateRange.end : 0
console.log(`[Export] 收集消息: sessionId=${sessionId}, 时间范围: ${beginTime} ~ ${endTime || '无限制'}`)
const cursor = await wcdbService.openMessageCursor(
sessionId,
500,
true,
dateRange?.start || 0,
dateRange?.end || 0
beginTime,
endTime
)
if (!cursor.success || !cursor.cursor) {
console.error(`[Export] 打开游标失败: ${cursor.error || '未知错误'}`)
return { rows, memberSet, firstTime, lastTime }
}
try {
let hasMore = true
let batchCount = 0
while (hasMore) {
const batch = await wcdbService.fetchMessageBatch(cursor.cursor)
if (!batch.success || !batch.rows) break
batchCount++
if (!batch.success) {
console.error(`[Export] 获取批次 ${batchCount} 失败: ${batch.error}`)
break
}
if (!batch.rows) {
console.warn(`[Export] 批次 ${batchCount} 无数据`)
break
}
console.log(`[Export] 批次 ${batchCount}: 收到 ${batch.rows.length} 条消息`)
for (const row of batch.rows) {
const createTime = parseInt(row.create_time || '0', 10)
if (dateRange) {
@@ -1918,8 +1957,17 @@ class ExportService {
}
hasMore = batch.hasMore === true
}
console.log(`[Export] 收集完成: 共 ${rows.length} 条消息, ${batchCount} 个批次`)
} catch (err) {
console.error(`[Export] 收集消息异常:`, err)
} finally {
await wcdbService.closeMessageCursor(cursor.cursor)
try {
await wcdbService.closeMessageCursor(cursor.cursor)
console.log(`[Export] 游标已关闭`)
} catch (err) {
console.error(`[Export] 关闭游标失败:`, err)
}
}
if (senderSet.size > 0) {
@@ -4562,6 +4610,12 @@ class ExportService {
</html>`);
return new Promise((resolve, reject) => {
stream.on('error', (err) => {
// 确保在流错误时销毁流,释放文件句柄
stream.destroy()
reject(err)
})
stream.end(() => {
onProgress?.({
current: 100,

View File

@@ -100,6 +100,7 @@ class HttpService {
private port: number = 5031
private running: boolean = false
private connections: Set<import('net').Socket> = new Set()
private connectionMutex: boolean = false
constructor() {
this.configService = ConfigService.getInstance()
@@ -120,9 +121,20 @@ class HttpService {
// 跟踪所有连接,以便关闭时能强制断开
this.server.on('connection', (socket) => {
this.connections.add(socket)
// 使用互斥锁防止并发修改
if (!this.connectionMutex) {
this.connectionMutex = true
this.connections.add(socket)
this.connectionMutex = false
}
socket.on('close', () => {
this.connections.delete(socket)
// 使用互斥锁防止并发修改
if (!this.connectionMutex) {
this.connectionMutex = true
this.connections.delete(socket)
this.connectionMutex = false
}
})
})
@@ -150,11 +162,20 @@ class HttpService {
async stop(): Promise<void> {
return new Promise((resolve) => {
if (this.server) {
// 强制关闭所有活动连接
for (const socket of this.connections) {
socket.destroy()
}
// 使用互斥锁保护连接集合操作
this.connectionMutex = true
const socketsToClose = Array.from(this.connections)
this.connections.clear()
this.connectionMutex = false
// 强制关闭所有活动连接
for (const socket of socketsToClose) {
try {
socket.destroy()
} catch (err) {
console.error('[HttpService] Error destroying socket:', err)
}
}
this.server.close(() => {
this.running = false

View File

@@ -45,6 +45,7 @@ type DecryptResult = {
localPath?: string
error?: string
isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图)
liveVideoPath?: string // 实况照片的视频路径
}
type HardlinkState = {
@@ -61,6 +62,7 @@ export class ImageDecryptService {
private cacheIndexed = false
private cacheIndexing: Promise<void> | null = null
private updateFlags = new Map<string, boolean>()
private noLiveSet = new Set<string>() // 已确认无 live 视频的图片路径
private logInfo(message: string, meta?: Record<string, unknown>): void {
if (!this.configService.get('logEnabled')) return
@@ -116,8 +118,9 @@ export class ImageDecryptService {
} else {
this.updateFlags.delete(key)
}
const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(cached)
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(cached))
return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate }
return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate, liveVideoPath }
}
if (cached && !this.isImageFile(cached)) {
this.resolvedCache.delete(key)
@@ -136,8 +139,9 @@ export class ImageDecryptService {
} else {
this.updateFlags.delete(key)
}
const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(existing)
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(existing))
return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate }
return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate, liveVideoPath }
}
}
this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName })
@@ -151,13 +155,25 @@ export class ImageDecryptService {
return { success: false, error: '缺少图片标识' }
}
if (payload.force) {
const hdCached = this.findCachedOutput(cacheKey, true, payload.sessionId)
if (hdCached && existsSync(hdCached) && this.isImageFile(hdCached) && !this.isThumbnailPath(hdCached)) {
const dataUrl = this.fileToDataUrl(hdCached)
const localPath = dataUrl || this.filePathToUrl(hdCached)
const liveVideoPath = this.checkLiveVideoCache(hdCached)
this.emitCacheResolved(payload, cacheKey, localPath)
return { success: true, localPath, isThumb: false, liveVideoPath }
}
}
if (!payload.force) {
const cached = this.resolvedCache.get(cacheKey)
if (cached && existsSync(cached) && this.isImageFile(cached)) {
const dataUrl = this.fileToDataUrl(cached)
const localPath = dataUrl || this.filePathToUrl(cached)
const liveVideoPath = this.isThumbnailPath(cached) ? undefined : this.checkLiveVideoCache(cached)
this.emitCacheResolved(payload, cacheKey, localPath)
return { success: true, localPath }
return { success: true, localPath, liveVideoPath }
}
if (cached && !this.isImageFile(cached)) {
this.resolvedCache.delete(cacheKey)
@@ -235,8 +251,9 @@ export class ImageDecryptService {
const dataUrl = this.fileToDataUrl(existing)
const localPath = dataUrl || this.filePathToUrl(existing)
const isThumb = this.isThumbnailPath(existing)
const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(existing)
this.emitCacheResolved(payload, cacheKey, localPath)
return { success: true, localPath, isThumb }
return { success: true, localPath, isThumb, liveVideoPath }
}
}
@@ -296,7 +313,15 @@ export class ImageDecryptService {
const dataUrl = this.bufferToDataUrl(decrypted, finalExt)
const localPath = dataUrl || this.filePathToUrl(outputPath)
this.emitCacheResolved(payload, cacheKey, localPath)
return { success: true, localPath, isThumb }
// 检测实况照片Motion Photo
let liveVideoPath: string | undefined
if (!isThumb && (finalExt === '.jpg' || finalExt === '.jpeg')) {
const videoPath = await this.extractMotionPhotoVideo(outputPath, decrypted)
if (videoPath) liveVideoPath = this.filePathToUrl(videoPath)
}
return { success: true, localPath, isThumb, liveVideoPath }
} catch (e) {
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
return { success: false, error: String(e) }
@@ -332,23 +357,37 @@ export class ImageDecryptService {
* 获取解密后的缓存目录(用于查找 hardlink.db
*/
private getDecryptedCacheDir(wxid: string): string | null {
const cachePath = this.configService.get('cachePath')
if (!cachePath) return null
const cleanedWxid = this.cleanAccountDirName(wxid)
const cacheAccountDir = join(cachePath, cleanedWxid)
const configured = this.configService.get('cachePath')
const documentsPath = app.getPath('documents')
const baseCandidates = Array.from(new Set([
configured || '',
join(documentsPath, 'WeFlow'),
join(documentsPath, 'WeFlowData'),
this.configService.getCacheBasePath()
].filter(Boolean)))
// 检查缓存目录下是否有 hardlink.db
if (existsSync(join(cacheAccountDir, 'hardlink.db'))) {
return cacheAccountDir
}
if (existsSync(join(cachePath, 'hardlink.db'))) {
return cachePath
}
const cacheHardlinkDir = join(cacheAccountDir, 'db_storage', 'hardlink')
if (existsSync(join(cacheHardlinkDir, 'hardlink.db'))) {
return cacheHardlinkDir
for (const base of baseCandidates) {
const accountCandidates = Array.from(new Set([
join(base, wxid),
join(base, cleanedWxid),
join(base, 'databases', wxid),
join(base, 'databases', cleanedWxid)
]))
for (const accountDir of accountCandidates) {
if (existsSync(join(accountDir, 'hardlink.db'))) {
return accountDir
}
const hardlinkSubdir = join(accountDir, 'db_storage', 'hardlink')
if (existsSync(join(hardlinkSubdir, 'hardlink.db'))) {
return hardlinkSubdir
}
}
if (existsSync(join(base, 'hardlink.db'))) {
return base
}
}
return null
}
@@ -357,7 +396,8 @@ export class ImageDecryptService {
existsSync(join(dirPath, 'hardlink.db')) ||
existsSync(join(dirPath, 'db_storage')) ||
existsSync(join(dirPath, 'FileStorage', 'Image')) ||
existsSync(join(dirPath, 'FileStorage', 'Image2'))
existsSync(join(dirPath, 'FileStorage', 'Image2')) ||
existsSync(join(dirPath, 'msg', 'attach'))
)
}
@@ -423,6 +463,12 @@ export class ImageDecryptService {
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath
}
const hdInDir = await this.searchDatFileInDir(dirname(hardlinkPath), imageDatName || imageMd5 || '', false)
if (hdInDir) {
this.cacheDatPath(accountDir, imageMd5, hdInDir)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdInDir)
return hdInDir
}
// 没找到高清图,返回 null不进行全局搜索
return null
}
@@ -440,9 +486,16 @@ export class ImageDecryptService {
// 找到缩略图但要求高清图,尝试同目录查找高清图变体
const hdPath = this.findHdVariantInSameDir(fallbackPath)
if (hdPath) {
this.cacheDatPath(accountDir, imageMd5, hdPath)
this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath
}
const hdInDir = await this.searchDatFileInDir(dirname(fallbackPath), imageDatName || imageMd5 || '', false)
if (hdInDir) {
this.cacheDatPath(accountDir, imageMd5, hdInDir)
this.cacheDatPath(accountDir, imageDatName, hdInDir)
return hdInDir
}
return null
}
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
@@ -465,15 +518,17 @@ export class ImageDecryptService {
this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath
}
const hdInDir = await this.searchDatFileInDir(dirname(hardlinkPath), imageDatName || '', false)
if (hdInDir) {
this.cacheDatPath(accountDir, imageDatName, hdInDir)
return hdInDir
}
return null
}
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
}
// 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢)
if (!allowThumbnail) {
return null
}
// force 模式下也继续尝试缓存目录/文件系统搜索,避免 hardlink.db 缺行时只能拿到缩略图
if (!imageDatName) return null
if (!skipResolvedCache) {
@@ -483,6 +538,8 @@ export class ImageDecryptService {
// 缓存的是缩略图,尝试找高清图
const hdPath = this.findHdVariantInSameDir(cached)
if (hdPath) return hdPath
const hdInDir = await this.searchDatFileInDir(dirname(cached), imageDatName, false)
if (hdInDir) return hdInDir
}
}
@@ -1681,6 +1738,76 @@ export class ImageDecryptService {
return mostCommonKey
}
/**
* 检查图片对应的 live 视频缓存,返回 file:// URL 或 undefined
* 已确认无 live 的路径会被记录,下次直接跳过
*/
private checkLiveVideoCache(imagePath: string): string | undefined {
if (this.noLiveSet.has(imagePath)) return undefined
const livePath = imagePath.replace(/\.(jpg|jpeg|png)$/i, '_live.mp4')
if (existsSync(livePath)) return this.filePathToUrl(livePath)
this.noLiveSet.add(imagePath)
return undefined
}
/**
* 检测并分离 Motion Photo实况照片
* Google Motion Photo = JPEG + MP4 拼接在一起
* 返回视频文件路径,如果不是实况照片则返回 null
*/
private async extractMotionPhotoVideo(imagePath: string, imageBuffer: Buffer): Promise<string | null> {
// 只处理 JPEG 文件
if (imageBuffer.length < 8) return null
if (imageBuffer[0] !== 0xff || imageBuffer[1] !== 0xd8) return null
// 从末尾向前搜索 MP4 ftyp 原子签名
// ftyp 原子结构: [4字节大小][ftyp(66 74 79 70)][品牌...]
// 实际起始位置在 ftyp 前4字节大小字段
const ftypSig = [0x66, 0x74, 0x79, 0x70] // 'ftyp'
let videoOffset: number | null = null
const searchEnd = Math.max(0, imageBuffer.length - 8)
for (let i = searchEnd; i > 0; i--) {
if (imageBuffer[i] === ftypSig[0] &&
imageBuffer[i + 1] === ftypSig[1] &&
imageBuffer[i + 2] === ftypSig[2] &&
imageBuffer[i + 3] === ftypSig[3]) {
// ftyp 前4字节是 box size实际 MP4 从这里开始
videoOffset = i - 4
break
}
}
// 备用:从 XMP 元数据中读取偏移量
if (videoOffset === null || videoOffset <= 0) {
try {
const text = imageBuffer.toString('latin1')
const match = text.match(/MediaDataOffset="(\d+)"/i) || text.match(/MicroVideoOffset="(\d+)"/i)
if (match) {
const offset = parseInt(match[1], 10)
if (offset > 0 && offset < imageBuffer.length) {
videoOffset = imageBuffer.length - offset
}
}
} catch { }
}
if (videoOffset === null || videoOffset <= 100) return null
// 验证视频部分确实以有效 MP4 数据开头
const videoStart = imageBuffer[videoOffset + 4] === 0x66 &&
imageBuffer[videoOffset + 5] === 0x74 &&
imageBuffer[videoOffset + 6] === 0x79 &&
imageBuffer[videoOffset + 7] === 0x70
if (!videoStart) return null
// 写出视频文件
const videoPath = imagePath.replace(/\.(jpg|jpeg|png)$/i, '_live.mp4')
const videoBuffer = imageBuffer.slice(videoOffset)
await writeFile(videoPath, videoBuffer)
return videoPath
}
/**
* 解包 wxgf 格式
* wxgf 是微信的图片格式,内部使用 HEVC 编码

View File

@@ -843,7 +843,7 @@ export class KeyService {
private findTemplateDatFiles(rootDir: string): string[] {
const files: string[] = []
const stack = [rootDir]
const maxFiles = 32
const maxFiles = 256
while (stack.length && files.length < maxFiles) {
const dir = stack.pop() as string
let entries: string[]
@@ -877,7 +877,7 @@ export class KeyService {
if (ma && mb) return mb.localeCompare(ma)
return 0
})
return files.slice(0, 16)
return files.slice(0, 128)
}
private getXorKey(templateFiles: string[]): number | null {

View File

@@ -458,8 +458,18 @@ export class VoiceTranscribeService {
writer.on('error', (err) => {
clearInterval(speedInterval)
// 确保在错误情况下也关闭文件句柄
writer.destroy()
reject(err)
})
response.on('error', (err) => {
clearInterval(speedInterval)
// 确保在响应错误时也关闭文件句柄
writer.destroy()
reject(err)
})
response.pipe(writer)
})
request.on('error', reject)

View File

@@ -1024,7 +1024,7 @@ export class WcdbCore {
}
try {
// 1. 打开游标 (使用 Ascending=1 从指定时间往后查)
const openRes = await this.openMessageCursorLite(sessionId, limit, true, minTime, 0)
const openRes = await this.openMessageCursor(sessionId, limit, true, minTime, 0)
if (!openRes.success || !openRes.cursor) {
return { success: false, error: openRes.error }
}
@@ -1620,12 +1620,20 @@ export class WcdbCore {
}
}
async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> {
async execQuery(kind: string, path: string | null, sql: string, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
if (!this.wcdbExecQuery) return { success: false, error: '接口未就绪' }
// 如果提供了参数,使用参数化查询(需要 C++ 层支持)
// 注意:当前 wcdbExecQuery 可能不支持参数化,这是一个占位符实现
// TODO: 需要更新 C++ 层的 wcdb_exec_query 以支持参数绑定
if (params && params.length > 0) {
console.warn('[wcdbCore] execQuery: 参数化查询暂未在 C++ 层实现,将使用原始 SQL可能存在注入风险')
}
const outPtr = [null as any]
const result = this.wcdbExecQuery(this.handle, kind, path || '', sql, outPtr)
if (result !== 0 || !outPtr[0]) {

View File

@@ -361,10 +361,10 @@ export class WcdbService {
}
/**
* 执行 SQL 查询
* 执行 SQL 查询(支持参数化查询)
*/
async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> {
return this.callWorker('execQuery', { kind, path, sql })
async execQuery(kind: string, path: string | null, sql: string, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> {
return this.callWorker('execQuery', { kind, path, sql, params })
}
/**

114
electron/utils/LRUCache.ts Normal file
View File

@@ -0,0 +1,114 @@
/**
* LRU (Least Recently Used) Cache implementation for memory management
*/
export class LRUCache<K, V> {
private cache: Map<K, V>
private maxSize: number
constructor(maxSize: number = 100) {
this.maxSize = maxSize
this.cache = new Map()
}
/**
* Get value from cache
*/
get(key: K): V | undefined {
const value = this.cache.get(key)
if (value !== undefined) {
// Move to end (most recently used)
this.cache.delete(key)
this.cache.set(key, value)
}
return value
}
/**
* Set value in cache
*/
set(key: K, value: V): void {
if (this.cache.has(key)) {
// Update existing
this.cache.delete(key)
} else if (this.cache.size >= this.maxSize) {
// Remove least recently used (first item)
const firstKey = this.cache.keys().next().value
if (firstKey !== undefined) {
this.cache.delete(firstKey)
}
}
this.cache.set(key, value)
}
/**
* Check if key exists
*/
has(key: K): boolean {
return this.cache.has(key)
}
/**
* Delete key from cache
*/
delete(key: K): boolean {
return this.cache.delete(key)
}
/**
* Clear all cache entries
*/
clear(): void {
this.cache.clear()
}
/**
* Get current cache size
*/
get size(): number {
return this.cache.size
}
/**
* Get all keys (for debugging)
*/
keys(): IterableIterator<K> {
return this.cache.keys()
}
/**
* Get all values (for debugging)
*/
values(): IterableIterator<V> {
return this.cache.values()
}
/**
* Get all entries (for iteration support)
*/
entries(): IterableIterator<[K, V]> {
return this.cache.entries()
}
/**
* Make LRUCache iterable (for...of support)
*/
[Symbol.iterator](): IterableIterator<[K, V]> {
return this.cache.entries()
}
/**
* Force cleanup (optional method for explicit memory management)
*/
cleanup(): void {
// In JavaScript/TypeScript, this is mainly for consistency
// The garbage collector will handle actual memory cleanup
if (this.cache.size > this.maxSize * 1.5) {
// Emergency cleanup if cache somehow exceeds limit
const entries = Array.from(this.cache.entries())
this.cache.clear()
// Keep only the most recent half
const keepEntries = entries.slice(-Math.floor(this.maxSize / 2))
keepEntries.forEach(([key, value]) => this.cache.set(key, value))
}
}
}

View File

@@ -118,7 +118,7 @@ if (parentPort) {
result = await core.closeMessageCursor(payload.cursor)
break
case 'execQuery':
result = await core.execQuery(payload.kind, payload.path, payload.sql)
result = await core.execQuery(payload.kind, payload.path, payload.sql, payload.params)
break
case 'getEmoticonCdnUrl':
result = await core.getEmoticonCdnUrl(payload.dbPath, payload.md5)

3
package-lock.json generated
View File

@@ -9823,9 +9823,6 @@
"sherpa-onnx-win-x64": "^1.12.23"
}
},
"node_modules/sherpa-onnx-node/node_modules/sherpa-onnx-darwin-x64": {
"optional": true
},
"node_modules/sherpa-onnx-win-ia32": {
"version": "1.12.23",
"resolved": "https://registry.npmmirror.com/sherpa-onnx-win-ia32/-/sherpa-onnx-win-ia32-1.12.23.tgz",

249
public/splash.html Normal file
View File

@@ -0,0 +1,249 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WeFlow</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%;
background: transparent;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
user-select: none;
-webkit-app-region: drag;
}
.splash {
width: 100%; height: 100%;
border-radius: 20px;
display: flex;
flex-direction: column;
}
/* 品牌区 */
.brand {
padding: 48px 52px 0;
display: flex;
align-items: center;
gap: 18px;
animation: fadeIn 0.4s ease both;
}
.logo {
width: 56px; height: 56px;
border-radius: 14px;
flex-shrink: 0;
}
.app-name {
font-size: 22px;
font-weight: 700;
letter-spacing: 0.3px;
}
.app-desc {
font-size: 12px;
margin-top: 5px;
opacity: 0.6;
}
.spacer { flex: 1; }
/* 底部进度区 */
.bottom {
padding: 0 48px 40px;
animation: fadeIn 0.4s ease 0.1s both;
}
/* 进度条轨道 */
.progress-track {
width: 100%;
height: 2px;
border-radius: 2px;
margin-bottom: 12px;
position: relative;
overflow: hidden;
}
/* 进度条填充 */
.progress-fill {
height: 100%;
width: 0%;
border-radius: 2px;
position: relative;
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
/* 扫光:只在有进度时显示,不循环 */
.progress-fill::after {
content: '';
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.5) 50%, transparent 100%);
animation: sweep 1.2s ease-out forwards;
opacity: 0;
}
/* 等待阶段:进度条末端呼吸光点 */
.progress-fill.waiting::before {
content: '';
position: absolute;
top: -1px; right: -2px;
width: 6px; height: 4px;
border-radius: 50%;
background: inherit;
filter: blur(2px);
animation: pulse 1.5s ease-in-out infinite;
}
.bottom-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.progress-text {
font-size: 11px;
opacity: 0.38;
}
.version {
font-size: 11px;
opacity: 0.25;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes sweep {
0% { opacity: 0; transform: translateX(-100%); }
20% { opacity: 1; }
80% { opacity: 1; }
100% { opacity: 0; transform: translateX(100%); }
}
@keyframes pulse {
0%, 100% { opacity: 0.4; transform: scaleX(1); }
50% { opacity: 1; transform: scaleX(1.8); }
}
</style>
</head>
<body>
<div class="splash" id="splash">
<div class="brand">
<img class="logo" src="./logo.png" alt="WeFlow" />
<div class="brand-text">
<div class="app-name" id="appName">WeFlow</div>
<div class="app-desc" id="appDesc">微信聊天记录管理工具</div>
</div>
</div>
<div class="spacer"></div>
<div class="bottom">
<div class="progress-track" id="progressTrack">
<div class="progress-fill" id="progressFill"></div>
</div>
<div class="bottom-row">
<div class="progress-text" id="progressText">正在启动...</div>
<div class="version" id="versionText"></div>
</div>
</div>
</div>
<script>
var themes = {
'cloud-dancer': {
light: { primary: '#8B7355', bg: '#F0EEE9', bgEnd: '#E5E1DA', text: '#3d3d3d', desc: '#8B7355' },
dark: { primary: '#C9A86C', bg: '#1a1816', bgEnd: '#252220', text: '#F0EEE9', desc: '#C9A86C' }
},
'corundum-blue': {
light: { primary: '#4A6670', bg: '#E8EEF0', bgEnd: '#D8E4E8', text: '#3d3d3d', desc: '#4A6670' },
dark: { primary: '#6A9AAA', bg: '#141a1c', bgEnd: '#1e2a2e', text: '#E0EEF2', desc: '#6A9AAA' }
},
'kiwi-green': {
light: { primary: '#7A9A5C', bg: '#E8F0E4', bgEnd: '#D8E8D2', text: '#3d3d3d', desc: '#7A9A5C' },
dark: { primary: '#9ABA7C', bg: '#161a14', bgEnd: '#222a1e', text: '#E8F0E4', desc: '#9ABA7C' }
},
'spicy-red': {
light: { primary: '#8B4049', bg: '#F0E8E8', bgEnd: '#E8D8D8', text: '#3d3d3d', desc: '#8B4049' },
dark: { primary: '#C06068', bg: '#1a1416', bgEnd: '#261e20', text: '#F2E8EA', desc: '#C06068' }
},
'teal-water': {
light: { primary: '#5A8A8A', bg: '#E4F0F0', bgEnd: '#D2E8E8', text: '#3d3d3d', desc: '#5A8A8A' },
dark: { primary: '#7ABAAA', bg: '#121a1a', bgEnd: '#1a2626', text: '#E0F2EE', desc: '#7ABAAA' }
},
'blossom-dream': {
light: { primary: '#D4849A', primaryEnd: '#D4849A', bg: '#FCF9FB', bgMid: '#F8F2F8', bgEnd: '#F2F6FB', text: '#2E2633', desc: '#D4849A' },
dark: { primary: '#C670C3', primaryEnd: '#8A60C0', bg: '#120B16', bgMid: '#1A1020', bgEnd: '#0E0B18', text: '#F2EAF4', desc: '#C670C3' }
}
};
function applyTheme(themeId, mode) {
var t = themes[themeId] || themes['cloud-dancer'];
var isDark = mode === 'dark';
if (mode === 'system') isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var c = isDark ? t.dark : t.light;
var el = document.getElementById('splash');
var fill = document.getElementById('progressFill');
if (themeId === 'blossom-dream') {
if (isDark) {
// 深色
el.style.background =
'radial-gradient(ellipse 60% 50% at 100% 0%, ' + c.primary + '28 0%, transparent 70%), ' +
'linear-gradient(150deg, ' + c.bg + ' 0%, ' + c.bgMid + ' 45%, ' + c.bgEnd + ' 100%)';
} else {
// 浅色
el.style.background = 'linear-gradient(150deg, ' + c.bg + ' 0%, ' + c.bgMid + ' 45%, ' + c.bgEnd + ' 100%)';
}
// 进度条
fill.style.background = 'linear-gradient(90deg, ' + c.primary + ' 0%, ' + c.primaryEnd + ' 100%)';
} else {
if (isDark) {
el.style.background =
'radial-gradient(ellipse 60% 50% at 100% 0%, ' + c.primary + '22 0%, transparent 70%), ' +
'linear-gradient(145deg, ' + c.bg + ' 0%, ' + c.bgEnd + ' 100%)';
} else {
el.style.background = 'linear-gradient(150deg, ' + c.bg + ' 0%, ' + c.bgEnd + ' 100%)';
}
fill.style.background = c.primary;
}
document.getElementById('appName').style.color = c.text;
document.getElementById('appDesc').style.color = c.desc;
document.getElementById('progressText').style.color = c.text;
document.getElementById('versionText').style.color = c.text;
document.getElementById('progressTrack').style.background = c.primary + (isDark ? '25' : '18');
}
// percent: 实际进度值waiting: 是否处于等待阶段
function updateProgress(percent, text, waiting) {
var fill = document.getElementById('progressFill');
var label = document.getElementById('progressText');
if (fill) {
fill.style.width = percent + '%';
if (waiting) {
fill.classList.add('waiting');
} else {
fill.classList.remove('waiting');
// 触发扫光:重置动画
fill.style.animation = 'none';
fill.offsetHeight;
fill.style.animation = '';
}
}
if (label && text) label.textContent = text;
}
function setVersion(ver) {
var el = document.getElementById('versionText');
if (el) el.textContent = 'v' + ver;
}
applyTheme('cloud-dancer', 'light');
</script>
</body>
</html>

View File

@@ -4,6 +4,48 @@
flex-direction: column;
background: var(--bg-primary);
animation: appFadeIn 0.35s ease-out;
position: relative;
overflow: hidden;
}
// 繁花如梦:底色层(::before+ 光晕层(::after分离避免 blur 吃掉边缘
[data-theme="blossom-dream"] .app-container {
background: transparent;
}
// ::before 纯底色,不模糊
[data-theme="blossom-dream"] .app-container::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
z-index: -2;
background: var(--bg-primary);
}
// ::after 光晕层,模糊叠加在底色上
[data-theme="blossom-dream"] .app-container::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
z-index: -1;
background:
radial-gradient(ellipse 55% 45% at 15% 20%, var(--blossom-pink) 0%, transparent 70%),
radial-gradient(ellipse 50% 40% at 85% 75%, var(--blossom-peach) 0%, transparent 65%),
radial-gradient(ellipse 45% 50% at 80% 10%, var(--blossom-blue) 0%, transparent 60%);
filter: blur(80px);
opacity: 0.75;
}
// 深色模式光晕更克制
[data-theme="blossom-dream"][data-mode="dark"] .app-container::after {
background:
radial-gradient(ellipse 55% 45% at 15% 20%, var(--blossom-pink) 0%, transparent 70%),
radial-gradient(ellipse 50% 40% at 85% 75%, var(--blossom-purple) 0%, transparent 65%),
radial-gradient(ellipse 45% 50% at 80% 10%, var(--blossom-blue) 0%, transparent 60%);
filter: blur(100px);
opacity: 0.2;
}
.window-drag-region {

View File

@@ -34,6 +34,7 @@ import UpdateProgressCapsule from './components/UpdateProgressCapsule'
import LockScreen from './components/LockScreen'
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal'
function App() {
const navigate = useNavigate()
@@ -312,7 +313,7 @@ function App() {
const checkLock = async () => {
// 并行获取配置,减少等待
const [enabled, useHello] = await Promise.all([
configService.getAuthEnabled(),
window.electronAPI.auth.verifyEnabled(),
configService.getAuthUseHello()
])
@@ -385,6 +386,7 @@ function App() {
{/* 全局批量转写进度浮窗 */}
<BatchTranscribeGlobal />
<BatchImageDecryptGlobal />
{/* 用户协议弹窗 */}
{showAgreement && !agreementLoading && (

View File

@@ -0,0 +1,133 @@
import React, { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { Loader2, X, Image as ImageIcon, Clock, CheckCircle, XCircle } from 'lucide-react'
import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore'
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
import '../styles/batchTranscribe.scss'
export const BatchImageDecryptGlobal: React.FC = () => {
const {
isBatchDecrypting,
progress,
showToast,
showResultToast,
result,
sessionName,
startTime,
setShowToast,
setShowResultToast
} = useBatchImageDecryptStore()
const voiceToastOccupied = useBatchTranscribeStore(
state => state.isBatchTranscribing && state.showToast
)
const [eta, setEta] = useState('')
useEffect(() => {
if (!isBatchDecrypting || !startTime || progress.current === 0) {
setEta('')
return
}
const timer = setInterval(() => {
const elapsed = Date.now() - startTime
if (elapsed <= 0) return
const rate = progress.current / elapsed
const remain = progress.total - progress.current
if (remain <= 0 || rate <= 0) {
setEta('')
return
}
const seconds = Math.ceil((remain / rate) / 1000)
if (seconds < 60) {
setEta(`${seconds}`)
} else {
const m = Math.floor(seconds / 60)
const s = seconds % 60
setEta(`${m}${s}`)
}
}, 1000)
return () => clearInterval(timer)
}, [isBatchDecrypting, progress.current, progress.total, startTime])
useEffect(() => {
if (!showResultToast) return
const timer = window.setTimeout(() => setShowResultToast(false), 6000)
return () => window.clearTimeout(timer)
}, [showResultToast, setShowResultToast])
const toastBottom = useMemo(() => (voiceToastOccupied ? 148 : 24), [voiceToastOccupied])
return (
<>
{showToast && isBatchDecrypting && createPortal(
<div className="batch-progress-toast" style={{ bottom: toastBottom }}>
<div className="batch-progress-toast-header">
<div className="batch-progress-toast-title">
<Loader2 size={14} className="spin" />
<span>{sessionName ? `${sessionName}` : ''}</span>
</div>
<button className="batch-progress-toast-close" onClick={() => setShowToast(false)} title="最小化">
<X size={14} />
</button>
</div>
<div className="batch-progress-toast-body">
<div className="progress-info-row">
<div className="progress-text">
<span>{progress.current} / {progress.total}</span>
<span className="progress-percent">
{progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0}%
</span>
</div>
{eta && (
<div className="progress-eta">
<Clock size={12} />
<span> {eta}</span>
</div>
)}
</div>
<div className="progress-bar">
<div
className="progress-fill"
style={{
width: `${progress.total > 0 ? (progress.current / progress.total) * 100 : 0}%`
}}
/>
</div>
</div>
</div>,
document.body
)}
{showResultToast && createPortal(
<div className="batch-progress-toast batch-inline-result-toast" style={{ bottom: toastBottom }}>
<div className="batch-progress-toast-header">
<div className="batch-progress-toast-title">
<ImageIcon size={14} />
<span></span>
</div>
<button className="batch-progress-toast-close" onClick={() => setShowResultToast(false)} title="关闭">
<X size={14} />
</button>
</div>
<div className="batch-progress-toast-body">
<div className="batch-inline-result-summary">
<div className="batch-inline-result-item success">
<CheckCircle size={14} />
<span> {result.success}</span>
</div>
<div className={`batch-inline-result-item ${result.fail > 0 ? 'fail' : 'muted'}`}>
<XCircle size={14} />
<span> {result.fail}</span>
</div>
</div>
</div>
</div>,
document.body
)}
</>
)
}

View File

@@ -1,5 +1,4 @@
import { useState, useEffect, useRef } from 'react'
import * as configService from '../services/config'
import { ArrowRight, Fingerprint, Lock, ScanFace, ShieldCheck } from 'lucide-react'
import './LockScreen.scss'
@@ -9,14 +8,6 @@ interface LockScreenProps {
useHello?: boolean
}
async function sha256(message: string) {
const msgBuffer = new TextEncoder().encode(message)
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
return hashHex
}
export default function LockScreen({ onUnlock, avatar, useHello = false }: LockScreenProps) {
const [password, setPassword] = useState('')
const [error, setError] = useState('')
@@ -49,19 +40,9 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
const quickStartHello = async () => {
try {
// 如果父组件已经告诉我们要用 Hello直接开始不等待 IPC
let shouldUseHello = useHello
// 为了稳健,如果 prop 没传(虽然现在都传了),再 check 一次 config
if (!shouldUseHello) {
shouldUseHello = await configService.getAuthUseHello()
}
if (shouldUseHello) {
// 标记为可用,显示按钮
if (useHello) {
setHelloAvailable(true)
setShowHello(true)
// 立即执行验证 (0延迟)
verifyHello()
}
} catch (e) {
@@ -96,25 +77,19 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
e?.preventDefault()
if (!password || isUnlocked) return
// 如果正在进行 Hello 验证它会自动失败或被取代UI上不用特意取消
// 因为 native 调用是模态的或者独立的,我们只要让 JS 状态不对锁住即可
// 不再检查 isVerifying因为我们允许打断 Hello
setIsVerifying(true)
setError('')
try {
const storedHash = await configService.getAuthPassword()
const inputHash = await sha256(password)
// 发送原始密码到主进程,由主进程验证并解密密钥
const result = await window.electronAPI.auth.unlock(password)
if (inputHash === storedHash) {
if (result.success) {
handleUnlock()
} else {
setError('密码错误')
setError(result.error || '密码错误')
setPassword('')
setIsVerifying(false)
// 如果密码错误,是否重新触发 Hello?
// 用户可能想重试密码,暂时不自动触发
}
} catch (e) {
setError('验证失败')

View File

@@ -7,10 +7,12 @@
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border-light);
// 浅色模式下使用不透明背景,避免透明窗口中通知过于透明
// 浅色模式下使用完全不透明背景,并禁用毛玻璃效果
[data-mode="light"] &,
:not([data-mode]) & {
background: rgba(255, 255, 255, 1);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
border-radius: 12px;
@@ -46,10 +48,16 @@
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
// 确保背景不透明
background: var(--bg-secondary, #2c2c2c);
// 确保背景完全不透明(通知是独立窗口,透明背景会穿透)
background: var(--bg-secondary-solid, var(--bg-secondary, #2c2c2c));
color: var(--text-primary, #ffffff);
// 浅色模式强制完全不透明白色背景
[data-mode="light"] &,
:not([data-mode]) & {
background: #ffffff !important;
}
box-shadow: none !important; // NO SHADOW
border: 1px solid var(--border-light, rgba(255, 255, 255, 0.1));

View File

@@ -103,4 +103,31 @@
background: var(--bg-tertiary);
color: var(--text-primary);
}
}
// 繁花如梦主题:侧边栏毛玻璃 + 激活项用主品牌色
[data-theme="blossom-dream"] .sidebar {
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-right: 1px solid rgba(255, 255, 255, 0.4);
}
[data-theme="blossom-dream"][data-mode="dark"] .sidebar {
background: rgba(34, 30, 36, 0.75);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-right: 1px solid rgba(255, 255, 255, 0.06);
}
// 激活项:主品牌色纵向微渐变
[data-theme="blossom-dream"] .nav-item.active {
background: linear-gradient(180deg, #D4849A 0%, #C4748A 100%);
}
// 深色激活项:用藕粉色,背景深灰底 + 粉色文字/图标(高阶玩法)
[data-theme="blossom-dream"][data-mode="dark"] .nav-item.active {
background: rgba(209, 158, 187, 0.15);
color: #D19EBB;
border: 1px solid rgba(209, 158, 187, 0.2);
}

View File

@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock } from 'lucide-react'
import { useAppStore } from '../stores/appStore'
import * as configService from '../services/config'
import './Sidebar.scss'
function Sidebar() {
@@ -12,7 +12,7 @@ function Sidebar() {
const setLocked = useAppStore(state => state.setLocked)
useEffect(() => {
configService.getAuthEnabled().then(setAuthEnabled)
window.electronAPI.auth.verifyEnabled().then(setAuthEnabled)
}, [])
const isActive = (path: string) => {

View File

@@ -21,6 +21,7 @@ interface SnsMedia {
interface SnsMediaGridProps {
mediaList: SnsMedia[]
postType?: number
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
onMediaDeleted?: () => void
}
@@ -80,7 +81,7 @@ const extractVideoFrame = async (videoPath: string): Promise<string> => {
})
}
const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void; onMediaDeleted?: () => void }) => {
const MediaItem = ({ media, postType, onPreview, onMediaDeleted }: { media: SnsMedia; postType?: number; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void; onMediaDeleted?: () => void }) => {
const [error, setError] = useState(false)
const [deleted, setDeleted] = useState(false)
const [loading, setLoading] = useState(true)
@@ -96,6 +97,8 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr
const isVideo = isSnsVideoUrl(media.url)
const isLive = !!media.livePhoto
const targetUrl = media.thumb || media.url
// type 7 的朋友圈媒体不需要解密,直接使用原始 URL
const skipDecrypt = postType === 7
// 视频重试失败时重试最多2次耗尽才标记删除
const videoRetryOrDelete = () => {
@@ -119,7 +122,7 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr
// For images, we proxy to get the local path/base64
const result = await window.electronAPI.sns.proxyImage({
url: targetUrl,
key: media.key
key: skipDecrypt ? undefined : media.key
})
if (cancelled) return
@@ -134,7 +137,7 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr
if (isLive && media.livePhoto?.url) {
window.electronAPI.sns.proxyImage({
url: media.livePhoto.url,
key: media.livePhoto.key || media.key
key: skipDecrypt ? undefined : (media.livePhoto.key || media.key)
}).then((res: any) => {
if (!cancelled && res.success && res.videoPath) {
setLiveVideoPath(`file://${res.videoPath.replace(/\\/g, '/')}`)
@@ -150,7 +153,7 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr
// Usually we need to call proxyImage with the video URL to decrypt it to cache
const result = await window.electronAPI.sns.proxyImage({
url: media.url,
key: media.key
key: skipDecrypt ? undefined : media.key
})
if (cancelled) return
@@ -201,7 +204,7 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr
try {
const res = await window.electronAPI.sns.proxyImage({
url: media.url,
key: media.key
key: skipDecrypt ? undefined : media.key
})
if (res.success && res.videoPath) {
const local = `file://${res.videoPath.replace(/\\/g, '/')}`
@@ -229,7 +232,7 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr
try {
const result = await window.electronAPI.sns.proxyImage({
url: media.url,
key: media.key
key: skipDecrypt ? undefined : media.key
})
if (result.success) {
@@ -334,7 +337,7 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr
)
}
export const SnsMediaGrid: React.FC<SnsMediaGridProps> = ({ mediaList, onPreview, onMediaDeleted }) => {
export const SnsMediaGrid: React.FC<SnsMediaGridProps> = ({ mediaList, postType, onPreview, onMediaDeleted }) => {
if (!mediaList || mediaList.length === 0) return null
const count = mediaList.length
@@ -350,7 +353,7 @@ export const SnsMediaGrid: React.FC<SnsMediaGridProps> = ({ mediaList, onPreview
return (
<div className={`sns-media-grid ${gridClass}`}>
{mediaList.map((media, idx) => (
<MediaItem key={idx} media={media} onPreview={onPreview} onMediaDeleted={onMediaDeleted} />
<MediaItem key={idx} media={media} postType={postType} onPreview={onPreview} onMediaDeleted={onMediaDeleted} />
))}
</div>
)

View File

@@ -264,7 +264,7 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
{showMediaGrid && (
<div className="post-media-container">
<SnsMediaGrid mediaList={post.media} onPreview={onPreview} onMediaDeleted={[1, 54].includes(post.type ?? 0) ? () => setMediaDeleted(true) : undefined} />
<SnsMediaGrid mediaList={post.media} postType={post.type} onPreview={onPreview} onMediaDeleted={[1, 54].includes(post.type ?? 0) ? () => setMediaDeleted(true) : undefined} />
</div>
)}

View File

@@ -10,6 +10,12 @@
gap: 8px;
}
// 繁花如梦:标题栏毛玻璃
[data-theme="blossom-dream"] .title-bar {
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.title-logo {
width: 20px;
height: 20px;

View File

@@ -1041,12 +1041,13 @@
// 链接卡片消息样式
.link-message {
width: 280px;
background: var(--card-bg);
background: var(--card-inner-bg);
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid var(--border-color);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
&:hover {
background: var(--bg-hover);
@@ -1114,19 +1115,362 @@
}
}
}
.appmsg-meta-badge {
font-size: 11px;
line-height: 1;
color: var(--primary);
background: rgba(127, 127, 127, 0.08);
border: 1px solid rgba(127, 127, 127, 0.18);
border-radius: 999px;
padding: 3px 7px;
align-self: flex-start;
white-space: nowrap;
}
.link-desc-block {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.appmsg-url-line {
font-size: 11px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&.appmsg-rich-card {
.link-header {
flex-direction: column;
align-items: flex-start;
}
}
}
.link-thumb.theme-adaptive,
.miniapp-thumb.theme-adaptive {
transition: filter 0.2s ease;
}
[data-mode="dark"] {
.link-thumb.theme-adaptive,
.miniapp-thumb.theme-adaptive {
filter: invert(1) hue-rotate(180deg);
}
}
// 适配发送出去的消息中的链接卡片
.message-bubble.sent .link-message {
background: var(--card-bg);
border: 1px solid var(--border-color);
background: var(--sent-card-bg);
border: 1px solid rgba(255, 255, 255, 0.15);
&:hover {
background: var(--primary-hover);
border-color: rgba(255, 255, 255, 0.25);
}
.link-title {
color: var(--text-primary);
color: white;
}
.link-desc {
color: rgba(255, 255, 255, 0.8);
}
.appmsg-url-line {
color: rgba(255, 255, 255, 0.6);
}
}
// ============= 专属消息卡片 =============
// 红包卡片
.hongbao-message {
width: 240px;
background: linear-gradient(135deg, #e25b4a 0%, #c94535 100%);
border-radius: 12px;
padding: 14px 16px;
display: flex;
gap: 12px;
align-items: center;
cursor: default;
.hongbao-icon {
flex-shrink: 0;
svg {
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1));
}
}
.hongbao-info {
flex: 1;
color: white;
.hongbao-greeting {
font-size: 15px;
font-weight: 500;
margin-bottom: 6px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.hongbao-label {
font-size: 12px;
opacity: 0.8;
}
}
}
// 礼物卡片
.gift-message {
width: 240px;
background: linear-gradient(135deg, #f7a8b8 0%, #e88fa0 100%);
border-radius: 12px;
padding: 14px 16px;
cursor: default;
.gift-img {
width: 100%;
border-radius: 8px;
margin-bottom: 10px;
object-fit: cover;
}
.gift-info {
color: white;
.gift-wish {
font-size: 15px;
font-weight: 500;
margin-bottom: 4px;
}
.gift-price {
font-size: 13px;
font-weight: 600;
margin-bottom: 4px;
}
.gift-label {
font-size: 12px;
opacity: 0.7;
}
}
}
// 视频号卡片
.channel-video-card {
width: 200px;
background: var(--card-inner-bg);
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border-color);
position: relative;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: var(--primary);
}
.channel-video-cover {
position: relative;
width: 100%;
height: 160px;
background: #000;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.channel-video-cover-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #666;
}
.channel-video-duration {
position: absolute;
bottom: 6px;
right: 6px;
background: rgba(0, 0, 0, 0.6);
color: white;
font-size: 11px;
padding: 1px 5px;
border-radius: 3px;
}
}
.channel-video-info {
padding: 8px 10px;
.channel-video-title {
font-size: 13px;
color: var(--text-primary);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 4px;
}
.channel-video-author {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-secondary);
.channel-video-avatar {
width: 16px;
height: 16px;
border-radius: 50%;
}
}
}
}
// 音乐卡片
.music-message {
width: 240px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
display: flex;
overflow: hidden;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
opacity: 0.85;
border-color: var(--primary);
}
.music-cover {
width: 80px;
align-self: stretch;
flex-shrink: 0;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.music-info {
flex: 1;
min-width: 0;
overflow: hidden;
padding: 10px;
.music-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.music-artist {
font-size: 12px;
color: var(--text-secondary);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.music-source {
font-size: 11px;
color: var(--text-tertiary);
margin-top: 2px;
}
}
}
// 位置消息卡片
.location-message {
width: 240px;
background: var(--card-inner-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: var(--primary);
}
.location-text {
padding: 12px;
display: flex;
gap: 8px;
}
.location-icon {
flex-shrink: 0;
color: #e25b4a;
margin-top: 2px;
}
.location-info {
flex: 1;
min-width: 0;
.location-name {
font-size: 14px;
font-weight: 500;
margin-bottom: 2px;
color: var(--text-primary);
}
.location-label {
font-size: 11px;
color: var(--text-tertiary);
line-height: 1.4;
}
}
.location-map {
position: relative;
height: 100px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
}
// 暗色模式下地图瓦片反色
[data-mode="dark"] {
.location-map img {
filter: invert(1) hue-rotate(180deg) brightness(0.9) contrast(0.9);
}
}
@@ -1512,6 +1856,20 @@
}
}
// 卡片类消息:气泡变透明,让卡片自己做视觉容器(仅直接子元素,排除引用消息内的卡片)
.message-bubble .bubble-content:has(> .link-message),
.message-bubble .bubble-content:has(> .card-message),
.message-bubble .bubble-content:has(> .chat-record-message),
.message-bubble .bubble-content:has(> .official-message),
.message-bubble .bubble-content:has(> .channel-video-card),
.message-bubble .bubble-content:has(> .location-message) {
background: transparent !important;
padding: 0 !important;
border: none !important;
box-shadow: none !important;
backdrop-filter: none !important;
}
.emoji-image {
max-width: 120px;
max-height: 120px;
@@ -1581,6 +1939,23 @@
position: relative;
display: inline-block;
-webkit-app-region: no-drag;
.media-badge.live {
position: absolute;
top: 8px;
right: 8px;
left: auto;
width: 24px;
height: 24px;
background: rgba(0, 0, 0, 0.4);
border-radius: 50%;
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
color: white;
pointer-events: none;
}
}
.image-update-button {
@@ -1868,6 +2243,18 @@
.quoted-text {
color: var(--text-secondary);
white-space: pre-wrap;
.quoted-type-label {
font-style: italic;
opacity: 0.8;
}
.quoted-emoji-image {
width: 40px;
height: 40px;
vertical-align: middle;
object-fit: contain;
}
}
}
@@ -2470,10 +2857,17 @@
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--bg-tertiary);
padding: 12px 14px;
background: var(--card-inner-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
min-width: 200px;
transition: opacity 0.2s ease;
cursor: pointer;
&:hover {
opacity: 0.85;
}
.card-icon {
flex-shrink: 0;
@@ -2498,12 +2892,23 @@
}
}
// 聊天记录消息 (合并转发)
.chat-record-message {
background: var(--card-inner-bg) !important;
border: 1px solid var(--border-color) !important;
transition: opacity 0.2s ease;
cursor: pointer;
&:hover {
opacity: 0.85;
}
}
// 通话消息
.call-message {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
color: var(--text-secondary);
font-size: 13px;
@@ -2735,12 +3140,14 @@
.card-message,
.chat-record-message,
.miniapp-message {
background: rgba(255, 255, 255, 0.15);
.miniapp-message,
.appmsg-rich-card {
background: var(--sent-card-bg);
.card-name,
.miniapp-title,
.source-name {
.source-name,
.link-title {
color: white;
}
@@ -2748,7 +3155,9 @@
.miniapp-label,
.chat-record-item,
.chat-record-meta-line,
.chat-record-desc {
.chat-record-desc,
.link-desc,
.appmsg-url-line {
color: rgba(255, 255, 255, 0.8);
}
@@ -2761,6 +3170,12 @@
.chat-record-more {
color: rgba(255, 255, 255, 0.9);
}
.appmsg-meta-badge {
color: rgba(255, 255, 255, 0.92);
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.2);
}
}
.call-message {
@@ -3218,4 +3633,234 @@
}
}
}
}
.miniapp-message-rich {
.miniapp-thumb {
width: 42px;
height: 42px;
border-radius: 8px;
object-fit: cover;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
flex-shrink: 0;
}
}
// 名片消息样式
.card-message {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--card-inner-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
&:hover {
background: var(--bg-hover);
}
.card-icon {
width: 40px;
height: 40px;
border-radius: 8px;
overflow: hidden;
background: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
}
.card-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.card-name {
font-size: 15px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-wxid {
font-size: 12px;
color: var(--text-tertiary);
}
.card-label {
font-size: 11px;
color: var(--text-tertiary);
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid var(--border-color);
}
}
// 聊天记录消息外观
.chat-record-message {
background: var(--card-inner-bg) !important;
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
&:hover {
background: var(--bg-hover) !important;
}
.chat-record-list {
font-size: 13px;
color: var(--text-tertiary);
line-height: 1.6;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border-color);
.chat-record-item {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.source-name {
color: var(--text-secondary);
}
}
}
.chat-record-more {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 4px;
}
}
// 公众号文章图文消息外观 (大图模式)
.official-message {
display: flex;
flex-direction: column;
background: var(--card-inner-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s ease;
min-width: 240px;
max-width: 320px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
&:hover {
background: var(--bg-hover);
}
.official-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
.official-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
object-fit: cover;
}
.official-avatar-placeholder {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
svg {
width: 14px;
height: 14px;
}
}
.official-name {
font-size: 13px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.official-body {
display: flex;
flex-direction: column;
.official-cover-wrapper {
position: relative;
width: 100%;
padding-top: 42.5%; // ~2.35:1 aspectRatio standard for WeChat article covers
background: var(--bg-secondary);
overflow: hidden;
.official-cover {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.official-title-overlay {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
padding: 24px 12px 10px;
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.7));
color: #fff;
font-size: 15px;
font-weight: 500;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
.official-title-text {
padding: 0 12px 10px;
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.official-digest {
font-size: 13px;
color: var(--text-tertiary);
padding: 0 12px 12px;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
}

View File

@@ -4,9 +4,11 @@ import { useNavigate } from 'react-router-dom'
import { createPortal } from 'react-dom'
import { useChatStore } from '../stores/chatStore'
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore'
import type { ChatSession, Message } from '../types/models'
import { getEmojiPath } from 'wechat-emojis'
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
import { LivePhotoIcon } from '../components/LivePhotoIcon'
import { AnimatedStreamingText } from '../components/AnimatedStreamingText'
import JumpToDateDialog from '../components/JumpToDateDialog'
import * as configService from '../services/config'
@@ -26,6 +28,12 @@ interface XmlField {
path: string;
}
interface BatchImageDecryptCandidate {
imageMd5?: string
imageDatName?: string
createTime?: number
}
// 尝试解析 XML 为可编辑字段
function parseXmlToFields(xml: string): XmlField[] {
const fields: XmlField[] = []
@@ -300,11 +308,16 @@ function ChatPage(_props: ChatPageProps) {
// 批量语音转文字相关状态(进度/结果 由全局 store 管理)
const { isBatchTranscribing, progress: batchTranscribeProgress, showToast: showBatchProgress, startTranscribe, updateProgress, finishTranscribe, setShowToast: setShowBatchProgress } = useBatchTranscribeStore()
const { isBatchDecrypting, progress: batchDecryptProgress, startDecrypt, updateProgress: updateDecryptProgress, finishDecrypt, setShowToast: setShowBatchDecryptToast } = useBatchImageDecryptStore()
const [showBatchConfirm, setShowBatchConfirm] = useState(false)
const [batchVoiceCount, setBatchVoiceCount] = useState(0)
const [batchVoiceMessages, setBatchVoiceMessages] = useState<Message[] | null>(null)
const [batchVoiceDates, setBatchVoiceDates] = useState<string[]>([])
const [batchSelectedDates, setBatchSelectedDates] = useState<Set<string>>(new Set())
const [showBatchDecryptConfirm, setShowBatchDecryptConfirm] = useState(false)
const [batchImageMessages, setBatchImageMessages] = useState<BatchImageDecryptCandidate[] | null>(null)
const [batchImageDates, setBatchImageDates] = useState<string[]>([])
const [batchImageSelectedDates, setBatchImageSelectedDates] = useState<Set<string>>(new Set())
// 批量删除相关状态
const [isDeleting, setIsDeleting] = useState(false)
@@ -1433,6 +1446,37 @@ function ChatPage(_props: ChatPageProps) {
setShowBatchConfirm(true)
}, [sessions, currentSessionId, isBatchTranscribing])
const handleBatchDecrypt = useCallback(async () => {
if (!currentSessionId || isBatchDecrypting) return
const session = sessions.find(s => s.username === currentSessionId)
if (!session) {
alert('未找到当前会话')
return
}
const result = await window.electronAPI.chat.getAllImageMessages(currentSessionId)
if (!result.success || !result.images) {
alert(`获取图片消息失败: ${result.error || '未知错误'}`)
return
}
if (result.images.length === 0) {
alert('当前会话没有图片消息')
return
}
const dateSet = new Set<string>()
result.images.forEach((img: BatchImageDecryptCandidate) => {
if (img.createTime) dateSet.add(new Date(img.createTime * 1000).toISOString().slice(0, 10))
})
const sortedDates = Array.from(dateSet).sort((a, b) => b.localeCompare(a))
setBatchImageMessages(result.images)
setBatchImageDates(sortedDates)
setBatchImageSelectedDates(new Set(sortedDates))
setShowBatchDecryptConfirm(true)
}, [currentSessionId, isBatchDecrypting, sessions])
const handleExportCurrentSession = useCallback(() => {
if (!currentSessionId) return
navigate('/export', {
@@ -1556,6 +1600,88 @@ function ChatPage(_props: ChatPageProps) {
const selectAllBatchDates = useCallback(() => setBatchSelectedDates(new Set(batchVoiceDates)), [batchVoiceDates])
const clearAllBatchDates = useCallback(() => setBatchSelectedDates(new Set()), [])
const confirmBatchDecrypt = useCallback(async () => {
if (!currentSessionId) return
const selected = batchImageSelectedDates
if (selected.size === 0) {
alert('请至少选择一个日期')
return
}
const images = (batchImageMessages || []).filter(img =>
img.createTime && selected.has(new Date(img.createTime * 1000).toISOString().slice(0, 10))
)
if (images.length === 0) {
alert('所选日期下没有图片消息')
return
}
const session = sessions.find(s => s.username === currentSessionId)
if (!session) return
setShowBatchDecryptConfirm(false)
setBatchImageMessages(null)
setBatchImageDates([])
setBatchImageSelectedDates(new Set())
startDecrypt(images.length, session.displayName || session.username)
let successCount = 0
let failCount = 0
for (let i = 0; i < images.length; i++) {
const img = images[i]
try {
const r = await window.electronAPI.image.decrypt({
sessionId: session.username,
imageMd5: img.imageMd5,
imageDatName: img.imageDatName,
force: false
})
if (r?.success) successCount++
else failCount++
} catch {
failCount++
}
updateDecryptProgress(i + 1, images.length)
if (i % 5 === 0) {
await new Promise(resolve => setTimeout(resolve, 0))
}
}
finishDecrypt(successCount, failCount)
}, [batchImageMessages, batchImageSelectedDates, currentSessionId, finishDecrypt, sessions, startDecrypt, updateDecryptProgress])
const batchImageCountByDate = useMemo(() => {
const map = new Map<string, number>()
if (!batchImageMessages) return map
batchImageMessages.forEach(img => {
if (!img.createTime) return
const d = new Date(img.createTime * 1000).toISOString().slice(0, 10)
map.set(d, (map.get(d) ?? 0) + 1)
})
return map
}, [batchImageMessages])
const batchImageSelectedCount = useMemo(() => {
if (!batchImageMessages) return 0
return batchImageMessages.filter(img =>
img.createTime && batchImageSelectedDates.has(new Date(img.createTime * 1000).toISOString().slice(0, 10))
).length
}, [batchImageMessages, batchImageSelectedDates])
const toggleBatchImageDate = useCallback((date: string) => {
setBatchImageSelectedDates(prev => {
const next = new Set(prev)
if (next.has(date)) next.delete(date)
else next.add(date)
return next
})
}, [])
const selectAllBatchImageDates = useCallback(() => setBatchImageSelectedDates(new Set(batchImageDates)), [batchImageDates])
const clearAllBatchImageDates = useCallback(() => setBatchImageSelectedDates(new Set()), [])
const lastSelectedIdRef = useRef<number | null>(null)
const handleToggleSelection = useCallback((localId: number, isShiftKey: boolean = false) => {
@@ -1995,6 +2121,26 @@ function ChatPage(_props: ChatPageProps) {
<Mic size={18} />
)}
</button>
<button
className={`icon-btn batch-decrypt-btn${isBatchDecrypting ? ' transcribing' : ''}`}
onClick={() => {
if (isBatchDecrypting) {
setShowBatchDecryptToast(true)
} else {
handleBatchDecrypt()
}
}}
disabled={!currentSessionId}
title={isBatchDecrypting
? `批量解密中 (${batchDecryptProgress.current}/${batchDecryptProgress.total}),点击查看进度`
: '批量解密图片'}
>
{isBatchDecrypting ? (
<Loader2 size={18} className="spin" />
) : (
<ImageIcon size={18} />
)}
</button>
<button
className="icon-btn jump-to-time-btn"
onClick={async () => {
@@ -2360,6 +2506,66 @@ function ChatPage(_props: ChatPageProps) {
document.body
)}
{/* 消息右键菜单 */}
{showBatchDecryptConfirm && createPortal(
<div className="batch-modal-overlay" onClick={() => setShowBatchDecryptConfirm(false)}>
<div className="batch-modal-content batch-confirm-modal" onClick={(e) => e.stopPropagation()}>
<div className="batch-modal-header">
<ImageIcon size={20} />
<h3></h3>
</div>
<div className="batch-modal-body">
<p></p>
{batchImageDates.length > 0 && (
<div className="batch-dates-list-wrap">
<div className="batch-dates-actions">
<button type="button" className="batch-dates-btn" onClick={selectAllBatchImageDates}></button>
<button type="button" className="batch-dates-btn" onClick={clearAllBatchImageDates}></button>
</div>
<ul className="batch-dates-list">
{batchImageDates.map(dateStr => {
const count = batchImageCountByDate.get(dateStr) ?? 0
const checked = batchImageSelectedDates.has(dateStr)
return (
<li key={dateStr}>
<label className="batch-date-row">
<input
type="checkbox"
checked={checked}
onChange={() => toggleBatchImageDate(dateStr)}
/>
<span className="batch-date-label">{formatBatchDateLabel(dateStr)}</span>
<span className="batch-date-count">{count} </span>
</label>
</li>
)
})}
</ul>
</div>
)}
<div className="batch-info">
<div className="info-item">
<span className="label">:</span>
<span className="value">{batchImageSelectedDates.size} {batchImageSelectedCount} </span>
</div>
</div>
<div className="batch-warning">
<AlertCircle size={16} />
<span></span>
</div>
</div>
<div className="batch-modal-footer">
<button className="btn-secondary" onClick={() => setShowBatchDecryptConfirm(false)}>
</button>
<button className="btn-primary" onClick={confirmBatchDecrypt}>
<ImageIcon size={16} />
</button>
</div>
</div>
</div>,
document.body
)}
{contextMenu && createPortal(
<>
<div className="context-menu-overlay" onClick={() => setContextMenu(null)}
@@ -2574,6 +2780,31 @@ const voiceTranscriptCache = new Map<string, string>()
const senderAvatarCache = new Map<string, { avatarUrl?: string; displayName?: string }>()
const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>()
// 引用消息中的动画表情组件
function QuotedEmoji({ cdnUrl, md5 }: { cdnUrl: string; md5?: string }) {
const cacheKey = md5 || cdnUrl
const [localPath, setLocalPath] = useState<string | undefined>(() => emojiDataUrlCache.get(cacheKey))
const [loading, setLoading] = useState(false)
const [error, setError] = useState(false)
useEffect(() => {
if (localPath || loading || error) return
setLoading(true)
window.electronAPI.chat.downloadEmoji(cdnUrl, md5).then((result: { success: boolean; localPath?: string }) => {
if (result.success && result.localPath) {
emojiDataUrlCache.set(cacheKey, result.localPath)
setLocalPath(result.localPath)
} else {
setError(true)
}
}).catch(() => setError(true)).finally(() => setLoading(false))
}, [cdnUrl, md5, cacheKey, localPath, loading, error])
if (error || (!loading && !localPath)) return <span className="quoted-type-label">[]</span>
if (loading) return <span className="quoted-type-label">[]</span>
return <img src={localPath} alt="动画表情" className="quoted-emoji-image" />
}
// 消息气泡组件
function MessageBubble({
message,
@@ -2625,6 +2856,7 @@ function MessageBubble({
const [imageInView, setImageInView] = useState(false)
const imageForceHdAttempted = useRef<string | null>(null)
const imageForceHdPending = useRef(false)
const [imageLiveVideoPath, setImageLiveVideoPath] = useState<string | undefined>(undefined)
const [voiceError, setVoiceError] = useState(false)
const [voiceLoading, setVoiceLoading] = useState(false)
const [isVoicePlaying, setIsVoicePlaying] = useState(false)
@@ -2694,7 +2926,7 @@ function MessageBubble({
// 从缓存获取表情包 data URL
const cacheKey = message.emojiMd5 || message.emojiCdnUrl || ''
const [emojiLocalPath, setEmojiLocalPath] = useState<string | undefined>(
() => emojiDataUrlCache.get(cacheKey)
() => emojiDataUrlCache.get(cacheKey) || message.emojiLocalPath
)
const imageCacheKey = message.imageMd5 || message.imageDatName || `local:${message.localId}`
const [imageLocalPath, setImageLocalPath] = useState<string | undefined>(
@@ -2829,10 +3061,15 @@ function MessageBubble({
// 自动下载表情包
useEffect(() => {
if (emojiLocalPath) return
// 后端已从本地缓存找到文件(转发表情包无 CDN URL 的情况)
if (isEmoji && message.emojiLocalPath && !emojiLocalPath) {
setEmojiLocalPath(message.emojiLocalPath)
return
}
if (isEmoji && message.emojiCdnUrl && !emojiLoading && !emojiError) {
downloadEmoji()
}
}, [isEmoji, message.emojiCdnUrl, emojiLocalPath, emojiLoading, emojiError])
}, [isEmoji, message.emojiCdnUrl, message.emojiLocalPath, emojiLocalPath, emojiLoading, emojiError])
const requestImageDecrypt = useCallback(async (forceUpdate = false, silent = false) => {
if (!isImage) return
@@ -2853,7 +3090,8 @@ function MessageBubble({
imageDataUrlCache.set(imageCacheKey, result.localPath)
setImageLocalPath(result.localPath)
setImageHasUpdate(false)
return
if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath)
return result
}
}
@@ -2864,7 +3102,7 @@ function MessageBubble({
imageDataUrlCache.set(imageCacheKey, dataUrl)
setImageLocalPath(dataUrl)
setImageHasUpdate(false)
return
return { success: true, localPath: dataUrl } as any
}
if (!silent) setImageError(true)
} catch {
@@ -2872,6 +3110,7 @@ function MessageBubble({
} finally {
if (!silent) setImageLoading(false)
}
return { success: false } as any
}, [isImage, imageLoading, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64])
const triggerForceHd = useCallback(() => {
@@ -2902,6 +3141,55 @@ function MessageBubble({
void requestImageDecrypt()
}, [message.imageDatName, message.imageMd5, message.localId, requestImageDecrypt, session.username])
const handleOpenImageViewer = useCallback(async () => {
if (!imageLocalPath) return
let finalImagePath = imageLocalPath
let finalLiveVideoPath = imageLiveVideoPath || undefined
// If current cache is a thumbnail, wait for a silent force-HD decrypt before opening viewer.
if (imageHasUpdate) {
try {
const upgraded = await requestImageDecrypt(true, true)
if (upgraded?.success && upgraded.localPath) {
finalImagePath = upgraded.localPath
finalLiveVideoPath = upgraded.liveVideoPath || finalLiveVideoPath
}
} catch { }
}
// One more resolve helps when background/batch decrypt has produced a clearer image or live video
// but local component state hasn't caught up yet.
if (message.imageMd5 || message.imageDatName) {
try {
const resolved = await window.electronAPI.image.resolveCache({
sessionId: session.username,
imageMd5: message.imageMd5 || undefined,
imageDatName: message.imageDatName
})
if (resolved?.success && resolved.localPath) {
finalImagePath = resolved.localPath
finalLiveVideoPath = resolved.liveVideoPath || finalLiveVideoPath
imageDataUrlCache.set(imageCacheKey, resolved.localPath)
setImageLocalPath(resolved.localPath)
if (resolved.liveVideoPath) setImageLiveVideoPath(resolved.liveVideoPath)
setImageHasUpdate(Boolean(resolved.hasUpdate))
}
} catch { }
}
void window.electronAPI.window.openImageViewerWindow(finalImagePath, finalLiveVideoPath)
}, [
imageHasUpdate,
imageLiveVideoPath,
imageLocalPath,
imageCacheKey,
message.imageDatName,
message.imageMd5,
requestImageDecrypt,
session.username
])
useEffect(() => {
return () => {
if (imageClickTimerRef.current) {
@@ -2920,7 +3208,7 @@ function MessageBubble({
sessionId: session.username,
imageMd5: message.imageMd5 || undefined,
imageDatName: message.imageDatName
}).then((result: { success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }) => {
}).then((result: { success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }) => {
if (cancelled) return
if (result.success && result.localPath) {
imageDataUrlCache.set(imageCacheKey, result.localPath)
@@ -2928,6 +3216,7 @@ function MessageBubble({
setImageLocalPath(result.localPath)
setImageError(false)
}
if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath)
setImageHasUpdate(Boolean(result.hasUpdate))
}
}).catch(() => { })
@@ -3422,15 +3711,15 @@ function MessageBubble({
src={imageLocalPath}
alt="图片"
className="image-message"
onClick={() => {
if (imageHasUpdate) {
void requestImageDecrypt(true, true)
}
void window.electronAPI.window.openImageViewerWindow(imageLocalPath)
}}
onClick={() => { void handleOpenImageViewer() }}
onLoad={() => setImageError(false)}
onError={() => setImageError(true)}
/>
{imageLiveVideoPath && (
<div className="media-badge live">
<LivePhotoIcon size={14} />
</div>
)}
</div>
</>
)}
@@ -3685,16 +3974,24 @@ function MessageBubble({
// 名片消息
if (isCard) {
const cardName = message.cardNickname || message.cardUsername || '未知联系人'
const cardAvatar = message.cardAvatarUrl
return (
<div className="card-message">
<div className="card-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
{cardAvatar ? (
<img src={cardAvatar} alt="" style={{ width: '40px', height: '40px', objectFit: 'cover', borderRadius: '8px' }} referrerPolicy="no-referrer" />
) : (
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
)}
</div>
<div className="card-info">
<div className="card-name">{cardName}</div>
{message.cardUsername && message.cardUsername !== message.cardNickname && (
<div className="card-wxid">: {message.cardUsername}</div>
)}
<div className="card-label"></div>
</div>
</div>
@@ -3704,16 +4001,401 @@ function MessageBubble({
// 通话消息
if (isCall) {
return (
<div className="call-message">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
</svg>
<span>{message.parsedContent || '[通话]'}</span>
<div className="bubble-content">
<div className="call-message">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
</svg>
<span>{message.parsedContent || '[通话]'}</span>
</div>
</div>
)
}
// 位置消息
if (message.localType === 48) {
const raw = message.rawContent || ''
const poiname = raw.match(/poiname="([^"]*)"/)?.[1] || message.locationPoiname || '位置'
const label = raw.match(/label="([^"]*)"/)?.[1] || message.locationLabel || ''
const lat = parseFloat(raw.match(/x="([^"]*)"/)?.[1] || String(message.locationLat || 0))
const lng = parseFloat(raw.match(/y="([^"]*)"/)?.[1] || String(message.locationLng || 0))
const zoom = 15
const tileX = Math.floor((lng + 180) / 360 * Math.pow(2, zoom))
const latRad = lat * Math.PI / 180
const tileY = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * Math.pow(2, zoom))
const mapTileUrl = (lat && lng)
? `https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x=${tileX}&y=${tileY}&z=${zoom}`
: ''
return (
<div className="location-message" onClick={() => window.electronAPI.shell.openExternal(`https://uri.amap.com/marker?position=${lng},${lat}&name=${encodeURIComponent(poiname || label)}`)}>
<div className="location-text">
<div className="location-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
</div>
<div className="location-info">
{poiname && <div className="location-name">{poiname}</div>}
{label && <div className="location-label">{label}</div>}
</div>
</div>
{mapTileUrl && (
<div className="location-map">
<img src={mapTileUrl} alt="地图" referrerPolicy="no-referrer" />
</div>
)}
</div>
)
}
// 链接消息 (AppMessage)
const appMsgRichPreview = (() => {
const rawXml = message.rawContent || ''
if (!rawXml || (!rawXml.includes('<appmsg') && !rawXml.includes('&lt;appmsg'))) return null
let doc: Document | null = null
const getDoc = () => {
if (doc) return doc
try {
const start = rawXml.indexOf('<msg>')
const xml = start >= 0 ? rawXml.slice(start) : rawXml
doc = new DOMParser().parseFromString(xml, 'text/xml')
} catch {
doc = null
}
return doc
}
const q = (selector: string) => getDoc()?.querySelector(selector)?.textContent?.trim() || ''
const xmlType = message.xmlType || q('appmsg > type') || q('type')
// type 57: 引用回复消息,解析 refermsg 渲染为引用样式
if (xmlType === '57') {
const replyText = q('title') || cleanMessageContent(message.parsedContent) || ''
const referContent = q('refermsg > content') || ''
const referSender = q('refermsg > displayname') || ''
const referType = q('refermsg > type') || ''
// 根据被引用消息类型渲染对应内容
const renderReferContent = () => {
// 动画表情:解析嵌套 XML 提取 cdnurl 渲染
if (referType === '47') {
try {
const innerDoc = new DOMParser().parseFromString(referContent, 'text/xml')
const cdnUrl = innerDoc.querySelector('emoji')?.getAttribute('cdnurl') || ''
const md5 = innerDoc.querySelector('emoji')?.getAttribute('md5') || ''
if (cdnUrl) return <QuotedEmoji cdnUrl={cdnUrl} md5={md5} />
} catch { /* 解析失败降级 */ }
return <span className="quoted-type-label">[]</span>
}
// 各类型名称映射
const typeLabels: Record<string, string> = {
'3': '图片', '34': '语音', '43': '视频',
'49': '链接', '50': '通话', '10000': '系统消息', '10002': '撤回消息',
}
if (referType && typeLabels[referType]) {
return <span className="quoted-type-label">[{typeLabels[referType]}]</span>
}
// 普通文本或未知类型
return <>{renderTextWithEmoji(cleanMessageContent(referContent))}</>
}
return (
<div className="bubble-content">
<div className="quoted-message">
{referSender && <span className="quoted-sender">{referSender}</span>}
<span className="quoted-text">{renderReferContent()}</span>
</div>
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
</div>
)
}
const title = message.linkTitle || q('title') || cleanMessageContent(message.parsedContent) || 'Card'
const desc = message.appMsgDesc || q('des')
const url = message.linkUrl || q('url')
const thumbUrl = message.linkThumb || message.appMsgThumbUrl || q('thumburl') || q('cdnthumburl') || q('cover') || q('coverurl')
const musicUrl = message.appMsgMusicUrl || message.appMsgDataUrl || q('musicurl') || q('playurl') || q('dataurl') || q('lowurl')
const sourceName = message.appMsgSourceName || q('sourcename')
const sourceDisplayName = q('sourcedisplayname') || ''
const appName = message.appMsgAppName || q('appname')
const sourceUsername = message.appMsgSourceUsername || q('sourceusername')
const finderName =
message.finderNickname ||
message.finderUsername ||
q('findernickname') ||
q('finder_nickname') ||
q('finderusername') ||
q('finder_username')
const lower = rawXml.toLowerCase()
const kind = message.appMsgKind || (
(xmlType === '2001' || lower.includes('hongbao')) ? 'red-packet'
: (xmlType === '115' ? 'gift'
: ((xmlType === '33' || xmlType === '36') ? 'miniapp'
: (((xmlType === '5' || xmlType === '49') && (sourceUsername.startsWith('gh_') || !!sourceName || appName.includes('公众号'))) ? 'official-link'
: (xmlType === '51' ? 'finder'
: (xmlType === '3' ? 'music'
: ((xmlType === '5' || xmlType === '49') ? 'link' // Fallback for standard links
: (!!musicUrl ? 'music' : '')))))))
)
if (!kind) return null
// 对视频号提取真实标题,避免出现 "当前版本不支持该内容"
let displayTitle = title
if (kind === 'finder' && (!displayTitle || displayTitle.includes('不支持'))) {
try {
const d = new DOMParser().parseFromString(rawXml, 'text/xml')
displayTitle = d.querySelector('finderFeed desc')?.textContent?.trim() || desc || ''
} catch {
displayTitle = desc || ''
}
}
const openExternal = (e: React.MouseEvent, nextUrl?: string) => {
if (!nextUrl) return
e.stopPropagation()
if (window.electronAPI?.shell?.openExternal) {
window.electronAPI.shell.openExternal(nextUrl)
} else {
window.open(nextUrl, '_blank')
}
}
const metaLabel =
kind === 'red-packet' ? '红包'
: kind === 'finder' ? (finderName || '视频号')
: kind === 'location' ? '位置'
: kind === 'music' ? (sourceName || appName || '音乐')
: (sourceName || appName || (sourceUsername.startsWith('gh_') ? '公众号' : ''))
const renderCard = (cardKind: string, clickableUrl?: string) => (
<div
className={`link-message appmsg-rich-card ${cardKind}`}
onClick={clickableUrl ? (e) => openExternal(e, clickableUrl) : undefined}
title={clickableUrl}
>
<div className="link-header">
<div className="link-title" title={title}>{title}</div>
{metaLabel ? <div className="appmsg-meta-badge">{metaLabel}</div> : null}
</div>
<div className="link-body">
<div className="link-desc-block">
{desc ? <div className="link-desc" title={desc}>{desc}</div> : null}
</div>
{thumbUrl ? (
<img
src={thumbUrl}
alt=""
className={`link-thumb${((cardKind === 'miniapp') || /\.svg(?:$|\?)/i.test(thumbUrl)) ? ' theme-adaptive' : ''}`}
loading="lazy"
referrerPolicy="no-referrer"
/>
) : (
<div className={`link-thumb-placeholder ${cardKind}`}>{cardKind.slice(0, 2).toUpperCase()}</div>
)}
</div>
</div>
)
if (kind === 'quote') {
// 引用回复消息appMsgKind='quote'xmlType=57
const replyText = message.linkTitle || q('title') || cleanMessageContent(message.parsedContent) || ''
const referContent = message.quotedContent || q('refermsg > content') || ''
const referSender = message.quotedSender || q('refermsg > displayname') || ''
return (
<div className="bubble-content">
<div className="quoted-message">
{referSender && <span className="quoted-sender">{referSender}</span>}
<span className="quoted-text">{renderTextWithEmoji(cleanMessageContent(referContent))}</span>
</div>
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
</div>
)
}
if (kind === 'red-packet') {
// 专属红包卡片
const greeting = (() => {
try {
const d = getDoc()
if (!d) return ''
return d.querySelector('receivertitle')?.textContent?.trim() ||
d.querySelector('sendertitle')?.textContent?.trim() || ''
} catch { return '' }
})()
return (
<div className="hongbao-message">
<div className="hongbao-icon">
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
<rect x="4" y="6" width="32" height="28" rx="4" fill="white" fillOpacity="0.3" />
<rect x="4" y="6" width="32" height="14" rx="4" fill="white" fillOpacity="0.2" />
<circle cx="20" cy="20" r="6" fill="white" fillOpacity="0.4" />
<text x="20" y="24" textAnchor="middle" fill="white" fontSize="12" fontWeight="bold">¥</text>
</svg>
</div>
<div className="hongbao-info">
<div className="hongbao-greeting">{greeting || '恭喜发财,大吉大利'}</div>
<div className="hongbao-label"></div>
</div>
</div>
)
}
if (kind === 'gift') {
// 礼物卡片
const giftImg = message.giftImageUrl || thumbUrl
const giftWish = message.giftWish || title || '送你一份心意'
const giftPriceRaw = message.giftPrice
const giftPriceYuan = giftPriceRaw ? (parseInt(giftPriceRaw) / 100).toFixed(2) : ''
return (
<div className="gift-message">
{giftImg && <img className="gift-img" src={giftImg} alt="" referrerPolicy="no-referrer" />}
<div className="gift-info">
<div className="gift-wish">{giftWish}</div>
{giftPriceYuan && <div className="gift-price">¥{giftPriceYuan}</div>}
<div className="gift-label"></div>
</div>
</div>
)
}
if (kind === 'finder') {
// 视频号专属卡片
const coverUrl = message.finderCoverUrl || thumbUrl
const duration = message.finderDuration
const authorName = finderName || ''
const authorAvatar = message.finderAvatar
const fmtDuration = duration ? `${Math.floor(duration / 60)}:${String(duration % 60).padStart(2, '0')}` : ''
return (
<div className="channel-video-card" onClick={url ? (e) => openExternal(e, url) : undefined}>
<div className="channel-video-cover">
{coverUrl ? (
<img src={coverUrl} alt="" referrerPolicy="no-referrer" />
) : (
<div className="channel-video-cover-placeholder">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
</div>
)}
{fmtDuration && <span className="channel-video-duration">{fmtDuration}</span>}
</div>
<div className="channel-video-info">
<div className="channel-video-title">{displayTitle || '视频号视频'}</div>
<div className="channel-video-author">
{authorAvatar && <img className="channel-video-avatar" src={authorAvatar} alt="" referrerPolicy="no-referrer" />}
<span>{authorName || '视频号'}</span>
</div>
</div>
</div>
)
}
if (kind === 'music') {
// 音乐专属卡片
const albumUrl = message.musicAlbumUrl || thumbUrl
const playUrl = message.musicUrl || musicUrl || url
const songTitle = title || '未知歌曲'
const artist = desc || ''
const appLabel = sourceName || appName || ''
return (
<div className="music-message" onClick={playUrl ? (e) => openExternal(e, playUrl) : undefined}>
<div className="music-cover">
{albumUrl ? (
<img src={albumUrl} alt="" referrerPolicy="no-referrer" />
) : (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
)}
</div>
<div className="music-info">
<div className="music-title">{songTitle}</div>
{artist && <div className="music-artist">{artist}</div>}
{appLabel && <div className="music-source">{appLabel}</div>}
</div>
</div>
)
}
if (kind === 'official-link') {
const authorAvatar = q('publisher > headimg') || q('brand_info > headimgurl') || q('appmsg > avatar') || q('headimgurl') || message.cardAvatarUrl
const authorName = sourceDisplayName || q('publisher > nickname') || sourceName || appName || '公众号'
const coverPic = q('mmreader > category > item > cover') || thumbUrl
const digest = q('mmreader > category > item > digest') || desc
const articleTitle = q('mmreader > category > item > title') || title
return (
<div className="official-message" onClick={url ? (e) => openExternal(e, url) : undefined}>
<div className="official-header">
{authorAvatar ? (
<img src={authorAvatar} alt="" className="official-avatar" referrerPolicy="no-referrer" />
) : (
<div className="official-avatar-placeholder">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</div>
)}
<span className="official-name">{authorName}</span>
</div>
<div className="official-body">
{coverPic ? (
<div className="official-cover-wrapper">
<img src={coverPic} alt="" className="official-cover" referrerPolicy="no-referrer" />
<div className="official-title-overlay">{articleTitle}</div>
</div>
) : (
<div className="official-title-text">{articleTitle}</div>
)}
{digest && <div className="official-digest">{digest}</div>}
</div>
</div>
)
}
if (kind === 'link') return renderCard('link', url || undefined)
if (kind === 'card') return renderCard('card', url || undefined)
if (kind === 'miniapp') {
return (
<div className="miniapp-message miniapp-message-rich">
<div className="miniapp-icon">
<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" />
</svg>
</div>
<div className="miniapp-info">
<div className="miniapp-title">{title}</div>
<div className="miniapp-label">{metaLabel || '小程序'}</div>
</div>
{thumbUrl ? (
<img
src={thumbUrl}
alt=""
className={`miniapp-thumb${/\.svg(?:$|\?)/i.test(thumbUrl) ? ' theme-adaptive' : ''}`}
loading="lazy"
referrerPolicy="no-referrer"
/>
) : null}
</div>
)
}
return null
})()
if (appMsgRichPreview) {
return appMsgRichPreview
}
const isAppMsg = message.rawContent?.includes('<appmsg') || (message.parsedContent && message.parsedContent.includes('<appmsg'))
if (isAppMsg) {
@@ -3741,6 +4423,44 @@ function MessageBubble({
console.error('解析 AppMsg 失败:', e)
}
// 引用回复消息 (type=57),防止被误判为链接
if (appMsgType === '57') {
const replyText = parsedDoc?.querySelector('title')?.textContent?.trim() || cleanMessageContent(message.parsedContent) || ''
const referContent = parsedDoc?.querySelector('refermsg > content')?.textContent?.trim() || ''
const referSender = parsedDoc?.querySelector('refermsg > displayname')?.textContent?.trim() || ''
const referType = parsedDoc?.querySelector('refermsg > type')?.textContent?.trim() || ''
const renderReferContent2 = () => {
if (referType === '47') {
try {
const innerDoc = new DOMParser().parseFromString(referContent, 'text/xml')
const cdnUrl = innerDoc.querySelector('emoji')?.getAttribute('cdnurl') || ''
const md5 = innerDoc.querySelector('emoji')?.getAttribute('md5') || ''
if (cdnUrl) return <QuotedEmoji cdnUrl={cdnUrl} md5={md5} />
} catch { /* 解析失败降级 */ }
return <span className="quoted-type-label">[]</span>
}
const typeLabels: Record<string, string> = {
'3': '图片', '34': '语音', '43': '视频',
'49': '链接', '50': '通话', '10000': '系统消息', '10002': '撤回消息',
}
if (referType && typeLabels[referType]) {
return <span className="quoted-type-label">[{typeLabels[referType]}]</span>
}
return <>{renderTextWithEmoji(cleanMessageContent(referContent))}</>
}
return (
<div className="bubble-content">
<div className="quoted-message">
{referSender && <span className="quoted-sender">{referSender}</span>}
<span className="quoted-text">{renderReferContent2()}</span>
</div>
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
</div>
)
}
// 群公告消息 (type=87)
if (appMsgType === '87') {
const announcementText = textAnnouncement || desc || '群公告'
@@ -3973,7 +4693,7 @@ function MessageBubble({
if (isEmoji) {
// ... (keep existing emoji logic)
// 没有 cdnUrl 或加载失败,显示占位符
if (!message.emojiCdnUrl || emojiError) {
if ((!message.emojiCdnUrl && !message.emojiLocalPath) || emojiError) {
return (
<div className="emoji-unavailable">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">

View File

@@ -29,7 +29,7 @@
.blob-1 {
width: 400px;
height: 400px;
background: rgba(139, 115, 85, 0.25);
background: rgba(var(--primary-rgb), 0.25);
top: -100px;
left: -50px;
animation-duration: 25s;
@@ -38,7 +38,7 @@
.blob-2 {
width: 350px;
height: 350px;
background: rgba(139, 115, 85, 0.15);
background: rgba(var(--primary-rgb), 0.15);
bottom: -50px;
right: -50px;
animation-duration: 30s;
@@ -74,7 +74,7 @@
margin: 0 0 16px;
color: var(--text-primary);
letter-spacing: -2px;
background: linear-gradient(135deg, var(--text-primary) 0%, rgba(139, 115, 85, 0.8) 100%);
background: linear-gradient(135deg, var(--primary) 0%, rgba(var(--primary-rgb), 0.6) 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;

View File

@@ -46,6 +46,18 @@
background: var(--bg-tertiary);
color: var(--text-primary);
}
&:disabled {
cursor: default;
opacity: 1;
}
&.live-play-btn {
&.active {
background: rgba(var(--primary-rgb, 76, 132, 255), 0.16);
color: var(--primary, #4c84ff);
}
}
}
.scale-text {
@@ -78,14 +90,40 @@
cursor: grabbing;
}
img {
.media-wrapper {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
will-change: transform;
}
img,
video {
display: block;
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;
}
.live-video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: fill;
pointer-events: none;
opacity: 0;
will-change: opacity;
transition: opacity 0.3s ease-in-out;
}
.live-video.visible {
opacity: 1;
}
}
}

View File

@@ -1,18 +1,26 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom'
import { ZoomIn, ZoomOut, RotateCw, RotateCcw } from 'lucide-react'
import { LivePhotoIcon } from '../components/LivePhotoIcon'
import './ImageWindow.scss'
export default function ImageWindow() {
const [searchParams] = useSearchParams()
const imagePath = searchParams.get('imagePath')
const liveVideoPath = searchParams.get('liveVideoPath')
const hasLiveVideo = !!liveVideoPath
const [isPlayingLive, setIsPlayingLive] = useState(false)
const [isVideoVisible, setIsVideoVisible] = useState(false)
const videoRef = useRef<HTMLVideoElement>(null)
const liveCleanupTimerRef = useRef<number | null>(null)
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,
@@ -22,11 +30,49 @@ export default function ImageWindow() {
startPosY: 0
})
const clearLiveCleanupTimer = useCallback(() => {
if (liveCleanupTimerRef.current !== null) {
window.clearTimeout(liveCleanupTimerRef.current)
liveCleanupTimerRef.current = null
}
}, [])
const stopLivePlayback = useCallback((immediate = false) => {
clearLiveCleanupTimer()
setIsVideoVisible(false)
if (immediate) {
if (videoRef.current) {
videoRef.current.pause()
videoRef.current.currentTime = 0
}
setIsPlayingLive(false)
return
}
liveCleanupTimerRef.current = window.setTimeout(() => {
if (videoRef.current) {
videoRef.current.pause()
videoRef.current.currentTime = 0
}
setIsPlayingLive(false)
liveCleanupTimerRef.current = null
}, 300)
}, [clearLiveCleanupTimer])
const handlePlayLiveVideo = useCallback(() => {
if (!liveVideoPath || isPlayingLive) return
clearLiveCleanupTimer()
setIsPlayingLive(true)
setIsVideoVisible(false)
}, [clearLiveCleanupTimer, liveVideoPath, isPlayingLive])
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)
@@ -39,7 +85,7 @@ export default function ImageWindow() {
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
@@ -51,14 +97,37 @@ export default function ImageWindow() {
}
}, [])
// 视频挂载后再播放,避免点击瞬间 ref 尚未就绪导致丢播
useEffect(() => {
if (!isPlayingLive || !videoRef.current) return
const timer = window.setTimeout(() => {
const video = videoRef.current
if (!video || !isPlayingLive || !video.paused) return
video.currentTime = 0
void video.play().catch(() => {
stopLivePlayback(true)
})
}, 0)
return () => window.clearTimeout(timer)
}, [isPlayingLive, stopLivePlayback])
useEffect(() => {
return () => {
clearLiveCleanupTimer()
}
}, [clearLiveCleanupTimer])
// 使用原生事件监听器处理拖动
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
@@ -82,7 +151,7 @@ export default function ImageWindow() {
const handleMouseDown = (e: React.MouseEvent) => {
if (e.button !== 0) return
e.preventDefault()
dragStateRef.current = {
isDragging: true,
startX: e.clientX,
@@ -106,15 +175,25 @@ export default function ImageWindow() {
// 快捷键支持
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') window.electronAPI.window.close()
if (e.key === 'Escape') {
if (isPlayingLive) {
stopLivePlayback(true)
return
}
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()
if (e.key === ' ' && hasLiveVideo) {
e.preventDefault()
handlePlayLiveVideo()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleReset])
}, [handleReset, hasLiveVideo, handlePlayLiveVideo, isPlayingLive, stopLivePlayback])
if (!imagePath) {
return (
@@ -131,6 +210,20 @@ export default function ImageWindow() {
<div className="title-bar">
<div className="window-drag-area"></div>
<div className="title-bar-controls">
{hasLiveVideo && (
<>
<button
onClick={handlePlayLiveVideo}
title={isPlayingLive ? '正在播放实况' : '播放实况 (空格)'}
className={`live-play-btn ${isPlayingLive ? 'active' : ''}`}
disabled={isPlayingLive}
>
<LivePhotoIcon size={16} />
<span style={{ fontSize: 13, marginLeft: 4 }}>Live</span>
</button>
<div className="divider"></div>
</>
)}
<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>
@@ -140,22 +233,38 @@ export default function ImageWindow() {
</div>
</div>
<div
className="image-viewport"
<div
className="image-viewport"
ref={viewportRef}
onWheel={handleWheel}
onDoubleClick={handleDoubleClick}
onMouseDown={handleMouseDown}
>
<img
src={imagePath}
alt="Preview"
<div
className="media-wrapper"
style={{
transform: `translate(${position.x}px, ${position.y}px) scale(${displayScale}) rotate(${rotation}deg)`
}}
onLoad={handleImageLoad}
draggable={false}
/>
>
<img
src={imagePath}
alt="Preview"
onLoad={handleImageLoad}
draggable={false}
/>
{hasLiveVideo && isPlayingLive && (
<video
ref={videoRef}
src={liveVideoPath || ''}
className={`live-video ${isVideoVisible ? 'visible' : ''}`}
autoPlay
playsInline
preload="auto"
onPlaying={() => setIsVideoVisible(true)}
onEnded={() => stopLivePlayback(false)}
/>
)}
</div>
</div>
</div>
)

View File

@@ -146,6 +146,11 @@ function SettingsPage() {
const [helloAvailable, setHelloAvailable] = useState(false)
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [oldPassword, setOldPassword] = useState('')
const [helloPassword, setHelloPassword] = useState('')
const [disableLockPassword, setDisableLockPassword] = useState('')
const [showDisableLockInput, setShowDisableLockInput] = useState(false)
const [isLockMode, setIsLockMode] = useState(false)
const [isSettingHello, setIsSettingHello] = useState(false)
// HTTP API 设置 state
@@ -184,14 +189,6 @@ function SettingsPage() {
checkApiStatus()
}, [])
async function sha256(message: string) {
const msgBuffer = new TextEncoder().encode(message)
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
return hashHex
}
useEffect(() => {
loadConfig()
loadAppVersion()
@@ -279,10 +276,12 @@ function SettingsPage() {
const savedNotificationFilterMode = await configService.getNotificationFilterMode()
const savedNotificationFilterList = await configService.getNotificationFilterList()
const savedAuthEnabled = await configService.getAuthEnabled()
const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled()
const savedAuthUseHello = await configService.getAuthUseHello()
const savedIsLockMode = await window.electronAPI.auth.isLockMode()
setAuthEnabled(savedAuthEnabled)
setAuthUseHello(savedAuthUseHello)
setIsLockMode(savedIsLockMode)
if (savedPath) setDbPath(savedPath)
if (savedWxid) setWxid(savedWxid)
@@ -940,8 +939,16 @@ function SettingsPage() {
<div className="theme-grid">
{themes.map((theme) => (
<div key={theme.id} className={`theme-card ${currentTheme === theme.id ? 'active' : ''}`} onClick={() => setTheme(theme.id)}>
<div className="theme-preview" style={{ background: effectiveMode === 'dark' ? 'linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%)' : `linear-gradient(135deg, ${theme.bgColor} 0%, ${theme.bgColor}dd 100%)` }}>
<div className="theme-accent" style={{ background: theme.primaryColor }} />
<div className="theme-preview" style={{
background: effectiveMode === 'dark'
? (theme.id === 'blossom-dream' ? 'linear-gradient(150deg, #151316 0%, #1A1620 50%, #131018 100%)' : 'linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%)')
: (theme.id === 'blossom-dream' ? `linear-gradient(150deg, ${theme.bgColor} 0%, #F8F2F8 45%, #F2F6FB 100%)` : `linear-gradient(135deg, ${theme.bgColor} 0%, ${theme.bgColor}dd 100%)`)
}}>
<div className="theme-accent" style={{
background: theme.accentColor
? `linear-gradient(135deg, ${theme.primaryColor} 0%, ${theme.accentColor} 100%)`
: theme.primaryColor
}} />
</div>
<div className="theme-info">
<span className="theme-name">{theme.name}</span>
@@ -1931,6 +1938,10 @@ function SettingsPage() {
)
const handleSetupHello = async () => {
if (!helloPassword) {
showMessage('请输入当前密码以开启 Hello', false)
return
}
setIsSettingHello(true)
try {
const challenge = new Uint8Array(32)
@@ -1948,8 +1959,10 @@ function SettingsPage() {
})
if (credential) {
// 存储密码作为 Hello Secret以便 Hello 解锁时能派生密钥
await window.electronAPI.auth.setHelloSecret(helloPassword)
setAuthUseHello(true)
await configService.setAuthUseHello(true)
setHelloPassword('')
showMessage('Windows Hello 设置成功', true)
}
} catch (e: any) {
@@ -1967,18 +1980,40 @@ function SettingsPage() {
return
}
// 简单的保存逻辑,实际上应该先验证旧密码,但为了简化流程,这里直接允许覆盖
// 因为能进入设置页面说明已经解锁了
try {
const hash = await sha256(newPassword)
await configService.setAuthPassword(hash)
await configService.setAuthEnabled(true)
setAuthEnabled(true)
setNewPassword('')
setConfirmPassword('')
showMessage('密码已更新', true)
const lockMode = await window.electronAPI.auth.isLockMode()
if (authEnabled && lockMode) {
// 已开启应用锁且已是 lock: 模式 → 修改密码
if (!oldPassword) {
showMessage('请输入旧密码', false)
return
}
const result = await window.electronAPI.auth.changePassword(oldPassword, newPassword)
if (result.success) {
setNewPassword('')
setConfirmPassword('')
setOldPassword('')
showMessage('密码已更新', true)
} else {
showMessage(result.error || '密码更新失败', false)
}
} else {
// 未开启应用锁,或旧版 safe: 模式 → 开启/升级为 lock: 模式
const result = await window.electronAPI.auth.enableLock(newPassword)
if (result.success) {
setAuthEnabled(true)
setIsLockMode(true)
setNewPassword('')
setConfirmPassword('')
setOldPassword('')
showMessage('应用锁已开启', true)
} else {
showMessage(result.error || '开启失败', false)
}
}
} catch (e: any) {
showMessage('密码更新失败', false)
showMessage('操作失败', false)
}
}
@@ -2037,31 +2072,73 @@ function SettingsPage() {
<div className="form-group">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<label></label>
<span className="form-hint"></span>
<label></label>
<span className="form-hint">{
isLockMode ? '已开启' :
authEnabled ? '旧版模式 — 请重新设置密码以升级为新模式提高安全性' :
'未开启 — 请设置密码以开启'
}</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={authEnabled}
onChange={async (e) => {
const enabled = e.target.checked
setAuthEnabled(enabled)
await configService.setAuthEnabled(enabled)
}}
/>
<span className="switch-slider" />
</label>
{authEnabled && !showDisableLockInput && (
<button
className="btn btn-secondary btn-sm"
onClick={() => setShowDisableLockInput(true)}
>
</button>
)}
</div>
{showDisableLockInput && (
<div style={{ marginTop: 10, display: 'flex', gap: 10 }}>
<input
type="password"
className="field-input"
placeholder="输入当前密码以关闭"
value={disableLockPassword}
onChange={e => setDisableLockPassword(e.target.value)}
style={{ flex: 1 }}
/>
<button
className="btn btn-primary btn-sm"
disabled={!disableLockPassword}
onClick={async () => {
const result = await window.electronAPI.auth.disableLock(disableLockPassword)
if (result.success) {
setAuthEnabled(false)
setAuthUseHello(false)
setIsLockMode(false)
setShowDisableLockInput(false)
setDisableLockPassword('')
showMessage('应用锁已关闭', true)
} else {
showMessage(result.error || '关闭失败', false)
}
}}
></button>
<button
className="btn btn-secondary btn-sm"
onClick={() => { setShowDisableLockInput(false); setDisableLockPassword('') }}
></button>
</div>
)}
</div>
<div className="divider" />
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<label>{isLockMode ? '修改密码' : '设置密码并开启应用锁'}</label>
<span className="form-hint">{isLockMode ? '修改应用锁密码(需要旧密码验证)' : '设置密码后将自动开启应用锁'}</span>
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 10 }}>
{isLockMode && (
<input
type="password"
className="field-input"
placeholder="旧密码"
value={oldPassword}
onChange={e => setOldPassword(e.target.value)}
/>
)}
<input
type="password"
className="field-input"
@@ -2078,7 +2155,9 @@ function SettingsPage() {
onChange={e => setConfirmPassword(e.target.value)}
style={{ flex: 1 }}
/>
<button className="btn btn-primary" onClick={handleUpdatePassword} disabled={!newPassword}></button>
<button className="btn btn-primary" onClick={handleUpdatePassword} disabled={!newPassword}>
{isLockMode ? '更新' : '开启'}
</button>
</div>
</div>
</div>
@@ -2090,23 +2169,39 @@ function SettingsPage() {
<div>
<label>Windows Hello</label>
<span className="form-hint">使</span>
{!helloAvailable && <div className="form-hint warning" style={{ color: '#ff4d4f' }}> Windows Hello</div>}
{!authEnabled && <div className="form-hint warning" style={{ color: '#ff4d4f' }}></div>}
{!helloAvailable && authEnabled && <div className="form-hint warning" style={{ color: '#ff4d4f' }}> Windows Hello</div>}
</div>
<div>
{authUseHello ? (
<button className="btn btn-secondary btn-sm" onClick={() => setAuthUseHello(false)}></button>
<button className="btn btn-secondary btn-sm" onClick={async () => {
await window.electronAPI.auth.clearHelloSecret()
setAuthUseHello(false)
showMessage('Windows Hello 已关闭', true)
}}></button>
) : (
<button
className="btn btn-secondary btn-sm"
onClick={handleSetupHello}
disabled={!helloAvailable || isSettingHello}
disabled={!helloAvailable || isSettingHello || !authEnabled || !helloPassword}
>
{isSettingHello ? '设置中...' : '开启与设置'}
</button>
)}
</div>
</div>
{!authUseHello && authEnabled && (
<div style={{ marginTop: 10 }}>
<input
type="password"
className="field-input"
placeholder="输入当前密码以开启 Hello"
value={helloPassword}
onChange={e => setHelloPassword(e.target.value)}
/>
</div>
)}
</div>
</div>
)

View File

@@ -414,7 +414,8 @@
&.live {
top: 8px;
left: 8px;
right: 8px;
left: auto;
transform: none;
width: 24px;
height: 24px;

View File

@@ -1,6 +1,5 @@
import { useEffect, useLayoutEffect, useState, useRef, useCallback } from 'react'
import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Users, Info, ChevronLeft, ChevronRight } from 'lucide-react'
import { ImagePreview } from '../components/ImagePreview'
import JumpToDateDialog from '../components/JumpToDateDialog'
import './SnsPage.scss'
import { SnsPost } from '../types/sns'
@@ -32,7 +31,6 @@ export default function SnsPage() {
// UI states
const [showJumpDialog, setShowJumpDialog] = useState(false)
const [previewImage, setPreviewImage] = useState<{ src: string, isVideo?: boolean, liveVideoPath?: string } | null>(null)
const [debugPost, setDebugPost] = useState<SnsPost | null>(null)
// 导出相关状态
@@ -332,7 +330,13 @@ export default function SnsPage() {
<SnsPostItem
key={post.id}
post={post}
onPreview={(src, isVideo, liveVideoPath) => setPreviewImage({ src, isVideo, liveVideoPath })}
onPreview={(src, isVideo, liveVideoPath) => {
if (isVideo) {
void window.electronAPI.window.openVideoPlayerWindow(src)
} else {
void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined)
}
}}
onDebug={(p) => setDebugPost(p)}
/>
))}
@@ -394,15 +398,6 @@ export default function SnsPage() {
/>
{/* Dialogs and Overlays */}
{previewImage && (
<ImagePreview
src={previewImage.src}
isVideo={previewImage.isVideo}
liveVideoPath={previewImage.liveVideoPath}
onClose={() => setPreviewImage(null)}
/>
)}
<JumpToDateDialog
isOpen={showJumpDialog}
onClose={() => setShowJumpDialog(false)}

View File

@@ -0,0 +1,64 @@
import { create } from 'zustand'
export interface BatchImageDecryptState {
isBatchDecrypting: boolean
progress: { current: number; total: number }
showToast: boolean
showResultToast: boolean
result: { success: number; fail: number }
startTime: number
sessionName: string
startDecrypt: (total: number, sessionName: string) => void
updateProgress: (current: number, total: number) => void
finishDecrypt: (success: number, fail: number) => void
setShowToast: (show: boolean) => void
setShowResultToast: (show: boolean) => void
reset: () => void
}
export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set) => ({
isBatchDecrypting: false,
progress: { current: 0, total: 0 },
showToast: false,
showResultToast: false,
result: { success: 0, fail: 0 },
startTime: 0,
sessionName: '',
startDecrypt: (total, sessionName) => set({
isBatchDecrypting: true,
progress: { current: 0, total },
showToast: true,
showResultToast: false,
result: { success: 0, fail: 0 },
startTime: Date.now(),
sessionName
}),
updateProgress: (current, total) => set({
progress: { current, total }
}),
finishDecrypt: (success, fail) => set({
isBatchDecrypting: false,
showToast: false,
showResultToast: true,
result: { success, fail },
startTime: 0
}),
setShowToast: (show) => set({ showToast: show }),
setShowResultToast: (show) => set({ showResultToast: show }),
reset: () => set({
isBatchDecrypting: false,
progress: { current: 0, total: 0 },
showToast: false,
showResultToast: false,
result: { success: 0, fail: 0 },
startTime: 0,
sessionName: ''
})
}))

View File

@@ -1,7 +1,7 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export type ThemeId = 'cloud-dancer' | 'corundum-blue' | 'kiwi-green' | 'spicy-red' | 'teal-water'
export type ThemeId = 'cloud-dancer' | 'corundum-blue' | 'kiwi-green' | 'spicy-red' | 'teal-water' | 'blossom-dream'
export type ThemeMode = 'light' | 'dark' | 'system'
export interface ThemeInfo {
@@ -10,6 +10,8 @@ export interface ThemeInfo {
description: string
primaryColor: string
bgColor: string
// 可选副色,用于多彩主题的渐变预览
accentColor?: string
}
export const themes: ThemeInfo[] = [
@@ -20,6 +22,14 @@ export const themes: ThemeInfo[] = [
primaryColor: '#8B7355',
bgColor: '#F0EEE9'
},
{
id: 'blossom-dream',
name: '繁花如梦',
description: '晨曦花境 · 夜阑幽梦',
primaryColor: '#D4849A',
bgColor: '#FCF9FB',
accentColor: '#FFBE98'
},
{
id: 'corundum-blue',
name: '刚玉蓝',

View File

@@ -167,6 +167,50 @@
}
}
.batch-inline-result-toast {
.batch-progress-toast-title {
svg {
color: #22c55e;
}
}
.batch-inline-result-summary {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.batch-inline-result-item {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
border-radius: 8px;
background: var(--bg-tertiary);
font-size: 12px;
color: var(--text-secondary);
svg {
flex-shrink: 0;
}
&.success {
color: #16a34a;
svg { color: #16a34a; }
}
&.fail {
color: #dc2626;
svg { color: #dc2626; }
}
&.muted {
color: var(--text-tertiary, #999);
svg { color: var(--text-tertiary, #999); }
}
}
}
// 批量转写结果对话框
.batch-result-modal {
width: 420px;
@@ -293,4 +337,4 @@
}
}
}
}
}

View File

@@ -37,6 +37,8 @@
// 卡片背景
--card-bg: rgba(255, 255, 255, 0.7);
--card-inner-bg: #FAFAF7;
--sent-card-bg: var(--primary);
}
// ==================== 浅色主题 ====================
@@ -59,6 +61,8 @@
--bg-gradient: linear-gradient(135deg, #F0EEE9 0%, #E8E6E1 100%);
--primary-gradient: linear-gradient(135deg, #8B7355 0%, #A68B5B 100%);
--card-bg: rgba(255, 255, 255, 0.7);
--card-inner-bg: #FAFAF7;
--sent-card-bg: var(--primary);
}
// 刚玉蓝主题
@@ -79,6 +83,8 @@
--bg-gradient: linear-gradient(135deg, #E8EEF0 0%, #D8E4E8 100%);
--primary-gradient: linear-gradient(135deg, #4A6670 0%, #5A7A86 100%);
--card-bg: rgba(255, 255, 255, 0.7);
--card-inner-bg: #F8FAFB;
--sent-card-bg: var(--primary);
}
// 冰猕猴桃汁绿主题
@@ -99,6 +105,8 @@
--bg-gradient: linear-gradient(135deg, #E8F0E4 0%, #D8E8D0 100%);
--primary-gradient: linear-gradient(135deg, #7A9A5C 0%, #8AAA6C 100%);
--card-bg: rgba(255, 255, 255, 0.7);
--card-inner-bg: #F8FBF6;
--sent-card-bg: var(--primary);
}
// 辛辣红主题
@@ -119,6 +127,8 @@
--bg-gradient: linear-gradient(135deg, #F0E8E8 0%, #E8D8D8 100%);
--primary-gradient: linear-gradient(135deg, #8B4049 0%, #A05058 100%);
--card-bg: rgba(255, 255, 255, 0.7);
--card-inner-bg: #FAF8F8;
--sent-card-bg: var(--primary);
}
// 明水鸭色主题
@@ -139,6 +149,45 @@
--bg-gradient: linear-gradient(135deg, #E4F0F0 0%, #D4E8E8 100%);
--primary-gradient: linear-gradient(135deg, #5A8A8A 0%, #6A9A9A 100%);
--card-bg: rgba(255, 255, 255, 0.7);
--card-inner-bg: #F6FBFB;
--sent-card-bg: var(--primary);
}
// 繁花如梦 - 浅色(晨曦花境)
[data-theme="blossom-dream"][data-mode="light"],
[data-theme="blossom-dream"]:not([data-mode]) {
// 三色定义(供伪元素光晕使用,饱和度提高以便在底色上可见)
--blossom-pink: #F0A0B8;
--blossom-peach: #FFB07A;
--blossom-blue: #90B8E0;
// 主品牌色Pantone 粉晶 Rose Quartz
--primary: #D4849A;
--primary-rgb: 212, 132, 154;
--primary-hover: #C4748A;
--primary-light: rgba(212, 132, 154, 0.12);
// 背景三层:主背景最深(相对),面板次之,卡片最白
--bg-primary: #F5EDF2;
--bg-secondary: rgba(255, 255, 255, 0.82);
--bg-tertiary: rgba(212, 132, 154, 0.06);
--bg-hover: rgba(212, 132, 154, 0.09);
// 文字:提高对比度,主色接近纯黑只带微弱紫调
--text-primary: #1E1A22;
--text-secondary: #6B5F70;
--text-tertiary: #9A8A9E;
// 边框:粉色半透明,有存在感但不强硬
--border-color: rgba(212, 132, 154, 0.18);
--bg-gradient: linear-gradient(150deg, #F5EDF2 0%, #F0EAF6 50%, #EAF0F8 100%);
--primary-gradient: linear-gradient(135deg, #D4849A 0%, #E8A8B8 100%);
// 卡片:高不透明度白,与背景形成明显层次
--card-bg: rgba(255, 255, 255, 0.88);
--card-inner-bg: rgba(255, 255, 255, 0.95);
--sent-card-bg: var(--primary);
}
// ==================== 深色主题 ====================
@@ -151,6 +200,7 @@
--primary-light: rgba(201, 168, 108, 0.15);
--bg-primary: #1a1816;
--bg-secondary: rgba(40, 36, 32, 0.9);
--bg-secondary-solid: #282420;
--bg-tertiary: rgba(255, 255, 255, 0.05);
--bg-hover: rgba(255, 255, 255, 0.08);
--text-primary: #F0EEE9;
@@ -160,6 +210,8 @@
--bg-gradient: linear-gradient(135deg, #1a1816 0%, #252220 100%);
--primary-gradient: linear-gradient(135deg, #8B7355 0%, #C9A86C 100%);
--card-bg: rgba(40, 36, 32, 0.9);
--card-inner-bg: #27231F;
--sent-card-bg: var(--primary);
}
// 刚玉蓝 - 深色
@@ -170,6 +222,7 @@
--primary-light: rgba(106, 154, 170, 0.15);
--bg-primary: #141a1c;
--bg-secondary: rgba(30, 40, 44, 0.9);
--bg-secondary-solid: #1e282c;
--bg-tertiary: rgba(255, 255, 255, 0.05);
--bg-hover: rgba(255, 255, 255, 0.08);
--text-primary: #E8EEF0;
@@ -179,6 +232,8 @@
--bg-gradient: linear-gradient(135deg, #141a1c 0%, #1e282c 100%);
--primary-gradient: linear-gradient(135deg, #4A6670 0%, #6A9AAA 100%);
--card-bg: rgba(30, 40, 44, 0.9);
--card-inner-bg: #1D272A;
--sent-card-bg: var(--primary);
}
// 冰猕猴桃汁绿 - 深色
@@ -189,6 +244,7 @@
--primary-light: rgba(154, 186, 124, 0.15);
--bg-primary: #161a14;
--bg-secondary: rgba(34, 42, 30, 0.9);
--bg-secondary-solid: #222a1e;
--bg-tertiary: rgba(255, 255, 255, 0.05);
--bg-hover: rgba(255, 255, 255, 0.08);
--text-primary: #E8F0E4;
@@ -198,6 +254,8 @@
--bg-gradient: linear-gradient(135deg, #161a14 0%, #222a1e 100%);
--primary-gradient: linear-gradient(135deg, #7A9A5C 0%, #9ABA7C 100%);
--card-bg: rgba(34, 42, 30, 0.9);
--card-inner-bg: #21281D;
--sent-card-bg: var(--primary);
}
// 辛辣红 - 深色
@@ -208,6 +266,7 @@
--primary-light: rgba(192, 96, 104, 0.15);
--bg-primary: #1a1416;
--bg-secondary: rgba(42, 32, 34, 0.9);
--bg-secondary-solid: #2a2022;
--bg-tertiary: rgba(255, 255, 255, 0.05);
--bg-hover: rgba(255, 255, 255, 0.08);
--text-primary: #F0E8E8;
@@ -217,6 +276,8 @@
--bg-gradient: linear-gradient(135deg, #1a1416 0%, #2a2022 100%);
--primary-gradient: linear-gradient(135deg, #8B4049 0%, #C06068 100%);
--card-bg: rgba(42, 32, 34, 0.9);
--card-inner-bg: #281F21;
--sent-card-bg: var(--primary);
}
// 明水鸭色 - 深色
@@ -227,6 +288,7 @@
--primary-light: rgba(122, 186, 170, 0.15);
--bg-primary: #121a1a;
--bg-secondary: rgba(28, 42, 42, 0.9);
--bg-secondary-solid: #1c2a2a;
--bg-tertiary: rgba(255, 255, 255, 0.05);
--bg-hover: rgba(255, 255, 255, 0.08);
--text-primary: #E4F0F0;
@@ -236,6 +298,45 @@
--bg-gradient: linear-gradient(135deg, #121a1a 0%, #1c2a2a 100%);
--primary-gradient: linear-gradient(135deg, #5A8A8A 0%, #7ABAAA 100%);
--card-bg: rgba(28, 42, 42, 0.9);
--card-inner-bg: #1B2828;
--sent-card-bg: var(--primary);
}
// 繁花如梦 - 深色(夜阑幽梦)
[data-theme="blossom-dream"][data-mode="dark"] {
// 光晕色(供伪元素使用,降低饱和度避免刺眼)
--blossom-pink: #C670C3;
--blossom-purple: #5F4B8B;
--blossom-blue: #3A2A50;
// 主品牌色:藕粉/烟紫粉,降饱和度不刺眼
--primary: #D19EBB;
--primary-rgb: 209, 158, 187;
--primary-hover: #DDB0C8;
--primary-light: rgba(209, 158, 187, 0.15);
// 背景三层:极深黑灰底(去掉紫薯色),面板略浅,卡片再浅一级
--bg-primary: #151316;
--bg-secondary: rgba(34, 30, 36, 0.92);
--bg-secondary-solid: #221E24;
--bg-tertiary: rgba(255, 255, 255, 0.04);
--bg-hover: rgba(209, 158, 187, 0.1);
// 文字
--text-primary: #F0EAF4;
--text-secondary: #A898AE;
--text-tertiary: #6A5870;
// 边框:极细白色内发光,剥离层级
--border-color: rgba(255, 255, 255, 0.07);
--bg-gradient: linear-gradient(150deg, #151316 0%, #1A1620 50%, #131018 100%);
--primary-gradient: linear-gradient(135deg, #D19EBB 0%, #A878A8 100%);
// 卡片:比面板更亮一档,用深灰而非紫色
--card-bg: rgba(42, 38, 46, 0.92);
--card-inner-bg: rgba(52, 48, 56, 0.96);
--sent-card-bg: var(--primary);
}
// 重置样式

View File

@@ -11,7 +11,7 @@ export interface ElectronAPI {
setTitleBarOverlay: (options: { symbolColor: string }) => void
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise<void>
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
openImageViewerWindow: (imagePath: string) => Promise<void>
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => Promise<void>
openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean>
}
config: {
@@ -19,6 +19,17 @@ export interface ElectronAPI {
set: (key: string, value: unknown) => Promise<void>
clear: () => Promise<boolean>
}
auth: {
hello: (message?: string) => Promise<{ success: boolean; error?: string }>
verifyEnabled: () => Promise<boolean>
unlock: (password: string) => Promise<{ success: boolean; error?: string }>
enableLock: (password: string) => Promise<{ success: boolean; error?: string }>
disableLock: (password: string) => Promise<{ success: boolean; error?: string }>
changePassword: (oldPassword: string, newPassword: string) => Promise<{ success: boolean; error?: string }>
setHelloSecret: (password: string) => Promise<{ success: boolean }>
clearHelloSecret: () => Promise<{ success: boolean }>
isLockMode: () => Promise<boolean>
}
dialog: {
openFile: (options?: Electron.OpenDialogOptions) => Promise<Electron.OpenDialogReturnValue>
openDirectory: (options?: Electron.OpenDialogOptions) => Promise<Electron.OpenDialogReturnValue>
@@ -115,6 +126,11 @@ export interface ElectronAPI {
getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => Promise<{ success: boolean; data?: string; error?: string }>
getAllVoiceMessages: (sessionId: string) => Promise<{ success: boolean; messages?: Message[]; error?: string }>
getAllImageMessages: (sessionId: string) => Promise<{
success: boolean
images?: { imageMd5?: string; imageDatName?: string; createTime?: number }[]
error?: string
}>
resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }>
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }>
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
@@ -125,7 +141,7 @@ export interface ElectronAPI {
image: {
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; error?: string }>
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }>
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }>
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => Promise<boolean>
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void

View File

@@ -64,12 +64,39 @@ export interface Message {
fileSize?: number // 文件大小
fileExt?: string // 文件扩展名
xmlType?: string // XML 中的 type 字段
appMsgKind?: string // 归一化 appmsg 类型
appMsgDesc?: string
appMsgAppName?: string
appMsgSourceName?: string
appMsgSourceUsername?: string
appMsgThumbUrl?: string
appMsgMusicUrl?: string
appMsgDataUrl?: string
appMsgLocationLabel?: string
finderNickname?: string
finderUsername?: string
finderCoverUrl?: string // 视频号封面图
finderAvatar?: string // 视频号作者头像
finderDuration?: number // 视频号时长(秒)
// 位置消息
locationLat?: number // 纬度
locationLng?: number // 经度
locationPoiname?: string // 地点名称
locationLabel?: string // 详细地址
// 音乐消息
musicAlbumUrl?: string // 专辑封面
musicUrl?: string // 播放链接
// 礼物消息
giftImageUrl?: string // 礼物商品图
giftWish?: string // 祝福语
giftPrice?: string // 价格(分)
// 转账消息
transferPayerUsername?: string // 转账付款方 wxid
transferReceiverUsername?: string // 转账收款方 wxid
// 名片消息
cardUsername?: string // 名片的微信ID
cardNickname?: string // 名片的昵称
cardAvatarUrl?: string // 名片头像 URL
// 聊天记录
chatRecordTitle?: string // 聊天记录标题
chatRecordList?: ChatRecordItem[] // 聊天记录列表

8
src/vite-env.d.ts vendored
View File

@@ -5,6 +5,14 @@ interface Window {
// ... other methods ...
auth: {
hello: (message?: string) => Promise<{ success: boolean; error?: string }>
verifyEnabled: () => Promise<boolean>
unlock: (password: string) => Promise<{ success: boolean; error?: string }>
enableLock: (password: string) => Promise<{ success: boolean; error?: string }>
disableLock: (password: string) => Promise<{ success: boolean; error?: string }>
changePassword: (oldPassword: string, newPassword: string) => Promise<{ success: boolean; error?: string }>
setHelloSecret: (password: string) => Promise<{ success: boolean }>
clearHelloSecret: () => Promise<{ success: boolean }>
isLockMode: () => Promise<boolean>
}
// For brevity, using 'any' for other parts or properly importing types if available.
// In a real scenario, you'd likely want to keep the full interface definition consistent with preload.ts