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
+110
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) => {
+6
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)
+150 -62
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')
}
/**
+458 -25
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
})
}
}
+7 -5
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 配置已迁移到新结构')
}
}
@@ -1237,6 +1237,12 @@ class DataManagementService {
* 启用自动更新(文件监听 + 定时检查)
*/
enableAutoUpdate(intervalSeconds: number = 30): void {
// 检查配置是否允许自动更新
if (!this.configService.get('autoUpdateDatabase')) {
console.log('[DataManagement] 自动更新配置为关闭,跳过启动')
return
}
if (this.autoUpdateEnabled) {
this.disableAutoUpdate()
}
@@ -1251,6 +1257,11 @@ class DataManagementService {
this.autoUpdateInterval = setInterval(async () => {
if (this.isUpdating) return
// 再次检查配置,以防运行时被修改
if (!this.configService.get('autoUpdateDatabase')) {
return
}
const checkResult = await this.checkForUpdates()
if (checkResult.hasUpdate) {
// 通知监听器
@@ -1342,6 +1353,9 @@ class DataManagementService {
this.dbWatcher = fs.watch(dbStoragePath, { recursive: true }, async (eventType, filename) => {
if (!filename || this.isUpdating) return
// 检查配置
if (!this.configService.get('autoUpdateDatabase')) return
// 只监听 .db 文件
if (!filename.toLowerCase().endsWith('.db')) return
@@ -1392,6 +1406,11 @@ class DataManagementService {
* 触发更新(带频率限制和队列管理)
*/
private triggerUpdate(): void {
// 检查配置
if (!this.configService.get('autoUpdateDatabase')) {
return
}
// 如果正在更新,增加待处理计数
if (this.isUpdating) {
this.pendingUpdateCount++
@@ -1432,6 +1451,11 @@ class DataManagementService {
* @param silent 是否静默更新(不显示进度)
*/
async autoIncrementalUpdate(silent: boolean = false): Promise<{ success: boolean; updated: boolean; error?: string }> {
// 检查配置
if (!this.configService.get('autoUpdateDatabase')) {
return { success: true, updated: false }
}
if (this.isUpdating) {
// 如果正在更新,返回待处理状态
this.pendingUpdateCount++
File diff suppressed because it is too large Load Diff
+865
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])
}
}
+18 -8
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()
}
}