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:
ILoveBingLu
2026-01-29 15:53:56 +08:00
parent eea7ee569c
commit ff05dbaa32
23 changed files with 12012 additions and 684 deletions

View File

@@ -7,7 +7,7 @@
**一款现代化的微信聊天记录查看与分析工具**
[![License](https://img.shields.io/badge/license-CC--BY--NC--SA--4.0-blue.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-2.1.5-green.svg)](package.json)
[![Version](https://img.shields.io/badge/version-2.1.6-green.svg)](package.json)
[![Platform](https://img.shields.io/badge/platform-Windows-0078D6.svg?logo=windows)]()
[![Electron](https://img.shields.io/badge/Electron-39-47848F.svg?logo=electron)]()
[![React](https://img.shields.io/badge/React-19-61DAFB.svg?logo=react)]()
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View File

@@ -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) => {

View File

@@ -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)

View File

@@ -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')
}
/**

View File

@@ -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
})
}
}

View File

@@ -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 配置已迁移到新结构')
}
}

View File

@@ -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

View 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> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
}
return text.replace(/[&<>"']/g, m => map[m])
}
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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/**/*"
]
}
}
}

View File

@@ -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>

View File

@@ -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: '语音转文字支持多模型选择,灵活平衡识别精度与速度,适配更多场景。'

View 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;
}
}
}
}
}

View 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(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/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>
)
}

View File

@@ -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);
}
}

View File

@@ -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="点击定位到文件所在文件夹"

View File

@@ -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>

View File

@@ -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)

View File

@@ -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: {

View File

@@ -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 // 语音/视频时长
}
// 分析数据