feat: 新增单库解密功能并支持数据库批量解密

在dataManagementService中实现decryptSingleDatabase方法,支持单个数据库文件解密
增强httpApiService服务,新增消息查询接口端点,支持多条件筛选与分页查询
在ChatPage中新增微信号复制 UI 组件,并优化消息加载逻辑
在DataManagementPage中新增数据库选择与批量解密能力,支持用户勾选多个数据库解密
更新ChatPage.scss和DataManagementPage.scss样式文件,适配新增 UI 元素
扩展 Electron API 类型定义,新增数据库解密与消息检索相关方法
This commit is contained in:
ILoveBingLu
2026-03-04 01:25:12 +08:00
parent 8f797fa70f
commit 79af4bfaa3
10 changed files with 1286 additions and 17 deletions

View File

@@ -1638,6 +1638,10 @@ function registerIpcHandlers() {
return dataManagementService.decryptAll()
})
ipcMain.handle('dataManagement:decryptSingleDatabase', async (_, filePath: string) => {
return dataManagementService.decryptSingleDatabase(filePath)
})
ipcMain.handle('dataManagement:incrementalUpdate', async () => {
return dataManagementService.incrementalUpdate()
})
@@ -1955,6 +1959,27 @@ function registerIpcHandlers() {
return result
})
ipcMain.handle('chat:getMessagesBefore', async (
_,
sessionId: string,
cursorSortSeq: number,
limit?: number,
cursorCreateTime?: number,
cursorLocalId?: number
) => {
const result = await chatService.getMessagesBefore(sessionId, cursorSortSeq, limit, cursorCreateTime, cursorLocalId)
if (!result.success) {
logService?.warn('Chat', '按游标获取更早消息失败', {
sessionId,
cursorSortSeq,
cursorCreateTime,
cursorLocalId,
error: result.error
})
}
return result
})
ipcMain.handle('chat:getAllVoiceMessages', async (_, sessionId: string) => {
const result = await chatService.getAllVoiceMessages(sessionId)
@@ -2022,6 +2047,19 @@ function registerIpcHandlers() {
return result
})
ipcMain.handle('chat:resolveEmojiPath', async (_, md5?: string, cdnUrl?: string, productId?: string, createTime?: number, encryptUrl?: string, aesKey?: string) => {
const result = await chatService.downloadEmoji(cdnUrl || '', md5, productId, createTime, encryptUrl, aesKey)
if (!result.success) {
logService?.warn('Chat', '解析表情缓存路径失败', { md5, cdnUrl, error: result.error })
return result
}
return {
success: true,
cachePath: result.cachePath,
localPath: result.localPath
}
})
ipcMain.handle('chat:close', async () => {
logService?.info('Chat', '关闭聊天服务')
chatService.close()

View File

@@ -161,6 +161,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
dataManagement: {
scanDatabases: () => ipcRenderer.invoke('dataManagement:scanDatabases'),
decryptAll: () => ipcRenderer.invoke('dataManagement:decryptAll'),
decryptSingleDatabase: (filePath: string) => ipcRenderer.invoke('dataManagement:decryptSingleDatabase', filePath),
incrementalUpdate: () => ipcRenderer.invoke('dataManagement:incrementalUpdate'),
getCurrentCachePath: () => ipcRenderer.invoke('dataManagement:getCurrentCachePath'),
getDefaultCachePath: () => ipcRenderer.invoke('dataManagement:getDefaultCachePath'),
@@ -239,6 +240,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
getContacts: () => ipcRenderer.invoke('chat:getContacts'),
getMessages: (sessionId: string, offset?: number, limit?: number) =>
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit),
getMessagesBefore: (
sessionId: string,
cursorSortSeq: number,
limit?: number,
cursorCreateTime?: number,
cursorLocalId?: number
) =>
ipcRenderer.invoke('chat:getMessagesBefore', sessionId, cursorSortSeq, limit, cursorCreateTime, cursorLocalId),
getAllVoiceMessages: (sessionId: string) =>
ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId),
getAllImageMessages: (sessionId: string) =>

View File

@@ -1285,6 +1285,278 @@ class ChatService extends EventEmitter {
}
}
/**
* 基于 sortSeq 游标,获取更早的消息(严格小于 cursorSortSeq
*/
async getMessagesBefore(
sessionId: string,
cursorSortSeq: number,
limit: number = 50,
cursorCreateTime?: number,
cursorLocalId?: number
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
try {
if (!this.dbDir) {
const connectResult = await this.connect()
if (!connectResult.success) {
return { success: false, error: connectResult.error || '数据库未连接' }
}
}
const myWxid = this.configService.get('myWxid')
const cleanedMyWxid = myWxid ? this.cleanAccountDirName(myWxid) : ''
const dbTablePairs = this.findSessionTables(sessionId)
if (dbTablePairs.length === 0) {
return { success: false, error: '未找到该会话的消息表' }
}
let allMessages: Message[] = []
const fetchLimitPerDb = Math.max(limit + 1, 50)
const effectiveCursorCreateTime = cursorCreateTime ?? Number.MAX_SAFE_INTEGER
const effectiveCursorLocalId = cursorLocalId ?? Number.MAX_SAFE_INTEGER
for (const { db, tableName, dbPath } of dbTablePairs) {
try {
const hasName2IdTable = this.checkTableExists(db, 'Name2Id')
let myRowId: number | null = null
if (myWxid && hasName2IdTable) {
const cacheKeyOriginal = `${dbPath}:${myWxid}`
const cachedRowIdOriginal = this.myRowIdCache.get(cacheKeyOriginal)
if (cachedRowIdOriginal !== undefined) {
myRowId = cachedRowIdOriginal
} else {
const row = db.prepare('SELECT rowid FROM Name2Id WHERE user_name = ?').get(myWxid) as any
if (row?.rowid) {
myRowId = row.rowid
this.myRowIdCache.set(cacheKeyOriginal, myRowId)
} else if (cleanedMyWxid && cleanedMyWxid !== myWxid) {
const cacheKeyCleaned = `${dbPath}:${cleanedMyWxid}`
const cachedRowIdCleaned = this.myRowIdCache.get(cacheKeyCleaned)
if (cachedRowIdCleaned !== undefined) {
myRowId = cachedRowIdCleaned
} else {
const row2 = db.prepare('SELECT rowid FROM Name2Id WHERE user_name = ?').get(cleanedMyWxid) as any
myRowId = row2?.rowid ?? null
this.myRowIdCache.set(cacheKeyCleaned, myRowId)
}
} else {
this.myRowIdCache.set(cacheKeyOriginal, null)
}
}
}
let sql: string
let rows: any[]
if (hasName2IdTable && myRowId !== null) {
sql = `SELECT m.*,
CASE WHEN m.real_sender_id = ? THEN 1 ELSE 0 END AS computed_is_send,
n.user_name AS sender_username
FROM ${tableName} m
LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid
WHERE (
m.sort_seq < ?
OR (m.sort_seq = ? AND m.create_time < ?)
OR (m.sort_seq = ? AND m.create_time = ? AND m.local_id < ?)
)
ORDER BY m.sort_seq DESC
LIMIT ?`
rows = db.prepare(sql).all(
myRowId,
cursorSortSeq,
cursorSortSeq,
effectiveCursorCreateTime,
cursorSortSeq,
effectiveCursorCreateTime,
effectiveCursorLocalId,
fetchLimitPerDb
) as any[]
} else if (hasName2IdTable) {
sql = `SELECT m.*, n.user_name AS sender_username
FROM ${tableName} m
LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid
WHERE (
m.sort_seq < ?
OR (m.sort_seq = ? AND m.create_time < ?)
OR (m.sort_seq = ? AND m.create_time = ? AND m.local_id < ?)
)
ORDER BY m.sort_seq DESC
LIMIT ?`
rows = db.prepare(sql).all(
cursorSortSeq,
cursorSortSeq,
effectiveCursorCreateTime,
cursorSortSeq,
effectiveCursorCreateTime,
effectiveCursorLocalId,
fetchLimitPerDb
) as any[]
} else {
sql = `SELECT * FROM ${tableName}
WHERE (
sort_seq < ?
OR (sort_seq = ? AND create_time < ?)
OR (sort_seq = ? AND create_time = ? AND local_id < ?)
)
ORDER BY sort_seq DESC
LIMIT ?`
rows = db.prepare(sql).all(
cursorSortSeq,
cursorSortSeq,
effectiveCursorCreateTime,
cursorSortSeq,
effectiveCursorCreateTime,
effectiveCursorLocalId,
fetchLimitPerDb
) as any[]
}
for (const row of rows) {
const content = this.decodeMessageContent(row.message_content, row.compress_content)
const localType = row.local_type || row.type || 1
const isSend = row.computed_is_send ?? row.is_send ?? null
let emojiCdnUrl: string | undefined
let emojiMd5: string | undefined
let emojiProductId: string | undefined
let quotedContent: string | undefined
let quotedSender: string | undefined
let quotedImageMd5: string | undefined
let quotedEmojiMd5: string | undefined
let quotedEmojiCdnUrl: string | undefined
let imageMd5: string | undefined
let imageDatName: string | undefined
let isLivePhoto: boolean | undefined
let videoMd5: string | undefined
let videoDuration: number | undefined
let voiceDuration: number | undefined
if (localType === 47 && content) {
const emojiInfo = this.parseEmojiInfo(content)
emojiCdnUrl = emojiInfo.cdnUrl
emojiMd5 = emojiInfo.md5
emojiProductId = emojiInfo.productId
} else if (localType === 3 && content) {
const imageInfo = this.parseImageInfo(content)
imageMd5 = imageInfo.md5
imageDatName = this.parseImageDatNameFromRow(row)
isLivePhoto = imageInfo.isLivePhoto
} else if (localType === 43 && content) {
videoMd5 = this.parseVideoMd5(content)
videoDuration = this.parseVideoDuration(content)
} else if (localType === 34 && content) {
voiceDuration = this.parseVoiceDuration(content)
} else if (localType === 244813135921 || (content && content.includes('<type>57</type>'))) {
const quoteInfo = this.parseQuoteMessage(content)
quotedContent = quoteInfo.content
quotedSender = quoteInfo.sender
quotedImageMd5 = quoteInfo.imageMd5
quotedEmojiMd5 = quoteInfo.emojiMd5
quotedEmojiCdnUrl = quoteInfo.emojiCdnUrl
}
let fileName: string | undefined
let fileSize: number | undefined
let fileExt: string | undefined
let fileMd5: string | undefined
if (localType === 49 && content) {
const fileInfo = this.parseFileInfo(content)
fileName = fileInfo.fileName
fileSize = fileInfo.fileSize
fileExt = fileInfo.fileExt
fileMd5 = fileInfo.fileMd5
}
let chatRecordList: ChatRecordItem[] | undefined
if (content) {
const xmlType = this.extractXmlValue(content, 'type')
if (xmlType === '19' || localType === 49) {
chatRecordList = this.parseChatHistory(content)
}
}
let transferPayerUsername: string | undefined
let transferReceiverUsername: string | undefined
if ((localType === 49 || localType === 8589934592049) && content) {
const xmlType = this.extractXmlValue(content, 'type')
if (xmlType === '2000') {
transferPayerUsername = this.extractXmlValue(content, 'payer_username') || undefined
transferReceiverUsername = this.extractXmlValue(content, 'receiver_username') || undefined
}
}
const parsedContent = this.parseMessageContent(content, localType)
allMessages.push({
localId: row.local_id || 0,
serverId: row.server_id || 0,
localType,
createTime: row.create_time || 0,
sortSeq: row.sort_seq || 0,
isSend,
senderUsername: row.sender_username || null,
parsedContent,
rawContent: content,
emojiCdnUrl,
emojiMd5,
productId: emojiProductId,
quotedContent,
quotedSender,
quotedImageMd5,
quotedEmojiMd5,
quotedEmojiCdnUrl,
imageMd5,
imageDatName,
isLivePhoto,
videoMd5,
videoDuration,
voiceDuration,
fileName,
fileSize,
fileExt,
fileMd5,
chatRecordList,
transferPayerUsername,
transferReceiverUsername
})
}
} catch (e: any) {
if (e?.code === 'SQLITE_CORRUPT' || e?.message?.includes('malformed')) {
console.error(`[ChatService] 数据库损坏: ${dbPath}`, e)
this.messageDbCache.delete(dbPath)
try { db.close() } catch { }
this.refreshMessageDbCache()
} else {
console.error('ChatService: 查询更早消息失败:', e)
}
}
}
allMessages.sort((a, b) => b.sortSeq - a.sortSeq)
const seen = new Set<string>()
allMessages = allMessages.filter(msg => {
const key = `${msg.serverId}-${msg.localId}-${msg.createTime}-${msg.sortSeq}`
if (seen.has(key)) return false
seen.add(key)
return true
})
const hasMore = allMessages.length > limit
const messages = allMessages.slice(0, limit)
messages.reverse()
return { success: true, messages, hasMore }
} catch (e) {
console.error('ChatService: 获取更早消息失败:', e)
return { success: false, error: String(e) }
}
}
/**
* 获取会话的所有语音消息(用于批量转写)
* 复用 getMessages 的查询逻辑,只查询语音消息类型
@@ -3487,7 +3759,7 @@ class ChatService extends EventEmitter {
* 下载或获取表情包本地缓存
* 如果 cdnUrl 为空但 md5 存在,则尝试通过本地存储或多种拼接规则下载
*/
async downloadEmoji(cdnUrl: string, md5?: string, productId?: string, createTime?: number, encryptUrl?: string, aesKey?: string): Promise<{ success: boolean; localPath?: string; error?: string }> {
async downloadEmoji(cdnUrl: string, md5?: string, productId?: string, createTime?: number, encryptUrl?: string, aesKey?: string): Promise<{ success: boolean; localPath?: string; cachePath?: string; error?: string }> {
// 如果没有 cdnUrl 也没有 md5无法处理
if (!cdnUrl && !md5) {
return { success: false, error: '无效的 CDN URL 和 MD5' }
@@ -3501,7 +3773,7 @@ class ChatService extends EventEmitter {
if (cached && fs.existsSync(cached)) {
const dataUrl = this.fileToDataUrl(cached)
if (dataUrl) {
return { success: true, localPath: dataUrl }
return { success: true, localPath: dataUrl, cachePath: cached }
}
}
@@ -3512,7 +3784,7 @@ class ChatService extends EventEmitter {
if (result) {
const dataUrl = this.fileToDataUrl(result)
if (dataUrl) {
return { success: true, localPath: dataUrl }
return { success: true, localPath: dataUrl, cachePath: result }
}
}
return { success: false, error: '下载失败' }
@@ -3532,7 +3804,7 @@ class ChatService extends EventEmitter {
emojiCache.set(cacheKey, filePath)
const dataUrl = this.fileToDataUrl(filePath)
if (dataUrl) {
return { success: true, localPath: dataUrl }
return { success: true, localPath: dataUrl, cachePath: filePath }
}
}
}
@@ -3656,7 +3928,7 @@ class ChatService extends EventEmitter {
const dataUrl = this.fileToDataUrl(localFile)
if (dataUrl) {
emojiCache.set(cacheKey, localFile)
return { success: true, localPath: dataUrl }
return { success: true, localPath: dataUrl, cachePath: localFile }
}
}
} catch (e) {
@@ -3678,7 +3950,7 @@ class ChatService extends EventEmitter {
const dataUrl = this.fileToDataUrl(localPath)
if (dataUrl) {
emojiCache.set(cacheKey, localPath)
return { success: true, localPath: dataUrl }
return { success: true, localPath: dataUrl, cachePath: localPath }
}
}
} catch (e) { }
@@ -3697,7 +3969,7 @@ class ChatService extends EventEmitter {
if (localPath) {
emojiCache.set(cacheKey, localPath)
const dataUrl = this.fileToDataUrl(localPath)
if (dataUrl) return { success: true, localPath: dataUrl }
if (dataUrl) return { success: true, localPath: dataUrl, cachePath: localPath }
}
} catch (e) {
// 忽略下载失败
@@ -3714,7 +3986,7 @@ class ChatService extends EventEmitter {
emojiCache.set(cacheKey, localPath)
const dataUrl = this.fileToDataUrl(localPath)
if (dataUrl) {
return { success: true, localPath: dataUrl }
return { success: true, localPath: dataUrl, cachePath: localPath }
}
}
} catch (e) {
@@ -3740,7 +4012,7 @@ class ChatService extends EventEmitter {
try { fs.unlinkSync(encLocalPath) } catch { }
emojiCache.set(cacheKey, outputPath)
const dataUrl = this.fileToDataUrl(outputPath)
if (dataUrl) return { success: true, localPath: dataUrl }
if (dataUrl) return { success: true, localPath: dataUrl, cachePath: outputPath }
}
} catch (e) {
console.warn('[ChatService] encryptUrl fallback 失败:', e)

View File

@@ -366,6 +366,47 @@ class DataManagementService {
}
}
/**
* 解密单个数据库
*/
async decryptSingleDatabase(filePath: string): Promise<{ success: boolean; error?: string }> {
try {
const key = this.configService.get('decryptKey')
if (!key) {
return { success: false, error: '请先在设置页面配置解密密钥' }
}
const scanResult = await this.scanDatabases()
if (!scanResult.success || !scanResult.databases) {
return { success: false, error: '扫描数据库失败' }
}
const dbFile = scanResult.databases.find(db => db.filePath === filePath)
if (!dbFile) {
return { success: false, error: '未找到指定的数据库文件' }
}
const outputDir = path.dirname(dbFile.decryptedPath!)
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true })
}
const result = await wechatDecryptService.decryptDatabase(
dbFile.filePath,
dbFile.decryptedPath!,
key
)
if (result.success) {
chatService.refreshMessageDbCache()
}
return result
} catch (e) {
return { success: false, error: String(e) }
}
}
/**
* 增量更新(只更新有变化的文件)
*/

View File

@@ -1,8 +1,13 @@
import * as http from 'http'
import { URL } from 'url'
import { URL, fileURLToPath } from 'url'
import { app } from 'electron'
import { existsSync, mkdirSync } from 'fs'
import { writeFile } from 'fs/promises'
import { join } from 'path'
import { ConfigService } from './config'
import { chatService } from './chatService'
import { imageDecryptService } from './imageDecryptService'
import { videoService } from './videoService'
interface ApiEnvelopeSuccess<T> {
success: true
@@ -139,6 +144,7 @@ class HttpApiService {
{ method: 'GET', path: '/v1/health', desc: '健康检查' },
{ method: 'GET', path: '/v1/status', desc: '服务状态' },
{ method: 'GET', path: '/v1/sessions', desc: '会话列表' },
{ method: 'GET', path: '/v1/messages', desc: '会话消息' },
{ method: 'GET', path: '/v1/contacts', desc: '联系人列表' }
],
lastError: this.startError
@@ -239,6 +245,164 @@ class HttpApiService {
return Math.max(min, Math.min(max, n))
}
private parseNumberList(value: string | null): number[] | null {
if (!value) return null
const nums = value
.split(',')
.map((x) => Number.parseInt(x.trim(), 10))
.filter((x) => Number.isFinite(x))
return nums.length > 0 ? nums : null
}
private parseStringSet(value: string | null): Set<string> | null {
if (!value) return null
const values = value
.split(',')
.map((x) => x.trim().toLowerCase())
.filter(Boolean)
return values.length > 0 ? new Set(values) : null
}
private parseFields(value: string | null): Set<string> {
const defaults = ['base', 'type', 'time', 'sender', 'metadata', 'media']
if (!value || !value.trim()) {
return new Set(defaults)
}
const parts = value
.split(',')
.map((x) => x.trim().toLowerCase())
.filter(Boolean)
if (parts.includes('all')) {
return new Set([
'all',
'base',
'type',
'time',
'sender',
'metadata',
'media',
'quote',
'file',
'transfer',
'chatrecord',
'raw',
'schema'
])
}
return new Set(parts)
}
private parseTimestampMs(value: string | null): number | null {
if (!value) return null
const raw = Number.parseInt(value, 10)
if (!Number.isFinite(raw) || raw <= 0) return null
return raw < 1_000_000_000_000 ? raw * 1000 : raw
}
private normalizeTimestampMs(value: number): number {
if (!Number.isFinite(value) || value <= 0) return 0
return value < 1_000_000_000_000 ? value * 1000 : value
}
private extractXmlType(content?: string): string | undefined {
if (!content) return undefined
const match = content.match(/<type>\s*([^<]+)\s*<\/type>/i)
return match?.[1]?.trim()
}
private fileUrlToPathMaybe(input?: string | null): string | null {
if (!input) return null
if (input.startsWith('file:///')) {
try {
return fileURLToPath(input)
} catch {
return null
}
}
return null
}
private sanitizePathPart(value: string): string {
return value.replace(/[\\/:*?"<>|]/g, '_')
}
private pruneEmpty(value: any): any {
if (value === null || value === undefined) return undefined
if (typeof value === 'string') return value === '' ? undefined : value
if (Array.isArray(value)) {
const next = value
.map((v) => this.pruneEmpty(v))
.filter((v) => v !== undefined)
return next.length > 0 ? next : undefined
}
if (typeof value === 'object') {
const out: Record<string, any> = {}
for (const [k, v] of Object.entries(value)) {
const pruned = this.pruneEmpty(v)
if (pruned !== undefined) out[k] = pruned
}
return Object.keys(out).length > 0 ? out : undefined
}
return value
}
private detectMessageKind(message: Record<string, any>): {
messageKind: string
typeLabel: string
appMsgType?: string
} {
const localType = Number(message.localType || 0)
const raw = String(message.rawContent || message.parsedContent || '')
const appMsgType = this.extractXmlType(raw)
if (localType === 1) return { messageKind: 'text', typeLabel: '文本' }
if (localType === 3) return { messageKind: 'image', typeLabel: '图片' }
if (localType === 34) return { messageKind: 'voice', typeLabel: '语音' }
if (localType === 42) return { messageKind: 'contact_card', typeLabel: '名片' }
if (localType === 43) return { messageKind: 'video', typeLabel: '视频' }
if (localType === 47) return { messageKind: 'emoji', typeLabel: '表情' }
if (localType === 48) return { messageKind: 'location', typeLabel: '位置' }
if (localType === 50) return { messageKind: 'voip', typeLabel: '音视频通话' }
if (localType === 10000) return { messageKind: 'system', typeLabel: '系统消息' }
if (localType === 244813135921) return { messageKind: 'quote', typeLabel: '引用消息' }
if (localType === 49 || appMsgType) {
switch (appMsgType) {
case '3':
return { messageKind: 'app_music', typeLabel: '音乐分享', appMsgType }
case '5':
case '49':
return { messageKind: 'app_link', typeLabel: '链接', appMsgType }
case '6':
return { messageKind: 'app_file', typeLabel: '文件', appMsgType }
case '19':
return { messageKind: 'app_chat_record', typeLabel: '聊天记录', appMsgType }
case '33':
case '36':
return { messageKind: 'app_mini_program', typeLabel: '小程序', appMsgType }
case '57':
return { messageKind: 'app_quote', typeLabel: '引用消息', appMsgType }
case '62':
return { messageKind: 'app_pat', typeLabel: '拍一拍', appMsgType }
case '87':
return { messageKind: 'app_announcement', typeLabel: '群公告', appMsgType }
case '115':
return { messageKind: 'app_gift', typeLabel: '微信礼物', appMsgType }
case '2000':
return { messageKind: 'app_transfer', typeLabel: '转账', appMsgType }
case '2001':
return { messageKind: 'app_red_packet', typeLabel: '红包', appMsgType }
default:
return { messageKind: 'app', typeLabel: '应用消息', appMsgType }
}
}
return { messageKind: 'unknown', typeLabel: `未知类型(${localType})` }
}
private parseTypeFilter(value: string | null): Set<ContactType> | null {
if (!value) return null
const allowed: ContactType[] = ['friend', 'group', 'official', 'former_friend', 'other']
@@ -317,6 +481,10 @@ class HttpApiService {
this.sendRedirect(res, '/v1/status')
return
}
if (pathname === '/api/v1/messages') {
this.sendRedirect(res, '/v1/messages')
return
}
if (pathname === '/api/v1/sessions') {
this.sendRedirect(res, '/v1/sessions')
return
@@ -537,6 +705,440 @@ class HttpApiService {
return
}
if (pathname === '/v1/messages') {
const sessionId = (url.searchParams.get('sessionId') || '').trim()
if (!sessionId) {
this.sendJson(
res,
400,
this.failure(
requestId,
'BAD_REQUEST',
'Missing required parameter: sessionId',
'Use query parameter: sessionId=<chat_username>'
)
)
return
}
const offset = this.parseIntInRange(url.searchParams.get('offset'), 0, 0, 100000)
const limit = this.parseIntInRange(url.searchParams.get('limit'), 50, 1, 200)
const sort = (url.searchParams.get('sort') || 'createTime_desc').trim()
const keyword = (url.searchParams.get('keyword') || '').trim().toLowerCase()
const msgTypeFilter = this.parseNumberList(url.searchParams.get('msgType'))
const messageKindFilter = this.parseStringSet(url.searchParams.get('messageKind'))
const appMsgTypeFilter = this.parseStringSet(url.searchParams.get('appMsgType'))
const startTimeMs = this.parseTimestampMs(url.searchParams.get('startTime'))
const endTimeMs = this.parseTimestampMs(url.searchParams.get('endTime'))
const includeRaw = this.parseBoolean(url.searchParams.get('includeRaw'), false)
const resolveMediaPath = this.parseBoolean(url.searchParams.get('resolveMediaPath'), true)
const resolveVoicePath = this.parseBoolean(url.searchParams.get('resolveVoicePath'), false)
const adaptive = this.parseBoolean(url.searchParams.get('adaptive'), true)
const maxScan = this.parseIntInRange(url.searchParams.get('maxScan'), 5000, 100, 20000)
const fields = this.parseFields(url.searchParams.get('fields'))
if (includeRaw) fields.add('raw')
const includeField = (name: string): boolean => fields.has('all') || fields.has(name)
const needKindForFilter = Boolean(messageKindFilter || appMsgTypeFilter)
const needKindForOutput = [
includeField('type'),
includeField('metadata'),
includeField('media')
].some(Boolean)
const shouldResolveMediaPath = includeField('media') && resolveMediaPath
const shouldResolveVoicePath = includeField('media') && resolveVoicePath
const includeChatRecordItems = includeField('chatrecord')
let myWxid = ''
let dbPath = ''
let cachePath = ''
if (shouldResolveVoicePath || shouldResolveMediaPath || includeField('file')) {
const runtimeConfig = new ConfigService()
myWxid = String(runtimeConfig.get('myWxid') || '')
dbPath = String(runtimeConfig.get('dbPath') || '')
cachePath = String(runtimeConfig.get('cachePath') || '')
runtimeConfig.close()
}
const fetchBatchSize = 200
const targetCount = offset + limit
let scanOffset = 0
let scanned = 0
let reachedEnd = false
const matched: any[] = []
while (scanned < maxScan && matched.length < targetCount) {
const part = await chatService.getMessages(sessionId, scanOffset, fetchBatchSize)
if (!part.success) {
this.sendJson(
res,
503,
this.failure(
requestId,
'DB_NOT_CONNECTED',
part.error || 'Failed to read messages',
'Please complete DB decrypt/setup in Settings and ensure sessionId is correct.'
)
)
return
}
const chunk = part.messages || []
if (chunk.length === 0) {
reachedEnd = true
break
}
scanned += chunk.length
scanOffset += chunk.length
for (const msg of chunk) {
if (msgTypeFilter && !msgTypeFilter.includes(Number(msg.localType || 0))) continue
if (needKindForFilter) {
const kindInfo = this.detectMessageKind(msg as Record<string, any>)
if (messageKindFilter && !messageKindFilter.has(kindInfo.messageKind)) continue
if (appMsgTypeFilter) {
const appMsgType = (kindInfo.appMsgType || '').toLowerCase()
if (!appMsgType || !appMsgTypeFilter.has(appMsgType)) continue
}
}
const tMs = this.normalizeTimestampMs(Number(msg.createTime || 0))
if (startTimeMs && tMs < startTimeMs) continue
if (endTimeMs && tMs > endTimeMs) continue
if (keyword) {
const parsed = String(msg.parsedContent || '').toLowerCase()
const raw = String(msg.rawContent || '').toLowerCase()
if (!parsed.includes(keyword) && !raw.includes(keyword)) continue
}
matched.push(msg)
}
if (!part.hasMore) {
reachedEnd = true
break
}
}
if (sort === 'createTime_asc') {
matched.sort((a, b) => Number(a.createTime || 0) - Number(b.createTime || 0))
} else {
matched.sort((a, b) => Number(b.createTime || 0) - Number(a.createTime || 0))
}
const page = matched.slice(offset, offset + limit)
const hasMore = reachedEnd ? matched.length > offset + page.length : true
const enrichOne = async (m: any): Promise<Record<string, any>> => {
const base = m as Record<string, any>
const kind = needKindForOutput ? this.detectMessageKind(base) : { messageKind: 'unknown', typeLabel: '未知类型', appMsgType: undefined }
const createTimeMs = this.normalizeTimestampMs(Number(base.createTime || 0))
const senderUsername = base.senderUsername || null
const metadata = {
localType: Number(base.localType || 0),
messageKind: kind.messageKind,
typeLabel: kind.typeLabel,
appMsgType: kind.appMsgType || null,
direction: Number(base.isSend) === 1 ? 'out' : 'in',
isSystem: Number(base.localType || 0) === 10000 || kind.messageKind === 'app_pat',
isMedia: ['image', 'voice', 'video', 'emoji'].includes(kind.messageKind),
hasRawContent: Boolean(base.rawContent),
hasParsedContent: Boolean(base.parsedContent),
hasQuote: Boolean(base.quotedContent || base.quotedImageMd5 || base.quotedEmojiMd5),
hasFile: Boolean(base.fileName || base.fileMd5),
hasTransfer: Boolean(base.transferPayerUsername || base.transferReceiverUsername),
hasChatRecord: Array.isArray(base.chatRecordList) && base.chatRecordList.length > 0,
isLivePhoto: Boolean(base.isLivePhoto)
}
const media = {
imageMd5: base.imageMd5 || null,
imageDatName: base.imageDatName || null,
imageCachePath: null as string | null,
emojiMd5: base.emojiMd5 || null,
emojiCdnUrl: base.emojiCdnUrl || null,
emojiCachePath: null as string | null,
videoMd5: base.videoMd5 || null,
videoDuration: base.videoDuration || null,
videoCachePath: null as string | null,
voiceDuration: base.voiceDuration || null,
voiceCachePath: null as string | null
}
if (shouldResolveMediaPath && (kind.messageKind === 'emoji' || kind.messageKind.startsWith('app_')) && (base.emojiMd5 || base.emojiCdnUrl)) {
try {
const emojiResult = await chatService.downloadEmoji(
String(base.emojiCdnUrl || ''),
base.emojiMd5,
base.productId,
Number(base.createTime || 0),
base.emojiEncryptUrl,
base.emojiAesKey
)
if (emojiResult.success && emojiResult.cachePath) {
media.emojiCachePath = emojiResult.cachePath
}
} catch {
// ignore media path resolve errors for API stability
}
}
if (shouldResolveMediaPath && kind.messageKind === 'image' && (base.imageMd5 || base.imageDatName)) {
try {
const resolved = await imageDecryptService.resolveCachedImage({
sessionId,
imageMd5: base.imageMd5,
imageDatName: base.imageDatName
})
if (resolved.success && resolved.localPath) {
media.imageCachePath = this.fileUrlToPathMaybe(resolved.localPath)
} else {
const decrypted = await imageDecryptService.decryptImage({
sessionId,
imageMd5: base.imageMd5,
imageDatName: base.imageDatName,
force: false
})
if (decrypted.success && decrypted.localPath) {
media.imageCachePath = this.fileUrlToPathMaybe(decrypted.localPath)
}
}
} catch {
// ignore image path resolve errors
}
}
if (shouldResolveMediaPath && kind.messageKind === 'video' && base.videoMd5) {
try {
const videoInfo = videoService.getVideoInfo(String(base.videoMd5))
if (videoInfo.exists && videoInfo.videoUrl) {
media.videoCachePath = this.fileUrlToPathMaybe(videoInfo.videoUrl)
}
} catch {
// ignore video path resolve errors
}
}
if (shouldResolveVoicePath && kind.messageKind === 'voice') {
try {
const voiceResult = await chatService.getVoiceData(
sessionId,
String(base.localId || ''),
Number(base.createTime || 0)
)
if (voiceResult.success && voiceResult.data) {
const baseCacheDir = cachePath || join(process.cwd(), 'cache')
const voiceDir = join(baseCacheDir, 'HttpApiVoices', this.sanitizePathPart(sessionId))
if (!existsSync(voiceDir)) {
mkdirSync(voiceDir, { recursive: true })
}
const fileName = `${Number(base.createTime || 0)}_${Number(base.localId || 0)}.wav`
const absPath = join(voiceDir, fileName)
await writeFile(absPath, Buffer.from(voiceResult.data, 'base64'))
media.voiceCachePath = absPath
}
} catch {
// ignore voice path resolve errors
}
}
const quote = metadata.hasQuote
? {
content: base.quotedContent || null,
sender: base.quotedSender || null,
imageMd5: base.quotedImageMd5 || null,
emojiMd5: base.quotedEmojiMd5 || null,
emojiCdnUrl: base.quotedEmojiCdnUrl || null
}
: null
const file = metadata.hasFile
? {
name: base.fileName || null,
size: base.fileSize || null,
ext: base.fileExt || null,
md5: base.fileMd5 || null,
absolutePath: null as string | null,
exists: false
}
: null
if (shouldResolveMediaPath && file?.name && dbPath && myWxid) {
try {
const msgDate = createTimeMs ? new Date(createTimeMs) : new Date()
const year = msgDate.getFullYear()
const month = String(msgDate.getMonth() + 1).padStart(2, '0')
const dateFolder = `${year}-${month}`
const abs = join(dbPath, myWxid, 'msg', 'file', dateFolder, String(file.name))
file.absolutePath = abs
file.exists = existsSync(abs)
} catch {
// ignore file path resolve errors
}
}
const transfer = metadata.hasTransfer
? {
payerUsername: base.transferPayerUsername || null,
receiverUsername: base.transferReceiverUsername || null
}
: null
const chatRecord = metadata.hasChatRecord
? {
count: Array.isArray(base.chatRecordList) ? base.chatRecordList.length : 0,
items: includeChatRecordItems ? (base.chatRecordList || []) : undefined
}
: null
const out: Record<string, any> = {}
if (includeField('base')) {
out.localId = base.localId || 0
out.serverId = base.serverId || 0
out.localType = Number(base.localType || 0)
out.createTime = Number(base.createTime || 0)
out.sortSeq = Number(base.sortSeq || 0)
out.isSend = base.isSend ?? null
out.senderUsername = senderUsername
out.parsedContent = base.parsedContent || ''
}
if (includeField('raw')) {
out.rawContent = base.rawContent || null
}
if (includeField('type')) {
out.messageKind = kind.messageKind
out.typeLabel = kind.typeLabel
out.appMsgType = kind.appMsgType || null
out.direction = metadata.direction
}
if (includeField('time')) {
out.createTimeMs = createTimeMs
out.createTimeIso = createTimeMs ? new Date(createTimeMs).toISOString() : null
}
if (includeField('sender')) {
out.sender = {
username: senderUsername,
isSelf: Number(base.isSend) === 1
}
}
if (includeField('metadata')) {
out.metadata = metadata
}
if (includeField('media')) {
out.media = media
}
if (includeField('quote')) {
out.quote = quote
}
if (includeField('file')) {
out.file = file
}
if (includeField('transfer')) {
out.transfer = transfer
}
if (includeField('chatrecord')) {
out.chatRecord = chatRecord
}
return out
}
const enrichResults = await Promise.allSettled(page.map((m) => enrichOne(m)))
const enrichedMessages = enrichResults
.filter((r): r is PromiseFulfilledResult<Record<string, any>> => r.status === 'fulfilled')
.map((r) => r.value)
const normalizedMessages = adaptive
? enrichedMessages.map((m) => this.pruneEmpty(m)).filter(Boolean)
: enrichedMessages
const finalMessages = includeRaw
? normalizedMessages
: normalizedMessages.map((m) => {
const { rawContent, ...rest } = m as Record<string, any>
return rest
})
const responsePayload: Record<string, any> = {
sessionId,
total: reachedEnd ? matched.length : null,
offset,
limit,
hasMore,
scanned,
maxScan,
sort,
filters: {
keyword,
msgType: msgTypeFilter,
messageKind: messageKindFilter ? Array.from(messageKindFilter) : null,
appMsgType: appMsgTypeFilter ? Array.from(appMsgTypeFilter) : null,
startTime: startTimeMs,
endTime: endTimeMs,
includeRaw,
resolveMediaPath,
resolveVoicePath,
adaptive,
fields: Array.from(fields)
},
messages: finalMessages
}
if (includeField('schema')) {
responsePayload.messageTypeSchema = {
messageKind: {
text: '文本',
image: '图片',
voice: '语音',
video: '视频',
emoji: '表情',
location: '位置',
contact_card: '名片',
system: '系统消息',
quote: '引用消息',
voip: '音视频通话',
app_link: '链接',
app_file: '文件',
app_chat_record: '聊天记录',
app_mini_program: '小程序',
app_transfer: '转账',
app_red_packet: '红包',
app_announcement: '群公告',
app_pat: '拍一拍',
app_gift: '微信礼物',
app_music: '音乐分享',
app: '应用消息',
unknown: '未知类型'
},
direction: {
out: '我发送',
in: '我接收'
}
}
}
this.sendJson(res, 200, this.success(requestId, responsePayload))
return
}
if (pathname === '/v1/contacts') {
const q = (url.searchParams.get('q') || '').trim().toLowerCase()
const typeFilter = this.parseTypeFilter(url.searchParams.get('type'))

View File

@@ -2869,6 +2869,38 @@
color: var(--primary);
font-weight: 600;
}
&.value-with-action {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
}
}
.inline-copy-btn {
width: 18px;
height: 18px;
border: none;
background: transparent;
border-radius: 4px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
}

View File

@@ -253,6 +253,10 @@ function ChatPage(_props: ChatPageProps) {
const updateStatusTimerRef = useRef<NodeJS.Timeout | null>(null)
const isUserOperatingRef = useRef<boolean>(false) // 标记用户是否正在操作
const [currentOffset, setCurrentOffset] = useState(0)
const [isDateJumpMode, setIsDateJumpMode] = useState(false)
const [dateJumpCursorSortSeq, setDateJumpCursorSortSeq] = useState<number | null>(null)
const [dateJumpCursorCreateTime, setDateJumpCursorCreateTime] = useState<number | null>(null)
const [dateJumpCursorLocalId, setDateJumpCursorLocalId] = useState<number | null>(null)
// 更新状态管理
const setIsUpdating = useUpdateStatusStore(state => state.setIsUpdating)
@@ -316,6 +320,16 @@ function ChatPage(_props: ChatPageProps) {
const [batchImageDates, setBatchImageDates] = useState<string[]>([])
const [batchImageSelectedDates, setBatchImageSelectedDates] = useState<Set<string>>(new Set())
const copyText = useCallback(async (text: string) => {
try {
await navigator.clipboard.writeText(text || '')
setCopyToast(true)
setTimeout(() => setCopyToast(false), 2000)
} catch (e) {
console.error('复制失败:', e)
}
}, [])
// 检查图片密钥配置XOR 和 AES 都需要配置)
useEffect(() => {
Promise.all([getImageXorKey(), getImageAesKey()]).then(([xorKey, aesKey]) => {
@@ -466,6 +480,10 @@ function ChatPage(_props: ChatPageProps) {
if (offset === 0) {
setLoadingMessages(true)
setMessages([])
setIsDateJumpMode(false)
setDateJumpCursorSortSeq(null)
setDateJumpCursorCreateTime(null)
setDateJumpCursorLocalId(null)
// 标记用户正在操作(首次加载)
isUserOperatingRef.current = true
} else {
@@ -528,6 +546,14 @@ function ChatPage(_props: ChatPageProps) {
const cleanup = window.electronAPI.chat.onNewMessages((data: { sessionId: string; messages: Message[] }) => {
if (data.sessionId === currentSessionId && data.messages && data.messages.length > 0) {
const listEl = messageListRef.current
let shouldAutoScroll = false
if (listEl) {
const { scrollTop, scrollHeight, clientHeight } = listEl
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
shouldAutoScroll = distanceFromBottom < 120
}
setMessages((prev: Message[]) => {
// 使用与后端一致的多维 Key (serverId + localId + createTime + sortSeq) 进行去重
const existingKeys = new Set(
@@ -546,8 +572,10 @@ function ChatPage(_props: ChatPageProps) {
return [...prev, ...newMsgs]
})
// 平滑滚动到底部
requestAnimationFrame(() => scrollToBottom(true))
// 仅当用户已在底部附近时才自动滚动,避免浏览历史时被打断
if (shouldAutoScroll) {
requestAnimationFrame(() => scrollToBottom(true))
}
}
})
@@ -604,6 +632,74 @@ function ChatPage(_props: ChatPageProps) {
}
// 滚动加载更多 + 显示/隐藏回到底部按钮
const loadMoreMessagesInDateJumpMode = useCallback(async () => {
if (!currentSessionId || dateJumpCursorSortSeq === null || isLoadingMore || !hasMoreMessages) return
const listEl = messageListRef.current
const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null
setLoadingMore(true)
try {
const result = await window.electronAPI.chat.getMessagesBefore(
currentSessionId,
dateJumpCursorSortSeq,
50,
dateJumpCursorCreateTime ?? undefined,
dateJumpCursorLocalId ?? undefined
)
if (result.success && result.messages) {
const existingKeys = new Set(
messagesRef.current.map(m => `${m.serverId}-${m.localId}-${m.createTime}-${m.sortSeq}`)
)
const uniqueOlderMessages = result.messages.filter(msg =>
!existingKeys.has(`${msg.serverId}-${msg.localId}-${msg.createTime}-${msg.sortSeq}`)
)
if (uniqueOlderMessages.length === 0) {
setHasMoreMessages(false)
return
}
appendMessages(uniqueOlderMessages, true)
const oldestSortSeq = uniqueOlderMessages[0]?.sortSeq
const oldestCreateTime = uniqueOlderMessages[0]?.createTime
const oldestLocalId = uniqueOlderMessages[0]?.localId
if (typeof oldestSortSeq !== 'number' || oldestSortSeq >= dateJumpCursorSortSeq) {
setHasMoreMessages(false)
} else {
setDateJumpCursorSortSeq(oldestSortSeq)
setDateJumpCursorCreateTime(typeof oldestCreateTime === 'number' ? oldestCreateTime : null)
setDateJumpCursorLocalId(typeof oldestLocalId === 'number' ? oldestLocalId : null)
setHasMoreMessages(result.hasMore ?? false)
}
if (firstMsgEl && listEl) {
requestAnimationFrame(() => {
listEl.scrollTop = firstMsgEl.offsetTop - 80
})
}
} else {
setHasMoreMessages(false)
}
} catch (e) {
console.error('日期跳转模式加载更多失败:', e)
} finally {
setLoadingMore(false)
}
}, [
currentSessionId,
dateJumpCursorSortSeq,
dateJumpCursorCreateTime,
dateJumpCursorLocalId,
isLoadingMore,
hasMoreMessages,
appendMessages,
setHasMoreMessages,
setLoadingMore
])
const handleScroll = useCallback(() => {
if (!messageListRef.current) return
@@ -617,10 +713,14 @@ function ChatPage(_props: ChatPageProps) {
if (!isLoadingMore && hasMoreMessages && currentSessionId) {
const threshold = clientHeight * 0.3
if (scrollTop < threshold) {
loadMessages(currentSessionId, currentOffset)
if (isDateJumpMode) {
loadMoreMessagesInDateJumpMode()
} else {
loadMessages(currentSessionId, currentOffset)
}
}
}
}, [isLoadingMore, hasMoreMessages, currentSessionId, currentOffset])
}, [isLoadingMore, hasMoreMessages, currentSessionId, currentOffset, isDateJumpMode, loadMoreMessagesInDateJumpMode])
// 滚动到底部
const scrollToBottom = useCallback((smooth: boolean | React.MouseEvent = true) => {
@@ -657,8 +757,12 @@ function ChatPage(_props: ChatPageProps) {
if (result.success && result.messages && result.messages.length > 0) {
// 清空当前消息并加载新消息
setMessages(result.messages)
setHasMoreMessages(true) // 假设还有更多历史消息
setHasMoreMessages(true)
setCurrentOffset(result.messages.length)
setIsDateJumpMode(true)
setDateJumpCursorSortSeq(result.messages[0]?.sortSeq ?? null)
setDateJumpCursorCreateTime(result.messages[0]?.createTime ?? null)
setDateJumpCursorLocalId(result.messages[0]?.localId ?? null)
// 滚动到顶部显示目标日期的消息
requestAnimationFrame(() => {
@@ -1752,7 +1856,17 @@ function ChatPage(_props: ChatPageProps) {
<div className="detail-item">
<Hash size={14} />
<span className="label">ID</span>
<span className="value">{sessionDetail.wxid}</span>
<span className="value value-with-action">
<span>{sessionDetail.wxid}</span>
<button
type="button"
className="inline-copy-btn"
title="复制微信ID"
onClick={() => copyText(sessionDetail.wxid)}
>
<Copy size={12} />
</button>
</span>
</div>
{sessionDetail.remark && (
<div className="detail-item">

View File

@@ -202,6 +202,29 @@
background: var(--bg-tertiary);
}
.db-checkbox {
width: 18px;
height: 18px;
cursor: pointer;
flex-shrink: 0;
border-radius: 6px;
border: 2px solid var(--border-color);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&.checked {
background: var(--primary);
border-color: var(--primary);
color: white;
}
&:hover {
border-color: var(--primary);
}
}
.status-icon {
width: 28px;
height: 28px;

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import { Database, Check, Circle, Unlock, RefreshCw, RefreshCcw, Image as ImageIcon, Smile, Download, Trash2 } from 'lucide-react'
import { Database, Check, Circle, Unlock, RefreshCw, RefreshCcw, Image as ImageIcon, Smile, Download, Trash2, Minus, X } from 'lucide-react'
import './DataManagementPage.scss'
interface DatabaseFile {
@@ -38,6 +38,8 @@ function DataManagementPage() {
const [emojiCount, setEmojiCount] = useState({ total: 0, decrypted: 0 })
const [isLoading, setIsLoading] = useState(false)
const [isDecrypting, setIsDecrypting] = useState(false)
const [decryptingDbPath, setDecryptingDbPath] = useState<string | null>(null)
const [selectedDbs, setSelectedDbs] = useState<Set<string>>(new Set())
const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null)
const [progress, setProgress] = useState<any>(null)
const [deleteConfirm, setDeleteConfirm] = useState<DeleteConfirmData>({ image: null as any, show: false })
@@ -324,6 +326,90 @@ function DataManagementPage() {
}
}
const handleDecryptSingle = async (db: DatabaseFile) => {
const isChatOpen = await window.electronAPI.window.isChatWindowOpen()
if (isChatOpen) {
showMessage('请先关闭聊天窗口再进行解密操作', false)
return
}
setDecryptingDbPath(db.filePath)
try {
const result = await window.electronAPI.dataManagement.decryptSingleDatabase(db.filePath)
if (result.success) {
showMessage(`${db.fileName} 解密成功`, true)
await loadDatabases()
} else {
showMessage(result.error || '解密失败', false)
}
} catch (e) {
showMessage(`解密失败: ${e}`, false)
} finally {
setDecryptingDbPath(null)
}
}
const handleToggleSelect = (filePath: string) => {
setSelectedDbs(prev => {
const newSet = new Set(prev)
if (newSet.has(filePath)) {
newSet.delete(filePath)
} else {
newSet.add(filePath)
}
return newSet
})
}
const handleSelectAll = () => {
setSelectedDbs(new Set(databases.map(db => db.filePath)))
}
const handleDeselectAll = () => {
setSelectedDbs(new Set())
}
const handleBatchDecrypt = async () => {
if (selectedDbs.size === 0) {
showMessage('请先选择要解密的数据库', false)
return
}
const isChatOpen = await window.electronAPI.window.isChatWindowOpen()
if (isChatOpen) {
showMessage('请先关闭聊天窗口再进行解密操作', false)
return
}
setIsDecrypting(true)
let successCount = 0
let failCount = 0
try {
for (const filePath of selectedDbs) {
const db = databases.find(d => d.filePath === filePath)
if (!db) continue
setDecryptingDbPath(filePath)
const result = await window.electronAPI.dataManagement.decryptSingleDatabase(filePath)
if (result.success) {
successCount++
} else {
failCount++
}
}
showMessage(`批量解密完成!成功: ${successCount}, 失败: ${failCount}`, failCount === 0)
setSelectedDbs(new Set())
await loadDatabases()
} catch (e) {
showMessage(`批量解密失败: ${e}`, false)
} finally {
setIsDecrypting(false)
setDecryptingDbPath(null)
}
}
const [isDeletingThumbs, setIsDeletingThumbs] = useState(false)
const [thumbDeleteConfirm, setThumbDeleteConfirm] = useState<{ show: boolean; count: number }>({ show: false, count: 0 })
@@ -632,6 +718,27 @@ function DataManagementPage() {
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
</button>
{selectedDbs.size > 0 ? (
<>
<button className="btn btn-secondary" onClick={handleDeselectAll}>
<Minus size={16} />
</button>
<button
className="btn btn-warning"
onClick={handleBatchDecrypt}
disabled={isDecrypting}
>
<RefreshCw size={16} />
({selectedDbs.size})
</button>
</>
) : (
<button className="btn btn-secondary" onClick={handleSelectAll} disabled={databases.length === 0}>
<Check size={16} />
</button>
)}
{needsUpdateCount > 0 && (
<button
className="btn btn-warning"
@@ -656,6 +763,12 @@ function DataManagementPage() {
<div className="database-list">
{databases.map((db, index) => (
<div key={index} className={`database-item ${db.isDecrypted ? (db.needsUpdate ? 'needs-update' : 'decrypted') : 'pending'}`}>
<div
className={`db-checkbox ${selectedDbs.has(db.filePath) ? 'checked' : ''}`}
onClick={() => handleToggleSelect(db.filePath)}
>
{selectedDbs.has(db.filePath) && <Check size={14} />}
</div>
<div className={`status-icon ${db.isDecrypted ? (db.needsUpdate ? 'needs-update' : 'decrypted') : 'pending'}`}>
{db.isDecrypted ? <Check size={16} /> : <Circle size={16} />}
</div>
@@ -670,6 +783,15 @@ function DataManagementPage() {
<div className={`db-status ${db.isDecrypted ? (db.needsUpdate ? 'needs-update' : 'decrypted') : 'pending'}`}>
{db.isDecrypted ? (db.needsUpdate ? '需更新' : '已解密') : '待解密'}
</div>
<button
className="btn btn-sm btn-secondary"
onClick={() => handleDecryptSingle(db)}
disabled={decryptingDbPath === db.filePath}
title="重新解密此数据库"
>
<RefreshCw size={14} className={decryptingDbPath === db.filePath ? 'spin' : ''} />
{decryptingDbPath === db.filePath ? '解密中' : '重新解密'}
</button>
</div>
))}

View File

@@ -171,6 +171,10 @@ export interface ElectronAPI {
failCount?: number
error?: string
}>
decryptSingleDatabase: (filePath: string) => Promise<{
success: boolean
error?: string
}>
incrementalUpdate: () => Promise<{
success: boolean
successCount?: number
@@ -295,6 +299,18 @@ export interface ElectronAPI {
hasMore?: boolean;
error?: string
}>
getMessagesBefore: (
sessionId: string,
cursorSortSeq: number,
limit?: number,
cursorCreateTime?: number,
cursorLocalId?: number
) => Promise<{
success: boolean;
messages?: Message[];
hasMore?: boolean;
error?: string
}>
getAllVoiceMessages: (sessionId: string) => Promise<{
success: boolean;
messages?: Message[];