mirror of
https://mirror.skon.top/github.com/ILoveBingLu/CipherTalk
synced 2026-04-22 06:10:10 +08:00
feat(chat): 新增聊天记录独立窗口和日期查询功能
- 新增聊天记录独立窗口(ChatHistoryPage),支持在单独窗口中查看完整聊天记录 - 实现 createChatHistoryWindow 函数,支持窗口复用和主题适配 - 新增 IPC 处理器用于打开聊天记录窗口和获取单条消息 - 添加 getMessagesByDate 和 getDatesWithMessages 方法,支持按日期查询消息 - 在 preload.ts 中暴露新的 IPC 调用接口 - 新增 ChatHistoryPage.tsx 和 ChatHistoryPage.scss 组件文件 - 更新 package.json 依赖项和 package-lock.json - 更新 README.md,新增爱发电赞助支持入口 - 添加爱发电二维码图片资源 - 版本号更新至 2.1.6 - 优化聊天页面和设置页面的用户体验 - 更新类型定义和配置文件以支持新功能
This commit is contained in:
18
README.md
18
README.md
@@ -7,7 +7,7 @@
|
||||
**一款现代化的微信聊天记录查看与分析工具**
|
||||
|
||||
[](LICENSE)
|
||||
[](package.json)
|
||||
[](package.json)
|
||||
[]()
|
||||
[]()
|
||||
[]()
|
||||
@@ -19,6 +19,22 @@
|
||||
|
||||
---
|
||||
|
||||
## 💖 赞助支持
|
||||
|
||||
如果这个项目对你有帮助,欢迎通过爱发电支持我们的开发工作!
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://afdian.com/a/ILoveBingLu">
|
||||
<img src="aifadian.jpg" alt="爱发电" width="300" />
|
||||
</a>
|
||||
|
||||
你的支持是我们持续更新的动力 ❤️
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
<table>
|
||||
|
||||
BIN
aifadian.jpg
Normal file
BIN
aifadian.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
110
electron/main.ts
110
electron/main.ts
@@ -83,6 +83,8 @@ let purchaseWindow: BrowserWindow | null = null
|
||||
let aiSummaryWindow: BrowserWindow | null = null
|
||||
// 引导窗口实例
|
||||
let welcomeWindow: BrowserWindow | null = null
|
||||
// 聊天记录窗口实例
|
||||
let chatHistoryWindow: BrowserWindow | null = null
|
||||
|
||||
/**
|
||||
* 获取当前主题的 URL 查询参数
|
||||
@@ -310,6 +312,87 @@ function createGroupAnalyticsWindow() {
|
||||
return groupAnalyticsWindow
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建独立的聊天记录窗口
|
||||
*/
|
||||
function createChatHistoryWindow(sessionId: string, messageId: number) {
|
||||
// 如果已存在,聚焦到现有窗口
|
||||
if (chatHistoryWindow && !chatHistoryWindow.isDestroyed()) {
|
||||
if (chatHistoryWindow.isMinimized()) {
|
||||
chatHistoryWindow.restore()
|
||||
}
|
||||
chatHistoryWindow.focus()
|
||||
|
||||
// 导航到新记录
|
||||
const themeParams = getThemeQueryParams()
|
||||
if (process.env.VITE_DEV_SERVER_URL) {
|
||||
chatHistoryWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}?${themeParams}#/chat-history/${sessionId}/${messageId}`)
|
||||
} else {
|
||||
chatHistoryWindow.loadFile(join(__dirname, '../dist/index.html'), {
|
||||
hash: `/chat-history/${sessionId}/${messageId}`,
|
||||
query: { theme: configService?.get('theme') || 'cloud-dancer', mode: configService?.get('themeMode') || 'light' }
|
||||
})
|
||||
}
|
||||
return chatHistoryWindow
|
||||
}
|
||||
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||
const iconPath = isDev
|
||||
? join(__dirname, '../public/icon.ico')
|
||||
: join(process.resourcesPath, 'icon.ico')
|
||||
|
||||
const isDark = nativeTheme.shouldUseDarkColors
|
||||
|
||||
chatHistoryWindow = new BrowserWindow({
|
||||
width: 600,
|
||||
height: 800,
|
||||
minWidth: 400,
|
||||
minHeight: 500,
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
},
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: {
|
||||
color: '#00000000',
|
||||
symbolColor: isDark ? '#ffffff' : '#1a1a1a',
|
||||
height: 32
|
||||
},
|
||||
show: false,
|
||||
backgroundColor: isDark ? '#1A1A1A' : '#F0F0F0',
|
||||
autoHideMenuBar: true
|
||||
})
|
||||
|
||||
chatHistoryWindow.once('ready-to-show', () => {
|
||||
chatHistoryWindow?.show()
|
||||
})
|
||||
|
||||
const themeParams = getThemeQueryParams()
|
||||
if (process.env.VITE_DEV_SERVER_URL) {
|
||||
chatHistoryWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}?${themeParams}#/chat-history/${sessionId}/${messageId}`)
|
||||
|
||||
chatHistoryWindow.webContents.on('before-input-event', (event, input) => {
|
||||
if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
|
||||
chatHistoryWindow?.webContents.openDevTools()
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
chatHistoryWindow.loadFile(join(__dirname, '../dist/index.html'), {
|
||||
hash: `/chat-history/${sessionId}/${messageId}`,
|
||||
query: { theme: configService?.get('theme') || 'cloud-dancer', mode: configService?.get('themeMode') || 'light' }
|
||||
})
|
||||
}
|
||||
|
||||
chatHistoryWindow.on('closed', () => {
|
||||
chatHistoryWindow = null
|
||||
})
|
||||
|
||||
return chatHistoryWindow
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建独立的年度报告窗口
|
||||
*/
|
||||
@@ -1083,6 +1166,17 @@ function registerIpcHandlers() {
|
||||
return true
|
||||
})
|
||||
|
||||
// 打开聊天记录窗口
|
||||
ipcMain.handle('window:openChatHistoryWindow', (_, sessionId: string, messageId: number) => {
|
||||
createChatHistoryWindow(sessionId, messageId)
|
||||
return true
|
||||
})
|
||||
|
||||
// 获取单条消息
|
||||
ipcMain.handle('chat:getMessage', async (_, sessionId: string, localId: number) => {
|
||||
return chatService.getMessageByLocalId(sessionId, localId)
|
||||
})
|
||||
|
||||
// 更新窗口控件主题色
|
||||
ipcMain.on('window:setTitleBarOverlay', (event, options: { symbolColor: string }) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
@@ -1599,6 +1693,22 @@ function registerIpcHandlers() {
|
||||
return result
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getMessagesByDate', async (_, sessionId: string, targetTimestamp: number, limit?: number) => {
|
||||
const result = await chatService.getMessagesByDate(sessionId, targetTimestamp, limit)
|
||||
if (!result.success) {
|
||||
logService?.warn('Chat', '按日期获取消息失败', { sessionId, targetTimestamp, error: result.error })
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getDatesWithMessages', async (_, sessionId: string, year: number, month: number) => {
|
||||
const result = await chatService.getDatesWithMessages(sessionId, year, month)
|
||||
if (!result.success) {
|
||||
logService?.warn('Chat', '获取有消息日期失败', { sessionId, year, month, error: result.error })
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// 导出相关
|
||||
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => {
|
||||
return exportService.exportSessions(sessionIds, outputDir, options, (progress) => {
|
||||
|
||||
@@ -73,6 +73,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
|
||||
openBrowserWindow: (url: string, title?: string) => ipcRenderer.invoke('window:openBrowserWindow', url, title),
|
||||
openAISummaryWindow: (sessionId: string, sessionName: string) => ipcRenderer.invoke('window:openAISummaryWindow', sessionId, sessionName),
|
||||
openChatHistoryWindow: (sessionId: string, messageId: number) => ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId),
|
||||
resizeToFitVideo: (videoWidth: number, videoHeight: number) => ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight),
|
||||
splashReady: () => ipcRenderer.send('window:splashReady'),
|
||||
onSplashFadeOut: (callback: () => void) => {
|
||||
@@ -202,6 +203,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
setCurrentSession: (sessionId: string | null) => ipcRenderer.invoke('chat:setCurrentSession', sessionId),
|
||||
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
|
||||
getVoiceData: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime),
|
||||
getMessagesByDate: (sessionId: string, targetTimestamp: number, limit?: number) =>
|
||||
ipcRenderer.invoke('chat:getMessagesByDate', sessionId, targetTimestamp, limit),
|
||||
getMessage: (sessionId: string, localId: number) => ipcRenderer.invoke('chat:getMessage', sessionId, localId),
|
||||
getDatesWithMessages: (sessionId: string, year: number, month: number) =>
|
||||
ipcRenderer.invoke('chat:getDatesWithMessages', sessionId, year, month),
|
||||
onSessionsUpdated: (callback: (sessions: any[]) => void) => {
|
||||
const listener = (_: any, sessions: any[]) => callback(sessions)
|
||||
ipcRenderer.on('chat:sessions-updated', listener)
|
||||
|
||||
@@ -205,84 +205,172 @@ ${detailInstructions[detail as keyof typeof detailInstructions] || detailInstruc
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单的 XML 值提取辅助函数
|
||||
*/
|
||||
private extractXmlValue(xml: string, tagName: string): string {
|
||||
if (!xml) return ''
|
||||
const regex = new RegExp(`<${tagName}(?:\\s+[^>]*)?>([\\s\\S]*?)</${tagName}>`, 'i')
|
||||
const match = regex.exec(xml)
|
||||
return match ? match[1].replace(/<!\[CDATA\[(.*?)]]>/g, '$1').trim() : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化消息
|
||||
*/
|
||||
/**
|
||||
* 格式化消息
|
||||
* 格式化消息(完全依赖后端解析结果,不重复解析)
|
||||
*/
|
||||
private formatMessages(messages: Message[], contacts: Map<string, Contact>, sessionId: string): string {
|
||||
return messages.map(msg => {
|
||||
const formattedLines: string[] = []
|
||||
|
||||
messages.forEach(msg => {
|
||||
// 获取发送者显示名称
|
||||
const contact = contacts.get(msg.senderUsername || '')
|
||||
const sender = contact?.remark || contact?.nickName || msg.senderUsername || '未知'
|
||||
|
||||
// 格式化时间
|
||||
const time = new Date(msg.createTime * 1000).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
// 格式化时间:YYYY-MM-DD-HH:MM:SS
|
||||
const date = new Date(msg.createTime * 1000)
|
||||
const time = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}-${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`
|
||||
|
||||
// 调试日志:检查聊天记录消息
|
||||
if (msg.parsedContent && msg.parsedContent.includes('[聊天记录]')) {
|
||||
console.log('[AIService] 发现聊天记录消息:', {
|
||||
localType: msg.localType,
|
||||
parsedContent: msg.parsedContent.substring(0, 100),
|
||||
hasChatRecordList: !!msg.chatRecordList,
|
||||
chatRecordListLength: msg.chatRecordList?.length || 0,
|
||||
rawContentPreview: msg.rawContent?.substring(0, 200)
|
||||
})
|
||||
}
|
||||
|
||||
// 处理不同类型的消息
|
||||
let content = msg.parsedContent || ''
|
||||
let content = ''
|
||||
let messageType = '文本'
|
||||
|
||||
// 语音消息 (Type 34)
|
||||
if (msg.localType === 34) {
|
||||
// 尝试获取转写缓存
|
||||
// 注意:转写服务使用的是会话ID+创建时间作为键
|
||||
// 特殊处理1:聊天记录(有详细列表)
|
||||
// 后端在 parseChatHistory() 中检查 <type>19</type> 并填充 chatRecordList
|
||||
if (msg.chatRecordList && msg.chatRecordList.length > 0) {
|
||||
messageType = '聊天记录'
|
||||
const recordCount = msg.chatRecordList.length
|
||||
const recordLines: string[] = []
|
||||
|
||||
// 从 parsedContent 提取标题(格式:[聊天记录] 标题)
|
||||
let title = '聊天记录'
|
||||
if (msg.parsedContent && msg.parsedContent.startsWith('[聊天记录]')) {
|
||||
title = msg.parsedContent.replace('[聊天记录]', '').trim() || '聊天记录'
|
||||
}
|
||||
|
||||
recordLines.push(title)
|
||||
recordLines.push(`共${recordCount}条消息:`)
|
||||
|
||||
// 遍历聊天记录列表
|
||||
msg.chatRecordList.forEach((record, index) => {
|
||||
const recordSender = record.sourcename || '未知'
|
||||
|
||||
// 根据datatype判断消息类型
|
||||
let recordContent = ''
|
||||
if (record.datatype === 1) {
|
||||
// 文本消息
|
||||
recordContent = record.datadesc || record.datatitle || ''
|
||||
} else if (record.datatype === 3) {
|
||||
recordContent = '[图片]'
|
||||
} else if (record.datatype === 34) {
|
||||
recordContent = '[语音]'
|
||||
} else if (record.datatype === 43) {
|
||||
recordContent = '[视频]'
|
||||
} else if (record.datatype === 47) {
|
||||
recordContent = '[表情包]'
|
||||
} else if (record.datatype === 8 || record.datatype === 49) {
|
||||
// 文件消息
|
||||
recordContent = `[文件] ${record.datatitle || record.datadesc || ''}`
|
||||
} else {
|
||||
recordContent = record.datadesc || record.datatitle || '[媒体消息]'
|
||||
}
|
||||
|
||||
recordLines.push(` 第${index + 1}条 - ${recordSender}: ${recordContent}`)
|
||||
})
|
||||
|
||||
content = recordLines.join('\n')
|
||||
}
|
||||
// 特殊处理2:语音消息 - 尝试获取转写文本
|
||||
else if (msg.localType === 34) {
|
||||
messageType = '语音'
|
||||
const transcript = voiceTranscribeService.getCachedTranscript(sessionId, msg.createTime)
|
||||
content = transcript ? `[语音] ${transcript}` : '[语音]'
|
||||
content = transcript || msg.parsedContent || '[语音消息]'
|
||||
}
|
||||
// 视频 (Type 43)
|
||||
else if (msg.localType === 43) {
|
||||
content = '[视频]'
|
||||
// 特殊处理3:撤回消息 - 跳过
|
||||
else if (msg.localType === 10002) {
|
||||
return
|
||||
}
|
||||
// 表情包 (Type 47)
|
||||
else if (msg.localType === 47) {
|
||||
// 尝试从 rawContent 提取信息
|
||||
const raw = msg.rawContent || ''
|
||||
// 尝试提取 cdnurl 或其他标识,但通常表情包没有有意义的文本名字
|
||||
// 这里主要区分是自定义表情还是商店表情
|
||||
const md5 = this.extractXmlValue(raw, 'md5')
|
||||
content = md5 ? `[表情包]` : '[表情包]'
|
||||
}
|
||||
// 文件/链接 (Type 49)
|
||||
else if (msg.localType === 49) {
|
||||
// 提取标题和链接/描述
|
||||
const raw = msg.rawContent || ''
|
||||
const title = this.extractXmlValue(raw, 'title')
|
||||
const url = this.extractXmlValue(raw, 'url')
|
||||
const desc = this.extractXmlValue(raw, 'des')
|
||||
|
||||
let label = '[文件/链接]'
|
||||
const type = this.extractXmlValue(raw, 'type')
|
||||
if (type === '5') label = '[链接]' // 网页链接
|
||||
if (type === '6') label = '[文件]' // 文件
|
||||
if (type === '33' || type === '36') label = '[小程序]'
|
||||
if (type === '57') label = '[引用]' // 引用产生的 AppMsg
|
||||
|
||||
if (title) {
|
||||
content = `${label} ${title}`
|
||||
if (url && type === '5') content += ` (${url})`
|
||||
else if (desc && type !== '57' && desc.length < 50) content += ` - ${desc}`
|
||||
// 其他所有消息:直接使用后端解析的 parsedContent
|
||||
else {
|
||||
content = msg.parsedContent || '[消息]'
|
||||
|
||||
// 根据 parsedContent 的前缀判断消息类型
|
||||
if (content.startsWith('[图片]')) {
|
||||
messageType = '图片'
|
||||
} else if (content.startsWith('[视频]')) {
|
||||
messageType = '视频'
|
||||
} else if (content.startsWith('[动画表情]') || content.startsWith('[表情包]')) {
|
||||
messageType = '表情包'
|
||||
} else if (content.startsWith('[文件]')) {
|
||||
messageType = '文件'
|
||||
} else if (content.startsWith('[转账]')) {
|
||||
messageType = '转账'
|
||||
} else if (content.startsWith('[链接]')) {
|
||||
messageType = '链接'
|
||||
} else if (content.startsWith('[小程序]')) {
|
||||
messageType = '小程序'
|
||||
} else if (content.startsWith('[聊天记录]')) {
|
||||
messageType = '聊天记录'
|
||||
} else if (content.startsWith('[引用消息]') || msg.localType === 244813135921) {
|
||||
messageType = '引用'
|
||||
} else if (content.startsWith('[位置]')) {
|
||||
messageType = '位置'
|
||||
} else if (content.startsWith('[名片]')) {
|
||||
messageType = '名片'
|
||||
} else if (content.startsWith('[通话]')) {
|
||||
messageType = '通话'
|
||||
} else if (msg.localType === 10000) {
|
||||
messageType = '系统'
|
||||
} else if (msg.localType === 1) {
|
||||
messageType = '文本'
|
||||
} else {
|
||||
content = label
|
||||
// 未知类型,记录日志以便调试
|
||||
console.log(`[AIService] 未知消息类型: localType=${msg.localType}, parsedContent=${content.substring(0, 100)}`)
|
||||
messageType = '未知'
|
||||
}
|
||||
}
|
||||
|
||||
return `[${time}] ${sender}: ${content}`
|
||||
}).join('\n')
|
||||
// 跳过空内容的消息(但保留图片、视频、表情包等媒体消息)
|
||||
if (!content && messageType !== '图片' && messageType !== '视频' && messageType !== '表情包') {
|
||||
return
|
||||
}
|
||||
|
||||
// 格式化输出:[消息类型] {发送者:时间 内容}
|
||||
if (messageType === '文本') {
|
||||
formattedLines.push(`[文本] {${sender}:${time} ${content}}`)
|
||||
} else if (messageType === '转账') {
|
||||
formattedLines.push(`[转账] {${sender}:${time} ${content}}`)
|
||||
} else if (messageType === '链接') {
|
||||
formattedLines.push(`[链接] {${sender}:${time} ${content}}`)
|
||||
} else if (messageType === '文件') {
|
||||
formattedLines.push(`[文件] {${sender}:${time} ${content}}`)
|
||||
} else if (messageType === '语音') {
|
||||
formattedLines.push(`[语音] {${sender}:${time} ${content}}`)
|
||||
} else if (messageType === '图片') {
|
||||
formattedLines.push(`[图片] {${sender}:${time}}`)
|
||||
} else if (messageType === '视频') {
|
||||
formattedLines.push(`[视频] {${sender}:${time}}`)
|
||||
} else if (messageType === '表情包') {
|
||||
formattedLines.push(`[表情包] {${sender}:${time}}`)
|
||||
} else if (messageType === '小程序') {
|
||||
formattedLines.push(`[小程序] {${sender}:${time} ${content}}`)
|
||||
} else if (messageType === '聊天记录') {
|
||||
formattedLines.push(`[聊天记录] {${sender}:${time} ${content}}`)
|
||||
} else if (messageType === '引用') {
|
||||
formattedLines.push(`[引用] {${sender}:${time} ${content}}`)
|
||||
} else if (messageType === '位置') {
|
||||
formattedLines.push(`[位置] {${sender}:${time} ${content}}`)
|
||||
} else if (messageType === '名片') {
|
||||
formattedLines.push(`[名片] {${sender}:${time} ${content}}`)
|
||||
} else if (messageType === '通话') {
|
||||
formattedLines.push(`[通话] {${sender}:${time} ${content}}`)
|
||||
} else if (messageType === '系统') {
|
||||
formattedLines.push(`[系统消息] {${time} ${content}}`)
|
||||
} else {
|
||||
formattedLines.push(`[${messageType}] {${sender}:${time} ${content}}`)
|
||||
}
|
||||
})
|
||||
|
||||
return formattedLines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface Message {
|
||||
// 引用消息相关
|
||||
quotedContent?: string
|
||||
quotedSender?: string
|
||||
quotedImageMd5?: string
|
||||
// 图片相关
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
@@ -60,6 +61,30 @@ export interface Message {
|
||||
fileSize?: number // 文件大小(字节)
|
||||
fileExt?: string // 文件扩展名
|
||||
fileMd5?: string // 文件 MD5
|
||||
chatRecordList?: ChatRecordItem[] // 聊天记录列表 (Type 19)
|
||||
}
|
||||
|
||||
export interface ChatRecordItem {
|
||||
datatype: number
|
||||
datadesc?: string
|
||||
datatitle?: string
|
||||
sourcename?: string
|
||||
sourcetime?: string
|
||||
sourceheadurl?: string
|
||||
fileext?: string
|
||||
datasize?: number
|
||||
messageuuid?: string
|
||||
// 媒体信息
|
||||
dataurl?: string
|
||||
datathumburl?: string
|
||||
datacdnurl?: string
|
||||
qaeskey?: string
|
||||
aeskey?: string
|
||||
md5?: string
|
||||
imgheight?: number
|
||||
imgwidth?: number
|
||||
thumbheadurl?: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
@@ -1090,6 +1115,7 @@ class ChatService extends EventEmitter {
|
||||
let emojiProductId: string | undefined
|
||||
let quotedContent: string | undefined
|
||||
let quotedSender: string | undefined
|
||||
let quotedImageMd5: string | undefined
|
||||
let imageMd5: string | undefined
|
||||
let imageDatName: string | undefined
|
||||
let videoMd5: string | undefined
|
||||
@@ -1115,6 +1141,7 @@ class ChatService extends EventEmitter {
|
||||
const quoteInfo = this.parseQuoteMessage(content)
|
||||
quotedContent = quoteInfo.content
|
||||
quotedSender = quoteInfo.sender
|
||||
quotedImageMd5 = quoteInfo.imageMd5
|
||||
}
|
||||
|
||||
// 解析文件消息 (localType === 49 且 XML 中 type=6)
|
||||
@@ -1130,6 +1157,16 @@ class ChatService extends EventEmitter {
|
||||
fileMd5 = fileInfo.fileMd5
|
||||
}
|
||||
|
||||
// 解析聊天记录 (localType === 49 且 XML 中 type=19,或者直接检查 XML type=19)
|
||||
let chatRecordList: ChatRecordItem[] | undefined
|
||||
if (content) {
|
||||
// 先检查 XML 中是否有 type=19
|
||||
const xmlType = this.extractXmlValue(content, 'type')
|
||||
if (xmlType === '19' || localType === 49) {
|
||||
chatRecordList = this.parseChatHistory(content)
|
||||
}
|
||||
}
|
||||
|
||||
const parsedContent = this.parseMessageContent(content, localType)
|
||||
|
||||
allMessages.push({
|
||||
@@ -1147,6 +1184,7 @@ class ChatService extends EventEmitter {
|
||||
productId: emojiProductId,
|
||||
quotedContent,
|
||||
quotedSender,
|
||||
quotedImageMd5,
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
videoMd5,
|
||||
@@ -1154,7 +1192,8 @@ class ChatService extends EventEmitter {
|
||||
fileName,
|
||||
fileSize,
|
||||
fileExt,
|
||||
fileMd5
|
||||
fileMd5,
|
||||
chatRecordList
|
||||
})
|
||||
}
|
||||
} catch (e: any) {
|
||||
@@ -1210,6 +1249,286 @@ class ChatService extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据日期获取消息(用于日期跳转)
|
||||
* @param sessionId 会话ID
|
||||
* @param targetTimestamp 目标日期的 Unix 时间戳(秒)
|
||||
* @param limit 返回消息数量
|
||||
* @returns 返回目标日期当天或之后最近的消息列表
|
||||
*/
|
||||
async getMessagesByDate(
|
||||
sessionId: string,
|
||||
targetTimestamp: number,
|
||||
limit: number = 50
|
||||
): Promise<{ success: boolean; messages?: Message[]; targetIndex?: number; error?: string }> {
|
||||
try {
|
||||
if (!this.dbDir) {
|
||||
const connectResult = await this.connect()
|
||||
if (!connectResult.success) {
|
||||
return { success: false, error: connectResult.error || '数据库未连接' }
|
||||
}
|
||||
}
|
||||
|
||||
const myWxid = this.configService.get('myWxid')
|
||||
const cleanedMyWxid = myWxid ? this.cleanAccountDirName(myWxid) : ''
|
||||
|
||||
const dbTablePairs = this.findSessionTables(sessionId)
|
||||
if (dbTablePairs.length === 0) {
|
||||
return { success: false, error: '未找到该会话的消息表' }
|
||||
}
|
||||
|
||||
// 计算目标日期的开始时间戳(当天 00:00:00)
|
||||
const targetDate = new Date(targetTimestamp * 1000)
|
||||
targetDate.setHours(0, 0, 0, 0)
|
||||
const dayStartTimestamp = Math.floor(targetDate.getTime() / 1000)
|
||||
|
||||
// 从所有数据库查找目标日期或之后的第一条消息
|
||||
let allMessages: Message[] = []
|
||||
|
||||
for (const { db, tableName, dbPath } of dbTablePairs) {
|
||||
try {
|
||||
const hasName2IdTable = this.checkTableExists(db, 'Name2Id')
|
||||
|
||||
let myRowId: number | null = null
|
||||
if (myWxid && hasName2IdTable) {
|
||||
const cacheKeyOriginal = `${dbPath}:${myWxid}`
|
||||
const cachedRowIdOriginal = this.myRowIdCache.get(cacheKeyOriginal)
|
||||
|
||||
if (cachedRowIdOriginal !== undefined) {
|
||||
myRowId = cachedRowIdOriginal
|
||||
} else {
|
||||
const row = db.prepare('SELECT rowid FROM Name2Id WHERE user_name = ?').get(myWxid) as any
|
||||
if (row?.rowid) {
|
||||
myRowId = row.rowid
|
||||
this.myRowIdCache.set(cacheKeyOriginal, myRowId)
|
||||
} else if (cleanedMyWxid && cleanedMyWxid !== myWxid) {
|
||||
const cacheKeyCleaned = `${dbPath}:${cleanedMyWxid}`
|
||||
const cachedRowIdCleaned = this.myRowIdCache.get(cacheKeyCleaned)
|
||||
|
||||
if (cachedRowIdCleaned !== undefined) {
|
||||
myRowId = cachedRowIdCleaned
|
||||
} else {
|
||||
const row2 = db.prepare('SELECT rowid FROM Name2Id WHERE user_name = ?').get(cleanedMyWxid) as any
|
||||
myRowId = row2?.rowid ?? null
|
||||
this.myRowIdCache.set(cacheKeyCleaned, myRowId)
|
||||
}
|
||||
} else {
|
||||
this.myRowIdCache.set(cacheKeyOriginal, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 查询目标日期或之后的消息,按时间升序获取
|
||||
let sql: string
|
||||
let rows: any[]
|
||||
|
||||
if (hasName2IdTable && myRowId !== null) {
|
||||
sql = `SELECT m.*,
|
||||
CASE WHEN m.real_sender_id = ? THEN 1 ELSE 0 END AS computed_is_send,
|
||||
n.user_name AS sender_username
|
||||
FROM ${tableName} m
|
||||
LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid
|
||||
WHERE m.create_time >= ?
|
||||
ORDER BY m.create_time ASC, m.sort_seq ASC
|
||||
LIMIT ?`
|
||||
rows = db.prepare(sql).all(myRowId, dayStartTimestamp, limit * 2) as any[]
|
||||
} else if (hasName2IdTable) {
|
||||
sql = `SELECT m.*, n.user_name AS sender_username
|
||||
FROM ${tableName} m
|
||||
LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid
|
||||
WHERE m.create_time >= ?
|
||||
ORDER BY m.create_time ASC, m.sort_seq ASC
|
||||
LIMIT ?`
|
||||
rows = db.prepare(sql).all(dayStartTimestamp, limit * 2) as any[]
|
||||
} else {
|
||||
sql = `SELECT * FROM ${tableName}
|
||||
WHERE create_time >= ?
|
||||
ORDER BY create_time ASC, sort_seq ASC
|
||||
LIMIT ?`
|
||||
rows = db.prepare(sql).all(dayStartTimestamp, limit * 2) as any[]
|
||||
}
|
||||
|
||||
// 处理消息
|
||||
for (const row of rows) {
|
||||
const content = this.decodeMessageContent(row.message_content, row.compress_content)
|
||||
const localType = row.local_type || row.type || 1
|
||||
const isSend = row.computed_is_send ?? row.is_send ?? null
|
||||
|
||||
let emojiCdnUrl: string | undefined
|
||||
let emojiMd5: string | undefined
|
||||
let emojiProductId: string | undefined
|
||||
let quotedContent: string | undefined
|
||||
let quotedSender: string | undefined
|
||||
let quotedImageMd5: string | undefined
|
||||
let imageMd5: string | undefined
|
||||
let imageDatName: string | undefined
|
||||
let videoMd5: string | undefined
|
||||
let voiceDuration: number | undefined
|
||||
|
||||
if (localType === 47 && content) {
|
||||
const emojiInfo = this.parseEmojiInfo(content)
|
||||
emojiCdnUrl = emojiInfo.cdnUrl
|
||||
emojiMd5 = emojiInfo.md5
|
||||
emojiProductId = emojiInfo.productId
|
||||
} else if (localType === 3 && content) {
|
||||
const imageInfo = this.parseImageInfo(content)
|
||||
imageMd5 = imageInfo.md5
|
||||
imageDatName = this.parseImageDatNameFromRow(row)
|
||||
} else if (localType === 43 && content) {
|
||||
videoMd5 = this.parseVideoMd5(content)
|
||||
} else if (localType === 34 && content) {
|
||||
voiceDuration = this.parseVoiceDuration(content)
|
||||
} else if (localType === 244813135921 || (content && content.includes('<type>57</type>'))) {
|
||||
const quoteInfo = this.parseQuoteMessage(content)
|
||||
quotedContent = quoteInfo.content
|
||||
quotedSender = quoteInfo.sender
|
||||
quotedImageMd5 = quoteInfo.imageMd5
|
||||
}
|
||||
|
||||
let fileName: string | undefined
|
||||
let fileSize: number | undefined
|
||||
let fileExt: string | undefined
|
||||
let fileMd5: string | undefined
|
||||
if (localType === 49 && content) {
|
||||
const fileInfo = this.parseFileInfo(content)
|
||||
fileName = fileInfo.fileName
|
||||
fileSize = fileInfo.fileSize
|
||||
fileExt = fileInfo.fileExt
|
||||
fileMd5 = fileInfo.fileMd5
|
||||
}
|
||||
|
||||
// 解析聊天记录 (检查 XML type=19)
|
||||
let chatRecordList: ChatRecordItem[] | undefined
|
||||
if (content) {
|
||||
const xmlType = this.extractXmlValue(content, 'type')
|
||||
if (xmlType === '19' || localType === 49) {
|
||||
chatRecordList = this.parseChatHistory(content)
|
||||
}
|
||||
}
|
||||
|
||||
const parsedContent = this.parseMessageContent(content, localType)
|
||||
|
||||
allMessages.push({
|
||||
localId: row.local_id || 0,
|
||||
serverId: row.server_id || 0,
|
||||
localType,
|
||||
createTime: row.create_time || 0,
|
||||
sortSeq: row.sort_seq || 0,
|
||||
isSend,
|
||||
senderUsername: row.sender_username || null,
|
||||
parsedContent,
|
||||
rawContent: content,
|
||||
emojiCdnUrl,
|
||||
emojiMd5,
|
||||
productId: emojiProductId,
|
||||
quotedContent,
|
||||
quotedSender,
|
||||
quotedImageMd5,
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
videoMd5,
|
||||
voiceDuration,
|
||||
fileName,
|
||||
fileSize,
|
||||
fileExt,
|
||||
fileMd5,
|
||||
chatRecordList
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('ChatService: 按日期查询消息失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 按时间升序排序
|
||||
allMessages.sort((a, b) => a.createTime - b.createTime || a.sortSeq - b.sortSeq)
|
||||
|
||||
// 去重
|
||||
const seen = new Set<string>()
|
||||
allMessages = allMessages.filter(msg => {
|
||||
const key = `${msg.serverId}-${msg.localId}-${msg.createTime}-${msg.sortSeq}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
|
||||
// 取前 limit 条
|
||||
const messages = allMessages.slice(0, limit)
|
||||
|
||||
if (messages.length === 0) {
|
||||
return { success: true, messages: [], targetIndex: -1 }
|
||||
}
|
||||
|
||||
return { success: true, messages, targetIndex: 0 }
|
||||
} catch (e) {
|
||||
console.error('ChatService: 按日期获取消息失败:', e)
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定月份中有消息的日期列表
|
||||
* @param sessionId 会话ID
|
||||
* @param year 年份
|
||||
* @param month 月份 (1-12)
|
||||
* @returns 有消息的日期字符串列表 (YYYY-MM-DD)
|
||||
*/
|
||||
async getDatesWithMessages(
|
||||
sessionId: string,
|
||||
year: number,
|
||||
month: number
|
||||
): Promise<{ success: boolean; dates?: string[]; error?: string }> {
|
||||
try {
|
||||
if (!this.dbDir) {
|
||||
const connectResult = await this.connect()
|
||||
if (!connectResult.success) {
|
||||
return { success: false, error: connectResult.error || '数据库未连接' }
|
||||
}
|
||||
}
|
||||
|
||||
const dbTablePairs = this.findSessionTables(sessionId)
|
||||
if (dbTablePairs.length === 0) {
|
||||
return { success: true, dates: [] }
|
||||
}
|
||||
|
||||
// 计算该月的起止时间戳
|
||||
// 注意:month 参数是 1-12,但 Date 构造函数用 0-11
|
||||
const startDate = new Date(year, month - 1, 1, 0, 0, 0)
|
||||
const endDate = new Date(year, month, 0, 23, 59, 59, 999) // 下个月第0天即本月最后一天
|
||||
|
||||
const startTimestamp = Math.floor(startDate.getTime() / 1000)
|
||||
const endTimestamp = Math.floor(endDate.getTime() / 1000)
|
||||
|
||||
const datesSet = new Set<string>()
|
||||
|
||||
for (const { db, tableName } of dbTablePairs) {
|
||||
try {
|
||||
// 只查询 create_time 字段以优化性能
|
||||
const sql = `SELECT create_time FROM ${tableName}
|
||||
WHERE create_time BETWEEN ? AND ?`
|
||||
|
||||
const rows = db.prepare(sql).all(startTimestamp, endTimestamp) as { create_time: number }[]
|
||||
|
||||
for (const row of rows) {
|
||||
const date = new Date(row.create_time * 1000)
|
||||
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||
datesSet.add(dateStr)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`ChatService: 查询表 ${tableName} 日期失败`, e)
|
||||
}
|
||||
}
|
||||
|
||||
// 排序
|
||||
const sortedDates = Array.from(datesSet).sort()
|
||||
|
||||
return { success: true, dates: sortedDates }
|
||||
} catch (e) {
|
||||
console.error('ChatService: 获取有消息的日期失败:', e)
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析消息内容
|
||||
*/
|
||||
@@ -1254,11 +1573,20 @@ class ChatService extends EventEmitter {
|
||||
const title = this.extractXmlValue(content, 'title')
|
||||
return title || '[引用消息]'
|
||||
default:
|
||||
// 检查是否是 type=57 的引用消息
|
||||
if (xmlType === '57') {
|
||||
const title = this.extractXmlValue(content, 'title')
|
||||
return title || '[引用消息]'
|
||||
// 对于未知的 localType,检查 XML type 来判断消息类型
|
||||
if (xmlType) {
|
||||
// 如果有 XML type,尝试按 type 49 的逻辑解析
|
||||
if (xmlType === '2000' || xmlType === '5' || xmlType === '6' || xmlType === '19' ||
|
||||
xmlType === '33' || xmlType === '36' || xmlType === '49' || xmlType === '57') {
|
||||
return this.parseType49(content)
|
||||
}
|
||||
// type=57 的引用消息
|
||||
if (xmlType === '57') {
|
||||
const title = this.extractXmlValue(content, 'title')
|
||||
return title || '[引用消息]'
|
||||
}
|
||||
}
|
||||
// 其他情况
|
||||
if (content.length > 200) {
|
||||
return this.getMessageTypeLabel(localType)
|
||||
}
|
||||
@@ -1287,6 +1615,8 @@ class ChatService extends EventEmitter {
|
||||
return `[链接] ${title}`
|
||||
case '6':
|
||||
return `[文件] ${title}`
|
||||
case '19':
|
||||
return `[聊天记录] ${title}`
|
||||
case '33':
|
||||
case '36':
|
||||
return `[小程序] ${title}`
|
||||
@@ -1300,6 +1630,80 @@ class ChatService extends EventEmitter {
|
||||
return '[消息]'
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析合并转发的聊天记录 (Type 19)
|
||||
*/
|
||||
private parseChatHistory(content: string): ChatRecordItem[] | undefined {
|
||||
try {
|
||||
const type = this.extractXmlValue(content, 'type')
|
||||
if (type !== '19') return undefined
|
||||
|
||||
// 提取 recorditem 中的 CDATA
|
||||
// CDATA 格式: <recorditem><![CDATA[ ... ]]></recorditem>
|
||||
const match = /<recorditem>[\s\S]*?<!\[CDATA\[([\s\S]*?)\]\]>[\s\S]*?<\/recorditem>/.exec(content)
|
||||
if (!match) return undefined
|
||||
|
||||
const innerXml = match[1]
|
||||
|
||||
const items: ChatRecordItem[] = []
|
||||
// 使用更宽松的正则匹配 dataitem
|
||||
const itemRegex = /<dataitem\s+(.*?)>([\s\S]*?)<\/dataitem>/g
|
||||
let itemMatch
|
||||
|
||||
while ((itemMatch = itemRegex.exec(innerXml)) !== null) {
|
||||
const attrs = itemMatch[1]
|
||||
const body = itemMatch[2]
|
||||
|
||||
const datatypeMatch = /datatype="(\d+)"/.exec(attrs)
|
||||
const datatype = datatypeMatch ? parseInt(datatypeMatch[1]) : 0
|
||||
|
||||
const sourcename = this.extractXmlValue(body, 'sourcename')
|
||||
const sourcetime = this.extractXmlValue(body, 'sourcetime')
|
||||
const sourceheadurl = this.extractXmlValue(body, 'sourceheadurl')
|
||||
const datadesc = this.extractXmlValue(body, 'datadesc')
|
||||
const datatitle = this.extractXmlValue(body, 'datatitle')
|
||||
const fileext = this.extractXmlValue(body, 'fileext')
|
||||
const datasize = parseInt(this.extractXmlValue(body, 'datasize') || '0')
|
||||
const messageuuid = this.extractXmlValue(body, 'messageuuid')
|
||||
|
||||
// 提取媒体信息
|
||||
const dataurl = this.extractXmlValue(body, 'dataurl')
|
||||
const datathumburl = this.extractXmlValue(body, 'datathumburl') || this.extractXmlValue(body, 'thumburl')
|
||||
const datacdnurl = this.extractXmlValue(body, 'datacdnurl') || this.extractXmlValue(body, 'cdnurl')
|
||||
const aeskey = this.extractXmlValue(body, 'aeskey') || this.extractXmlValue(body, 'qaeskey')
|
||||
const md5 = this.extractXmlValue(body, 'md5') || this.extractXmlValue(body, 'datamd5')
|
||||
const imgheight = parseInt(this.extractXmlValue(body, 'imgheight') || '0')
|
||||
const imgwidth = parseInt(this.extractXmlValue(body, 'imgwidth') || '0')
|
||||
const duration = parseInt(this.extractXmlValue(body, 'duration') || '0')
|
||||
|
||||
items.push({
|
||||
datatype,
|
||||
sourcename,
|
||||
sourcetime,
|
||||
sourceheadurl,
|
||||
datadesc: this.decodeHtmlEntities(datadesc),
|
||||
datatitle: this.decodeHtmlEntities(datatitle),
|
||||
fileext,
|
||||
datasize,
|
||||
messageuuid,
|
||||
dataurl: this.decodeHtmlEntities(dataurl),
|
||||
datathumburl: this.decodeHtmlEntities(datathumburl),
|
||||
datacdnurl: this.decodeHtmlEntities(datacdnurl),
|
||||
aeskey: this.decodeHtmlEntities(aeskey),
|
||||
md5,
|
||||
imgheight,
|
||||
imgwidth,
|
||||
duration
|
||||
})
|
||||
}
|
||||
|
||||
return items.length > 0 ? items : undefined
|
||||
} catch (e) {
|
||||
console.error('ChatService: 解析聊天记录失败:', e)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析表情包信息
|
||||
*/
|
||||
@@ -1495,7 +1899,7 @@ class ChatService extends EventEmitter {
|
||||
/**
|
||||
* 解析引用消息
|
||||
*/
|
||||
private parseQuoteMessage(content: string): { content?: string; sender?: string } {
|
||||
private parseQuoteMessage(content: string): { content?: string; sender?: string; imageMd5?: string } {
|
||||
try {
|
||||
// 提取 refermsg 部分
|
||||
const referMsgStart = content.indexOf('<refermsg>')
|
||||
@@ -1514,9 +1918,11 @@ class ChatService extends EventEmitter {
|
||||
displayName = ''
|
||||
}
|
||||
|
||||
// 提取引用内容
|
||||
const referContent = this.extractXmlValue(referMsgXml, 'content')
|
||||
// 提取引用内容并解码
|
||||
let referContent = this.extractXmlValue(referMsgXml, 'content')
|
||||
referContent = this.decodeHtmlEntities(referContent)
|
||||
const referType = this.extractXmlValue(referMsgXml, 'type')
|
||||
let imageMd5: string | undefined
|
||||
|
||||
// 根据类型渲染引用内容
|
||||
let displayContent = referContent
|
||||
@@ -1527,6 +1933,9 @@ class ChatService extends EventEmitter {
|
||||
break
|
||||
case '3':
|
||||
displayContent = '[图片]'
|
||||
// 尝试从引用的内容 XML 中提取图片 MD5
|
||||
const innerMd5 = this.extractXmlValue(referContent, 'md5')
|
||||
imageMd5 = innerMd5 || undefined
|
||||
break
|
||||
case '34':
|
||||
displayContent = '[语音]'
|
||||
@@ -1538,7 +1947,8 @@ class ChatService extends EventEmitter {
|
||||
displayContent = '[动画表情]'
|
||||
break
|
||||
case '49':
|
||||
displayContent = '[链接]'
|
||||
const appTitle = this.extractXmlValue(referContent, 'title')
|
||||
displayContent = appTitle || '[链接]'
|
||||
break
|
||||
case '42':
|
||||
displayContent = '[名片]'
|
||||
@@ -1556,7 +1966,8 @@ class ChatService extends EventEmitter {
|
||||
|
||||
return {
|
||||
content: displayContent,
|
||||
sender: displayName || undefined
|
||||
sender: displayName || undefined,
|
||||
imageMd5
|
||||
}
|
||||
} catch {
|
||||
return {}
|
||||
@@ -3100,7 +3511,10 @@ class ChatService extends EventEmitter {
|
||||
/**
|
||||
* 获取单条消息
|
||||
*/
|
||||
private getMessageByLocalId(sessionId: string, localId: number): Message | null {
|
||||
/**
|
||||
* 获取单条消息
|
||||
*/
|
||||
public async getMessageByLocalId(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> {
|
||||
const dbTablePairs = this.findSessionTables(sessionId)
|
||||
|
||||
for (const { db, tableName } of dbTablePairs) {
|
||||
@@ -3111,15 +3525,22 @@ class ChatService extends EventEmitter {
|
||||
const localType = row.local_type || row.type || 1
|
||||
|
||||
return {
|
||||
localId: row.local_id || 0,
|
||||
serverId: row.server_id || 0,
|
||||
localType,
|
||||
createTime: row.create_time || 0,
|
||||
sortSeq: row.sort_seq || 0,
|
||||
isSend: row.is_send ?? null,
|
||||
senderUsername: row.sender_username || null,
|
||||
parsedContent: this.parseMessageContent(content, localType),
|
||||
rawContent: content
|
||||
success: true,
|
||||
message: {
|
||||
localId: row.local_id || 0,
|
||||
serverId: row.server_id || 0,
|
||||
localType,
|
||||
createTime: row.create_time || 0,
|
||||
sortSeq: row.sort_seq || 0,
|
||||
isSend: row.is_send ?? null,
|
||||
senderUsername: row.sender_username || null,
|
||||
parsedContent: this.parseMessageContent(content, localType),
|
||||
rawContent: content,
|
||||
chatRecordList: content ? (() => {
|
||||
const xmlType = this.extractXmlValue(content, 'type')
|
||||
return (xmlType === '19' || localType === 49) ? this.parseChatHistory(content) : undefined
|
||||
})() : undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -3127,7 +3548,7 @@ class ChatService extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
return { success: false, error: 'Message not found' }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3144,9 +3565,9 @@ class ChatService extends EventEmitter {
|
||||
// 如果没有传入 createTime,尝试从数据库获取
|
||||
let msgCreateTime = createTime
|
||||
if (!msgCreateTime) {
|
||||
const msg = this.getMessageByLocalId(sessionId, localId)
|
||||
if (msg) {
|
||||
msgCreateTime = msg.createTime
|
||||
const result = await this.getMessageByLocalId(sessionId, localId)
|
||||
if (result.success && result.message) {
|
||||
msgCreateTime = result.message.createTime
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3564,6 +3985,7 @@ class ChatService extends EventEmitter {
|
||||
let emojiProductId: string | undefined
|
||||
let quotedContent: string | undefined
|
||||
let quotedSender: string | undefined
|
||||
let quotedImageMd5: string | undefined
|
||||
let imageMd5: string | undefined
|
||||
let imageDatName: string | undefined
|
||||
let videoMd5: string | undefined
|
||||
@@ -3593,10 +4015,19 @@ class ChatService extends EventEmitter {
|
||||
fileSize = fileInfo.fileSize
|
||||
fileExt = fileInfo.fileExt
|
||||
fileMd5 = fileInfo.fileMd5
|
||||
}
|
||||
|
||||
let chatRecordList: ChatRecordItem[] | undefined
|
||||
if (content) {
|
||||
const xmlType = this.extractXmlValue(content, 'type')
|
||||
if (xmlType === '19' || localType === 49) {
|
||||
chatRecordList = this.parseChatHistory(content)
|
||||
}
|
||||
} else if (localType === 244813135921 || (content && content.includes('<type>57</type>'))) {
|
||||
const quoteInfo = this.parseQuoteMessage(content)
|
||||
quotedContent = quoteInfo.content
|
||||
quotedSender = quoteInfo.sender
|
||||
quotedImageMd5 = quoteInfo.imageMd5
|
||||
}
|
||||
|
||||
const parsedContent = this.parseMessageContent(content, localType)
|
||||
@@ -3616,6 +4047,7 @@ class ChatService extends EventEmitter {
|
||||
productId: emojiProductId,
|
||||
quotedContent,
|
||||
quotedSender,
|
||||
quotedImageMd5,
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
videoMd5,
|
||||
@@ -3623,7 +4055,8 @@ class ChatService extends EventEmitter {
|
||||
fileName,
|
||||
fileSize,
|
||||
fileExt,
|
||||
fileMd5
|
||||
fileMd5,
|
||||
chatRecordList
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ interface ConfigSchema {
|
||||
|
||||
// 数据管理相关
|
||||
skipIntegrityCheck: boolean
|
||||
autoUpdateDatabase: boolean // 是否自动更新数据库
|
||||
|
||||
// AI 相关
|
||||
aiCurrentProvider: string // 当前选中的提供商
|
||||
@@ -75,6 +76,7 @@ const defaults: ConfigSchema = {
|
||||
activationData: '',
|
||||
logLevel: 'WARN', // 默认只记录警告和错误
|
||||
skipIntegrityCheck: false, // 默认进行完整性检查
|
||||
autoUpdateDatabase: true, // 默认开启自动更新
|
||||
// AI 默认配置
|
||||
aiCurrentProvider: 'zhipu',
|
||||
aiProviderConfigs: {}, // 空对象,用户配置后填充
|
||||
@@ -148,12 +150,12 @@ export class ConfigService {
|
||||
const oldProviderRow = this.db.prepare("SELECT value FROM config WHERE key = 'aiProvider'").get() as { value: string } | undefined
|
||||
const oldApiKeyRow = this.db.prepare("SELECT value FROM config WHERE key = 'aiApiKey'").get() as { value: string } | undefined
|
||||
const oldModelRow = this.db.prepare("SELECT value FROM config WHERE key = 'aiModel'").get() as { value: string } | undefined
|
||||
|
||||
|
||||
if (oldProviderRow && oldApiKeyRow) {
|
||||
const oldProvider = JSON.parse(oldProviderRow.value)
|
||||
const oldApiKey = JSON.parse(oldApiKeyRow.value)
|
||||
const oldModel = oldModelRow ? JSON.parse(oldModelRow.value) : ''
|
||||
|
||||
|
||||
// 如果有旧配置且 API Key 不为空,迁移到新结构
|
||||
if (oldApiKey) {
|
||||
const newConfigs: any = {}
|
||||
@@ -161,13 +163,13 @@ export class ConfigService {
|
||||
apiKey: oldApiKey,
|
||||
model: oldModel
|
||||
}
|
||||
|
||||
|
||||
this.db.prepare("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)").run('aiCurrentProvider', JSON.stringify(oldProvider))
|
||||
this.db.prepare("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)").run('aiProviderConfigs', JSON.stringify(newConfigs))
|
||||
|
||||
|
||||
// 删除旧配置
|
||||
this.db.prepare("DELETE FROM config WHERE key IN ('aiProvider', 'aiApiKey', 'aiModel')").run()
|
||||
|
||||
|
||||
console.log('[Config] AI 配置已迁移到新结构')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1237,6 +1237,12 @@ class DataManagementService {
|
||||
* 启用自动更新(文件监听 + 定时检查)
|
||||
*/
|
||||
enableAutoUpdate(intervalSeconds: number = 30): void {
|
||||
// 检查配置是否允许自动更新
|
||||
if (!this.configService.get('autoUpdateDatabase')) {
|
||||
console.log('[DataManagement] 自动更新配置为关闭,跳过启动')
|
||||
return
|
||||
}
|
||||
|
||||
if (this.autoUpdateEnabled) {
|
||||
this.disableAutoUpdate()
|
||||
}
|
||||
@@ -1251,6 +1257,11 @@ class DataManagementService {
|
||||
this.autoUpdateInterval = setInterval(async () => {
|
||||
if (this.isUpdating) return
|
||||
|
||||
// 再次检查配置,以防运行时被修改
|
||||
if (!this.configService.get('autoUpdateDatabase')) {
|
||||
return
|
||||
}
|
||||
|
||||
const checkResult = await this.checkForUpdates()
|
||||
if (checkResult.hasUpdate) {
|
||||
// 通知监听器
|
||||
@@ -1342,6 +1353,9 @@ class DataManagementService {
|
||||
this.dbWatcher = fs.watch(dbStoragePath, { recursive: true }, async (eventType, filename) => {
|
||||
if (!filename || this.isUpdating) return
|
||||
|
||||
// 检查配置
|
||||
if (!this.configService.get('autoUpdateDatabase')) return
|
||||
|
||||
// 只监听 .db 文件
|
||||
if (!filename.toLowerCase().endsWith('.db')) return
|
||||
|
||||
@@ -1392,6 +1406,11 @@ class DataManagementService {
|
||||
* 触发更新(带频率限制和队列管理)
|
||||
*/
|
||||
private triggerUpdate(): void {
|
||||
// 检查配置
|
||||
if (!this.configService.get('autoUpdateDatabase')) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果正在更新,增加待处理计数
|
||||
if (this.isUpdating) {
|
||||
this.pendingUpdateCount++
|
||||
@@ -1432,6 +1451,11 @@ class DataManagementService {
|
||||
* @param silent 是否静默更新(不显示进度)
|
||||
*/
|
||||
async autoIncrementalUpdate(silent: boolean = false): Promise<{ success: boolean; updated: boolean; error?: string }> {
|
||||
// 检查配置
|
||||
if (!this.configService.get('autoUpdateDatabase')) {
|
||||
return { success: true, updated: false }
|
||||
}
|
||||
|
||||
if (this.isUpdating) {
|
||||
// 如果正在更新,返回待处理状态
|
||||
this.pendingUpdateCount++
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
865
electron/services/htmlExportGenerator.ts
Normal file
865
electron/services/htmlExportGenerator.ts
Normal file
@@ -0,0 +1,865 @@
|
||||
/**
|
||||
* HTML 导出生成器
|
||||
* 负责生成聊天记录的 HTML 展示页面
|
||||
* 使用外部资源引用,避免文件过大
|
||||
*/
|
||||
|
||||
export interface HtmlExportMessage {
|
||||
timestamp: number
|
||||
sender: string
|
||||
senderName: string
|
||||
type: number
|
||||
content: string | null
|
||||
rawContent: string
|
||||
isSend: boolean
|
||||
chatRecords?: HtmlChatRecord[]
|
||||
}
|
||||
|
||||
export interface HtmlChatRecord {
|
||||
sender: string
|
||||
senderDisplayName: string
|
||||
timestamp: number
|
||||
formattedTime: string
|
||||
type: string
|
||||
datatype: number
|
||||
content: string
|
||||
senderAvatar?: string
|
||||
fileExt?: string
|
||||
fileSize?: number
|
||||
}
|
||||
|
||||
export interface HtmlMember {
|
||||
id: string
|
||||
name: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export interface HtmlExportData {
|
||||
meta: {
|
||||
sessionId: string
|
||||
sessionName: string
|
||||
isGroup: boolean
|
||||
exportTime: number
|
||||
messageCount: number
|
||||
dateRange: { start: number; end: number } | null
|
||||
}
|
||||
members: HtmlMember[]
|
||||
messages: HtmlExportMessage[]
|
||||
}
|
||||
|
||||
export class HtmlExportGenerator {
|
||||
/**
|
||||
* 生成 HTML 主文件(引用外部 CSS 和 JS)
|
||||
*/
|
||||
static generateHtmlWithData(exportData: HtmlExportData): string {
|
||||
const escapedSessionName = this.escapeHtml(exportData.meta.sessionName)
|
||||
const dateRangeText = exportData.meta.dateRange
|
||||
? `${new Date(exportData.meta.dateRange.start * 1000).toLocaleDateString('zh-CN')} - ${new Date(exportData.meta.dateRange.end * 1000).toLocaleDateString('zh-CN')}`
|
||||
: ''
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${escapedSessionName} - 聊天记录</title>
|
||||
<link rel="stylesheet" href="./styles.css">
|
||||
<style>
|
||||
/* 仅保留关键的内联样式,确保基本布局 */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>${escapedSessionName}</h1>
|
||||
<div class="meta">
|
||||
<span>共 ${exportData.messages.length} 条消息</span>
|
||||
${dateRangeText ? `<span> | ${dateRangeText}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<input type="text" id="searchInput" placeholder="搜索消息内容..." />
|
||||
<button onclick="app.searchMessages()">搜索</button>
|
||||
<button onclick="app.clearSearch()">清除</button>
|
||||
<div class="stats">
|
||||
<span id="messageStats">共 ${exportData.messages.length} 条消息</span>
|
||||
<span id="loadedStats"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="scrollContainer" class="scroll-container">
|
||||
<div id="messagesContainer" class="messages">
|
||||
<div class="loading">正在加载聊天记录...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
由 CipherTalk 导出 | ${new Date(exportData.meta.exportTime).toLocaleString('zh-CN')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="./data.js"></script>
|
||||
<script src="./app.js"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成外部 CSS 文件
|
||||
*/
|
||||
static generateCss(): string {
|
||||
return `/* CipherTalk 聊天记录导出样式 */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
animation: slideIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 头部样式 */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 40px 30px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
|
||||
animation: pulse 15s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 32px;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 700;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
text-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.header .meta {
|
||||
font-size: 15px;
|
||||
opacity: 0.95;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 控制栏样式 */
|
||||
.controls {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
z-index: 100;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.controls input[type="text"] {
|
||||
flex: 1;
|
||||
min-width: 250px;
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.controls input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.controls button {
|
||||
padding: 12px 24px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.controls button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.controls button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.controls .stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.controls .stats span {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 滚动容器 */
|
||||
.scroll-container {
|
||||
height: calc(100vh - 280px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
will-change: scroll-position;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.scroll-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.scroll-container::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.scroll-container::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.scroll-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
/* 消息容器 */
|
||||
.messages {
|
||||
padding: 20px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.message-placeholder {
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.no-messages {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
.no-messages {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 消息样式 */
|
||||
.message {
|
||||
display: flex;
|
||||
margin-bottom: 20px;
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.message:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.message.sent {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.message .avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.message .avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.message .content-wrapper {
|
||||
max-width: 65%;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
.message.sent .content-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message .sender-name {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.message .bubble {
|
||||
background: white;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
position: relative;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
||||
transition: box-shadow 0.2s;
|
||||
max-width: 100%;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message .bubble:hover {
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
.message.sent .bubble {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.25);
|
||||
}
|
||||
|
||||
.message .time {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.message.sent .time {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 聊天记录引用 */
|
||||
.chat-records {
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(0,0,0,0.04);
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid #667eea;
|
||||
}
|
||||
|
||||
.message.sent .chat-records {
|
||||
background: rgba(255,255,255,0.15);
|
||||
border-left-color: rgba(255,255,255,0.6);
|
||||
}
|
||||
|
||||
.chat-records .title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
color: #667eea;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.message.sent .chat-records .title {
|
||||
color: rgba(255,255,255,0.95);
|
||||
}
|
||||
|
||||
.chat-record-item {
|
||||
font-size: 12px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.chat-record-item:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-record-item .record-sender {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.message.sent .chat-record-item .record-sender {
|
||||
color: rgba(255,255,255,0.95);
|
||||
}
|
||||
|
||||
.chat-record-item .record-time {
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.message.sent .chat-record-item .record-time {
|
||||
color: rgba(255,255,255,0.75);
|
||||
}
|
||||
|
||||
.chat-record-item .record-content {
|
||||
margin-top: 2px;
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.message.sent .chat-record-item .record-content {
|
||||
color: rgba(255,255,255,0.9);
|
||||
}
|
||||
|
||||
/* 页脚 */
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
border-top: 2px solid #f0f0f0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.container {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.controls input[type="text"] {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.controls .stats {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
margin-left: 0;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.scroll-container {
|
||||
height: calc(100vh - 320px);
|
||||
}
|
||||
|
||||
.messages {
|
||||
padding: 20px 15px;
|
||||
}
|
||||
|
||||
.message .content-wrapper {
|
||||
max-width: 75%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 打印样式 */
|
||||
@media print {
|
||||
body {
|
||||
background: white;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成数据 JS 文件(作为全局变量)
|
||||
*/
|
||||
static generateDataJs(exportData: HtmlExportData): string {
|
||||
return `// CipherTalk 聊天记录数据
|
||||
window.CHAT_DATA = ${JSON.stringify(exportData, null, 2)};`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成外部 JavaScript 文件
|
||||
*/
|
||||
static generateJs(): string {
|
||||
return `// CipherTalk 聊天记录导出应用
|
||||
|
||||
class ChatApp {
|
||||
constructor() {
|
||||
this.allData = window.CHAT_DATA;
|
||||
this.filteredMessages = this.allData.messages;
|
||||
|
||||
// 无感加载配置
|
||||
this.batchSize = 30; // 每次加载30条
|
||||
this.loadedCount = 0; // 已加载数量
|
||||
this.isLoading = false; // 是否正在加载
|
||||
|
||||
// DOM 元素
|
||||
this.scrollContainer = null;
|
||||
this.messagesContainer = null;
|
||||
this.loadMoreObserver = null;
|
||||
this.sentinel = null; // 哨兵元素
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
try {
|
||||
if (!this.allData) {
|
||||
throw new Error('数据加载失败');
|
||||
}
|
||||
|
||||
// 获取DOM元素
|
||||
this.scrollContainer = document.getElementById('scrollContainer');
|
||||
this.messagesContainer = document.getElementById('messagesContainer');
|
||||
|
||||
// 清空容器
|
||||
this.messagesContainer.innerHTML = '';
|
||||
|
||||
// 绑定事件
|
||||
this.bindEvents();
|
||||
|
||||
// 设置 Intersection Observer(必须在 loadMoreMessages 之前)
|
||||
this.setupIntersectionObserver();
|
||||
|
||||
// 初始加载
|
||||
this.loadMoreMessages();
|
||||
|
||||
// 更新统计信息
|
||||
this.updateStats();
|
||||
} catch (error) {
|
||||
console.error('初始化失败:', error);
|
||||
document.getElementById('messagesContainer').innerHTML =
|
||||
\`<div class="error">加载失败: \${error.message}</div>\`;
|
||||
}
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// 搜索框回车
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
searchInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
this.searchMessages();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setupIntersectionObserver() {
|
||||
// 创建哨兵元素
|
||||
this.sentinel = document.createElement('div');
|
||||
this.sentinel.className = 'message-placeholder';
|
||||
this.sentinel.textContent = '加载中...';
|
||||
this.sentinel.style.display = 'none';
|
||||
|
||||
// 创建 Intersection Observer
|
||||
this.loadMoreObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting && !this.isLoading) {
|
||||
this.loadMoreMessages();
|
||||
}
|
||||
});
|
||||
}, {
|
||||
root: this.scrollContainer,
|
||||
rootMargin: '200px', // 提前200px开始加载
|
||||
threshold: 0.1
|
||||
});
|
||||
}
|
||||
|
||||
loadMoreMessages() {
|
||||
if (this.isLoading) return;
|
||||
if (this.loadedCount >= this.filteredMessages.length) {
|
||||
// 所有消息已加载完毕
|
||||
if (this.sentinel && this.sentinel.parentNode) {
|
||||
this.sentinel.remove();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
|
||||
// 计算本次加载的范围
|
||||
const start = this.loadedCount;
|
||||
const end = Math.min(start + this.batchSize, this.filteredMessages.length);
|
||||
const batch = this.filteredMessages.slice(start, end);
|
||||
|
||||
// 创建文档片段
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
// 渲染消息
|
||||
batch.forEach(msg => {
|
||||
const messageElement = this.createMessageElement(msg);
|
||||
fragment.appendChild(messageElement);
|
||||
});
|
||||
|
||||
// 移除旧的哨兵
|
||||
if (this.sentinel && this.sentinel.parentNode) {
|
||||
this.sentinel.remove();
|
||||
}
|
||||
|
||||
// 添加消息到容器
|
||||
this.messagesContainer.appendChild(fragment);
|
||||
|
||||
// 更新已加载数量
|
||||
this.loadedCount = end;
|
||||
|
||||
// 如果还有更多消息,添加哨兵
|
||||
if (this.loadedCount < this.filteredMessages.length) {
|
||||
this.sentinel.style.display = 'flex';
|
||||
this.messagesContainer.appendChild(this.sentinel);
|
||||
|
||||
// 观察哨兵
|
||||
this.loadMoreObserver.observe(this.sentinel);
|
||||
}
|
||||
|
||||
this.isLoading = false;
|
||||
this.updateStats();
|
||||
}
|
||||
|
||||
createMessageElement(msg) {
|
||||
const div = document.createElement('div');
|
||||
div.className = msg.isSend ? 'message sent' : 'message';
|
||||
div.innerHTML = this.renderMessage(msg);
|
||||
return div;
|
||||
}
|
||||
|
||||
renderMessage(msg) {
|
||||
const member = this.allData.members.find(m => m.id === msg.sender);
|
||||
const senderName = member ? member.name : msg.senderName;
|
||||
const avatar = member && member.avatar ? member.avatar : null;
|
||||
const time = new Date(msg.timestamp * 1000).toLocaleString('zh-CN');
|
||||
|
||||
// 生成头像
|
||||
let avatarHtml = '';
|
||||
if (avatar) {
|
||||
avatarHtml = \`<img src="\${this.escapeHtml(avatar)}" alt="\${this.escapeHtml(senderName)}" onerror="this.style.display='none';this.parentElement.textContent='\${senderName.charAt(0).toUpperCase()}'" />\`;
|
||||
} else {
|
||||
avatarHtml = senderName.charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
// 生成消息内容
|
||||
let contentHtml = msg.content ? this.escapeHtml(msg.content) : '<em style="opacity:0.6">无内容</em>';
|
||||
|
||||
// 如果有聊天记录,添加聊天记录展示
|
||||
let chatRecordsHtml = '';
|
||||
if (msg.chatRecords && msg.chatRecords.length > 0) {
|
||||
chatRecordsHtml = '<div class="chat-records">';
|
||||
chatRecordsHtml += '<div class="title">📋 聊天记录引用</div>';
|
||||
for (const record of msg.chatRecords) {
|
||||
chatRecordsHtml += \`
|
||||
<div class="chat-record-item">
|
||||
<div>
|
||||
<span class="record-sender">\${this.escapeHtml(record.senderDisplayName)}</span>
|
||||
<span class="record-time">\${this.escapeHtml(record.formattedTime)}</span>
|
||||
</div>
|
||||
<div class="record-content">\${this.escapeHtml(record.content)}</div>
|
||||
</div>
|
||||
\`;
|
||||
}
|
||||
chatRecordsHtml += '</div>';
|
||||
}
|
||||
|
||||
return \`
|
||||
<div class="avatar">\${avatarHtml}</div>
|
||||
<div class="content-wrapper">
|
||||
<div class="sender-name">\${this.escapeHtml(senderName)}</div>
|
||||
<div class="bubble">
|
||||
\${contentHtml}
|
||||
\${chatRecordsHtml}
|
||||
</div>
|
||||
<div class="time">\${time}</div>
|
||||
</div>
|
||||
\`;
|
||||
}
|
||||
|
||||
searchMessages() {
|
||||
const keyword = document.getElementById('searchInput').value.trim().toLowerCase();
|
||||
if (!keyword) {
|
||||
this.filteredMessages = this.allData.messages;
|
||||
} else {
|
||||
this.filteredMessages = this.allData.messages.filter(msg => {
|
||||
// 搜索消息内容
|
||||
if (msg.content && msg.content.toLowerCase().includes(keyword)) {
|
||||
return true;
|
||||
}
|
||||
// 搜索发送者名称
|
||||
const member = this.allData.members.find(m => m.id === msg.sender);
|
||||
const senderName = member ? member.name : msg.senderName;
|
||||
if (senderName.toLowerCase().includes(keyword)) {
|
||||
return true;
|
||||
}
|
||||
// 搜索聊天记录内容
|
||||
if (msg.chatRecords) {
|
||||
for (const record of msg.chatRecords) {
|
||||
if (record.content.toLowerCase().includes(keyword) ||
|
||||
record.senderDisplayName.toLowerCase().includes(keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// 重置并重新加载
|
||||
this.reset();
|
||||
}
|
||||
|
||||
clearSearch() {
|
||||
document.getElementById('searchInput').value = '';
|
||||
this.filteredMessages = this.allData.messages;
|
||||
this.reset();
|
||||
}
|
||||
|
||||
reset() {
|
||||
// 停止观察
|
||||
if (this.loadMoreObserver && this.sentinel && this.sentinel.parentNode) {
|
||||
this.loadMoreObserver.unobserve(this.sentinel);
|
||||
}
|
||||
|
||||
// 清空容器
|
||||
this.messagesContainer.innerHTML = '';
|
||||
|
||||
// 重置状态
|
||||
this.loadedCount = 0;
|
||||
this.isLoading = false;
|
||||
|
||||
// 滚动到顶部
|
||||
this.scrollContainer.scrollTop = 0;
|
||||
|
||||
// 重新设置观察器(必须在 loadMoreMessages 之前)
|
||||
this.setupIntersectionObserver();
|
||||
|
||||
// 重新加载
|
||||
this.loadMoreMessages();
|
||||
}
|
||||
|
||||
updateStats() {
|
||||
const totalCount = this.filteredMessages.length;
|
||||
document.getElementById('messageStats').textContent = \`共 \${totalCount} 条消息\`;
|
||||
document.getElementById('loadedStats').textContent = \`已加载 \${this.loadedCount} 条\`;
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化应用
|
||||
const app = new ChatApp();`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成数据 JSON 文件
|
||||
*/
|
||||
static generateDataJson(exportData: HtmlExportData): string {
|
||||
return JSON.stringify(exportData, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 转义
|
||||
*/
|
||||
private static escapeHtml(text: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}
|
||||
return text.replace(/[&<>"']/g, m => map[m])
|
||||
}
|
||||
}
|
||||
@@ -158,7 +158,7 @@ export class ImageDecryptService {
|
||||
return { success: false, error: '未找到高清图,请在微信中点开该图片查看后重试' }
|
||||
}
|
||||
if (!datPath) {
|
||||
console.error(`[ImageDecrypt] 未找到图片文件: ${payload.imageDatName || payload.imageMd5} sessionId=${payload.sessionId}`)
|
||||
console.warn(`[ImageDecrypt] 未找到图片文件: ${payload.imageDatName || payload.imageMd5} sessionId=${payload.sessionId}`)
|
||||
return { success: false, error: '未找到图片文件' }
|
||||
}
|
||||
|
||||
@@ -622,8 +622,9 @@ export class ImageDecryptService {
|
||||
continue
|
||||
}
|
||||
|
||||
// 构建路径: msg/attach/{dir1Name}/{dir2Name}/Img/{fileName}
|
||||
// 构建可能的所有路径结构(仅限 msg/attach)
|
||||
const possiblePaths = [
|
||||
// 常见结构: msg/attach/xx/yy/Img/name
|
||||
join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'Img', fileName),
|
||||
join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'mg', fileName),
|
||||
join(accountDir, 'msg', 'attach', dir1Name, dir2Name, fileName),
|
||||
@@ -719,7 +720,7 @@ export class ImageDecryptService {
|
||||
const lowerName = entry.name.toLowerCase()
|
||||
// 顶层目录过滤
|
||||
if (depth === 0) {
|
||||
if (['fileStorage', 'image', 'image2', 'msg', 'attach', 'img'].some(k => lowerName.includes(k))) {
|
||||
if (['image', 'image2', 'msg', 'attach', 'img'].some(k => lowerName.includes(k))) {
|
||||
queue.push({ path: fullPath, depth: depth + 1 })
|
||||
}
|
||||
} else {
|
||||
@@ -767,7 +768,12 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
private isThumbnailDat(fileName: string): boolean {
|
||||
return fileName.includes('.t.dat') || fileName.includes('_t.dat')
|
||||
const lower = fileName.toLowerCase()
|
||||
return (
|
||||
lower.includes('.t.dat') ||
|
||||
lower.includes('_t.dat') ||
|
||||
lower.includes('_thumb.dat')
|
||||
)
|
||||
}
|
||||
|
||||
private hasXVariant(baseLower: string): boolean {
|
||||
@@ -780,7 +786,11 @@ export class ImageDecryptService {
|
||||
const ext = extname(lower)
|
||||
const base = ext ? lower.slice(0, -ext.length) : lower
|
||||
// 支持新命名 _thumb 和旧命名 _t
|
||||
return base.endsWith('_t') || base.endsWith('_thumb')
|
||||
return (
|
||||
base.endsWith('_t') ||
|
||||
base.endsWith('_thumb') ||
|
||||
base.endsWith('.t')
|
||||
)
|
||||
}
|
||||
|
||||
private isHdPath(filePath: string): boolean {
|
||||
@@ -1187,7 +1197,7 @@ export class ImageDecryptService {
|
||||
roots.push(oldPath)
|
||||
|
||||
// 去重
|
||||
const uniqueRoots = [...new Set(roots)]
|
||||
const uniqueRoots = Array.from(new Set(roots))
|
||||
// 过滤存在的路径
|
||||
const existingRoots = uniqueRoots.filter(r => existsSync(r))
|
||||
|
||||
@@ -1773,13 +1783,13 @@ export class ImageDecryptService {
|
||||
* 清理 hardlink 数据库缓存(用于增量更新时释放文件)
|
||||
*/
|
||||
clearHardlinkCache(): void {
|
||||
for (const [accountDir, state] of this.hardlinkCache.entries()) {
|
||||
this.hardlinkCache.forEach((state, accountDir) => {
|
||||
try {
|
||||
state.db.close()
|
||||
} catch (e) {
|
||||
console.warn(`关闭 hardlink 数据库失败: ${accountDir}`, e)
|
||||
}
|
||||
}
|
||||
})
|
||||
this.hardlinkCache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
8502
package-lock.json
generated
Normal file
8502
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ciphertalk",
|
||||
"version": "2.1.5",
|
||||
"version": "2.1.6",
|
||||
"description": "密语 - 微信聊天记录查看工具",
|
||||
"author": "ILoveBingLu",
|
||||
"license": "CC-BY-NC-SA-4.0",
|
||||
@@ -22,6 +22,7 @@
|
||||
"@types/marked": "^5.0.2",
|
||||
"@types/react-virtualized-auto-sizer": "^1.0.4",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@xmldom/xmldom": "^0.9.6",
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"dom-to-image-more": "^3.7.2",
|
||||
"dompurify": "^3.3.1",
|
||||
@@ -144,4 +145,4 @@
|
||||
"resources/**/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
11
src/App.tsx
11
src/App.tsx
@@ -21,6 +21,7 @@ import VideoWindow from './pages/VideoWindow'
|
||||
import BrowserWindowPage from './pages/BrowserWindowPage'
|
||||
import SplashPage from './pages/SplashPage'
|
||||
import AISummaryWindow from './pages/AISummaryWindow'
|
||||
import ChatHistoryPage from './pages/ChatHistoryPage'
|
||||
import { useAppStore } from './stores/appStore'
|
||||
import { useThemeStore } from './stores/themeStore'
|
||||
import { useChatStore } from './stores/chatStore'
|
||||
@@ -263,6 +264,15 @@ function App() {
|
||||
return <AISummaryWindow />
|
||||
}
|
||||
|
||||
// 独立聊天记录窗口
|
||||
if (location.pathname.startsWith('/chat-history/')) {
|
||||
return (
|
||||
<div className="chat-window-container">
|
||||
<ChatHistoryPage />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 独立引导窗口
|
||||
if (isWelcomeWindow) {
|
||||
return <WelcomePage standalone />
|
||||
@@ -441,6 +451,7 @@ function App() {
|
||||
<Route path="/data-management" element={<DataManagementPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/export" element={<ExportPage />} />
|
||||
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
||||
</Routes>
|
||||
</RouteGuard>
|
||||
</main>
|
||||
|
||||
@@ -8,27 +8,27 @@ interface WhatsNewModalProps {
|
||||
|
||||
function WhatsNewModal({ onClose, version }: WhatsNewModalProps) {
|
||||
const updates = [
|
||||
{
|
||||
icon: <Rocket size={20} />,
|
||||
title: 'BUG修复',
|
||||
desc: '修复消息内容会出现重复的问题。'
|
||||
},
|
||||
// {
|
||||
// icon: <Rocket size={20} />,
|
||||
// title: '性能优化',
|
||||
// desc: '修复消息内容会出现重复的问题。'
|
||||
// },
|
||||
{
|
||||
icon: <MessageSquareQuote size={20} />,
|
||||
title: '优化',
|
||||
desc: '优化AI摘要,新增适配大模型。'
|
||||
},
|
||||
{
|
||||
icon: <Sparkles size={20} />,
|
||||
title: 'AI摘要',
|
||||
desc: '支持AI在单人会话以及群聊会话中进行AI摘要总结。(默认只能选择天数)'
|
||||
},
|
||||
{
|
||||
icon: <RefreshCw size={20} />,
|
||||
title: '体验升级',
|
||||
desc: '修复了一些已知的问题。'
|
||||
desc: '修复了一些已知问题。'
|
||||
}//,
|
||||
// {
|
||||
// icon: <Sparkles size={20} />,
|
||||
// title: 'AI摘要',
|
||||
// desc: '支持AI在单人会话以及群聊会话中进行AI摘要总结。(默认只能选择天数)'
|
||||
// },
|
||||
// {
|
||||
// icon: <RefreshCw size={20} />,
|
||||
// title: '体验升级',
|
||||
// desc: '修复了一些已知的问题。'
|
||||
// }//,
|
||||
// {
|
||||
// icon: <Mic size={20} />,
|
||||
// title: '语音增强',
|
||||
// desc: '语音转文字支持多模型选择,灵活平衡识别精度与速度,适配更多场景。'
|
||||
|
||||
124
src/pages/ChatHistoryPage.scss
Normal file
124
src/pages/ChatHistoryPage.scss
Normal file
@@ -0,0 +1,124 @@
|
||||
.chat-history-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--bg-secondary);
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
.history-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.status-msg {
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 40px;
|
||||
|
||||
&.error {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
&.empty {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.history-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
|
||||
// 复用聊天窗口的头像视觉风格
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: transparent; // 只显示头像图片本身,不要描边/底色
|
||||
border: none;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-letter {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
|
||||
.sender {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.time {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.bubble {
|
||||
display: inline-block;
|
||||
max-width: 70%;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border-radius: 18px 18px 18px 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
|
||||
&.image-bubble {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
|
||||
img {
|
||||
max-width: 300px;
|
||||
max-height: 300px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.media-tip {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
238
src/pages/ChatHistoryPage.tsx
Normal file
238
src/pages/ChatHistoryPage.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useLocation, useParams } from 'react-router-dom'
|
||||
import { ChatRecordItem } from '../types/models'
|
||||
import TitleBar from '../components/TitleBar'
|
||||
import MessageContent from '../components/MessageContent'
|
||||
import './ChatHistoryPage.scss'
|
||||
|
||||
export default function ChatHistoryPage() {
|
||||
const { sessionId, messageId } = useParams<{ sessionId: string; messageId: string }>()
|
||||
const location = useLocation()
|
||||
const [recordList, setRecordList] = useState<ChatRecordItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [title, setTitle] = useState('聊天记录')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// 简单的 XML 标签内容提取
|
||||
const extractXmlValue = (xml: string, tag: string): string => {
|
||||
const match = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`).exec(xml)
|
||||
return match ? match[1] : ''
|
||||
}
|
||||
|
||||
// 简单的 HTML 实体解码(常见几种即可)
|
||||
const decodeHtmlEntities = (text?: string): string | undefined => {
|
||||
if (!text) return text
|
||||
return text
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
|
||||
// 前端兜底解析合并转发聊天记录 (与后端逻辑类似)
|
||||
const parseChatHistory = (content: string): ChatRecordItem[] | undefined => {
|
||||
try {
|
||||
const type = extractXmlValue(content, 'type')
|
||||
if (type !== '19') return undefined
|
||||
|
||||
const match = /<recorditem>[\s\S]*?<!\[CDATA\[([\s\S]*?)\]\]>[\s\S]*?<\/recorditem>/.exec(content)
|
||||
if (!match) return undefined
|
||||
|
||||
const innerXml = match[1]
|
||||
const items: ChatRecordItem[] = []
|
||||
// 注意这里要使用 [\\s\\S] 而不是写错成 [\\s\\\\s]
|
||||
const itemRegex = /<dataitem\s+(.*?)>([\s\S]*?)<\/dataitem>/g
|
||||
let itemMatch: RegExpExecArray | null
|
||||
|
||||
while ((itemMatch = itemRegex.exec(innerXml)) !== null) {
|
||||
const attrs = itemMatch[1]
|
||||
const body = itemMatch[2]
|
||||
|
||||
const datatypeMatch = /datatype="(\d+)"/.exec(attrs)
|
||||
const datatype = datatypeMatch ? parseInt(datatypeMatch[1]) : 0
|
||||
|
||||
const sourcename = extractXmlValue(body, 'sourcename')
|
||||
const sourcetime = extractXmlValue(body, 'sourcetime')
|
||||
const sourceheadurl = extractXmlValue(body, 'sourceheadurl')
|
||||
const datadesc = extractXmlValue(body, 'datadesc')
|
||||
const datatitle = extractXmlValue(body, 'datatitle')
|
||||
const fileext = extractXmlValue(body, 'fileext')
|
||||
const datasize = parseInt(extractXmlValue(body, 'datasize') || '0')
|
||||
const messageuuid = extractXmlValue(body, 'messageuuid')
|
||||
|
||||
const dataurl = extractXmlValue(body, 'dataurl')
|
||||
const datathumburl = extractXmlValue(body, 'datathumburl') || extractXmlValue(body, 'thumburl')
|
||||
const datacdnurl = extractXmlValue(body, 'datacdnurl') || extractXmlValue(body, 'cdnurl')
|
||||
const aeskey = extractXmlValue(body, 'aeskey') || extractXmlValue(body, 'qaeskey')
|
||||
const md5 = extractXmlValue(body, 'md5') || extractXmlValue(body, 'datamd5')
|
||||
const imgheight = parseInt(extractXmlValue(body, 'imgheight') || '0')
|
||||
const imgwidth = parseInt(extractXmlValue(body, 'imgwidth') || '0')
|
||||
const duration = parseInt(extractXmlValue(body, 'duration') || '0')
|
||||
|
||||
items.push({
|
||||
datatype,
|
||||
sourcename,
|
||||
sourcetime,
|
||||
sourceheadurl,
|
||||
datadesc: decodeHtmlEntities(datadesc),
|
||||
datatitle: decodeHtmlEntities(datatitle),
|
||||
fileext,
|
||||
datasize,
|
||||
messageuuid,
|
||||
dataurl: decodeHtmlEntities(dataurl),
|
||||
datathumburl: decodeHtmlEntities(datathumburl),
|
||||
datacdnurl: decodeHtmlEntities(datacdnurl),
|
||||
aeskey: decodeHtmlEntities(aeskey),
|
||||
md5,
|
||||
imgheight,
|
||||
imgwidth,
|
||||
duration
|
||||
})
|
||||
}
|
||||
|
||||
return items.length > 0 ? items : undefined
|
||||
} catch (e) {
|
||||
console.error('前端解析聊天记录失败:', e)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// 统一从路由参数或 pathname 中解析 sessionId / messageId
|
||||
const getIds = () => {
|
||||
if (sessionId && messageId) {
|
||||
return { sid: sessionId, mid: messageId }
|
||||
}
|
||||
// 独立窗口场景下没有 Route 包裹,用 pathname 手动解析
|
||||
const match = /^\/chat-history\/([^/]+)\/([^/]+)/.exec(location.pathname)
|
||||
if (match) {
|
||||
return { sid: match[1], mid: match[2] }
|
||||
}
|
||||
return { sid: '', mid: '' }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
const { sid, mid } = getIds()
|
||||
if (!sid || !mid) {
|
||||
setError('无效的聊天记录链接')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await window.electronAPI.chat.getMessage(sid, parseInt(mid, 10))
|
||||
if (result.success && result.message) {
|
||||
const msg = result.message
|
||||
// 优先使用后端解析好的列表
|
||||
let records: ChatRecordItem[] | undefined = msg.chatRecordList
|
||||
|
||||
// 如果后端没有解析到,则在前端兜底解析一次
|
||||
if ((!records || records.length === 0) && msg.rawContent) {
|
||||
records = parseChatHistory(msg.rawContent) || []
|
||||
}
|
||||
|
||||
if (records && records.length > 0) {
|
||||
setRecordList(records)
|
||||
const match = /<title>(.*?)<\/title>/.exec(msg.rawContent || '')
|
||||
if (match) setTitle(match[1])
|
||||
} else {
|
||||
setError('暂时无法解析这条聊天记录')
|
||||
}
|
||||
} else {
|
||||
setError(result.error || '获取消息失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setError('加载详情失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
loadData()
|
||||
}, [sessionId, messageId])
|
||||
|
||||
return (
|
||||
<div className="chat-history-page">
|
||||
<TitleBar title={title} />
|
||||
<div className="history-list">
|
||||
{loading ? (
|
||||
<div className="status-msg">加载中...</div>
|
||||
) : error ? (
|
||||
<div className="status-msg error">{error}</div>
|
||||
) : recordList.length === 0 ? (
|
||||
<div className="status-msg empty">暂无可显示的聊天记录</div>
|
||||
) : (
|
||||
recordList.map((item, i) => (
|
||||
<HistoryItem key={i} item={item} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HistoryItem({ item }: { item: ChatRecordItem }) {
|
||||
// sourcetime 在合并转发里有两种格式:
|
||||
// 1) 时间戳(秒) 2) 已格式化的字符串 "2026-01-21 09:56:46"
|
||||
let time = ''
|
||||
if (item.sourcetime) {
|
||||
if (/^\d+$/.test(item.sourcetime)) {
|
||||
time = new Date(parseInt(item.sourcetime, 10) * 1000).toLocaleString()
|
||||
} else {
|
||||
time = item.sourcetime
|
||||
}
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
if (item.datatype === 1) {
|
||||
return <MessageContent content={item.datadesc || ''} />
|
||||
}
|
||||
if (item.datatype === 3) {
|
||||
// Image
|
||||
const src = item.datathumburl || item.datacdnurl
|
||||
if (src) {
|
||||
return (
|
||||
<div className="media-content">
|
||||
<img src={src} alt="图片" referrerPolicy="no-referrer" onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
target.parentElement?.insertAdjacentHTML('beforeend', '<div class="media-tip">图片无法加载</div>');
|
||||
}} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <div className="media-placeholder">[图片]</div>
|
||||
}
|
||||
if (item.datatype === 43) {
|
||||
return <div className="media-placeholder">[视频] {item.datatitle}</div>
|
||||
}
|
||||
if (item.datatype === 34) {
|
||||
return <div className="media-placeholder">[语音] {item.duration ? (item.duration / 1000).toFixed(0) + '"' : ''}</div>
|
||||
}
|
||||
// Fallback
|
||||
return <div className="text-content">{item.datadesc || item.datatitle || '[不支持的消息类型]'}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="history-item">
|
||||
<div className="avatar">
|
||||
{item.sourceheadurl ? (
|
||||
<img src={item.sourceheadurl} alt="" referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#888', fontSize: '12px' }}>
|
||||
{item.sourcename?.slice(0, 1)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="content-wrapper">
|
||||
<div className="header">
|
||||
<span className="sender">{item.sourcename || '未知发送者'}</span>
|
||||
<span className="time">{time}</span>
|
||||
</div>
|
||||
<div className={`bubble ${item.datatype === 3 ? 'image-bubble' : ''}`}>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -338,6 +338,12 @@
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
// 日期选择器包装器
|
||||
.date-picker-wrapper {
|
||||
position: relative;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -915,6 +921,7 @@
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
@@ -926,7 +933,7 @@
|
||||
|
||||
&.loading {
|
||||
background: var(--bg-tertiary);
|
||||
|
||||
|
||||
.avatar-skeleton {
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -1298,13 +1305,13 @@
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
||||
.avatar-skeleton-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
background: var(--bg-tertiary);
|
||||
|
||||
|
||||
.avatar-skeleton {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -1630,17 +1637,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 适配发送/接收样式
|
||||
// 适配发送/接收样式(跟随主题色)
|
||||
.message-bubble.sent .link-message {
|
||||
background: #fff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
.link-title {
|
||||
color: #333;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.link-desc {
|
||||
color: #666;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1707,6 +1714,73 @@
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
// 合并转发聊天记录卡片(复用 link-message 结构,仅做内容布局上的补充)
|
||||
.chat-record-message {
|
||||
.link-header {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.chat-record-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-record-meta-line {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chat-record-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 70px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-record-item {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.source-name {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.chat-record-more {
|
||||
font-size: 12px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.chat-record-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.chat-record-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: var(--primary-gradient);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 发送的文件消息样式
|
||||
@@ -1740,7 +1814,7 @@
|
||||
|
||||
.transfer-icon {
|
||||
flex-shrink: 0;
|
||||
|
||||
|
||||
svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -1776,7 +1850,7 @@
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 4px;
|
||||
|
||||
|
||||
.sender-skeleton {
|
||||
display: inline-block;
|
||||
width: 60px;
|
||||
@@ -1797,6 +1871,41 @@
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
|
||||
.quoted-message-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.quoted-text-container {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quoted-image-container {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
.quoted-image-thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quoted-sender {
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
@@ -1809,6 +1918,7 @@
|
||||
|
||||
.quoted-text {
|
||||
color: var(--text-secondary);
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3121,6 +3231,221 @@ video::-webkit-media-controls-fullscreen-button {
|
||||
}
|
||||
}
|
||||
|
||||
.date-picker-dropdown {
|
||||
position: fixed;
|
||||
width: 320px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2), 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
z-index: 9999;
|
||||
animation: dropdownFadeIn 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
user-select: none;
|
||||
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 4px;
|
||||
margin-bottom: -4px;
|
||||
|
||||
.current-month {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.calendar-nav-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
text-align: center;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.weekday {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 500;
|
||||
padding: 4px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
row-gap: 4px;
|
||||
position: relative;
|
||||
min-height: 200px; // 确保至少有一定高度
|
||||
|
||||
.calendar-loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(2px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
border-radius: 8px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin: 0 auto;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 50%;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
|
||||
&.empty {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled):not(.selected) {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
&.today {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
color: #fff;
|
||||
|
||||
&::after {
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px var(--primary-light);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: var(--text-tertiary);
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
|
||||
.date-jump-today {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.date-jump-confirm {
|
||||
flex: 1;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--primary-gradient);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 2px 8px var(--primary-light);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px var(--primary-light);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modalFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -3131,4 +3456,77 @@ video::-webkit-media-controls-fullscreen-button {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dropdownFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
/* 以下这块旧样式原本强行把聊天记录气泡背景设为白色,会打破主题适配。现在只保留排版,不再设置 background。 */
|
||||
.chat-record-message {
|
||||
min-width: 240px;
|
||||
max-width: 300px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* If message-bubble handles background, we don't need background here.
|
||||
Currently message-bubble has padding. bubble-content usually wraps content.
|
||||
*/
|
||||
.chat-page .message-bubble .chat-record-message {
|
||||
/* Override bubble padding if needed? No, keep it inside. */
|
||||
|
||||
.chat-record-title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.chat-record-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
.chat-record-item {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.4;
|
||||
|
||||
.source-name {
|
||||
color: var(--text-tertiary);
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-record-desc {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.chat-record-more {
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-record-footer {
|
||||
margin-top: 8px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef, useCallback, memo } from 'react'
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Image as ImageIcon, Play, Video, Copy, ZoomIn, CheckSquare, Check, Edit, Link, Sparkles, FileText, FileArchive } from 'lucide-react'
|
||||
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Image as ImageIcon, Play, Video, Copy, ZoomIn, CheckSquare, Check, Edit, Link, Sparkles, FileText, FileArchive, Users } from 'lucide-react'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useUpdateStatusStore } from '../stores/updateStatusStore'
|
||||
import ChatBackground from '../components/ChatBackground'
|
||||
@@ -284,6 +284,15 @@ function ChatPage(_props: ChatPageProps) {
|
||||
const [showEnlargeView, setShowEnlargeView] = useState<{ message: Message; content: string } | null>(null)
|
||||
const [copyToast, setCopyToast] = useState(false)
|
||||
const [showMessageInfo, setShowMessageInfo] = useState<Message | null>(null) // 消息信息弹窗
|
||||
const [showDatePicker, setShowDatePicker] = useState(false) // 日期选择器弹窗
|
||||
const [selectedDate, setSelectedDate] = useState<string>('') // 选中的日期 (YYYY-MM-DD)
|
||||
const [viewDate, setViewDate] = useState(new Date()) // 日历当前显示的月份
|
||||
const [availableDates, setAvailableDates] = useState<Set<string>>(new Set()) // 当前月份有消息的日期
|
||||
const [isLoadingDates, setIsLoadingDates] = useState(false) // 加载日期状态
|
||||
const [isJumpingToDate, setIsJumpingToDate] = useState(false) // 正在跳转
|
||||
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null)
|
||||
const datePickerRef = useRef<HTMLDivElement>(null) // 日期选择器容器引用
|
||||
const dateButtonRef = useRef<HTMLButtonElement>(null) // 日期按钮引用
|
||||
|
||||
// 检查图片密钥配置(XOR 和 AES 都需要配置)
|
||||
useEffect(() => {
|
||||
@@ -600,6 +609,89 @@ function ChatPage(_props: ChatPageProps) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 日期跳转处理
|
||||
const handleJumpToDate = useCallback(async () => {
|
||||
if (!selectedDate || !currentSessionId || isJumpingToDate) return
|
||||
|
||||
setIsJumpingToDate(true)
|
||||
setShowDatePicker(false)
|
||||
|
||||
try {
|
||||
// 将选中的日期转换为 Unix 时间戳(秒)
|
||||
const targetDate = new Date(selectedDate)
|
||||
targetDate.setHours(0, 0, 0, 0)
|
||||
const targetTimestamp = Math.floor(targetDate.getTime() / 1000)
|
||||
|
||||
const result = await window.electronAPI.chat.getMessagesByDate(currentSessionId, targetTimestamp, 50)
|
||||
|
||||
if (result.success && result.messages && result.messages.length > 0) {
|
||||
// 清空当前消息并加载新消息
|
||||
setMessages(result.messages)
|
||||
setHasMoreMessages(true) // 假设还有更多历史消息
|
||||
setCurrentOffset(result.messages.length)
|
||||
|
||||
// 滚动到顶部显示目标日期的消息
|
||||
requestAnimationFrame(() => {
|
||||
if (messageListRef.current) {
|
||||
messageListRef.current.scrollTop = 0
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 没有找到消息,可能日期太新
|
||||
console.log('未找到该日期或之后的消息')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('跳转到日期失败:', e)
|
||||
} finally {
|
||||
setIsJumpingToDate(false)
|
||||
}
|
||||
}, [selectedDate, currentSessionId, isJumpingToDate, setMessages, setHasMoreMessages])
|
||||
|
||||
// 加载当前月份有消息的日期
|
||||
useEffect(() => {
|
||||
if (!showDatePicker || !currentSessionId) return
|
||||
|
||||
const fetchDates = async () => {
|
||||
setIsLoadingDates(true)
|
||||
try {
|
||||
const year = viewDate.getFullYear()
|
||||
const month = viewDate.getMonth() + 1
|
||||
// 同时加载上个月和下个月的日期,防止切换时闪烁(这里简单处理只加载当月)
|
||||
const result = await window.electronAPI.chat.getDatesWithMessages(currentSessionId, year, month)
|
||||
if (result.success && result.dates) {
|
||||
setAvailableDates(new Set(result.dates))
|
||||
} else {
|
||||
setAvailableDates(new Set())
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载有消息的日期失败:', e)
|
||||
} finally {
|
||||
setIsLoadingDates(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchDates()
|
||||
}, [viewDate, currentSessionId, showDatePicker])
|
||||
|
||||
// 点击外部关闭日期选择器
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as Node
|
||||
// 检查是否点击在日期选择器包装器或下拉框内部
|
||||
const isClickInsideWrapper = datePickerRef.current?.contains(target)
|
||||
const isClickInsideDropdown = (target as Element).closest?.('.date-picker-dropdown')
|
||||
|
||||
if (!isClickInsideWrapper && !isClickInsideDropdown) {
|
||||
setShowDatePicker(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showDatePicker) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [showDatePicker])
|
||||
|
||||
// 拖动调节侧边栏宽度
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -963,6 +1055,181 @@ function ChatPage(_props: ChatPageProps) {
|
||||
>
|
||||
<Sparkles size={18} />
|
||||
</button>
|
||||
<div className="date-picker-wrapper" ref={datePickerRef}>
|
||||
<button
|
||||
ref={dateButtonRef}
|
||||
className={`icon-btn date-jump-btn ${showDatePicker ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
if (!showDatePicker && dateButtonRef.current) {
|
||||
const rect = dateButtonRef.current.getBoundingClientRect()
|
||||
// 下拉框右边缘与按钮右边缘对齐
|
||||
const dropdownWidth = 320 // 增加宽度以容纳日历
|
||||
let left = rect.right - dropdownWidth
|
||||
// 确保不会超出屏幕左边
|
||||
if (left < 10) left = 10
|
||||
setDropdownPosition({
|
||||
top: rect.bottom + 8,
|
||||
left
|
||||
})
|
||||
// 重置视图到当前选中日期或今天
|
||||
setViewDate(selectedDate ? new Date(selectedDate) : new Date())
|
||||
}
|
||||
setShowDatePicker(!showDatePicker)
|
||||
}}
|
||||
title="跳转到日期"
|
||||
>
|
||||
<Calendar size={18} />
|
||||
</button>
|
||||
{showDatePicker && dropdownPosition && createPortal(
|
||||
<div
|
||||
className="date-picker-dropdown"
|
||||
style={{
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left,
|
||||
position: 'fixed',
|
||||
zIndex: 99999
|
||||
}}
|
||||
ref={(node) => {
|
||||
// 简单的点击外部检测需要这个 ref,但我们已经在 useEffect 中处理了关闭逻辑
|
||||
// 这里主要是为了确保它能被检测到
|
||||
if (node) {
|
||||
// 将这个 node 关联到 ref,以便 handleClickOutside 可以检查
|
||||
// 由于 ref 是针对 div 的,我们可以给 dropdown 一个单独的 ref 或者不使用 ref
|
||||
// 只要 handleClickOutside 逻辑能工作即可
|
||||
}
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 日历头部:月份切换 */}
|
||||
<div className="calendar-header">
|
||||
<button
|
||||
className="calendar-nav-btn"
|
||||
onClick={() => {
|
||||
const newDate = new Date(viewDate)
|
||||
newDate.setMonth(newDate.getMonth() - 1)
|
||||
setViewDate(newDate)
|
||||
}}
|
||||
>
|
||||
<ChevronDown size={16} style={{ transform: 'rotate(90deg)' }} />
|
||||
</button>
|
||||
<span className="current-month">
|
||||
{viewDate.getFullYear()}年 {viewDate.getMonth() + 1}月
|
||||
</span>
|
||||
<button
|
||||
className="calendar-nav-btn nav-next"
|
||||
onClick={() => {
|
||||
const newDate = new Date(viewDate)
|
||||
newDate.setMonth(newDate.getMonth() + 1)
|
||||
// 不允许查看未来月份(如果本月是未来)
|
||||
const now = new Date()
|
||||
if (newDate.getFullYear() > now.getFullYear() ||
|
||||
(newDate.getFullYear() === now.getFullYear() && newDate.getMonth() > now.getMonth())) {
|
||||
return
|
||||
}
|
||||
setViewDate(newDate)
|
||||
}}
|
||||
disabled={
|
||||
viewDate.getFullYear() === new Date().getFullYear() &&
|
||||
viewDate.getMonth() === new Date().getMonth()
|
||||
}
|
||||
>
|
||||
<ChevronDown size={16} style={{ transform: 'rotate(-90deg)' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 星期表头 */}
|
||||
<div className="calendar-weekdays">
|
||||
{['日', '一', '二', '三', '四', '五', '六'].map(d => (
|
||||
<div key={d} className="weekday">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 日期网格 */}
|
||||
<div className="calendar-grid">
|
||||
{(() => {
|
||||
const year = viewDate.getFullYear()
|
||||
const month = viewDate.getMonth()
|
||||
|
||||
// 当月第一天
|
||||
const firstDay = new Date(year, month, 1)
|
||||
// 当月最后一天
|
||||
const lastDay = new Date(year, month + 1, 0)
|
||||
|
||||
const daysInMonth = lastDay.getDate()
|
||||
const startDayOfWeek = firstDay.getDay() // 0-6
|
||||
|
||||
const days = []
|
||||
// 填充上个月的空位
|
||||
for (let i = 0; i < startDayOfWeek; i++) {
|
||||
days.push(<div key={`empty-${i}`} className="calendar-day empty"></div>)
|
||||
}
|
||||
|
||||
// 填充当月日期
|
||||
const today = new Date()
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
const currentDate = new Date(year, month, i)
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(i).padStart(2, '0')}`
|
||||
const isSelected = selectedDate === dateStr
|
||||
const isToday = today.toDateString() === currentDate.toDateString()
|
||||
const isFuture = currentDate > today
|
||||
const hasMessage = availableDates.has(dateStr)
|
||||
|
||||
// 禁用条件:是未来日期,或者(不是未来日期且没有消息)
|
||||
// 但如果在加载中,暂时不禁用非未来的日期,或者显示加载状态
|
||||
const isDisabled = isFuture || (!isFuture && !hasMessage)
|
||||
|
||||
days.push(
|
||||
<button
|
||||
key={i}
|
||||
className={`calendar-day ${isSelected ? 'selected' : ''} ${isToday ? 'today' : ''} ${isDisabled ? 'disabled' : ''}`}
|
||||
onClick={() => {
|
||||
if (isDisabled) return
|
||||
setSelectedDate(dateStr)
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
title={isFuture ? '未来时间' : (!hasMessage ? '无消息' : undefined)}
|
||||
>
|
||||
{i}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return days
|
||||
})()}
|
||||
{isLoadingDates && (
|
||||
<div className="calendar-loading-overlay">
|
||||
<Loader2 size={24} className="spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="calendar-footer">
|
||||
<button
|
||||
className="date-jump-today"
|
||||
onClick={() => {
|
||||
const now = new Date()
|
||||
const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||
setSelectedDate(dateStr)
|
||||
setViewDate(now)
|
||||
}}
|
||||
>
|
||||
回到今天
|
||||
</button>
|
||||
<button
|
||||
className="date-jump-confirm"
|
||||
onClick={handleJumpToDate}
|
||||
disabled={!selectedDate || isJumpingToDate}
|
||||
>
|
||||
{isJumpingToDate ? (
|
||||
<><Loader2 size={14} className="spin" /> 跳转中...</>
|
||||
) : (
|
||||
'跳转'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className={`icon-btn detail-btn ${showDetailPanel ? 'active' : ''}`}
|
||||
onClick={toggleDetailPanel}
|
||||
@@ -1534,6 +1801,12 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
|
||||
() => imageDataUrlCache.get(imageCacheKey)
|
||||
)
|
||||
|
||||
// 引用图片缓存
|
||||
const quotedImageCacheKey = message.quotedImageMd5 || ''
|
||||
const [quotedImageLocalPath, setQuotedImageLocalPath] = useState<string | undefined>(
|
||||
() => quotedImageCacheKey ? imageDataUrlCache.get(quotedImageCacheKey) : undefined
|
||||
)
|
||||
|
||||
const formatTime = (timestamp: number): string => {
|
||||
const date = new Date(timestamp * 1000)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
@@ -2091,6 +2364,40 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
|
||||
}
|
||||
}, [isImage, imageCacheKey, message.imageDatName, message.imageMd5])
|
||||
|
||||
// 引用图片自动解密
|
||||
useEffect(() => {
|
||||
if (!message.quotedImageMd5) return
|
||||
if (quotedImageLocalPath) return
|
||||
|
||||
const doDecrypt = async () => {
|
||||
try {
|
||||
// 先尝试从缓存获取
|
||||
const cached = await window.electronAPI.image.resolveCache({
|
||||
sessionId: session.username,
|
||||
imageMd5: message.quotedImageMd5
|
||||
})
|
||||
if (cached.success && cached.localPath) {
|
||||
imageDataUrlCache.set(message.quotedImageMd5!, cached.localPath)
|
||||
setQuotedImageLocalPath(cached.localPath)
|
||||
return
|
||||
}
|
||||
|
||||
// 自动解密
|
||||
const result = await window.electronAPI.image.decrypt({
|
||||
sessionId: session.username,
|
||||
imageMd5: message.quotedImageMd5,
|
||||
force: false
|
||||
})
|
||||
if (result.success && result.localPath) {
|
||||
imageDataUrlCache.set(message.quotedImageMd5!, result.localPath)
|
||||
setQuotedImageLocalPath(result.localPath)
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
enqueueDecrypt(doDecrypt)
|
||||
}, [message.quotedImageMd5, quotedImageLocalPath, session.username])
|
||||
|
||||
if (isSystem) {
|
||||
// 系统类消息:包含“拍一拍”等 appmsg(type=62)
|
||||
let systemText = message.parsedContent || '[系统消息]'
|
||||
@@ -2135,8 +2442,25 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
|
||||
return (
|
||||
<div className="bubble-content">
|
||||
<div className="quoted-message">
|
||||
{message.quotedSender && <span className="quoted-sender">{message.quotedSender}</span>}
|
||||
<span className="quoted-text">{message.quotedContent}</span>
|
||||
<div className="quoted-message-content">
|
||||
<div className="quoted-text-container">
|
||||
{message.quotedSender && <span className="quoted-sender">{message.quotedSender}</span>}
|
||||
<span className="quoted-text">{message.quotedContent}</span>
|
||||
</div>
|
||||
{quotedImageLocalPath && (
|
||||
<div className="quoted-image-container">
|
||||
<img
|
||||
src={quotedImageLocalPath}
|
||||
alt="引用图片"
|
||||
className="quoted-image-thumb"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
window.electronAPI.window.openImageViewerWindow(quotedImageLocalPath)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="message-text"><MessageContent content={message.parsedContent} /></div>
|
||||
</div>
|
||||
@@ -2500,6 +2824,66 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
|
||||
)
|
||||
}
|
||||
|
||||
// 聊天记录 (type=19)
|
||||
if (appMsgType === '19') {
|
||||
const recordList = message.chatRecordList || []
|
||||
const displayTitle = title || '群聊的聊天记录'
|
||||
const metaText =
|
||||
recordList.length > 0
|
||||
? `共 ${recordList.length} 条聊天记录`
|
||||
: desc || '聊天记录'
|
||||
|
||||
const previewItems = recordList.slice(0, 4)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="link-message chat-record-message"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
window.electronAPI.window.openChatHistoryWindow(session.username, message.localId)
|
||||
}}
|
||||
title="点击查看详细聊天记录"
|
||||
>
|
||||
<div className="link-header">
|
||||
<div className="link-title" title={displayTitle}>
|
||||
{displayTitle}
|
||||
</div>
|
||||
</div>
|
||||
<div className="link-body">
|
||||
<div className="chat-record-preview">
|
||||
{previewItems.length > 0 ? (
|
||||
<>
|
||||
<div className="chat-record-meta-line" title={metaText}>
|
||||
{metaText}
|
||||
</div>
|
||||
<div className="chat-record-list">
|
||||
{previewItems.map((item, i) => (
|
||||
<div key={i} className="chat-record-item">
|
||||
<span className="source-name">
|
||||
{item.sourcename ? `${item.sourcename}: ` : ''}
|
||||
</span>
|
||||
{item.datadesc || item.datatitle || '[媒体消息]'}
|
||||
</div>
|
||||
))}
|
||||
{recordList.length > previewItems.length && (
|
||||
<div className="chat-record-more">还有 {recordList.length - previewItems.length} 条…</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="chat-record-desc">
|
||||
{desc || '点击打开查看完整聊天记录'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="chat-record-icon">
|
||||
<MessageSquare size={18} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 文件消息 (type=6):渲染为文件卡片
|
||||
if (appMsgType === '6') {
|
||||
// 优先使用从接口获取的文件信息,否则从 XML 解析
|
||||
@@ -2544,14 +2928,14 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
|
||||
}
|
||||
|
||||
const wxid = userInfo.userInfo.wxid
|
||||
|
||||
|
||||
// 文件存储在 {微信存储目录}\{账号文件夹}\msg\file\{年-月}\ 目录下
|
||||
// 根据消息创建时间计算日期目录
|
||||
const msgDate = new Date(message.createTime * 1000)
|
||||
const year = msgDate.getFullYear()
|
||||
const month = String(msgDate.getMonth() + 1).padStart(2, '0')
|
||||
const dateFolder = `${year}-${month}`
|
||||
|
||||
|
||||
// 构建完整文件路径(包括文件名)
|
||||
const filePath = `${wechatDir}\\${wxid}\\msg\\file\\${dateFolder}\\${fileName}`
|
||||
|
||||
@@ -2563,7 +2947,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
|
||||
console.warn('无法定位到具体文件,尝试打开文件夹:', err)
|
||||
const fileDir = `${wechatDir}\\${wxid}\\msg\\file\\${dateFolder}`
|
||||
const result = await window.electronAPI.shell.openPath(fileDir)
|
||||
|
||||
|
||||
// 如果还是失败,打开上级目录
|
||||
if (result) {
|
||||
console.warn('无法打开月份文件夹,尝试打开上级目录')
|
||||
@@ -2577,8 +2961,8 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="file-message"
|
||||
<div
|
||||
className="file-message"
|
||||
onClick={handleFileClick}
|
||||
style={{ cursor: 'pointer' }}
|
||||
title="点击定位到文件所在文件夹"
|
||||
|
||||
@@ -104,6 +104,7 @@ function SettingsPage() {
|
||||
const [skipIntegrityCheck, setSkipIntegrityCheck] = useState(false)
|
||||
const [exportDefaultDateRange, setExportDefaultDateRange] = useState<number>(0)
|
||||
const [exportDefaultAvatars, setExportDefaultAvatars] = useState<boolean>(true)
|
||||
const [autoUpdateDatabase, setAutoUpdateDatabase] = useState(true)
|
||||
|
||||
// AI 相关配置状态
|
||||
const [aiProvider, setAiProviderState] = useState('zhipu')
|
||||
@@ -142,6 +143,7 @@ function SettingsPage() {
|
||||
const savedSttLanguages = await configService.getSttLanguages()
|
||||
const savedSttModelType = await configService.getSttModelType()
|
||||
const savedSkipIntegrityCheck = await configService.getSkipIntegrityCheck()
|
||||
const savedAutoUpdateDatabase = await configService.getAutoUpdateDatabase()
|
||||
|
||||
if (savedKey) setDecryptKey(savedKey)
|
||||
if (savedPath) setDbPath(savedPath)
|
||||
@@ -157,6 +159,7 @@ function SettingsPage() {
|
||||
}
|
||||
setSttModelType(savedSttModelType)
|
||||
setSkipIntegrityCheck(savedSkipIntegrityCheck)
|
||||
setAutoUpdateDatabase(savedAutoUpdateDatabase)
|
||||
|
||||
const savedQuoteStyle = await configService.getQuoteStyle()
|
||||
setQuoteStyle(savedQuoteStyle)
|
||||
@@ -648,6 +651,8 @@ function SettingsPage() {
|
||||
|
||||
// 保存完整性检查设置
|
||||
await configService.setSkipIntegrityCheck(skipIntegrityCheck)
|
||||
// 保存自动更新设置
|
||||
await configService.setAutoUpdateDatabase(autoUpdateDatabase)
|
||||
|
||||
// 保存引用样式
|
||||
await configService.setQuoteStyle(quoteStyle)
|
||||
@@ -767,7 +772,28 @@ function SettingsPage() {
|
||||
</div>
|
||||
|
||||
{/* 数据库解密部分 */}
|
||||
<h3 className="section-title">数据库解密(已支持自动更新)</h3>
|
||||
<h3 className="section-title">数据库解密与同步</h3>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="toggle-setting">
|
||||
<div className="toggle-header">
|
||||
<label className="toggle-label">
|
||||
<span className="toggle-title">开启数据库自动增量同步</span>
|
||||
<div className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoUpdateDatabase}
|
||||
onChange={(e) => setAutoUpdateDatabase(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider" />
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="toggle-description">
|
||||
<p>当检测到微信数据库文件变化时(如收到新消息),自动将新数据同步到密语。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>解密密钥</label>
|
||||
|
||||
@@ -19,12 +19,29 @@ export const CONFIG_KEYS = {
|
||||
QUOTE_STYLE: 'quoteStyle',
|
||||
SKIP_INTEGRITY_CHECK: 'skipIntegrityCheck',
|
||||
EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange',
|
||||
EXPORT_DEFAULT_AVATARS: 'exportDefaultAvatars'
|
||||
EXPORT_DEFAULT_AVATARS: 'exportDefaultAvatars',
|
||||
AUTO_UPDATE_DATABASE: 'autoUpdateDatabase'
|
||||
} as const
|
||||
|
||||
// 当前协议版本 - 更新协议内容时递增此版本号
|
||||
export const CURRENT_AGREEMENT_VERSION = 2
|
||||
|
||||
// ... existing code ...
|
||||
|
||||
// 获取是否自动更新数据库
|
||||
export async function getAutoUpdateDatabase(): Promise<boolean> {
|
||||
const value = await config.get(CONFIG_KEYS.AUTO_UPDATE_DATABASE)
|
||||
return value !== undefined ? (value as boolean) : true
|
||||
}
|
||||
|
||||
// 设置是否自动更新数据库
|
||||
export async function setAutoUpdateDatabase(enable: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AUTO_UPDATE_DATABASE, enable)
|
||||
}
|
||||
|
||||
|
||||
// --- AI 摘要配置 ---
|
||||
|
||||
// 获取解密密钥
|
||||
export async function getDecryptKey(): Promise<string | null> {
|
||||
const value = await config.get(CONFIG_KEYS.DECRYPT_KEY)
|
||||
|
||||
14
src/types/electron.d.ts
vendored
14
src/types/electron.d.ts
vendored
@@ -23,6 +23,7 @@ export interface ElectronAPI {
|
||||
openBrowserWindow: (url: string, title?: string) => Promise<void>
|
||||
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
|
||||
openAISummaryWindow: (sessionId: string, sessionName: string) => Promise<boolean>
|
||||
openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean>
|
||||
}
|
||||
config: {
|
||||
get: (key: string) => Promise<unknown>
|
||||
@@ -226,6 +227,19 @@ export interface ElectronAPI {
|
||||
data?: string // base64 encoded WAV
|
||||
error?: string
|
||||
}>
|
||||
getMessagesByDate: (sessionId: string, targetTimestamp: number, limit?: number) => Promise<{
|
||||
success: boolean
|
||||
messages?: Message[]
|
||||
targetIndex?: number
|
||||
targetIndex?: number
|
||||
error?: string
|
||||
}>
|
||||
getMessage: (sessionId: string, localId: number) => Promise<{ success: boolean; message?: Message; error?: string }>
|
||||
getDatesWithMessages: (sessionId: string, year: number, month: number) => Promise<{
|
||||
success: boolean
|
||||
dates?: string[]
|
||||
error?: string
|
||||
}>
|
||||
onSessionsUpdated: (callback: (sessions: ChatSession[]) => void) => () => void
|
||||
}
|
||||
analytics: {
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface Message {
|
||||
// 引用消息
|
||||
quotedContent?: string
|
||||
quotedSender?: string
|
||||
quotedImageMd5?: string
|
||||
// 视频相关
|
||||
videoMd5?: string
|
||||
rawContent?: string
|
||||
@@ -60,6 +61,30 @@ export interface Message {
|
||||
fileSize?: number // 文件大小(字节)
|
||||
fileExt?: string // 文件扩展名
|
||||
fileMd5?: string // 文件 MD5
|
||||
chatRecordList?: ChatRecordItem[] // 聊天记录列表 (Type 19)
|
||||
}
|
||||
|
||||
export interface ChatRecordItem {
|
||||
datatype: number
|
||||
datadesc?: string
|
||||
datatitle?: string
|
||||
sourcename?: string
|
||||
sourcetime?: string
|
||||
sourceheadurl?: string
|
||||
fileext?: string
|
||||
datasize?: number
|
||||
messageuuid?: string
|
||||
// 媒体信息
|
||||
dataurl?: string // 原始地址
|
||||
datathumburl?: string // 缩略图地址
|
||||
datacdnurl?: string // CDN地址
|
||||
qaeskey?: string // AES Key (通常在 recorditem 中是 qaeskey 或 aeskey)
|
||||
aeskey?: string
|
||||
md5?: string
|
||||
imgheight?: number
|
||||
imgwidth?: number
|
||||
thumbheadurl?: string // 视频/图片缩略图
|
||||
duration?: number // 语音/视频时长
|
||||
}
|
||||
|
||||
// 分析数据
|
||||
|
||||
Reference in New Issue
Block a user