mirror of
https://mirror.skon.top/github.com/ILoveBingLu/CipherTalk
synced 2026-04-21 13:50:19 +08:00
feat: 更新版本号至 2.2.2,新增文件操作功能
- 在 IPC 中新增文件删除和复制功能,支持文件管理 - 更新 README.md,反映版本号变更 - 优化缓存清理逻辑,确保数据库连接安全关闭 - 改进 HTML 导出生成器,支持更现代化的样式和功能 - 增强数据管理页面的用户体验,添加下载进度提示
This commit is contained in:
@@ -7,11 +7,11 @@
|
||||
**一款现代化的微信聊天记录查看与分析工具**
|
||||
|
||||
[](LICENSE)
|
||||
[](package.json)
|
||||
[](package.json)
|
||||
[]()
|
||||
[]()
|
||||
[]()
|
||||
[](https://t.me/weflow_cc)
|
||||
[](https://t.me/CipherTalk)
|
||||
|
||||
[功能特性](#-功能特性) • [快速开始](#-快速开始) • [技术栈](#️-技术栈) • [贡献指南](#-贡献指南) • [许可证](#-许可证)
|
||||
|
||||
|
||||
@@ -1026,6 +1026,34 @@ function registerIpcHandlers() {
|
||||
return dialog.showSaveDialog(options)
|
||||
})
|
||||
|
||||
// 文件操作
|
||||
ipcMain.handle('file:delete', async (_, filePath: string) => {
|
||||
try {
|
||||
const fs = await import('fs')
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath)
|
||||
return { success: true }
|
||||
} else {
|
||||
return { success: false, error: '文件不存在' }
|
||||
}
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('file:copy', async (_, sourcePath: string, destPath: string) => {
|
||||
try {
|
||||
const fs = await import('fs')
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
return { success: false, error: '源文件不存在' }
|
||||
}
|
||||
fs.copyFileSync(sourcePath, destPath)
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('shell:openPath', async (_, path: string) => {
|
||||
const { shell } = await import('electron')
|
||||
return shell.openPath(path)
|
||||
|
||||
@@ -30,6 +30,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
saveFile: (options: any) => ipcRenderer.invoke('dialog:saveFile', options)
|
||||
},
|
||||
|
||||
// 文件操作
|
||||
file: {
|
||||
delete: (filePath: string) => ipcRenderer.invoke('file:delete', filePath),
|
||||
copy: (sourcePath: string, destPath: string) => ipcRenderer.invoke('file:copy', sourcePath, destPath)
|
||||
},
|
||||
|
||||
// Shell
|
||||
shell: {
|
||||
openPath: (path: string) => ipcRenderer.invoke('shell:openPath', path),
|
||||
|
||||
@@ -111,38 +111,81 @@ export class CacheService {
|
||||
*/
|
||||
async clearDatabases(): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const wxid = this.configService.get('myWxid')
|
||||
if (!wxid) {
|
||||
console.warn('[CacheService] 未配置 wxid,无法清理数据库缓存')
|
||||
return { success: false, error: '未配置 wxid' }
|
||||
}
|
||||
|
||||
// 先断开所有数据库连接
|
||||
console.log('[CacheService] 断开数据库连接...')
|
||||
try {
|
||||
const { chatService } = await import('./chatService')
|
||||
chatService.close()
|
||||
console.log('[CacheService] 已关闭 chatService')
|
||||
} catch (e) {
|
||||
console.warn('关闭 chatService 失败:', e)
|
||||
}
|
||||
|
||||
// 关闭语音转文字缓存数据库
|
||||
try {
|
||||
const { voiceTranscribeService } = await import('./voiceTranscribeService')
|
||||
if (voiceTranscribeService && (voiceTranscribeService as any).cacheDb) {
|
||||
try {
|
||||
;(voiceTranscribeService as any).cacheDb.close()
|
||||
;(voiceTranscribeService as any).cacheDb = null
|
||||
console.log('[CacheService] 已关闭语音转文字缓存数据库')
|
||||
} catch (e) {
|
||||
console.warn('关闭语音转文字缓存数据库失败:', e)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('导入 voiceTranscribeService 失败:', e)
|
||||
}
|
||||
|
||||
// 等待文件句柄释放(增加等待时间)
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
const cachePath = this.getEffectiveCachePath()
|
||||
console.log('[CacheService] 缓存路径:', cachePath)
|
||||
|
||||
if (!existsSync(cachePath)) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
const wxid = this.configService.get('myWxid')
|
||||
if (wxid) {
|
||||
const possibleFolderNames = [
|
||||
wxid,
|
||||
(wxid as string).replace('wxid_', ''),
|
||||
(wxid as string).split('_').slice(0, 2).join('_'),
|
||||
]
|
||||
for (const folderName of possibleFolderNames) {
|
||||
const wxidFolderPath = join(cachePath, folderName)
|
||||
if (existsSync(wxidFolderPath)) {
|
||||
this.clearDbFilesInFolder(wxidFolderPath)
|
||||
// 查找并删除 wxid 文件夹(包含所有解密后的数据库)
|
||||
const possibleFolderNames = [
|
||||
wxid,
|
||||
(wxid as string).replace('wxid_', ''),
|
||||
(wxid as string).split('_').slice(0, 2).join('_'),
|
||||
]
|
||||
|
||||
let deleted = false
|
||||
for (const folderName of possibleFolderNames) {
|
||||
const wxidFolderPath = join(cachePath, folderName)
|
||||
if (existsSync(wxidFolderPath)) {
|
||||
console.log('[CacheService] 找到 wxid 文件夹,准备删除:', wxidFolderPath)
|
||||
try {
|
||||
rmSync(wxidFolderPath, { recursive: true, force: true })
|
||||
console.log('[CacheService] 成功删除 wxid 文件夹')
|
||||
deleted = true
|
||||
break
|
||||
} catch (e: any) {
|
||||
console.error('[CacheService] 删除 wxid 文件夹失败:', e)
|
||||
return { success: false, error: `删除失败: ${e.message}` }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const files = readdirSync(cachePath)
|
||||
for (const file of files) {
|
||||
const filePath = join(cachePath, file)
|
||||
const stat = statSync(filePath)
|
||||
if (stat.isFile() && file.endsWith('.db')) {
|
||||
rmSync(filePath, { force: true })
|
||||
}
|
||||
if (!deleted) {
|
||||
console.warn('[CacheService] 未找到 wxid 文件夹')
|
||||
return { success: false, error: '未找到数据库缓存文件夹' }
|
||||
}
|
||||
|
||||
console.log('[CacheService] 数据库缓存清理完成')
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
console.error('[CacheService] 清理数据库缓存失败:', e)
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
@@ -164,6 +207,24 @@ export class CacheService {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// 先关闭可能占用数据库文件的服务
|
||||
try {
|
||||
const { voiceTranscribeService } = await import('./voiceTranscribeService')
|
||||
if (voiceTranscribeService && (voiceTranscribeService as any).cacheDb) {
|
||||
try {
|
||||
;(voiceTranscribeService as any).cacheDb.close()
|
||||
;(voiceTranscribeService as any).cacheDb = null
|
||||
} catch (e) {
|
||||
console.warn('关闭语音转文字缓存数据库失败:', e)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('导入 voiceTranscribeService 失败:', e)
|
||||
}
|
||||
|
||||
// 等待一下确保文件句柄释放
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
// 清除指定的缓存目录
|
||||
const dirsToRemove = ['images', 'Images', 'Emojis', 'logs']
|
||||
|
||||
@@ -202,11 +263,20 @@ export class CacheService {
|
||||
// 递归清除子目录中的.db文件
|
||||
this.clearDbFilesInFolder(filePath)
|
||||
} else if (stat.isFile() && file.endsWith('.db')) {
|
||||
rmSync(filePath, { force: true })
|
||||
try {
|
||||
rmSync(filePath, { force: true })
|
||||
} catch (e: any) {
|
||||
// 如果文件被占用,跳过并记录警告
|
||||
if (e.code === 'EBUSY' || e.code === 'EPERM') {
|
||||
console.warn(`跳过被占用的数据库文件: ${file}`)
|
||||
} else {
|
||||
console.error(`删除数据库文件失败: ${file}`, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略权限错误等
|
||||
console.error('清除文件夹中的数据库文件失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,9 @@ export interface Message {
|
||||
fileExt?: string // 文件扩展名
|
||||
fileMd5?: string // 文件 MD5
|
||||
chatRecordList?: ChatRecordItem[] // 聊天记录列表 (Type 19)
|
||||
// 转账消息相关
|
||||
transferPayerUsername?: string // 转账付款方 wxid
|
||||
transferReceiverUsername?: string // 转账收款方 wxid
|
||||
}
|
||||
|
||||
export interface ChatRecordItem {
|
||||
@@ -354,6 +357,8 @@ class ChatService extends EventEmitter {
|
||||
}
|
||||
this.sessionDb = null
|
||||
this.contactDb = null
|
||||
this.emoticonDb = null
|
||||
this.emotionDb = null
|
||||
this.headImageDb = null
|
||||
this.messageDbCache.clear()
|
||||
this.knownMessageDbFiles.clear()
|
||||
|
||||
@@ -1031,16 +1031,28 @@ class DataManagementService {
|
||||
return { success: false, error: '缓存目录不存在,请先解密数据库' }
|
||||
}
|
||||
|
||||
// 图片统一存放在 images 目录下
|
||||
const directories: { wxid: string; path: string }[] = []
|
||||
|
||||
// 图片目录
|
||||
const imagesDir = path.join(cachePath, 'images')
|
||||
if (!fs.existsSync(imagesDir)) {
|
||||
if (fs.existsSync(imagesDir)) {
|
||||
directories.push({ wxid, path: imagesDir })
|
||||
}
|
||||
|
||||
// 表情包目录
|
||||
const emojisDir = path.join(cachePath, 'Emojis')
|
||||
if (fs.existsSync(emojisDir)) {
|
||||
directories.push({ wxid, path: emojisDir })
|
||||
}
|
||||
|
||||
if (directories.length === 0) {
|
||||
return { success: false, error: '图片目录不存在,请先解密数据库' }
|
||||
}
|
||||
|
||||
// 返回图片目录(所有账号共享)
|
||||
// 返回所有目录
|
||||
return {
|
||||
success: true,
|
||||
directories: [{ wxid, path: imagesDir }]
|
||||
directories
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取图片目录失败:', e)
|
||||
|
||||
@@ -6,6 +6,8 @@ import { ConfigService } from './config'
|
||||
import { voiceTranscribeService } from './voiceTranscribeService'
|
||||
import * as XLSX from 'xlsx'
|
||||
import { HtmlExportGenerator } from './htmlExportGenerator'
|
||||
import { imageDecryptService } from './imageDecryptService'
|
||||
import { videoService } from './videoService'
|
||||
|
||||
// ChatLab 0.0.2 格式类型定义
|
||||
interface ChatLabHeader {
|
||||
@@ -84,6 +86,11 @@ export interface ExportOptions {
|
||||
dateRange?: { start: number; end: number } | null
|
||||
exportMedia?: boolean
|
||||
exportAvatars?: boolean
|
||||
exportImages?: boolean
|
||||
exportVideos?: boolean
|
||||
exportEmojis?: boolean
|
||||
exportVoices?: boolean
|
||||
mediaPathMap?: Map<number, string>
|
||||
}
|
||||
|
||||
export interface ContactExportOptions {
|
||||
@@ -427,7 +434,7 @@ class ExportService {
|
||||
// 检查 XML 中的 type 标签(支持大 localType 的情况)
|
||||
const xmlTypeMatch = /<type>(\d+)<\/type>/i.exec(content)
|
||||
const xmlType = xmlTypeMatch ? parseInt(xmlTypeMatch[1]) : null
|
||||
|
||||
|
||||
// 特殊处理 type 49 或 XML type
|
||||
if (localType === 49 || xmlType) {
|
||||
const subType = xmlType || 0
|
||||
@@ -440,7 +447,7 @@ class ExportService {
|
||||
case 2000: return 99 // 转账 -> OTHER (ChatLab 没有转账类型)
|
||||
case 5:
|
||||
case 49: return 7 // 链接 -> LINK
|
||||
default:
|
||||
default:
|
||||
if (xmlType) return 7 // 有 XML type 但未知,默认为链接
|
||||
}
|
||||
}
|
||||
@@ -519,7 +526,7 @@ class ExportService {
|
||||
/**
|
||||
* 解析消息内容为可读文本
|
||||
*/
|
||||
private parseMessageContent(content: string, localType: number, sessionId?: string, createTime?: number): string | null {
|
||||
private parseMessageContent(content: string, localType: number, sessionId?: string, createTime?: number, mediaPathMap?: Map<number, string>): string | null {
|
||||
if (!content) return null
|
||||
|
||||
// 检查 XML 中的 type 标签(支持大 localType 的情况)
|
||||
@@ -529,25 +536,42 @@ class ExportService {
|
||||
switch (localType) {
|
||||
case 1: // 文本
|
||||
return this.stripSenderPrefix(content)
|
||||
case 3: return '[图片]'
|
||||
case 3: {
|
||||
// 图片消息:如果有媒体映射表,返回相对路径
|
||||
if (mediaPathMap && createTime && mediaPathMap.has(createTime)) {
|
||||
return `[图片] ${mediaPathMap.get(createTime)}`
|
||||
}
|
||||
return '[图片]'
|
||||
}
|
||||
case 34: {
|
||||
// 语音消息 - 尝试获取转写文字
|
||||
if (sessionId && createTime) {
|
||||
const transcript = voiceTranscribeService.getCachedTranscript(sessionId, createTime)
|
||||
if (transcript) {
|
||||
return `[语音消息] ${transcript}`
|
||||
}
|
||||
// 语音消息
|
||||
const transcript = (sessionId && createTime) ? voiceTranscribeService.getCachedTranscript(sessionId, createTime) : null
|
||||
if (mediaPathMap && createTime && mediaPathMap.has(createTime)) {
|
||||
return `[语音消息] ${mediaPathMap.get(createTime)}${transcript ? ' ' + transcript : ''}`
|
||||
}
|
||||
if (transcript) {
|
||||
return `[语音消息] ${transcript}`
|
||||
}
|
||||
return '[语音消息]'
|
||||
}
|
||||
case 42: return '[名片]'
|
||||
case 43: return '[视频]'
|
||||
case 47: return '[动画表情]'
|
||||
case 43: {
|
||||
if (mediaPathMap && createTime && mediaPathMap.has(createTime)) {
|
||||
return `[视频] ${mediaPathMap.get(createTime)}`
|
||||
}
|
||||
return '[视频]'
|
||||
}
|
||||
case 47: {
|
||||
if (mediaPathMap && createTime && mediaPathMap.has(createTime)) {
|
||||
return `[动画表情] ${mediaPathMap.get(createTime)}`
|
||||
}
|
||||
return '[动画表情]'
|
||||
}
|
||||
case 48: return '[位置]'
|
||||
case 49: {
|
||||
const title = this.extractXmlValue(content, 'title')
|
||||
const type = this.extractXmlValue(content, 'type')
|
||||
|
||||
|
||||
// 群公告消息(type 87)
|
||||
if (type === '87') {
|
||||
const textAnnouncement = this.extractXmlValue(content, 'textannouncement')
|
||||
@@ -556,7 +580,7 @@ class ExportService {
|
||||
}
|
||||
return '[群公告]'
|
||||
}
|
||||
|
||||
|
||||
// 转账消息特殊处理
|
||||
if (type === '2000') {
|
||||
const feedesc = this.extractXmlValue(content, 'feedesc')
|
||||
@@ -566,7 +590,7 @@ class ExportService {
|
||||
}
|
||||
return '[转账]'
|
||||
}
|
||||
|
||||
|
||||
if (type === '6') return title ? `[文件] ${title}` : '[文件]'
|
||||
if (type === '19') return title ? `[聊天记录] ${title}` : '[聊天记录]'
|
||||
if (type === '33' || type === '36') return title ? `[小程序] ${title}` : '[小程序]'
|
||||
@@ -585,7 +609,7 @@ class ExportService {
|
||||
// 对于未知的 localType,检查 XML type 来判断消息类型
|
||||
if (xmlType) {
|
||||
const title = this.extractXmlValue(content, 'title')
|
||||
|
||||
|
||||
// 群公告消息(type 87)
|
||||
if (xmlType === '87') {
|
||||
const textAnnouncement = this.extractXmlValue(content, 'textannouncement')
|
||||
@@ -594,7 +618,7 @@ class ExportService {
|
||||
}
|
||||
return '[群公告]'
|
||||
}
|
||||
|
||||
|
||||
// 转账消息
|
||||
if (xmlType === '2000') {
|
||||
const feedesc = this.extractXmlValue(content, 'feedesc')
|
||||
@@ -604,18 +628,18 @@ class ExportService {
|
||||
}
|
||||
return '[转账]'
|
||||
}
|
||||
|
||||
|
||||
// 其他类型
|
||||
if (xmlType === '6') return title ? `[文件] ${title}` : '[文件]'
|
||||
if (xmlType === '19') return title ? `[聊天记录] ${title}` : '[聊天记录]'
|
||||
if (xmlType === '33' || xmlType === '36') return title ? `[小程序] ${title}` : '[小程序]'
|
||||
if (xmlType === '57') return title || '[引用消息]'
|
||||
if (xmlType === '5' || xmlType === '49') return title ? `[链接] ${title}` : '[链接]'
|
||||
|
||||
|
||||
// 有 title 就返回 title
|
||||
if (title) return title
|
||||
}
|
||||
|
||||
|
||||
// 最后尝试提取文本内容
|
||||
return this.stripSenderPrefix(content) || null
|
||||
}
|
||||
@@ -633,17 +657,17 @@ class ExportService {
|
||||
*/
|
||||
private extractRevokerInfo(content: string): { isRevoke: boolean; isSelfRevoke?: boolean; revokerWxid?: string } {
|
||||
if (!content) return { isRevoke: false }
|
||||
|
||||
|
||||
// 检查是否是撤回消息
|
||||
if (!content.includes('revokemsg') && !content.includes('撤回')) {
|
||||
return { isRevoke: false }
|
||||
}
|
||||
|
||||
|
||||
// 检查是否是 "你撤回了" - 自己撤回
|
||||
if (content.includes('你撤回')) {
|
||||
return { isRevoke: true, isSelfRevoke: true }
|
||||
}
|
||||
|
||||
|
||||
// 尝试从 <session> 标签提取(格式: wxid_xxx)
|
||||
const sessionMatch = /<session>([^<]+)<\/session>/i.exec(content)
|
||||
if (sessionMatch) {
|
||||
@@ -653,13 +677,13 @@ class ExportService {
|
||||
return { isRevoke: true, revokerWxid: session }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 尝试从 <fromusername> 提取
|
||||
const fromUserMatch = /<fromusername>([^<]+)<\/fromusername>/i.exec(content)
|
||||
if (fromUserMatch) {
|
||||
return { isRevoke: true, revokerWxid: fromUserMatch[1].trim() }
|
||||
}
|
||||
|
||||
|
||||
// 是撤回消息但无法提取撤回者
|
||||
return { isRevoke: true }
|
||||
}
|
||||
@@ -762,7 +786,7 @@ class ExportService {
|
||||
|
||||
// 判断是否是自己发送
|
||||
const isSend = row.is_send === 1 || senderUsername === cleanedMyWxid
|
||||
|
||||
|
||||
// 确定实际发送者
|
||||
let actualSender: string
|
||||
if (localType === 10000 || localType === 266287972401) {
|
||||
@@ -868,10 +892,10 @@ class ExportService {
|
||||
|
||||
// 构建 ChatLab 格式消息
|
||||
const chatLabMessages: ChatLabMessage[] = []
|
||||
|
||||
|
||||
for (const msg of allMessages) {
|
||||
const memberInfo = memberSet.get(msg.senderUsername) || { platformId: msg.senderUsername, accountName: msg.senderUsername }
|
||||
let parsedContent = this.parseMessageContent(msg.content, msg.localType, sessionId, msg.createTime)
|
||||
let parsedContent = this.parseMessageContent(msg.content, msg.localType, sessionId, msg.createTime, options.mediaPathMap)
|
||||
|
||||
// 转账消息:追加 "谁转账给谁" 信息
|
||||
if (parsedContent && parsedContent.startsWith('[转账]') && msg.content) {
|
||||
@@ -896,16 +920,16 @@ class ExportService {
|
||||
type: this.convertMessageType(msg.localType, msg.content),
|
||||
content: parsedContent
|
||||
}
|
||||
|
||||
|
||||
// 添加可选字段
|
||||
if (msg.groupNickname) message.groupNickname = msg.groupNickname
|
||||
if (msg.platformMessageId) message.platformMessageId = msg.platformMessageId
|
||||
if (msg.replyToMessageId) message.replyToMessageId = msg.replyToMessageId
|
||||
|
||||
|
||||
// 如果有聊天记录,添加为嵌套字段
|
||||
if (msg.chatRecordList && msg.chatRecordList.length > 0) {
|
||||
const chatRecords: ChatRecordItem[] = []
|
||||
|
||||
|
||||
for (const record of msg.chatRecordList) {
|
||||
// 解析时间戳 (格式: "YYYY-MM-DD HH:MM:SS")
|
||||
let recordTimestamp = msg.createTime
|
||||
@@ -931,7 +955,7 @@ class ExportService {
|
||||
// 转换消息类型
|
||||
let recordType = 0 // TEXT
|
||||
let recordContent = record.datadesc || record.datatitle || ''
|
||||
|
||||
|
||||
switch (record.datatype) {
|
||||
case 1:
|
||||
recordType = 0 // TEXT
|
||||
@@ -969,14 +993,14 @@ class ExportService {
|
||||
type: recordType,
|
||||
content: recordContent
|
||||
}
|
||||
|
||||
|
||||
// 添加头像(如果启用导出头像)
|
||||
if (options.exportAvatars && record.sourceheadurl) {
|
||||
chatRecord.avatar = record.sourceheadurl
|
||||
}
|
||||
|
||||
|
||||
chatRecords.push(chatRecord)
|
||||
|
||||
|
||||
// 添加成员信息
|
||||
if (record.sourcename && !memberSet.has(record.sourcename)) {
|
||||
memberSet.set(record.sourcename, {
|
||||
@@ -986,10 +1010,10 @@ class ExportService {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
message.chatRecords = chatRecords
|
||||
}
|
||||
|
||||
|
||||
chatLabMessages.push(message)
|
||||
}
|
||||
|
||||
@@ -1173,7 +1197,7 @@ class ExportService {
|
||||
// 转换消息类型名称
|
||||
let typeName = '文本消息'
|
||||
let content = record.datadesc || record.datatitle || ''
|
||||
|
||||
|
||||
switch (record.datatype) {
|
||||
case 1:
|
||||
typeName = '文本消息'
|
||||
@@ -1376,7 +1400,7 @@ class ExportService {
|
||||
if (content) {
|
||||
const xmlTypeMatch = /<type>(\d+)<\/type>/i.exec(content)
|
||||
const xmlType = xmlTypeMatch ? xmlTypeMatch[1] : null
|
||||
|
||||
|
||||
if (xmlType) {
|
||||
switch (xmlType) {
|
||||
case '87': return '群公告'
|
||||
@@ -1552,7 +1576,7 @@ class ExportService {
|
||||
type: this.getMessageTypeName(localType, content),
|
||||
localType,
|
||||
chatLabType: this.convertMessageType(localType, content),
|
||||
content: this.parseMessageContent(content, localType, sessionId, createTime),
|
||||
content: this.parseMessageContent(content, localType, sessionId, createTime, options.mediaPathMap),
|
||||
rawContent: content, // 保留原始内容(用于转账描述解析)
|
||||
isSend: isSend ? 1 : 0,
|
||||
senderUsername: actualSender,
|
||||
@@ -1772,7 +1796,7 @@ class ExportService {
|
||||
const time = new Date(msg.createTime * 1000)
|
||||
|
||||
// 获取消息内容(使用统一的解析方法)
|
||||
let messageContent = this.parseMessageContent(msg.content, msg.type, sessionId, msg.createTime)
|
||||
let messageContent = this.parseMessageContent(msg.content, msg.type, sessionId, msg.createTime, options.mediaPathMap)
|
||||
|
||||
// 转账消息:追加 "谁转账给谁" 信息
|
||||
if (messageContent && messageContent.startsWith('[转账]') && msg.content) {
|
||||
@@ -1975,7 +1999,7 @@ class ExportService {
|
||||
sender: actualSender,
|
||||
senderName: senderInfo.displayName,
|
||||
type: localType,
|
||||
content: this.parseMessageContent(content, localType, sessionId, createTime),
|
||||
content: this.parseMessageContent(content, localType, sessionId, createTime, options.mediaPathMap),
|
||||
rawContent: content,
|
||||
isSend,
|
||||
chatRecords: chatRecordList ? this.formatChatRecordsForJson(chatRecordList, options) : undefined
|
||||
@@ -2032,28 +2056,15 @@ class ExportService {
|
||||
messages: allMessages
|
||||
}
|
||||
|
||||
// 创建导出目录
|
||||
const exportDir = path.dirname(outputPath)
|
||||
const baseName = path.basename(outputPath, '.html')
|
||||
const exportFolder = path.join(exportDir, baseName)
|
||||
|
||||
// 如果目录不存在则创建
|
||||
if (!fs.existsSync(exportFolder)) {
|
||||
fs.mkdirSync(exportFolder, { recursive: true })
|
||||
// 直接写入单文件 HTML(CSS/JS/数据全部内联)
|
||||
const outputDir = path.dirname(outputPath)
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true })
|
||||
}
|
||||
|
||||
// 生成并写入各个文件
|
||||
const htmlPath = path.join(exportFolder, 'index.html')
|
||||
const cssPath = path.join(exportFolder, 'styles.css')
|
||||
const jsPath = path.join(exportFolder, 'app.js')
|
||||
const dataPath = path.join(exportFolder, 'data.js')
|
||||
fs.writeFileSync(outputPath, HtmlExportGenerator.generateHtmlWithData(exportData), 'utf-8')
|
||||
|
||||
fs.writeFileSync(htmlPath, HtmlExportGenerator.generateHtmlWithData(exportData), 'utf-8')
|
||||
fs.writeFileSync(cssPath, HtmlExportGenerator.generateCss(), 'utf-8')
|
||||
fs.writeFileSync(jsPath, HtmlExportGenerator.generateJs(), 'utf-8')
|
||||
fs.writeFileSync(dataPath, HtmlExportGenerator.generateDataJs(exportData), 'utf-8')
|
||||
|
||||
return { success: true, outputPath: htmlPath }
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
console.error('ExportService: HTML 导出失败:', e)
|
||||
return { success: false, error: String(e) }
|
||||
@@ -2137,24 +2148,53 @@ class ExportService {
|
||||
})
|
||||
|
||||
// 生成文件名(清理非法字符)
|
||||
const safeName = sessionInfo.displayName.replace(/[<>:"/\\|?*]/g, '_')
|
||||
const safeName = sessionInfo.displayName.replace(/[<>:"\/\\|?*]/g, '_')
|
||||
let ext = '.json'
|
||||
if (options.format === 'chatlab-jsonl') ext = '.jsonl'
|
||||
else if (options.format === 'excel') ext = '.xlsx'
|
||||
else if (options.format === 'html') ext = '.html'
|
||||
const outputPath = path.join(outputDir, `${safeName}${ext}`)
|
||||
|
||||
// 当导出媒体时,创建会话子文件夹,把文件和媒体都放进去
|
||||
const hasMedia = options.exportImages || options.exportVideos || options.exportEmojis || options.exportVoices
|
||||
const sessionOutputDir = hasMedia ? path.join(outputDir, safeName) : outputDir
|
||||
if (hasMedia && !fs.existsSync(sessionOutputDir)) {
|
||||
fs.mkdirSync(sessionOutputDir, { recursive: true })
|
||||
}
|
||||
|
||||
const outputPath = path.join(sessionOutputDir, `${safeName}${ext}`)
|
||||
|
||||
// 先导出媒体文件,收集路径映射表
|
||||
let mediaPathMap: Map<number, string> | undefined
|
||||
if (hasMedia) {
|
||||
try {
|
||||
mediaPathMap = await this.exportMediaFiles(sessionId, safeName, sessionOutputDir, options, (detail) => {
|
||||
onProgress?.({
|
||||
current: i + 1,
|
||||
total: sessionIds.length,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'writing',
|
||||
detail
|
||||
})
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(`导出 ${sessionId} 媒体文件失败:`, e)
|
||||
}
|
||||
}
|
||||
|
||||
// 将媒体路径映射表附加到 options 上
|
||||
const exportOpts = mediaPathMap ? { ...options, mediaPathMap } : options
|
||||
|
||||
let result: { success: boolean; error?: string }
|
||||
|
||||
// 根据格式选择导出方法
|
||||
if (options.format === 'json') {
|
||||
result = await this.exportSessionToDetailedJson(sessionId, outputPath, options)
|
||||
result = await this.exportSessionToDetailedJson(sessionId, outputPath, exportOpts)
|
||||
} else if (options.format === 'chatlab' || options.format === 'chatlab-jsonl') {
|
||||
result = await this.exportSessionToChatLab(sessionId, outputPath, options)
|
||||
result = await this.exportSessionToChatLab(sessionId, outputPath, exportOpts)
|
||||
} else if (options.format === 'excel') {
|
||||
result = await this.exportSessionToExcel(sessionId, outputPath, options)
|
||||
result = await this.exportSessionToExcel(sessionId, outputPath, exportOpts)
|
||||
} else if (options.format === 'html') {
|
||||
result = await this.exportSessionToHtml(sessionId, outputPath, options)
|
||||
result = await this.exportSessionToHtml(sessionId, outputPath, exportOpts)
|
||||
} else {
|
||||
result = { success: false, error: `不支持的格式: ${options.format}` }
|
||||
}
|
||||
@@ -2185,8 +2225,561 @@ class ExportService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出通讯录
|
||||
* 导出会话的媒体文件(图片和视频)
|
||||
*/
|
||||
private async exportMediaFiles(
|
||||
sessionId: string,
|
||||
safeName: string,
|
||||
outputDir: string,
|
||||
options: ExportOptions,
|
||||
onDetail?: (detail: string) => void
|
||||
): Promise<Map<number, string>> {
|
||||
// 返回 createTime → 相对路径 的映射表
|
||||
const mediaPathMap = new Map<number, string>()
|
||||
|
||||
const dbTablePairs = this.findSessionTables(sessionId)
|
||||
if (dbTablePairs.length === 0) return mediaPathMap
|
||||
|
||||
// 创建媒体输出目录(直接在会话文件夹下创建子目录)
|
||||
const imageOutDir = options.exportImages ? path.join(outputDir, 'images') : ''
|
||||
const videoOutDir = options.exportVideos ? path.join(outputDir, 'videos') : ''
|
||||
const emojiOutDir = options.exportEmojis ? path.join(outputDir, 'emojis') : ''
|
||||
|
||||
if (options.exportImages && !fs.existsSync(imageOutDir)) {
|
||||
fs.mkdirSync(imageOutDir, { recursive: true })
|
||||
}
|
||||
if (options.exportVideos && !fs.existsSync(videoOutDir)) {
|
||||
fs.mkdirSync(videoOutDir, { recursive: true })
|
||||
}
|
||||
if (options.exportEmojis && !fs.existsSync(emojiOutDir)) {
|
||||
fs.mkdirSync(emojiOutDir, { recursive: true })
|
||||
}
|
||||
|
||||
let imageCount = 0
|
||||
let videoCount = 0
|
||||
let emojiCount = 0
|
||||
|
||||
// 构建查询条件:只查需要的消息类型
|
||||
const typeConditions: string[] = []
|
||||
if (options.exportImages) typeConditions.push('3')
|
||||
if (options.exportVideos) typeConditions.push('43')
|
||||
if (options.exportEmojis) typeConditions.push('47')
|
||||
|
||||
// 图片/视频/表情循环(语音在后面独立处理)
|
||||
if (typeConditions.length > 0) {
|
||||
for (const { db, tableName } of dbTablePairs) {
|
||||
try {
|
||||
const hasName2Id = db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='Name2Id'"
|
||||
).get()
|
||||
|
||||
const typeFilter = typeConditions.map(t => `local_type = ${t}`).join(' OR ')
|
||||
|
||||
// 用 SELECT * 获取完整行,包含 packed_info_data
|
||||
let sql: string
|
||||
if (hasName2Id) {
|
||||
sql = `SELECT m.* FROM ${tableName} m WHERE (${typeFilter}) ORDER BY m.create_time ASC`
|
||||
} else {
|
||||
sql = `SELECT * FROM ${tableName} WHERE (${typeFilter}) ORDER BY create_time ASC`
|
||||
}
|
||||
|
||||
const rows = db.prepare(sql).all() as any[]
|
||||
|
||||
for (const row of rows) {
|
||||
const createTime = row.create_time || 0
|
||||
|
||||
// 时间范围过滤
|
||||
if (options.dateRange) {
|
||||
if (createTime < options.dateRange.start || createTime > options.dateRange.end) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const localType = row.local_type || row.type || 1
|
||||
const content = this.decodeMessageContent(row.message_content, row.compress_content)
|
||||
|
||||
// 导出图片
|
||||
if (options.exportImages && localType === 3) {
|
||||
try {
|
||||
// 从 XML 提取 md5
|
||||
const imageMd5 = this.extractXmlValue(content, 'md5') ||
|
||||
(/\<img[^>]*\smd5\s*=\s*['"]([^'"]+)['"]/i.exec(content))?.[1] ||
|
||||
undefined
|
||||
|
||||
// 从 packed_info_data 解析 dat 文件名(缓存文件以此命名)
|
||||
const imageDatName = this.parseImageDatName(row)
|
||||
|
||||
if (imageMd5 || imageDatName) {
|
||||
const cacheResult = await imageDecryptService.decryptImage({
|
||||
sessionId,
|
||||
imageMd5,
|
||||
imageDatName
|
||||
})
|
||||
|
||||
if (cacheResult.success && cacheResult.localPath) {
|
||||
// localPath 是 file:///path?v=xxx 格式,转为本地路径
|
||||
let filePath = cacheResult.localPath
|
||||
.replace(/\?v=\d+$/, '')
|
||||
.replace(/^file:\/\/\//i, '')
|
||||
filePath = decodeURIComponent(filePath)
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
const ext = path.extname(filePath) || '.jpg'
|
||||
const fileName = `${createTime}_${imageMd5 || imageDatName}${ext}`
|
||||
const destPath = path.join(imageOutDir, fileName)
|
||||
if (!fs.existsSync(destPath)) {
|
||||
fs.copyFileSync(filePath, destPath)
|
||||
imageCount++
|
||||
// 记录映射:createTime → 相对路径
|
||||
mediaPathMap.set(createTime, `images/${fileName}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 跳过单张图片的错误
|
||||
}
|
||||
}
|
||||
|
||||
// 导出视频
|
||||
if (options.exportVideos && localType === 43) {
|
||||
try {
|
||||
const videoMd5 = videoService.parseVideoMd5(content)
|
||||
if (videoMd5) {
|
||||
const videoInfo = videoService.getVideoInfo(videoMd5)
|
||||
if (videoInfo.exists && videoInfo.videoUrl) {
|
||||
const videoPath = videoInfo.videoUrl.replace(/^file:\/\/\//i, '').replace(/\//g, path.sep)
|
||||
if (fs.existsSync(videoPath)) {
|
||||
const fileName = `${createTime}_${videoMd5}.mp4`
|
||||
const destPath = path.join(videoOutDir, fileName)
|
||||
if (!fs.existsSync(destPath)) {
|
||||
fs.copyFileSync(videoPath, destPath)
|
||||
videoCount++
|
||||
// 记录映射:createTime → 相对路径
|
||||
mediaPathMap.set(createTime, `videos/${fileName}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 跳过单个视频的错误
|
||||
}
|
||||
}
|
||||
|
||||
// 导出表情包
|
||||
if (options.exportEmojis && localType === 47) {
|
||||
try {
|
||||
// 从 XML 提取 cdnUrl 和 md5
|
||||
const cdnUrlMatch = /cdnurl\s*=\s*['"]([^'"]+)['"]/i.exec(content)
|
||||
const thumbUrlMatch = /thumburl\s*=\s*['"]([^'"]+)['"]/i.exec(content)
|
||||
const md5Match = /md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) ||
|
||||
/<md5>([^<]+)<\/md5>/i.exec(content)
|
||||
|
||||
let cdnUrl = cdnUrlMatch?.[1] || thumbUrlMatch?.[1] || ''
|
||||
const emojiMd5 = md5Match?.[1] || ''
|
||||
|
||||
if (cdnUrl) {
|
||||
cdnUrl = cdnUrl.replace(/&/g, '&')
|
||||
}
|
||||
|
||||
if (emojiMd5 || cdnUrl) {
|
||||
const cacheKey = emojiMd5 || this.hashString(cdnUrl)
|
||||
// 确定文件扩展名
|
||||
const ext = cdnUrl.includes('.gif') || content.includes('type="2"') ? '.gif' : '.png'
|
||||
const fileName = `${createTime}_${cacheKey}${ext}`
|
||||
const destPath = path.join(emojiOutDir, fileName)
|
||||
|
||||
if (!fs.existsSync(destPath)) {
|
||||
// 1. 先检查本地缓存(cachePath/Emojis/)
|
||||
let sourceFile = this.findLocalEmoji(cacheKey)
|
||||
|
||||
// 2. 找不到就从 CDN 下载
|
||||
if (!sourceFile && cdnUrl) {
|
||||
sourceFile = await this.downloadEmojiFile(cdnUrl, cacheKey)
|
||||
}
|
||||
|
||||
if (sourceFile && fs.existsSync(sourceFile)) {
|
||||
fs.copyFileSync(sourceFile, destPath)
|
||||
emojiCount++
|
||||
mediaPathMap.set(createTime, `emojis/${fileName}`)
|
||||
}
|
||||
} else {
|
||||
// 文件已存在,只记录映射
|
||||
mediaPathMap.set(createTime, `emojis/${fileName}`)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 跳过单个表情的错误
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Export] 读取媒体消息失败:`, e)
|
||||
}
|
||||
}
|
||||
} // 结束 typeConditions > 0
|
||||
|
||||
// === 语音导出(独立流程:需要从 MediaDb 读取) ===
|
||||
let voiceCount = 0
|
||||
if (options.exportVoices) {
|
||||
const voiceOutDir = path.join(outputDir, 'voices')
|
||||
if (!fs.existsSync(voiceOutDir)) {
|
||||
fs.mkdirSync(voiceOutDir, { recursive: true })
|
||||
}
|
||||
|
||||
onDetail?.('正在导出语音消息...')
|
||||
|
||||
// 1. 收集所有语音消息的 createTime
|
||||
const voiceCreateTimes: number[] = []
|
||||
for (const { db, tableName } of dbTablePairs) {
|
||||
try {
|
||||
let sql = `SELECT create_time FROM ${tableName} WHERE local_type = 34`
|
||||
if (options.dateRange) {
|
||||
sql += ` AND create_time >= ${options.dateRange.start} AND create_time <= ${options.dateRange.end}`
|
||||
}
|
||||
sql += ` ORDER BY create_time`
|
||||
const rows = db.prepare(sql).all() as any[]
|
||||
for (const row of rows) {
|
||||
if (row.create_time) voiceCreateTimes.push(row.create_time)
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
if (voiceCreateTimes.length > 0) {
|
||||
// 2. 查找 MediaDb
|
||||
const mediaDbs = this.findMediaDbs()
|
||||
|
||||
if (mediaDbs.length > 0) {
|
||||
// 3. 只初始化一次 silk-wasm
|
||||
let silkWasm: any = null
|
||||
try {
|
||||
silkWasm = require('silk-wasm')
|
||||
} catch (e) {
|
||||
console.error('[Export] silk-wasm 加载失败:', e)
|
||||
}
|
||||
|
||||
if (silkWasm) {
|
||||
// 4. 打开所有 MediaDb,预先建立 VoiceInfo 查询
|
||||
interface VoiceDbInfo {
|
||||
db: InstanceType<typeof Database>
|
||||
voiceTable: string
|
||||
dataColumn: string
|
||||
timeColumn: string
|
||||
chatNameIdColumn: string | null
|
||||
name2IdTable: string | null
|
||||
}
|
||||
const voiceDbs: VoiceDbInfo[] = []
|
||||
|
||||
for (const dbPath of mediaDbs) {
|
||||
try {
|
||||
const mediaDb = new Database(dbPath, { readonly: true })
|
||||
const tables = mediaDb.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'VoiceInfo%'"
|
||||
).all() as any[]
|
||||
if (tables.length === 0) { mediaDb.close(); continue }
|
||||
|
||||
const voiceTable = tables[0].name
|
||||
const columns = mediaDb.prepare(`PRAGMA table_info('${voiceTable}')`).all() as any[]
|
||||
const colNames = columns.map((c: any) => c.name.toLowerCase())
|
||||
|
||||
const dataColumn = colNames.find((c: string) => ['voice_data', 'buf', 'voicebuf', 'data'].includes(c))
|
||||
const timeColumn = colNames.find((c: string) => ['create_time', 'createtime', 'time'].includes(c))
|
||||
if (!dataColumn || !timeColumn) { mediaDb.close(); continue }
|
||||
|
||||
const chatNameIdColumn = colNames.find((c: string) => ['chat_name_id', 'chatnameid', 'chat_nameid'].includes(c)) || null
|
||||
const n2iTables = mediaDb.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Name2Id%'").all() as any[]
|
||||
const name2IdTable = n2iTables.length > 0 ? n2iTables[0].name : null
|
||||
|
||||
voiceDbs.push({ db: mediaDb, voiceTable, dataColumn, timeColumn, chatNameIdColumn, name2IdTable })
|
||||
} catch { }
|
||||
}
|
||||
|
||||
// 5. 串行处理语音(避免内存溢出)
|
||||
const myWxid = this.configService.get('myWxid')
|
||||
const candidates = [sessionId]
|
||||
if (myWxid && myWxid !== sessionId) candidates.push(myWxid)
|
||||
|
||||
const total = voiceCreateTimes.length
|
||||
for (let idx = 0; idx < total; idx++) {
|
||||
const createTime = voiceCreateTimes[idx]
|
||||
const fileName = `${createTime}.wav`
|
||||
const destPath = path.join(voiceOutDir, fileName)
|
||||
|
||||
// 已存在则跳过
|
||||
if (fs.existsSync(destPath)) {
|
||||
mediaPathMap.set(createTime, `voices/${fileName}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 在 MediaDb 中查找 SILK 数据
|
||||
let silkData: Buffer | null = null
|
||||
for (const vdb of voiceDbs) {
|
||||
try {
|
||||
// 策略1: chatNameId + createTime
|
||||
if (vdb.chatNameIdColumn && vdb.name2IdTable) {
|
||||
for (const cand of candidates) {
|
||||
const n2i = vdb.db.prepare(`SELECT rowid FROM ${vdb.name2IdTable} WHERE user_name = ?`).get(cand) as any
|
||||
if (n2i?.rowid) {
|
||||
const row = vdb.db.prepare(`SELECT ${vdb.dataColumn} AS data FROM ${vdb.voiceTable} WHERE ${vdb.chatNameIdColumn} = ? AND ${vdb.timeColumn} = ? LIMIT 1`).get(n2i.rowid, createTime) as any
|
||||
if (row?.data) {
|
||||
silkData = this.decodeVoiceBlob(row.data)
|
||||
if (silkData) break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 策略2: 仅 createTime
|
||||
if (!silkData) {
|
||||
const row = vdb.db.prepare(`SELECT ${vdb.dataColumn} AS data FROM ${vdb.voiceTable} WHERE ${vdb.timeColumn} = ? LIMIT 1`).get(createTime) as any
|
||||
if (row?.data) {
|
||||
silkData = this.decodeVoiceBlob(row.data)
|
||||
}
|
||||
}
|
||||
if (silkData) break
|
||||
} catch { }
|
||||
}
|
||||
|
||||
if (!silkData) continue
|
||||
|
||||
try {
|
||||
// SILK → PCM → WAV(串行,立即释放)
|
||||
const result = await silkWasm.decode(silkData, 24000)
|
||||
silkData = null // 释放 SILK 数据
|
||||
if (!result?.data) continue
|
||||
const pcmData = Buffer.from(result.data)
|
||||
const wavData = this.createWavBuffer(pcmData, 24000)
|
||||
fs.writeFileSync(destPath, wavData)
|
||||
voiceCount++
|
||||
mediaPathMap.set(createTime, `voices/${fileName}`)
|
||||
} catch { }
|
||||
|
||||
// 进度日志
|
||||
if ((idx + 1) % 10 === 0 || idx === total - 1) {
|
||||
onDetail?.(`语音导出: ${idx + 1}/${total}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 关闭所有 MediaDb
|
||||
for (const vdb of voiceDbs) {
|
||||
try { vdb.db.close() } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parts: string[] = []
|
||||
if (imageCount > 0) parts.push(`${imageCount} 张图片`)
|
||||
if (videoCount > 0) parts.push(`${videoCount} 个视频`)
|
||||
if (emojiCount > 0) parts.push(`${emojiCount} 个表情`)
|
||||
if (voiceCount > 0) parts.push(`${voiceCount} 条语音`)
|
||||
const summary = parts.length > 0 ? `媒体导出完成: ${parts.join(', ')}` : '无媒体文件'
|
||||
onDetail?.(summary)
|
||||
console.log(`[Export] ${sessionId} ${summary}`)
|
||||
return mediaPathMap
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数据库行的 packed_info_data 中解析图片 dat 文件名
|
||||
* 复制自 chatService.parseImageDatNameFromRow 逻辑
|
||||
*/
|
||||
private parseImageDatName(row: Record<string, any>): string | undefined {
|
||||
// 尝试多种可能的字段名
|
||||
const fieldNames = [
|
||||
'packed_info_data', 'packed_info', 'packedInfoData', 'packedInfo',
|
||||
'PackedInfoData', 'PackedInfo',
|
||||
'WCDB_CT_packed_info_data', 'WCDB_CT_packed_info',
|
||||
'WCDB_CT_PackedInfoData', 'WCDB_CT_PackedInfo'
|
||||
]
|
||||
let packed: any = undefined
|
||||
for (const name of fieldNames) {
|
||||
if (row[name] !== undefined && row[name] !== null) {
|
||||
packed = row[name]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 解码为 Buffer
|
||||
let buffer: Buffer | null = null
|
||||
if (!packed) return undefined
|
||||
if (Buffer.isBuffer(packed)) {
|
||||
buffer = packed
|
||||
} else if (packed instanceof Uint8Array) {
|
||||
buffer = Buffer.from(packed)
|
||||
} else if (Array.isArray(packed)) {
|
||||
buffer = Buffer.from(packed)
|
||||
} else if (typeof packed === 'string') {
|
||||
const trimmed = packed.trim()
|
||||
if (/^[a-fA-F0-9]+$/.test(trimmed) && trimmed.length % 2 === 0) {
|
||||
try { buffer = Buffer.from(trimmed, 'hex') } catch { }
|
||||
}
|
||||
if (!buffer) {
|
||||
try { buffer = Buffer.from(trimmed, 'base64') } catch { }
|
||||
}
|
||||
} else if (typeof packed === 'object' && Array.isArray(packed.data)) {
|
||||
buffer = Buffer.from(packed.data)
|
||||
}
|
||||
|
||||
if (!buffer || buffer.length === 0) return undefined
|
||||
|
||||
// 提取可打印字符
|
||||
const printable: number[] = []
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
const byte = buffer[i]
|
||||
if (byte >= 0x20 && byte <= 0x7e) {
|
||||
printable.push(byte)
|
||||
} else {
|
||||
printable.push(0x20)
|
||||
}
|
||||
}
|
||||
const text = Buffer.from(printable).toString('utf-8')
|
||||
|
||||
// 匹配 dat 文件名
|
||||
const match = /([0-9a-fA-F]{8,})(?:\.t)?\.dat/.exec(text)
|
||||
if (match?.[1]) return match[1].toLowerCase()
|
||||
const hexMatch = /([0-9a-fA-F]{16,})/.exec(text)
|
||||
return hexMatch?.[1]?.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单字符串哈希(用于无 md5 时生成缓存 key)
|
||||
*/
|
||||
private hashString(str: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const chr = str.charCodeAt(i)
|
||||
hash = ((hash << 5) - hash) + chr
|
||||
hash |= 0
|
||||
}
|
||||
return Math.abs(hash).toString(16)
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找本地缓存的表情包文件
|
||||
*/
|
||||
private findLocalEmoji(cacheKey: string): string | null {
|
||||
try {
|
||||
const cachePath = this.configService.get('cachePath')
|
||||
if (!cachePath) return null
|
||||
|
||||
const emojiCacheDir = path.join(cachePath, 'Emojis')
|
||||
if (!fs.existsSync(emojiCacheDir)) return null
|
||||
|
||||
// 检查各种扩展名
|
||||
const extensions = ['.gif', '.png', '.webp', '.jpg', '.jpeg', '']
|
||||
for (const ext of extensions) {
|
||||
const filePath = path.join(emojiCacheDir, `${cacheKey}${ext}`)
|
||||
if (fs.existsSync(filePath)) {
|
||||
const stat = fs.statSync(filePath)
|
||||
if (stat.isFile() && stat.size > 0) return filePath
|
||||
}
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 CDN 下载表情包文件并缓存
|
||||
*/
|
||||
private async downloadEmojiFile(cdnUrl: string, cacheKey: string): Promise<string | null> {
|
||||
try {
|
||||
const cachePath = this.configService.get('cachePath')
|
||||
if (!cachePath) return null
|
||||
|
||||
const emojiCacheDir = path.join(cachePath, 'Emojis')
|
||||
if (!fs.existsSync(emojiCacheDir)) {
|
||||
fs.mkdirSync(emojiCacheDir, { recursive: true })
|
||||
}
|
||||
|
||||
// 使用 https/http 模块下载
|
||||
const { net } = require('electron')
|
||||
const response = await net.fetch(cdnUrl, {
|
||||
method: 'GET',
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' }
|
||||
})
|
||||
|
||||
if (!response.ok) return null
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer())
|
||||
if (buffer.length === 0) return null
|
||||
|
||||
// 检测文件类型
|
||||
let ext = '.gif'
|
||||
if (buffer[0] === 0x89 && buffer[1] === 0x50) ext = '.png'
|
||||
else if (buffer[0] === 0xFF && buffer[1] === 0xD8) ext = '.jpg'
|
||||
else if (buffer[0] === 0x52 && buffer[1] === 0x49) ext = '.webp'
|
||||
|
||||
const filePath = path.join(emojiCacheDir, `${cacheKey}${ext}`)
|
||||
fs.writeFileSync(filePath, buffer)
|
||||
return filePath
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找 media 数据库文件
|
||||
*/
|
||||
private findMediaDbs(): string[] {
|
||||
if (!this.dbDir) return []
|
||||
const result: string[] = []
|
||||
try {
|
||||
const files = fs.readdirSync(this.dbDir)
|
||||
for (const file of files) {
|
||||
const lower = file.toLowerCase()
|
||||
if (lower.startsWith('media') && lower.endsWith('.db')) {
|
||||
result.push(path.join(this.dbDir, file))
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码语音 Blob 数据为 Buffer
|
||||
*/
|
||||
private decodeVoiceBlob(raw: any): Buffer | null {
|
||||
if (!raw) return null
|
||||
if (Buffer.isBuffer(raw)) return raw
|
||||
if (raw instanceof Uint8Array) return Buffer.from(raw)
|
||||
if (Array.isArray(raw)) return Buffer.from(raw)
|
||||
if (typeof raw === 'string') {
|
||||
const trimmed = raw.trim()
|
||||
if (/^[a-fA-F0-9]+$/.test(trimmed) && trimmed.length % 2 === 0) {
|
||||
try { return Buffer.from(trimmed, 'hex') } catch { }
|
||||
}
|
||||
try { return Buffer.from(trimmed, 'base64') } catch { }
|
||||
}
|
||||
if (typeof raw === 'object' && Array.isArray(raw.data)) {
|
||||
return Buffer.from(raw.data)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* PCM 数据生成 WAV 文件 Buffer
|
||||
*/
|
||||
private createWavBuffer(pcmData: Buffer, sampleRate: number = 24000, channels: number = 1): Buffer {
|
||||
const pcmLength = pcmData.length
|
||||
const header = Buffer.alloc(44)
|
||||
header.write('RIFF', 0)
|
||||
header.writeUInt32LE(36 + pcmLength, 4)
|
||||
header.write('WAVE', 8)
|
||||
header.write('fmt ', 12)
|
||||
header.writeUInt32LE(16, 16)
|
||||
header.writeUInt16LE(1, 20)
|
||||
header.writeUInt16LE(channels, 22)
|
||||
header.writeUInt32LE(sampleRate, 24)
|
||||
header.writeUInt32LE(sampleRate * channels * 2, 28)
|
||||
header.writeUInt16LE(channels * 2, 32)
|
||||
header.writeUInt16LE(16, 34)
|
||||
header.write('data', 36)
|
||||
header.writeUInt32LE(pcmLength, 40)
|
||||
return Buffer.concat([header, pcmData])
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出通讯录
|
||||
*/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -51,12 +51,14 @@ export class ImageDecryptService {
|
||||
private updateFlags = new Map<string, boolean>()
|
||||
|
||||
async resolveCachedImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }): Promise<DecryptResult & { hasUpdate?: boolean }> {
|
||||
await this.ensureCacheIndexed()
|
||||
// 不再等待缓存索引,直接查找
|
||||
const cacheKeys = this.getCacheKeys(payload)
|
||||
const cacheKey = cacheKeys[0]
|
||||
if (!cacheKey) {
|
||||
return { success: false, error: '缺少图片标识' }
|
||||
}
|
||||
|
||||
// 1. 先检查内存缓存(最快)
|
||||
for (const key of cacheKeys) {
|
||||
const cached = this.resolvedCache.get(key)
|
||||
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
||||
@@ -76,8 +78,9 @@ export class ImageDecryptService {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 快速查找缓存文件(优先查找当前 sessionId 的最新日期目录)
|
||||
for (const key of cacheKeys) {
|
||||
const existing = this.findCachedOutput(key, payload.sessionId)
|
||||
const existing = this.findCachedOutputFast(key, payload.sessionId)
|
||||
if (existing) {
|
||||
this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing)
|
||||
const localPath = this.filePathToUrl(existing)
|
||||
@@ -92,11 +95,16 @@ export class ImageDecryptService {
|
||||
return { success: true, localPath, hasUpdate }
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 后台启动完整索引(不阻塞当前请求)
|
||||
if (!this.cacheIndexed && !this.cacheIndexing) {
|
||||
void this.ensureCacheIndexed()
|
||||
}
|
||||
|
||||
return { success: false, error: '未找到缓存图片' }
|
||||
}
|
||||
|
||||
async decryptImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }): Promise<DecryptResult> {
|
||||
await this.ensureCacheIndexed()
|
||||
const cacheKey = payload.imageMd5 || payload.imageDatName
|
||||
if (!cacheKey) {
|
||||
return { success: false, error: '缺少图片标识' }
|
||||
@@ -104,8 +112,9 @@ export class ImageDecryptService {
|
||||
|
||||
// 即使 force=true,也先检查是否有高清图缓存
|
||||
if (payload.force) {
|
||||
// 查找高清图缓存
|
||||
const hdCached = this.findCachedOutput(cacheKey, payload.sessionId, true)
|
||||
// 快速查找高清图缓存
|
||||
const hdCached = this.findCachedOutputFast(cacheKey, payload.sessionId, true) ||
|
||||
this.findCachedOutput(cacheKey, payload.sessionId, true)
|
||||
if (hdCached && existsSync(hdCached) && this.isImageFile(hdCached)) {
|
||||
const localPath = this.filePathToUrl(hdCached)
|
||||
return { success: true, localPath, isThumb: false }
|
||||
@@ -379,14 +388,21 @@ export class ImageDecryptService {
|
||||
return hardlinkPath
|
||||
}
|
||||
// hardlink 找到的是缩略图,但要求高清图
|
||||
// 尝试在同一目录下查找高清图变体(快速查找,不遍历)
|
||||
// 尝试在同一目录下查找高清图变体(快速查找)
|
||||
const hdPath = this.findHdVariantInSameDir(hardlinkPath)
|
||||
if (hdPath) {
|
||||
this.cacheDatPath(accountDir, imageMd5, hdPath)
|
||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||
return hdPath
|
||||
}
|
||||
// 没找到高清图,返回 null(不进行全局搜索)
|
||||
// 同目录没找到高清图,尝试在该目录下搜索
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -405,6 +421,12 @@ 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
|
||||
}
|
||||
}
|
||||
@@ -419,6 +441,9 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1027,6 +1052,61 @@ export class ImageDecryptService {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速查找缓存文件(直接构造路径,不遍历目录)
|
||||
* 用于 resolveCachedImage,避免全局扫描
|
||||
*/
|
||||
private findCachedOutputFast(cacheKey: string, sessionId?: string, preferHd: boolean = false): string | null {
|
||||
if (!sessionId) return null
|
||||
|
||||
const normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase())
|
||||
const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
|
||||
const allRoots = this.getAllCacheRoots()
|
||||
|
||||
// 构造最近 3 个月的日期目录
|
||||
const now = new Date()
|
||||
const recentMonths: string[] = []
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||
recentMonths.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`)
|
||||
}
|
||||
|
||||
// 直接构造路径并检查文件是否存在
|
||||
for (const root of allRoots) {
|
||||
for (const dateDir of recentMonths) {
|
||||
const imageDir = join(root, sessionId, dateDir)
|
||||
|
||||
// 批量构造所有可能的路径
|
||||
const candidates: string[] = []
|
||||
|
||||
if (preferHd) {
|
||||
// 优先高清图
|
||||
for (const ext of extensions) {
|
||||
candidates.push(join(imageDir, `${normalizedKey}_hd${ext}`))
|
||||
}
|
||||
for (const ext of extensions) {
|
||||
candidates.push(join(imageDir, `${normalizedKey}_thumb${ext}`))
|
||||
}
|
||||
} else {
|
||||
// 优先缩略图
|
||||
for (const ext of extensions) {
|
||||
candidates.push(join(imageDir, `${normalizedKey}_thumb${ext}`))
|
||||
}
|
||||
for (const ext of extensions) {
|
||||
candidates.push(join(imageDir, `${normalizedKey}_hd${ext}`))
|
||||
}
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理旧的 .hevc 文件(ffmpeg 转换失败时遗留的)
|
||||
*/
|
||||
|
||||
69
src/App.scss
69
src/App.scss
@@ -14,7 +14,11 @@
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 24px;
|
||||
padding: 24px 24px 0 24px; // 移除底部 padding
|
||||
|
||||
&.no-overflow {
|
||||
overflow: hidden; // 数据管理页面禁用外层滚动
|
||||
}
|
||||
}
|
||||
|
||||
// 更新提示悬浮卡片
|
||||
@@ -264,3 +268,66 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 下载进度胶囊
|
||||
.download-progress-capsule {
|
||||
position: fixed;
|
||||
top: 40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
background: rgba(30, 30, 30, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
z-index: 2000;
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
animation: capsuleSlideDown 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.progress-bar-bg {
|
||||
width: 100px;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--primary);
|
||||
border-radius: 2px;
|
||||
transition: width 0.2s linear;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes capsuleSlideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
|
||||
31
src/App.tsx
31
src/App.tsx
@@ -31,7 +31,7 @@ import * as configService from './services/config'
|
||||
import { initTldList } from './utils/linkify'
|
||||
import LockScreen from './pages/LockScreen'
|
||||
import { useAuthStore } from './stores/authStore'
|
||||
import { X, Shield } from 'lucide-react'
|
||||
import { X, Shield, Loader2 } from 'lucide-react'
|
||||
import './App.scss'
|
||||
|
||||
function App() {
|
||||
@@ -52,6 +52,7 @@ function App() {
|
||||
|
||||
// 更新提示状态
|
||||
const [updateInfo, setUpdateInfo] = useState<{ version: string; releaseNotes: string } | null>(null)
|
||||
const [downloadProgress, setDownloadProgress] = useState<number | null>(null)
|
||||
|
||||
// 加载主题配置
|
||||
useEffect(() => {
|
||||
@@ -153,6 +154,16 @@ function App() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 监听下载进度
|
||||
useEffect(() => {
|
||||
const removeDownloadListener = window.electronAPI.app.onDownloadProgress?.((progress) => {
|
||||
setDownloadProgress(progress)
|
||||
})
|
||||
return () => {
|
||||
removeDownloadListener?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const dismissUpdate = () => {
|
||||
setUpdateInfo(null)
|
||||
}
|
||||
@@ -436,8 +447,11 @@ function App() {
|
||||
<div className="update-toast-title">发现新版本</div>
|
||||
<div className="update-toast-version">v{updateInfo.version} 已发布</div>
|
||||
</div>
|
||||
<button className="update-toast-btn" onClick={() => { navigate('/settings?tab=about'); dismissUpdate(); }}>
|
||||
查看更新
|
||||
<button className="update-toast-btn" onClick={() => {
|
||||
window.electronAPI.app.downloadAndInstall()
|
||||
dismissUpdate()
|
||||
}}>
|
||||
立即更新
|
||||
</button>
|
||||
<button className="update-toast-close" onClick={dismissUpdate}>
|
||||
<X size={14} />
|
||||
@@ -447,7 +461,7 @@ function App() {
|
||||
|
||||
<div className="main-layout">
|
||||
<Sidebar />
|
||||
<main className="content">
|
||||
<main className={`content ${location.pathname === '/data-management' ? 'no-overflow' : ''}`}>
|
||||
<RouteGuard>
|
||||
<Routes>
|
||||
<Route path="/" element={<WelcomePage />} />
|
||||
@@ -463,6 +477,15 @@ function App() {
|
||||
</main>
|
||||
</div>
|
||||
<DecryptProgressOverlay />
|
||||
{downloadProgress !== null && (
|
||||
<div className="download-progress-capsule">
|
||||
<Loader2 className="spin" size={14} />
|
||||
<span>正在下载更新... {downloadProgress.toFixed(0)}%</span>
|
||||
<div className="progress-bar-bg">
|
||||
<div className="progress-bar-fill" style={{ width: `${downloadProgress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isLocked && <LockScreen />}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -15,15 +15,15 @@
|
||||
.whats-new-modal {
|
||||
width: 600px;
|
||||
max-width: 90%;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: scaleUp 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
position: relative;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
.modal-header {
|
||||
padding: 40px 32px 24px;
|
||||
@@ -31,8 +31,8 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(180deg, #FAF8F5 0%, #FFFFFF 100%);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.03);
|
||||
background: linear-gradient(180deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
.version-tag {
|
||||
display: inline-flex;
|
||||
@@ -52,19 +52,15 @@
|
||||
h2 {
|
||||
font-size: 26px;
|
||||
line-height: 1.2;
|
||||
color: #1a1a1a;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 8px;
|
||||
letter-spacing: -0.5px;
|
||||
background: linear-gradient(135deg, #2c2c2c 0%, #5a5a5a 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #888;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
font-weight: 400;
|
||||
@@ -89,11 +85,11 @@
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: #F9F7F5;
|
||||
background: var(--bg-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #8B7355;
|
||||
color: var(--primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -101,13 +97,13 @@
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -118,30 +114,53 @@
|
||||
.modal-footer {
|
||||
padding: 16px 32px 32px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: var(--bg-secondary);
|
||||
|
||||
.start-btn {
|
||||
background: #2c2c2c;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 48px;
|
||||
border-radius: 16px;
|
||||
font-size: 16px;
|
||||
button {
|
||||
padding: 10px 20px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
&:hover {
|
||||
background: #000;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.telegram-btn {
|
||||
background: #2AABEE;
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(42, 171, 238, 0.2);
|
||||
|
||||
&:hover {
|
||||
background: #229ED9;
|
||||
box-shadow: 0 6px 16px rgba(42, 171, 238, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.start-btn {
|
||||
background: var(--text-primary);
|
||||
color: var(--bg-primary);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 装饰元素
|
||||
@@ -152,7 +171,7 @@
|
||||
left: -50px;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
background: radial-gradient(circle, rgba(139, 115, 85, 0.05) 0%, transparent 70%);
|
||||
background: radial-gradient(circle, var(--primary-alpha-10) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Zap, Layout, Monitor, MessageSquareQuote, RefreshCw, Mic, Rocket, Sparkles } from 'lucide-react'
|
||||
import { Package, Image, Mic, Filter, Send } from 'lucide-react'
|
||||
import './WhatsNewModal.scss'
|
||||
|
||||
interface WhatsNewModalProps {
|
||||
@@ -8,38 +8,32 @@ interface WhatsNewModalProps {
|
||||
|
||||
function WhatsNewModal({ onClose, version }: WhatsNewModalProps) {
|
||||
const updates = [
|
||||
// {
|
||||
// icon: <Rocket size={20} />,
|
||||
// title: '性能优化',
|
||||
// desc: '修复消息内容会出现重复的问题。'
|
||||
// },
|
||||
{
|
||||
icon: <MessageSquareQuote size={20} />,
|
||||
title: '优化1',
|
||||
desc: '优化图片加载逻辑。'
|
||||
icon: <Package size={20} />,
|
||||
title: '媒体导出',
|
||||
desc: '导出聊天记录时可同时导出图片、视频、表情包和语音消息。'
|
||||
},
|
||||
{
|
||||
icon: <MessageSquareQuote size={20} />,
|
||||
title: '优化2',
|
||||
desc: '优化批量语音转文字功能。'
|
||||
icon: <Image size={20} />,
|
||||
title: '图片自动解密',
|
||||
desc: '导出时自动解密未缓存的图片,无需提前在密语聊天窗口浏览。'
|
||||
},
|
||||
// {
|
||||
// icon: <Sparkles size={20} />,
|
||||
// title: 'AI摘要',
|
||||
// desc: '支持AI在单人会话以及群聊会话中进行AI摘要总结。(默认只能选择天数)'
|
||||
// },
|
||||
// {
|
||||
// icon: <RefreshCw size={20} />,
|
||||
// title: '体验升级',
|
||||
// desc: '修复了一些已知的问题。'
|
||||
// }//,
|
||||
{
|
||||
icon: <Mic size={20} />,
|
||||
title: '新功能',
|
||||
desc: '数据管理界面可查看所有解密后的图片。'
|
||||
title: '语音导出',
|
||||
desc: '支持将语音消息解码为 WAV 格式导出,含转写文字。'
|
||||
},
|
||||
{
|
||||
icon: <Filter size={20} />,
|
||||
title: '分类导出',
|
||||
desc: '导出时可按群聊或个人聊天筛选,支持日期范围过滤。'
|
||||
}
|
||||
]
|
||||
|
||||
const handleTelegram = () => {
|
||||
window.electronAPI?.shell?.openExternal?.('https://t.me/+p7YzmRMBm-gzNzJl')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="whats-new-overlay">
|
||||
<div className="whats-new-modal">
|
||||
@@ -66,6 +60,10 @@ function WhatsNewModal({ onClose, version }: WhatsNewModalProps) {
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button className="telegram-btn" onClick={handleTelegram}>
|
||||
<Send size={16} />
|
||||
加入 Telegram 频道
|
||||
</button>
|
||||
<button className="start-btn" onClick={onClose}>
|
||||
开启新旅程
|
||||
</button>
|
||||
|
||||
@@ -45,6 +45,53 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
height: calc(100vh - 140px); // 设置固定高度
|
||||
overflow-y: auto; // 启用滚动
|
||||
overflow-x: hidden;
|
||||
|
||||
// 滚动条样式
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.media-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-section {
|
||||
@@ -127,6 +174,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
.database-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -318,15 +374,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 媒体网格样式
|
||||
// 媒体网格样式 - 直接显示在主区域
|
||||
.media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); // 自动填充,充分利用空间
|
||||
gap: 12px;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
max-height: calc(100vh - 200px); // 设置最大高度
|
||||
overflow-y: auto; // 启用垂直滚动
|
||||
overflow-x: hidden;
|
||||
|
||||
// 滚动条样式
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
@@ -346,7 +404,7 @@
|
||||
}
|
||||
|
||||
&.emoji-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); // 表情包也自适应
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,6 +422,10 @@
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
border-color: var(--primary);
|
||||
|
||||
.media-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.pending {
|
||||
@@ -381,6 +443,7 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.media-placeholder {
|
||||
@@ -402,6 +465,49 @@
|
||||
}
|
||||
}
|
||||
|
||||
.media-actions {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
.action-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
backdrop-filter: blur(8px);
|
||||
|
||||
&.download-btn {
|
||||
background: rgba(59, 130, 246, 0.9);
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: rgba(37, 99, 235, 1);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
&.delete-btn {
|
||||
background: rgba(239, 68, 68, 0.9);
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: rgba(220, 38, 38, 1);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.media-info {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
@@ -435,8 +541,6 @@
|
||||
}
|
||||
|
||||
.emoji-item {
|
||||
aspect-ratio: 1;
|
||||
|
||||
img {
|
||||
padding: 12px;
|
||||
object-fit: contain;
|
||||
@@ -452,6 +556,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.more-hint {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
@@ -494,6 +618,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
|
||||
.progress-card {
|
||||
background: var(--bg-primary);
|
||||
@@ -542,6 +667,95 @@
|
||||
}
|
||||
}
|
||||
|
||||
.delete-confirm-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
animation: fadeIn 0.2s ease;
|
||||
|
||||
.delete-confirm-card {
|
||||
background: var(--bg-primary);
|
||||
border-radius: 16px;
|
||||
padding: 28px 32px;
|
||||
min-width: 400px;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
animation: slideUp 0.2s ease;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.confirm-message {
|
||||
margin: 0 0 12px;
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.confirm-detail {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.confirm-warning {
|
||||
margin: 0 0 24px;
|
||||
font-size: 12px;
|
||||
color: #ef4444;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
|
||||
.btn {
|
||||
min-width: 100px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 图片列表样式
|
||||
.current-dir {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { Database, Check, Circle, Unlock, RefreshCw, RefreshCcw, Image as ImageIcon, Smile } from 'lucide-react'
|
||||
import { Database, Check, Circle, Unlock, RefreshCw, RefreshCcw, Image as ImageIcon, Smile, Download, Trash2 } from 'lucide-react'
|
||||
import './DataManagementPage.scss'
|
||||
|
||||
interface DatabaseFile {
|
||||
@@ -22,6 +22,11 @@ interface ImageFileInfo {
|
||||
version: number
|
||||
}
|
||||
|
||||
interface DeleteConfirmData {
|
||||
image: ImageFileInfo
|
||||
show: boolean
|
||||
}
|
||||
|
||||
type TabType = 'database' | 'images' | 'emojis'
|
||||
|
||||
function DataManagementPage() {
|
||||
@@ -29,11 +34,21 @@ function DataManagementPage() {
|
||||
const [databases, setDatabases] = useState<DatabaseFile[]>([])
|
||||
const [images, setImages] = useState<ImageFileInfo[]>([])
|
||||
const [emojis, setEmojis] = useState<ImageFileInfo[]>([])
|
||||
const [imageCount, setImageCount] = useState({ total: 0, decrypted: 0 })
|
||||
const [emojiCount, setEmojiCount] = useState({ total: 0, decrypted: 0 })
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isDecrypting, setIsDecrypting] = useState(false)
|
||||
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 })
|
||||
const location = useLocation()
|
||||
|
||||
// 懒加载相关状态
|
||||
const [displayedImageCount, setDisplayedImageCount] = useState(20)
|
||||
const [displayedEmojiCount, setDisplayedEmojiCount] = useState(20)
|
||||
const imageGridRef = useRef<HTMLDivElement>(null)
|
||||
const emojiGridRef = useRef<HTMLDivElement>(null)
|
||||
const loadMoreThreshold = 300 // 距离底部多少像素时加载更多
|
||||
|
||||
const loadDatabases = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
@@ -66,35 +81,36 @@ function DataManagementPage() {
|
||||
return
|
||||
}
|
||||
|
||||
// 扫描第一个目录的图片
|
||||
const firstDir = dirsResult.directories[0]
|
||||
console.log('[DataManagement] 扫描目录:', firstDir.path)
|
||||
|
||||
const result = await window.electronAPI.dataManagement.scanImages(firstDir.path)
|
||||
console.log('[DataManagement] 扫描结果:', result)
|
||||
|
||||
if (result.success && result.images) {
|
||||
console.log('[DataManagement] 找到图片数量:', result.images.length)
|
||||
|
||||
// 分离图片和表情包
|
||||
const imageList: ImageFileInfo[] = []
|
||||
const emojiList: ImageFileInfo[] = []
|
||||
|
||||
result.images.forEach(img => {
|
||||
console.log('[DataManagement] 处理图片:', img.fileName, '路径:', img.filePath)
|
||||
// 根据路径判断是否是表情包
|
||||
if (img.filePath.includes('CustomEmotions') || img.filePath.includes('emoji')) {
|
||||
emojiList.push(img)
|
||||
} else {
|
||||
imageList.push(img)
|
||||
}
|
||||
})
|
||||
|
||||
console.log('[DataManagement] 图片分类完成 - 普通图片:', imageList.length, '表情包:', emojiList.length)
|
||||
setImages(imageList)
|
||||
setEmojis(emojiList)
|
||||
} else {
|
||||
showMessage(result.error || '扫描图片失败', false)
|
||||
// 找到 images 和 Emojis 目录
|
||||
const imagesDir = dirsResult.directories.find(d => d.path.includes('images'))
|
||||
const emojisDir = dirsResult.directories.find(d => d.path.includes('Emojis'))
|
||||
|
||||
// 扫描普通图片
|
||||
if (imagesDir) {
|
||||
console.log('[DataManagement] 扫描图片目录:', imagesDir.path)
|
||||
const result = await window.electronAPI.dataManagement.scanImages(imagesDir.path)
|
||||
if (result.success && result.images) {
|
||||
console.log('[DataManagement] 找到普通图片:', result.images.length, '个')
|
||||
setImages(result.images)
|
||||
setImageCount({
|
||||
total: result.images.length,
|
||||
decrypted: result.images.filter(img => img.isDecrypted).length
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 扫描表情包
|
||||
if (emojisDir) {
|
||||
console.log('[DataManagement] 扫描表情包目录:', emojisDir.path)
|
||||
const result = await window.electronAPI.dataManagement.scanImages(emojisDir.path)
|
||||
if (result.success && result.images) {
|
||||
console.log('[DataManagement] 找到表情包:', result.images.length, '个')
|
||||
setEmojis(result.images)
|
||||
setEmojiCount({
|
||||
total: result.images.length,
|
||||
decrypted: result.images.filter(emoji => emoji.isDecrypted).length
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[DataManagement] 扫描图片异常:', e)
|
||||
@@ -104,6 +120,46 @@ function DataManagementPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 页面加载时预加载图片数量(不加载详细数据)
|
||||
useEffect(() => {
|
||||
const loadImageCounts = async () => {
|
||||
try {
|
||||
const dirsResult = await window.electronAPI.dataManagement.getImageDirectories()
|
||||
if (dirsResult.success && dirsResult.directories && dirsResult.directories.length > 0) {
|
||||
// 找到 images 和 Emojis 目录
|
||||
const imagesDir = dirsResult.directories.find(d => d.path.includes('images'))
|
||||
const emojisDir = dirsResult.directories.find(d => d.path.includes('Emojis'))
|
||||
|
||||
// 扫描普通图片数量
|
||||
if (imagesDir) {
|
||||
const result = await window.electronAPI.dataManagement.scanImages(imagesDir.path)
|
||||
if (result.success && result.images) {
|
||||
setImageCount({
|
||||
total: result.images.length,
|
||||
decrypted: result.images.filter(img => img.isDecrypted).length
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 扫描表情包数量
|
||||
if (emojisDir) {
|
||||
const result = await window.electronAPI.dataManagement.scanImages(emojisDir.path)
|
||||
if (result.success && result.images) {
|
||||
setEmojiCount({
|
||||
total: result.images.length,
|
||||
decrypted: result.images.filter(emoji => emoji.isDecrypted).length
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[DataManagement] 预加载图片数量失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
loadImageCounts()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'database') {
|
||||
loadDatabases()
|
||||
@@ -273,6 +329,9 @@ function DataManagementPage() {
|
||||
loadDatabases()
|
||||
} else if (activeTab === 'images' || activeTab === 'emojis') {
|
||||
loadImages()
|
||||
// 重置懒加载计数
|
||||
setDisplayedImageCount(20)
|
||||
setDisplayedEmojiCount(20)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,12 +349,142 @@ function DataManagementPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadImage = async (e: React.MouseEvent, image: ImageFileInfo) => {
|
||||
e.stopPropagation() // 阻止触发点击打开图片
|
||||
|
||||
if (!image.isDecrypted || !image.decryptedPath) {
|
||||
showMessage('图片未解密,无法下载', false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 直接使用浏览器的下载功能
|
||||
const link = document.createElement('a')
|
||||
link.href = image.decryptedPath.startsWith('file://')
|
||||
? image.decryptedPath
|
||||
: `file:///${image.decryptedPath.replace(/\\/g, '/')}`
|
||||
link.download = image.fileName
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
showMessage('下载成功', true)
|
||||
} catch (e) {
|
||||
showMessage(`下载失败: ${e}`, false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteImage = async (e: React.MouseEvent, image: ImageFileInfo) => {
|
||||
e.stopPropagation() // 阻止触发点击打开图片
|
||||
|
||||
if (!image.isDecrypted || !image.decryptedPath) {
|
||||
showMessage('图片未解密,无法删除', false)
|
||||
return
|
||||
}
|
||||
|
||||
// 显示自定义确认对话框
|
||||
setDeleteConfirm({ image, show: true })
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
const image = deleteConfirm.image
|
||||
setDeleteConfirm({ image: null as any, show: false })
|
||||
|
||||
try {
|
||||
// 删除解密后的文件
|
||||
const result = await window.electronAPI.file.delete(image.decryptedPath!)
|
||||
|
||||
if (result.success) {
|
||||
showMessage('删除成功', true)
|
||||
// 刷新列表
|
||||
await loadImages()
|
||||
} else {
|
||||
showMessage(`删除失败: ${result.error}`, false)
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage(`删除失败: ${e}`, false)
|
||||
}
|
||||
}
|
||||
|
||||
const cancelDelete = () => {
|
||||
setDeleteConfirm({ image: null as any, show: false })
|
||||
}
|
||||
|
||||
// 懒加载:监听滚动事件
|
||||
useEffect(() => {
|
||||
const handleScroll = (e: Event) => {
|
||||
const target = e.target as HTMLDivElement
|
||||
const scrollTop = target.scrollTop
|
||||
const scrollHeight = target.scrollHeight
|
||||
const clientHeight = target.clientHeight
|
||||
|
||||
// 距离底部小于阈值时加载更多
|
||||
if (scrollHeight - scrollTop - clientHeight < loadMoreThreshold) {
|
||||
if (activeTab === 'images' && displayedImageCount < images.length) {
|
||||
setDisplayedImageCount(prev => Math.min(prev + 20, images.length))
|
||||
} else if (activeTab === 'emojis' && displayedEmojiCount < emojis.length) {
|
||||
setDisplayedEmojiCount(prev => Math.min(prev + 20, emojis.length))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const imageGrid = imageGridRef.current
|
||||
const emojiGrid = emojiGridRef.current
|
||||
|
||||
if (activeTab === 'images' && imageGrid) {
|
||||
imageGrid.addEventListener('scroll', handleScroll)
|
||||
return () => imageGrid.removeEventListener('scroll', handleScroll)
|
||||
} else if (activeTab === 'emojis' && emojiGrid) {
|
||||
emojiGrid.addEventListener('scroll', handleScroll)
|
||||
return () => emojiGrid.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
}, [activeTab, displayedImageCount, displayedEmojiCount, images.length, emojis.length])
|
||||
|
||||
// 检查是否需要加载更多(如果没有滚动条)
|
||||
useEffect(() => {
|
||||
const checkAndLoadMore = () => {
|
||||
const grid = activeTab === 'images' ? imageGridRef.current : emojiGridRef.current
|
||||
if (!grid) return
|
||||
|
||||
const hasScroll = grid.scrollHeight > grid.clientHeight
|
||||
const hasMore = activeTab === 'images'
|
||||
? displayedImageCount < images.length
|
||||
: displayedEmojiCount < emojis.length
|
||||
|
||||
// 如果没有滚动条且还有更多内容,继续加载
|
||||
if (!hasScroll && hasMore) {
|
||||
if (activeTab === 'images') {
|
||||
setDisplayedImageCount(prev => Math.min(prev + 20, images.length))
|
||||
} else {
|
||||
setDisplayedEmojiCount(prev => Math.min(prev + 20, emojis.length))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 延迟检查,等待 DOM 渲染完成
|
||||
const timer = setTimeout(checkAndLoadMore, 100)
|
||||
return () => clearTimeout(timer)
|
||||
}, [activeTab, displayedImageCount, displayedEmojiCount, images.length, emojis.length])
|
||||
|
||||
// 切换标签时重置懒加载计数
|
||||
useEffect(() => {
|
||||
setDisplayedImageCount(20)
|
||||
setDisplayedEmojiCount(20)
|
||||
}, [activeTab])
|
||||
|
||||
const pendingCount = databases.filter(db => !db.isDecrypted).length
|
||||
const decryptedCount = databases.filter(db => db.isDecrypted).length
|
||||
const needsUpdateCount = databases.filter(db => db.needsUpdate).length
|
||||
|
||||
const decryptedImagesCount = images.filter(img => img.isDecrypted).length
|
||||
const decryptedEmojisCount = emojis.filter(emoji => emoji.isDecrypted).length
|
||||
// 使用预加载的计数,如果已加载详细数据则使用详细数据的计数
|
||||
const displayImageCount = images.length > 0 ? images.length : imageCount.total
|
||||
const displayDecryptedImagesCount = images.length > 0
|
||||
? images.filter(img => img.isDecrypted).length
|
||||
: imageCount.decrypted
|
||||
|
||||
const displayEmojiCount = emojis.length > 0 ? emojis.length : emojiCount.total
|
||||
const displayDecryptedEmojisCount = emojis.length > 0
|
||||
? emojis.filter(emoji => emoji.isDecrypted).length
|
||||
: emojiCount.decrypted
|
||||
|
||||
|
||||
return (
|
||||
@@ -326,6 +515,25 @@ function DataManagementPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deleteConfirm.show && (
|
||||
<div className="delete-confirm-overlay" onClick={cancelDelete}>
|
||||
<div className="delete-confirm-card" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>确认删除</h3>
|
||||
<p className="confirm-message">确定要删除这张图片吗?</p>
|
||||
<p className="confirm-detail">文件名: {deleteConfirm.image?.fileName}</p>
|
||||
<p className="confirm-warning">此操作不可恢复!</p>
|
||||
<div className="confirm-actions">
|
||||
<button className="btn btn-secondary" onClick={cancelDelete}>
|
||||
取消
|
||||
</button>
|
||||
<button className="btn btn-danger" onClick={confirmDelete}>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="page-header">
|
||||
<h1>数据管理</h1>
|
||||
<div className="header-tabs">
|
||||
@@ -341,20 +549,20 @@ function DataManagementPage() {
|
||||
onClick={() => setActiveTab('images')}
|
||||
>
|
||||
<ImageIcon size={16} />
|
||||
图片 ({decryptedImagesCount}/{images.length})
|
||||
图片 ({displayDecryptedImagesCount}/{displayImageCount})
|
||||
</button>
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'emojis' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('emojis')}
|
||||
>
|
||||
<Smile size={16} />
|
||||
表情包 ({decryptedEmojisCount}/{emojis.length})
|
||||
表情包 ({displayDecryptedEmojisCount}/{displayEmojiCount})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-scroll">
|
||||
{activeTab === 'database' && (
|
||||
{activeTab === 'database' && (
|
||||
<div className="page-scroll">
|
||||
<section className="page-section">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
@@ -418,133 +626,171 @@ function DataManagementPage() {
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'images' && (
|
||||
<section className="page-section">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>图片管理</h2>
|
||||
<p className="section-desc">
|
||||
{isLoading ? '正在扫描...' : `共 ${images.length} 张图片,${decryptedImagesCount} 张已解密`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="section-actions">
|
||||
<button className="btn btn-secondary" onClick={handleRefresh} disabled={isLoading}>
|
||||
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
{activeTab === 'images' && (
|
||||
<>
|
||||
<div className="media-header">
|
||||
<div>
|
||||
<h2>图片管理</h2>
|
||||
<p className="section-desc">
|
||||
{isLoading ? '正在扫描...' : `共 ${displayImageCount} 张图片,${displayDecryptedImagesCount} 张已解密`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="section-actions">
|
||||
<button className="btn btn-secondary" onClick={handleRefresh} disabled={isLoading}>
|
||||
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="media-grid">
|
||||
{images.slice(0, 100).map((image, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`media-item ${image.isDecrypted ? 'decrypted' : 'pending'}`}
|
||||
onClick={() => handleImageClick(image)}
|
||||
>
|
||||
{image.isDecrypted && image.decryptedPath ? (
|
||||
<div className="media-grid" ref={imageGridRef}>
|
||||
{images.slice(0, displayedImageCount).map((image, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`media-item ${image.isDecrypted ? 'decrypted' : 'pending'}`}
|
||||
onClick={() => handleImageClick(image)}
|
||||
>
|
||||
{image.isDecrypted && image.decryptedPath ? (
|
||||
<>
|
||||
<img
|
||||
src={image.decryptedPath.startsWith('data:') ? image.decryptedPath : `file:///${image.decryptedPath.replace(/\\/g, '/')}`}
|
||||
alt={image.fileName}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
console.error('[DataManagement] 图片加载失败:', image.decryptedPath)
|
||||
e.currentTarget.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="media-placeholder">
|
||||
<ImageIcon size={32} />
|
||||
<span>未解密</span>
|
||||
<div className="media-actions">
|
||||
<button
|
||||
className="action-btn download-btn"
|
||||
onClick={(e) => handleDownloadImage(e, image)}
|
||||
title="下载"
|
||||
>
|
||||
<Download size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="action-btn delete-btn"
|
||||
onClick={(e) => handleDeleteImage(e, image)}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="media-info">
|
||||
<span className="media-name">{image.fileName}</span>
|
||||
<span className="media-size">{formatFileSize(image.fileSize)}</span>
|
||||
</>
|
||||
) : (
|
||||
<div className="media-placeholder">
|
||||
<ImageIcon size={32} />
|
||||
<span>未解密</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="media-info">
|
||||
<span className="media-name">{image.fileName}</span>
|
||||
<span className="media-size">{formatFileSize(image.fileSize)}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!isLoading && images.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<ImageIcon size={48} strokeWidth={1} />
|
||||
<p>未找到图片文件</p>
|
||||
<p className="hint">请先解密数据库</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{images.length > 100 && (
|
||||
<div className="more-hint">
|
||||
仅显示前 100 张图片,共 {images.length} 张
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeTab === 'emojis' && (
|
||||
<section className="page-section">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>表情包管理</h2>
|
||||
<p className="section-desc">
|
||||
{isLoading ? '正在扫描...' : `共 ${emojis.length} 个表情包,${decryptedEmojisCount} 个已解密`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="section-actions">
|
||||
<button className="btn btn-secondary" onClick={handleRefresh} disabled={isLoading}>
|
||||
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="media-grid emoji-grid">
|
||||
{emojis.slice(0, 100).map((emoji, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`media-item emoji-item ${emoji.isDecrypted ? 'decrypted' : 'pending'}`}
|
||||
onClick={() => handleImageClick(emoji)}
|
||||
>
|
||||
{emoji.isDecrypted && emoji.decryptedPath ? (
|
||||
{!isLoading && images.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<ImageIcon size={48} strokeWidth={1} />
|
||||
<p>未找到图片文件</p>
|
||||
<p className="hint">请先解密数据库</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayedImageCount < images.length && (
|
||||
<div className="loading-more">
|
||||
加载中... ({displayedImageCount}/{images.length})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'emojis' && (
|
||||
<>
|
||||
<div className="media-header">
|
||||
<div>
|
||||
<h2>表情包管理</h2>
|
||||
<p className="section-desc">
|
||||
{isLoading ? '正在扫描...' : `共 ${displayEmojiCount} 个表情包,${displayDecryptedEmojisCount} 个已解密`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="section-actions">
|
||||
<button className="btn btn-secondary" onClick={handleRefresh} disabled={isLoading}>
|
||||
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="media-grid emoji-grid" ref={emojiGridRef}>
|
||||
{emojis.slice(0, displayedEmojiCount).map((emoji, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`media-item emoji-item ${emoji.isDecrypted ? 'decrypted' : 'pending'}`}
|
||||
onClick={() => handleImageClick(emoji)}
|
||||
>
|
||||
{emoji.isDecrypted && emoji.decryptedPath ? (
|
||||
<>
|
||||
<img
|
||||
src={emoji.decryptedPath.startsWith('data:') ? emoji.decryptedPath : `file:///${emoji.decryptedPath.replace(/\\/g, '/')}`}
|
||||
alt={emoji.fileName}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
console.error('[DataManagement] 表情包加载失败:', emoji.decryptedPath)
|
||||
e.currentTarget.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="media-placeholder">
|
||||
<Smile size={32} />
|
||||
<span>未解密</span>
|
||||
<div className="media-actions">
|
||||
<button
|
||||
className="action-btn download-btn"
|
||||
onClick={(e) => handleDownloadImage(e, emoji)}
|
||||
title="下载"
|
||||
>
|
||||
<Download size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="action-btn delete-btn"
|
||||
onClick={(e) => handleDeleteImage(e, emoji)}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="media-info">
|
||||
<span className="media-name">{emoji.fileName}</span>
|
||||
</>
|
||||
) : (
|
||||
<div className="media-placeholder">
|
||||
<Smile size={32} />
|
||||
<span>未解密</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="media-info">
|
||||
<span className="media-name">{emoji.fileName}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!isLoading && emojis.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<Smile size={48} strokeWidth={1} />
|
||||
<p>未找到表情包</p>
|
||||
<p className="hint">请先解密数据库</p>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && emojis.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<Smile size={48} strokeWidth={1} />
|
||||
<p>未找到表情包</p>
|
||||
<p className="hint">请先解密数据库</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{emojis.length > 100 && (
|
||||
<div className="more-hint">
|
||||
仅显示前 100 个表情包,共 {emojis.length} 个
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
{displayedEmojiCount < emojis.length && (
|
||||
<div className="loading-more">
|
||||
加载中... ({displayedEmojiCount}/{emojis.length})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -111,12 +111,59 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 会话类型筛选按钮组
|
||||
.session-type-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 20px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.type-filter-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba(var(--primary-rgb), 0.1);
|
||||
color: var(--primary);
|
||||
border-color: rgba(var(--primary-rgb), 0.3);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.select-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px 12px;
|
||||
|
||||
.select-actions-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.select-all-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -131,12 +178,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
.select-type-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--primary-rgb), 0.08);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.selected-count {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,7 +591,7 @@
|
||||
|
||||
.export-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@@ -856,15 +928,39 @@
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.progress-phase {
|
||||
font-size: 13px;
|
||||
color: var(--primary);
|
||||
margin: 0 0 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 20px;
|
||||
margin: 0 0 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.progress-detail {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.progress-export-options {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0 0 16px;
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 6px;
|
||||
display: inline-flex;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
background: var(--bg-secondary);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Search, Download, FolderOpen, RefreshCw, Check, FileJson, FileText, Table, Loader2, X, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink, MessageSquare, Users, User } from 'lucide-react'
|
||||
import { Search, Download, FolderOpen, RefreshCw, Check, FileJson, FileText, Table, Loader2, X, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink, MessageSquare, Users, User, Filter, Image, Video, CircleUserRound, Smile, Mic } from 'lucide-react'
|
||||
import DateRangePicker from '../components/DateRangePicker'
|
||||
import { useTitleBarStore } from '../stores/titleBarStore'
|
||||
import * as configService from '../services/config'
|
||||
@@ -29,6 +29,10 @@ interface ExportOptions {
|
||||
startDate: string
|
||||
endDate: string
|
||||
exportAvatars: boolean
|
||||
exportImages: boolean
|
||||
exportVideos: boolean
|
||||
exportEmojis: boolean
|
||||
exportVoices: boolean
|
||||
}
|
||||
|
||||
interface ContactExportOptions {
|
||||
@@ -49,6 +53,9 @@ interface ExportResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
// 会话类型筛选
|
||||
type SessionTypeFilter = 'all' | 'group' | 'private'
|
||||
|
||||
function ExportPage() {
|
||||
const [activeTab, setActiveTab] = useState<ExportTab>('chat')
|
||||
const setTitleBarContent = useTitleBarStore(state => state.setRightContent)
|
||||
@@ -59,12 +66,13 @@ function ExportPage() {
|
||||
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set())
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
const [sessionTypeFilter, setSessionTypeFilter] = useState<SessionTypeFilter>('all')
|
||||
const [exportFolder, setExportFolder] = useState<string>('')
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const [exportProgress, setExportProgress] = useState({
|
||||
current: 0,
|
||||
total: 0,
|
||||
currentName: '',
|
||||
const [exportProgress, setExportProgress] = useState({
|
||||
current: 0,
|
||||
total: 0,
|
||||
currentName: '',
|
||||
phase: '',
|
||||
detail: ''
|
||||
})
|
||||
@@ -74,7 +82,11 @@ function ExportPage() {
|
||||
format: 'chatlab',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
exportAvatars: true
|
||||
exportAvatars: true,
|
||||
exportImages: false,
|
||||
exportVideos: false,
|
||||
exportEmojis: false,
|
||||
exportVoices: false
|
||||
})
|
||||
|
||||
// 通讯录导出状态
|
||||
@@ -104,12 +116,12 @@ function ExportPage() {
|
||||
let endDate = ''
|
||||
if (defaultDateRange > 0) {
|
||||
const today = new Date()
|
||||
|
||||
|
||||
const year = today.getFullYear()
|
||||
const month = String(today.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(today.getDate()).padStart(2, '0')
|
||||
const todayStr = `${year}-${month}-${day}`
|
||||
|
||||
|
||||
if (defaultDateRange === 1) {
|
||||
// 最近1天 = 今天
|
||||
startDate = todayStr
|
||||
@@ -118,11 +130,11 @@ function ExportPage() {
|
||||
// 其他天数:从 N 天前到今天
|
||||
const start = new Date(today)
|
||||
start.setDate(today.getDate() - defaultDateRange + 1)
|
||||
|
||||
|
||||
const startYear = start.getFullYear()
|
||||
const startMonth = String(start.getMonth() + 1).padStart(2, '0')
|
||||
const startDay = String(start.getDate()).padStart(2, '0')
|
||||
|
||||
|
||||
startDate = `${startYear}-${startMonth}-${startDay}`
|
||||
endDate = todayStr
|
||||
}
|
||||
@@ -148,11 +160,18 @@ function ExportPage() {
|
||||
// 监听导出进度
|
||||
useEffect(() => {
|
||||
const removeListener = window.electronAPI.export.onProgress((data) => {
|
||||
// 将 phase 英文映射为中文描述
|
||||
const phaseMap: Record<string, string> = {
|
||||
'preparing': '正在准备...',
|
||||
'exporting': '正在导出消息...',
|
||||
'writing': '正在写入文件...',
|
||||
'complete': '导出完成'
|
||||
}
|
||||
setExportProgress({
|
||||
current: data.current || 0,
|
||||
total: data.total || 0,
|
||||
currentName: data.currentSession || '',
|
||||
phase: data.phase || '',
|
||||
phase: (data.phase ? phaseMap[data.phase] : undefined) || data.phase || '',
|
||||
detail: data.detail || ''
|
||||
})
|
||||
})
|
||||
@@ -258,18 +277,28 @@ function ExportPage() {
|
||||
return () => setTitleBarContent(null)
|
||||
}, [activeTab, setTitleBarContent])
|
||||
|
||||
// 聊天会话搜索过滤
|
||||
// 聊天会话搜索与类型过滤
|
||||
useEffect(() => {
|
||||
if (!searchKeyword.trim()) {
|
||||
setFilteredSessions(sessions)
|
||||
return
|
||||
let filtered = sessions
|
||||
|
||||
// 类型过滤
|
||||
if (sessionTypeFilter === 'group') {
|
||||
filtered = filtered.filter(s => s.username.includes('@chatroom'))
|
||||
} else if (sessionTypeFilter === 'private') {
|
||||
filtered = filtered.filter(s => !s.username.includes('@chatroom'))
|
||||
}
|
||||
const lower = searchKeyword.toLowerCase()
|
||||
setFilteredSessions(sessions.filter(s =>
|
||||
s.displayName?.toLowerCase().includes(lower) ||
|
||||
s.username.toLowerCase().includes(lower)
|
||||
))
|
||||
}, [searchKeyword, sessions])
|
||||
|
||||
// 关键词过滤
|
||||
if (searchKeyword.trim()) {
|
||||
const lower = searchKeyword.toLowerCase()
|
||||
filtered = filtered.filter(s =>
|
||||
s.displayName?.toLowerCase().includes(lower) ||
|
||||
s.username.toLowerCase().includes(lower)
|
||||
)
|
||||
}
|
||||
|
||||
setFilteredSessions(filtered)
|
||||
}, [searchKeyword, sessions, sessionTypeFilter])
|
||||
|
||||
// 通讯录搜索过滤
|
||||
useEffect(() => {
|
||||
@@ -307,13 +336,29 @@ function ExportPage() {
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedSessions.size === filteredSessions.length) {
|
||||
if (selectedSessions.size === filteredSessions.length && filteredSessions.length > 0) {
|
||||
setSelectedSessions(new Set())
|
||||
} else {
|
||||
setSelectedSessions(new Set(filteredSessions.map(s => s.username)))
|
||||
}
|
||||
}
|
||||
|
||||
// 快捷选择:仅选群聊
|
||||
const selectOnlyGroups = () => {
|
||||
const groupUsernames = filteredSessions
|
||||
.filter(s => s.username.includes('@chatroom'))
|
||||
.map(s => s.username)
|
||||
setSelectedSessions(new Set(groupUsernames))
|
||||
}
|
||||
|
||||
// 快捷选择:仅选私聊
|
||||
const selectOnlyPrivate = () => {
|
||||
const privateUsernames = filteredSessions
|
||||
.filter(s => !s.username.includes('@chatroom'))
|
||||
.map(s => s.username)
|
||||
setSelectedSessions(new Set(privateUsernames))
|
||||
}
|
||||
|
||||
const toggleContact = (username: string) => {
|
||||
const newSet = new Set(selectedContacts)
|
||||
if (newSet.has(username)) {
|
||||
@@ -374,10 +419,14 @@ function ExportPage() {
|
||||
const exportOptions = {
|
||||
format: options.format,
|
||||
dateRange: (options.startDate && options.endDate) ? {
|
||||
start: Math.floor(new Date(options.startDate).getTime() / 1000),
|
||||
start: Math.floor(new Date(options.startDate + 'T00:00:00').getTime() / 1000),
|
||||
end: Math.floor(new Date(options.endDate + 'T23:59:59').getTime() / 1000)
|
||||
} : null,
|
||||
exportAvatars: options.exportAvatars
|
||||
exportAvatars: options.exportAvatars,
|
||||
exportImages: options.exportImages,
|
||||
exportVideos: options.exportVideos,
|
||||
exportEmojis: options.exportEmojis,
|
||||
exportVoices: options.exportVoices
|
||||
}
|
||||
|
||||
if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel' || options.format === 'html') {
|
||||
@@ -486,10 +535,43 @@ function ExportPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="select-actions">
|
||||
<button className="select-all-btn" onClick={toggleSelectAll}>
|
||||
{selectedSessions.size === filteredSessions.length && filteredSessions.length > 0 ? '取消全选' : '全选'}
|
||||
<div className="session-type-filter">
|
||||
<button
|
||||
className={`type-filter-btn ${sessionTypeFilter === 'all' ? 'active' : ''}`}
|
||||
onClick={() => setSessionTypeFilter('all')}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
<button
|
||||
className={`type-filter-btn ${sessionTypeFilter === 'group' ? 'active' : ''}`}
|
||||
onClick={() => setSessionTypeFilter('group')}
|
||||
>
|
||||
<Users size={13} />
|
||||
群聊
|
||||
</button>
|
||||
<button
|
||||
className={`type-filter-btn ${sessionTypeFilter === 'private' ? 'active' : ''}`}
|
||||
onClick={() => setSessionTypeFilter('private')}
|
||||
>
|
||||
<User size={13} />
|
||||
私聊
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="select-actions">
|
||||
<div className="select-actions-left">
|
||||
<button className="select-all-btn" onClick={toggleSelectAll}>
|
||||
{selectedSessions.size === filteredSessions.length && filteredSessions.length > 0 ? '取消全选' : '全选'}
|
||||
</button>
|
||||
<button className="select-type-btn" onClick={selectOnlyGroups} title="仅选中列表中的群聊">
|
||||
<Users size={12} />
|
||||
选群聊
|
||||
</button>
|
||||
<button className="select-type-btn" onClick={selectOnlyPrivate} title="仅选中列表中的私聊">
|
||||
<User size={12} />
|
||||
选私聊
|
||||
</button>
|
||||
</div>
|
||||
<span className="selected-count">已选 {selectedSessions.size} 个</span>
|
||||
</div>
|
||||
|
||||
@@ -578,8 +660,49 @@ function ExportPage() {
|
||||
onChange={e => setOptions(prev => ({ ...prev, exportAvatars: e.target.checked }))}
|
||||
/>
|
||||
<div className="custom-checkbox"></div>
|
||||
<CircleUserRound size={16} style={{ color: 'var(--text-tertiary)' }} />
|
||||
<span>导出头像</span>
|
||||
</label>
|
||||
<label className="checkbox-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.exportImages}
|
||||
onChange={e => setOptions(prev => ({ ...prev, exportImages: e.target.checked }))}
|
||||
/>
|
||||
<div className="custom-checkbox"></div>
|
||||
<Image size={16} style={{ color: 'var(--text-tertiary)' }} />
|
||||
<span>导出图片</span>
|
||||
</label>
|
||||
<label className="checkbox-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.exportVideos}
|
||||
onChange={e => setOptions(prev => ({ ...prev, exportVideos: e.target.checked }))}
|
||||
/>
|
||||
<div className="custom-checkbox"></div>
|
||||
<Video size={16} style={{ color: 'var(--text-tertiary)' }} />
|
||||
<span>导出视频</span>
|
||||
</label>
|
||||
<label className="checkbox-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.exportEmojis}
|
||||
onChange={e => setOptions(prev => ({ ...prev, exportEmojis: e.target.checked }))}
|
||||
/>
|
||||
<div className="custom-checkbox"></div>
|
||||
<Smile size={16} style={{ color: 'var(--text-tertiary)' }} />
|
||||
<span>导出表情包</span>
|
||||
</label>
|
||||
<label className="checkbox-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.exportVoices}
|
||||
onChange={e => setOptions(prev => ({ ...prev, exportVoices: e.target.checked }))}
|
||||
/>
|
||||
<div className="custom-checkbox"></div>
|
||||
<Mic size={16} style={{ color: 'var(--text-tertiary)' }} />
|
||||
<span>导出语音</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -824,8 +947,21 @@ function ExportPage() {
|
||||
</div>
|
||||
<h3>正在导出</h3>
|
||||
{exportProgress.phase && <p className="progress-phase">{exportProgress.phase}</p>}
|
||||
<p className="progress-text">{exportProgress.currentName || '准备中...'}</p>
|
||||
{exportProgress.currentName && (
|
||||
<p className="progress-text">当前会话: {exportProgress.currentName}</p>
|
||||
)}
|
||||
{exportProgress.detail && <p className="progress-detail">{exportProgress.detail}</p>}
|
||||
{!exportProgress.currentName && !exportProgress.detail && (
|
||||
<p className="progress-text">准备中...</p>
|
||||
)}
|
||||
<div className="progress-export-options">
|
||||
<span>格式: {options.format.toUpperCase()}</span>
|
||||
{options.exportImages && <span> · 含图片</span>}
|
||||
{options.exportVideos && <span> · 含视频</span>}
|
||||
{options.exportEmojis && <span> · 含表情</span>}
|
||||
{options.exportVoices && <span> · 含语音</span>}
|
||||
{options.exportAvatars && <span> · 含头像</span>}
|
||||
</div>
|
||||
{exportProgress.total > 0 && (
|
||||
<>
|
||||
<div className="progress-bar">
|
||||
@@ -834,7 +970,7 @@ function ExportPage() {
|
||||
style={{ width: `${(exportProgress.current / exportProgress.total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="progress-count">{exportProgress.current} / {exportProgress.total}</p>
|
||||
<p className="progress-count">{exportProgress.current} / {exportProgress.total} 个会话</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
4
src/types/electron.d.ts
vendored
4
src/types/electron.d.ts
vendored
@@ -44,6 +44,10 @@ export interface ElectronAPI {
|
||||
openFile: (options?: Electron.OpenDialogOptions) => Promise<Electron.OpenDialogReturnValue>
|
||||
saveFile: (options?: Electron.SaveDialogOptions) => Promise<Electron.SaveDialogReturnValue>
|
||||
}
|
||||
file: {
|
||||
delete: (filePath: string) => Promise<{ success: boolean; error?: string }>
|
||||
copy: (sourcePath: string, destPath: string) => Promise<{ success: boolean; error?: string }>
|
||||
}
|
||||
shell: {
|
||||
openPath: (path: string) => Promise<string>
|
||||
openExternal: (url: string) => Promise<void>
|
||||
|
||||
Reference in New Issue
Block a user