Merge pull request #136 from JiQingzhe2004/dev

feat: 一些适配
This commit is contained in:
Forrest
2026-01-29 22:03:02 +08:00
committed by GitHub
14 changed files with 1580 additions and 242 deletions

View File

@@ -369,6 +369,66 @@ function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHe
return win return win
} }
/**
* 创建独立的聊天记录窗口
*/
function createChatHistoryWindow(sessionId: string, messageId: number) {
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
const win = 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
})
win.once('ready-to-show', () => {
win.show()
})
if (process.env.VITE_DEV_SERVER_URL) {
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-history/${sessionId}/${messageId}`)
win.webContents.on('before-input-event', (event, input) => {
if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
if (win.webContents.isDevToolsOpened()) {
win.webContents.closeDevTools()
} else {
win.webContents.openDevTools()
}
event.preventDefault()
}
})
} else {
win.loadFile(join(__dirname, '../dist/index.html'), {
hash: `/chat-history/${sessionId}/${messageId}`
})
}
return win
}
function showMainWindow() { function showMainWindow() {
shouldShowMain = true shouldShowMain = true
if (mainWindowReady) { if (mainWindowReady) {
@@ -530,6 +590,12 @@ function registerIpcHandlers() {
createVideoPlayerWindow(videoPath, videoWidth, videoHeight) createVideoPlayerWindow(videoPath, videoWidth, videoHeight)
}) })
// 打开聊天记录窗口
ipcMain.handle('window:openChatHistoryWindow', (_, sessionId: string, messageId: number) => {
createChatHistoryWindow(sessionId, messageId)
return true
})
// 根据视频尺寸调整窗口大小 // 根据视频尺寸调整窗口大小
ipcMain.handle('window:resizeToFitVideo', (event, videoWidth: number, videoHeight: number) => { ipcMain.handle('window:resizeToFitVideo', (event, videoWidth: number, videoHeight: number) => {
const win = BrowserWindow.fromWebContents(event.sender) const win = BrowserWindow.fromWebContents(event.sender)
@@ -698,7 +764,7 @@ function registerIpcHandlers() {
}) })
}) })
ipcMain.handle('chat:getMessageById', async (_, sessionId: string, localId: number) => { ipcMain.handle('chat:getMessage', async (_, sessionId: string, localId: number) => {
return chatService.getMessageById(sessionId, localId) return chatService.getMessageById(sessionId, localId)
}) })

View File

@@ -57,7 +57,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) =>
ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight), ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
resizeToFitVideo: (videoWidth: number, videoHeight: number) => resizeToFitVideo: (videoWidth: number, videoHeight: number) =>
ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight) ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight),
openChatHistoryWindow: (sessionId: string, messageId: number) =>
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId)
}, },
// 数据库路径 // 数据库路径
@@ -121,7 +123,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
}, },
execQuery: (kind: string, path: string | null, sql: string) => execQuery: (kind: string, path: string | null, sql: string) =>
ipcRenderer.invoke('chat:execQuery', kind, path, sql), ipcRenderer.invoke('chat:execQuery', kind, path, sql),
getContacts: () => ipcRenderer.invoke('chat:getContacts') getContacts: () => ipcRenderer.invoke('chat:getContacts'),
getMessage: (sessionId: string, localId: number) =>
ipcRenderer.invoke('chat:getMessage', sessionId, localId)
}, },

View File

@@ -58,6 +58,26 @@ export interface Message {
encrypVer?: number encrypVer?: number
cdnThumbUrl?: string cdnThumbUrl?: string
voiceDurationSeconds?: number voiceDurationSeconds?: number
// Type 49 细分字段
linkTitle?: string // 链接/文件标题
linkUrl?: string // 链接 URL
linkThumb?: string // 链接缩略图
fileName?: string // 文件名
fileSize?: number // 文件大小
fileExt?: string // 文件扩展名
xmlType?: string // XML 中的 type 字段
// 名片消息
cardUsername?: string // 名片的微信ID
cardNickname?: string // 名片的昵称
// 聊天记录
chatRecordTitle?: string // 聊天记录标题
chatRecordList?: Array<{
datatype: number
sourcename: string
sourcetime: string
datadesc: string
datatitle?: string
}>
} }
export interface Contact { export interface Contact {
@@ -106,6 +126,9 @@ class ChatService {
timeColumn?: string timeColumn?: string
name2IdTable?: string name2IdTable?: string
}>() }>()
// 缓存会话表信息,避免每次查询
private sessionTablesCache = new Map<string, Array<{ tableName: string; dbPath: string }>>()
private readonly sessionTablesCacheTtl = 300000 // 5分钟
constructor() { constructor() {
this.configService = new ConfigService() this.configService = new ConfigService()
@@ -1023,6 +1046,26 @@ class ChatService {
let encrypVer: number | undefined let encrypVer: number | undefined
let cdnThumbUrl: string | undefined let cdnThumbUrl: string | undefined
let voiceDurationSeconds: number | undefined let voiceDurationSeconds: number | undefined
// Type 49 细分字段
let linkTitle: string | undefined
let linkUrl: string | undefined
let linkThumb: string | undefined
let fileName: string | undefined
let fileSize: number | undefined
let fileExt: string | undefined
let xmlType: string | undefined
// 名片消息
let cardUsername: string | undefined
let cardNickname: string | undefined
// 聊天记录
let chatRecordTitle: string | undefined
let chatRecordList: Array<{
datatype: number
sourcename: string
sourcetime: string
datadesc: string
datatitle?: string
}> | undefined
if (localType === 47 && content) { if (localType === 47 && content) {
const emojiInfo = this.parseEmojiInfo(content) const emojiInfo = this.parseEmojiInfo(content)
@@ -1040,6 +1083,23 @@ class ChatService {
videoMd5 = this.parseVideoMd5(content) videoMd5 = this.parseVideoMd5(content)
} else if (localType === 34 && content) { } else if (localType === 34 && content) {
voiceDurationSeconds = this.parseVoiceDurationSeconds(content) voiceDurationSeconds = this.parseVoiceDurationSeconds(content)
} else if (localType === 42 && content) {
// 名片消息
const cardInfo = this.parseCardInfo(content)
cardUsername = cardInfo.username
cardNickname = cardInfo.nickname
} else if (localType === 49 && content) {
// Type 49 消息(链接、文件、小程序、转账等)
const type49Info = this.parseType49Message(content)
xmlType = type49Info.xmlType
linkTitle = type49Info.linkTitle
linkUrl = type49Info.linkUrl
linkThumb = type49Info.linkThumb
fileName = type49Info.fileName
fileSize = type49Info.fileSize
fileExt = type49Info.fileExt
chatRecordTitle = type49Info.chatRecordTitle
chatRecordList = type49Info.chatRecordList
} else if (localType === 244813135921 || (content && content.includes('<type>57</type>'))) { } else if (localType === 244813135921 || (content && content.includes('<type>57</type>'))) {
const quoteInfo = this.parseQuoteMessage(content) const quoteInfo = this.parseQuoteMessage(content)
quotedContent = quoteInfo.content quotedContent = quoteInfo.content
@@ -1066,7 +1126,18 @@ class ChatService {
voiceDurationSeconds, voiceDurationSeconds,
aesKey, aesKey,
encrypVer, encrypVer,
cdnThumbUrl cdnThumbUrl,
linkTitle,
linkUrl,
linkThumb,
fileName,
fileSize,
fileExt,
xmlType,
cardUsername,
cardNickname,
chatRecordTitle,
chatRecordList
}) })
const last = messages[messages.length - 1] const last = messages[messages.length - 1]
if ((last.localType === 3 || last.localType === 34) && (last.localId === 0 || last.createTime === 0)) { if ((last.localType === 3 || last.localType === 34) && (last.localId === 0 || last.createTime === 0)) {
@@ -1164,18 +1235,36 @@ class ChatService {
return `[链接] ${title}` return `[链接] ${title}`
case '6': case '6':
return `[文件] ${title}` return `[文件] ${title}`
case '19':
return `[聊天记录] ${title}`
case '33': case '33':
case '36': case '36':
return `[小程序] ${title}` return `[小程序] ${title}`
case '57': case '57':
// 引用消息title 就是回复的内容 // 引用消息title 就是回复的内容
return title return title
case '2000':
return `[转账] ${title}`
default: default:
return title return title
} }
} }
// 如果没有 title根据 type 返回默认标签
switch (type) {
case '6':
return '[文件]'
case '19':
return '[聊天记录]'
case '33':
case '36':
return '[小程序]'
case '2000':
return '[转账]'
default:
return '[消息]' return '[消息]'
} }
}
/** /**
* 解析表情包信息 * 解析表情包信息
@@ -1458,6 +1547,185 @@ class ChatService {
} }
} }
/**
* 解析名片消息
* 格式: <msg username="wxid_xxx" nickname="昵称" ... />
*/
private parseCardInfo(content: string): { username?: string; nickname?: string } {
try {
if (!content) return {}
// 提取 username
const username = this.extractXmlAttribute(content, 'msg', 'username') || undefined
// 提取 nickname
const nickname = this.extractXmlAttribute(content, 'msg', 'nickname') || undefined
return { username, nickname }
} catch (e) {
console.error('[ChatService] 名片解析失败:', e)
return {}
}
}
/**
* 解析 Type 49 消息(链接、文件、小程序、转账等)
* 根据 <appmsg><type>X</type> 区分不同类型
*/
private parseType49Message(content: string): {
xmlType?: string
linkTitle?: string
linkUrl?: string
linkThumb?: string
fileName?: string
fileSize?: number
fileExt?: string
chatRecordTitle?: string
chatRecordList?: Array<{
datatype: number
sourcename: string
sourcetime: string
datadesc: string
datatitle?: string
}>
} {
try {
if (!content) return {}
// 提取 appmsg 中的 type
const xmlType = this.extractXmlValue(content, 'type')
if (!xmlType) return {}
const result: any = { xmlType }
// 提取通用字段
const title = this.extractXmlValue(content, 'title')
const url = this.extractXmlValue(content, 'url')
switch (xmlType) {
case '6': {
// 文件消息
result.fileName = title || this.extractXmlValue(content, 'filename')
result.linkTitle = result.fileName
// 提取文件大小
const fileSizeStr = this.extractXmlValue(content, 'totallen') ||
this.extractXmlValue(content, 'filesize')
if (fileSizeStr) {
const size = parseInt(fileSizeStr, 10)
if (!isNaN(size)) {
result.fileSize = size
}
}
// 提取文件扩展名
const fileExt = this.extractXmlValue(content, 'fileext')
if (fileExt) {
result.fileExt = fileExt
} else if (result.fileName) {
// 从文件名提取扩展名
const match = /\.([^.]+)$/.exec(result.fileName)
if (match) {
result.fileExt = match[1]
}
}
break
}
case '19': {
// 聊天记录
result.chatRecordTitle = title || '聊天记录'
// 解析聊天记录列表
const recordList: Array<{
datatype: number
sourcename: string
sourcetime: string
datadesc: string
datatitle?: string
}> = []
// 查找所有 <recorditem> 标签
const recordItemRegex = /<recorditem>([\s\S]*?)<\/recorditem>/gi
let match: RegExpExecArray | null
while ((match = recordItemRegex.exec(content)) !== null) {
const itemXml = match[1]
const datatypeStr = this.extractXmlValue(itemXml, 'datatype')
const sourcename = this.extractXmlValue(itemXml, 'sourcename')
const sourcetime = this.extractXmlValue(itemXml, 'sourcetime')
const datadesc = this.extractXmlValue(itemXml, 'datadesc')
const datatitle = this.extractXmlValue(itemXml, 'datatitle')
if (sourcename && datadesc) {
recordList.push({
datatype: datatypeStr ? parseInt(datatypeStr, 10) : 0,
sourcename,
sourcetime: sourcetime || '',
datadesc,
datatitle: datatitle || undefined
})
}
}
if (recordList.length > 0) {
result.chatRecordList = recordList
}
break
}
case '33':
case '36': {
// 小程序
result.linkTitle = title
result.linkUrl = url
// 提取缩略图
const thumbUrl = this.extractXmlValue(content, 'thumburl') ||
this.extractXmlValue(content, 'cdnthumburl')
if (thumbUrl) {
result.linkThumb = thumbUrl
}
break
}
case '2000': {
// 转账
result.linkTitle = title || '[转账]'
// 可以提取转账金额等信息
const payMemo = this.extractXmlValue(content, 'pay_memo')
const feedesc = this.extractXmlValue(content, 'feedesc')
if (payMemo) {
result.linkTitle = payMemo
} else if (feedesc) {
result.linkTitle = feedesc
}
break
}
default: {
// 其他类型,提取通用字段
result.linkTitle = title
result.linkUrl = url
const thumbUrl = this.extractXmlValue(content, 'thumburl') ||
this.extractXmlValue(content, 'cdnthumburl')
if (thumbUrl) {
result.linkThumb = thumbUrl
}
}
}
return result
} catch (e) {
console.error('[ChatService] Type 49 消息解析失败:', e)
return {}
}
}
//手动查找 media_*.db 文件(当 WCDB DLL 不支持 listMediaDbs 时的 fallback //手动查找 media_*.db 文件(当 WCDB DLL 不支持 listMediaDbs 时的 fallback
private async findMediaDbsManually(): Promise<string[]> { private async findMediaDbsManually(): Promise<string[]> {
try { try {
@@ -3198,19 +3466,35 @@ class ChatService {
async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> { async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> {
try { try {
// 1. 获取会话所在的消息 // 1. 尝试从缓存获取会话表信息
// 注意:这里使用 getMessageTableStats 而不是 getMessageTables因为前者包含 db_path let tables = this.sessionTablesCache.get(sessionId)
if (!tables) {
// 缓存未命中,查询数据库
const tableStats = await wcdbService.getMessageTableStats(sessionId) const tableStats = await wcdbService.getMessageTableStats(sessionId)
if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) { if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) {
return { success: false, error: '未找到会话消息表' } return { success: false, error: '未找到会话消息表' }
} }
// 2. 遍历表查找消息 (通常只有一个主表,但可能有归档) // 提取表信息并缓存
for (const tableInfo of tableStats.tables) { tables = tableStats.tables
const tableName = tableInfo.table_name || tableInfo.name .map(t => ({
const dbPath = tableInfo.db_path tableName: t.table_name || t.name,
if (!tableName || !dbPath) continue dbPath: t.db_path
}))
.filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }>
if (tables.length > 0) {
this.sessionTablesCache.set(sessionId, tables)
// 设置过期清理
setTimeout(() => {
this.sessionTablesCache.delete(sessionId)
}, this.sessionTablesCacheTtl)
}
}
// 2. 遍历表查找消息 (通常只有一个主表,但可能有归档)
for (const { tableName, dbPath } of tables) {
// 构造查询 // 构造查询
const sql = `SELECT * FROM ${tableName} WHERE local_id = ${localId} LIMIT 1` const sql = `SELECT * FROM ${tableName} WHERE local_id = ${localId} LIMIT 1`
const result = await wcdbService.execQuery('message', dbPath, sql) const result = await wcdbService.execQuery('message', dbPath, sql)

View File

@@ -415,14 +415,8 @@ export class ImageDecryptService {
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath) if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
return hardlinkPath return hardlinkPath
} }
// hardlink 找到的是缩略图,但要求高清图:尝试同目录查找高清变体 // hardlink 找到的是缩略图,但要求高清图,直接返回 null不再搜索
if (!allowThumbnail && isThumb) { if (!allowThumbnail && isThumb) {
const hdPath = this.findHdVariantInSameDir(hardlinkPath)
if (hdPath) {
this.cacheDatPath(accountDir, imageMd5, hdPath)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath
}
return null return null
} }
} }
@@ -438,11 +432,6 @@ export class ImageDecryptService {
return fallbackPath return fallbackPath
} }
if (!allowThumbnail && isThumb) { if (!allowThumbnail && isThumb) {
const hdPath = this.findHdVariantInSameDir(fallbackPath)
if (hdPath) {
this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath
}
return null return null
} }
} }
@@ -460,20 +449,15 @@ export class ImageDecryptService {
this.cacheDatPath(accountDir, imageDatName, hardlinkPath) this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
return hardlinkPath return hardlinkPath
} }
// hardlink 找到的是缩略图,但要求高清图:尝试同目录查找高清变体 // hardlink 找到的是缩略图,但要求高清图,直接返回 null
if (!allowThumbnail && isThumb) { if (!allowThumbnail && isThumb) {
const hdPath = this.findHdVariantInSameDir(hardlinkPath)
if (hdPath) {
this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath
}
return null return null
} }
} }
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
} }
// 如果要求高清图但 hardlink 没找到,也不要搜索全盘了(搜索太慢) // 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢)
if (!allowThumbnail) { if (!allowThumbnail) {
return null return null
} }
@@ -483,9 +467,6 @@ export class ImageDecryptService {
const cached = this.resolvedCache.get(imageDatName) const cached = this.resolvedCache.get(imageDatName)
if (cached && existsSync(cached)) { if (cached && existsSync(cached)) {
if (allowThumbnail || !this.isThumbnailPath(cached)) return cached if (allowThumbnail || !this.isThumbnailPath(cached)) return cached
// 缓存的是缩略图,尝试同目录找高清变体
const hdPath = this.findHdVariantInSameDir(cached)
if (hdPath) return hdPath
} }
} }
@@ -530,38 +511,6 @@ export class ImageDecryptService {
return this.searchDatFile(accountDir, imageDatName, true, true) return this.searchDatFile(accountDir, imageDatName, true, true)
} }
/**
* 在同目录中尝试查找高清图变体
* 缩略图: xxx_t.dat / xxx.t.dat -> 高清图: xxx_h.dat / xxx.h.dat / xxx.dat
*/
private findHdVariantInSameDir(thumbPath: string): string | null {
try {
const dir = dirname(thumbPath)
const fileName = basename(thumbPath).toLowerCase()
let baseName = fileName
if (baseName.endsWith('_t.dat')) {
baseName = baseName.slice(0, -6)
} else if (baseName.endsWith('.t.dat')) {
baseName = baseName.slice(0, -6)
} else {
return null
}
const variants = [
`${baseName}_h.dat`,
`${baseName}.h.dat`,
`${baseName}.dat`
]
for (const variant of variants) {
const candidate = join(dir, variant)
if (existsSync(candidate)) return candidate
}
} catch { }
return null
}
private async checkHasUpdate( private async checkHasUpdate(
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string },
cacheKey: string, cacheKey: string,
@@ -950,35 +899,63 @@ export class ImageDecryptService {
} }
private findCachedOutput(cacheKey: string, preferHd: boolean = false, sessionId?: string): string | null { private findCachedOutput(cacheKey: string, preferHd: boolean = false, sessionId?: string): string | null {
const root = this.getCacheRoot() const allRoots = this.getAllCacheRoots()
const normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase()) const normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase())
const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
// 遍历所有可能的缓存根路径
for (const root of allRoots) {
// 策略1: 新目录结构 Images/{sessionId}/{YYYY-MM}/{file}_hd.jpg
if (sessionId) { if (sessionId) {
const sessionDir = join(root, this.sanitizeDirName(sessionId)) const sessionDir = join(root, this.sanitizeDirName(sessionId))
if (existsSync(sessionDir)) { if (existsSync(sessionDir)) {
try { try {
const sessionEntries = readdirSync(sessionDir) const dateDirs = readdirSync(sessionDir, { withFileTypes: true })
for (const entry of sessionEntries) { .filter(d => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name))
const timeDir = join(sessionDir, entry) .map(d => d.name)
if (!this.isDirectory(timeDir)) continue .sort()
const hit = this.findCachedOutputInDir(timeDir, normalizedKey, extensions, preferHd) .reverse() // 最新的日期优先
if (hit) return hit
}
} catch {
// ignore
}
}
}
// 新目录结构: Images/{normalizedKey}/{normalizedKey}_thumb.jpg 或 _hd.jpg for (const dateDir of dateDirs) {
const imageDir = join(root, normalizedKey) const imageDir = join(sessionDir, dateDir)
if (existsSync(imageDir)) {
const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd) const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd)
if (hit) return hit if (hit) return hit
} }
} catch { }
}
}
// 兼容旧的平铺结构 // 策略2: 遍历所有 sessionId 目录查找(如果没有指定 sessionId
try {
const sessionDirs = readdirSync(root, { withFileTypes: true })
.filter(d => d.isDirectory())
.map(d => d.name)
for (const session of sessionDirs) {
const sessionDir = join(root, session)
// 检查是否是日期目录结构
try {
const subDirs = readdirSync(sessionDir, { withFileTypes: true })
.filter(d => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name))
.map(d => d.name)
for (const dateDir of subDirs) {
const imageDir = join(sessionDir, dateDir)
const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd)
if (hit) return hit
}
} catch { }
}
} catch { }
// 策略3: 旧目录结构 Images/{normalizedKey}/{normalizedKey}_thumb.jpg
const oldImageDir = join(root, normalizedKey)
if (existsSync(oldImageDir)) {
const hit = this.findCachedOutputInDir(oldImageDir, normalizedKey, extensions, preferHd)
if (hit) return hit
}
// 策略4: 最旧的平铺结构 Images/{file}.jpg
for (const ext of extensions) { for (const ext of extensions) {
const candidate = join(root, `${cacheKey}${ext}`) const candidate = join(root, `${cacheKey}${ext}`)
if (existsSync(candidate)) return candidate if (existsSync(candidate)) return candidate
@@ -987,6 +964,7 @@ export class ImageDecryptService {
const candidate = join(root, `${cacheKey}_t${ext}`) const candidate = join(root, `${cacheKey}_t${ext}`)
if (existsSync(candidate)) return candidate if (existsSync(candidate)) return candidate
} }
}
return null return null
} }
@@ -1155,15 +1133,19 @@ export class ImageDecryptService {
if (this.cacheIndexed) return if (this.cacheIndexed) return
if (this.cacheIndexing) return this.cacheIndexing if (this.cacheIndexing) return this.cacheIndexing
this.cacheIndexing = new Promise((resolve) => { this.cacheIndexing = new Promise((resolve) => {
const root = this.getCacheRoot() // 扫描所有可能的缓存根目录
const allRoots = this.getAllCacheRoots()
this.logInfo('开始索引缓存', { roots: allRoots.length })
for (const root of allRoots) {
try { try {
this.indexCacheDir(root, 2, 0) this.indexCacheDir(root, 3, 0) // 增加深度到3支持 sessionId/YYYY-MM 结构
} catch { } catch (e) {
this.cacheIndexed = true this.logError('索引目录失败', e, { root })
this.cacheIndexing = null
resolve()
return
} }
}
this.logInfo('缓存索引完成', { entries: this.resolvedCache.size })
this.cacheIndexed = true this.cacheIndexed = true
this.cacheIndexing = null this.cacheIndexing = null
resolve() resolve()
@@ -1171,6 +1153,39 @@ export class ImageDecryptService {
return this.cacheIndexing return this.cacheIndexing
} }
/**
* 获取所有可能的缓存根路径(用于查找已缓存的图片)
* 包含当前路径、配置路径、旧版本路径
*/
private getAllCacheRoots(): string[] {
const roots: string[] = []
const configured = this.configService.get('cachePath')
const documentsPath = app.getPath('documents')
// 主要路径(当前使用的)
const mainRoot = this.getCacheRoot()
roots.push(mainRoot)
// 如果配置了自定义路径,也检查其下的 Images
if (configured) {
roots.push(join(configured, 'Images'))
roots.push(join(configured, 'images'))
}
// 默认路径
roots.push(join(documentsPath, 'WeFlow', 'Images'))
roots.push(join(documentsPath, 'WeFlow', 'images'))
// 兼容旧路径(如果有的话)
roots.push(join(documentsPath, 'WeFlowData', 'Images'))
// 去重并过滤存在的路径
const uniqueRoots = Array.from(new Set(roots))
const existingRoots = uniqueRoots.filter(r => existsSync(r))
return existingRoots
}
private indexCacheDir(root: string, maxDepth: number, depth: number): void { private indexCacheDir(root: string, maxDepth: number, depth: number): void {
let entries: string[] let entries: string[]
try { try {

View File

@@ -246,15 +246,37 @@ export class WcdbCore {
// InitProtection (Added for security) // InitProtection (Added for security)
try { try {
this.wcdbInitProtection = this.lib.func('int32 InitProtection(const char* resourcePath)') this.wcdbInitProtection = this.lib.func('bool InitProtection(const char* resourcePath)')
const protectionCode = this.wcdbInitProtection(dllDir)
if (protectionCode !== 0) { // 尝试多个可能的资源路径
console.error('Core security check failed:', protectionCode) const resourcePaths = [
lastDllInitError = `初始化失败,错误码: ${protectionCode}` dllDir, // DLL 所在目录
return false dirname(dllDir), // 上级目录
this.resourcesPath, // 配置的资源路径
join(process.cwd(), 'resources') // 开发环境
].filter(Boolean)
let protectionOk = false
for (const resPath of resourcePaths) {
try {
console.log(`[WCDB] 尝试 InitProtection: ${resPath}`)
protectionOk = this.wcdbInitProtection(resPath)
if (protectionOk) {
console.log(`[WCDB] InitProtection 成功: ${resPath}`)
break
} }
} catch (e) { } catch (e) {
console.warn('InitProtection symbol not found or failed:', e) console.warn(`[WCDB] InitProtection 失败 (${resPath}):`, e)
}
}
if (!protectionOk) {
console.warn('[WCDB] Core security check failed - 继续运行但可能不稳定')
this.writeLog('InitProtection 失败,继续运行')
// 不返回 false允许继续运行
}
} catch (e) {
console.warn('InitProtection symbol not found:', e)
} }
// 定义类型 // 定义类型

View File

@@ -18,6 +18,7 @@ import ExportPage from './pages/ExportPage'
import VideoWindow from './pages/VideoWindow' import VideoWindow from './pages/VideoWindow'
import SnsPage from './pages/SnsPage' import SnsPage from './pages/SnsPage'
import ContactsPage from './pages/ContactsPage' import ContactsPage from './pages/ContactsPage'
import ChatHistoryPage from './pages/ChatHistoryPage'
import { useAppStore } from './stores/appStore' import { useAppStore } from './stores/appStore'
import { themes, useThemeStore, type ThemeId } from './stores/themeStore' import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
@@ -49,6 +50,7 @@ function App() {
const isAgreementWindow = location.pathname === '/agreement-window' const isAgreementWindow = location.pathname === '/agreement-window'
const isOnboardingWindow = location.pathname === '/onboarding-window' const isOnboardingWindow = location.pathname === '/onboarding-window'
const isVideoPlayerWindow = location.pathname === '/video-player-window' const isVideoPlayerWindow = location.pathname === '/video-player-window'
const isChatHistoryWindow = location.pathname.startsWith('/chat-history/')
const [themeHydrated, setThemeHydrated] = useState(false) const [themeHydrated, setThemeHydrated] = useState(false)
// 锁定状态 // 锁定状态
@@ -298,6 +300,11 @@ function App() {
return <VideoWindow /> return <VideoWindow />
} }
// 独立聊天记录窗口
if (isChatHistoryWindow) {
return <ChatHistoryPage />
}
// 主窗口 - 完整布局 // 主窗口 - 完整布局
return ( return (
<div className="app-container"> <div className="app-container">
@@ -392,6 +399,7 @@ function App() {
<Route path="/export" element={<ExportPage />} /> <Route path="/export" element={<ExportPage />} />
<Route path="/sns" element={<SnsPage />} /> <Route path="/sns" element={<SnsPage />} />
<Route path="/contacts" element={<ContactsPage />} /> <Route path="/contacts" element={<ContactsPage />} />
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
</Routes> </Routes>
</RouteGuard> </RouteGuard>
</main> </main>

View File

@@ -1,10 +1,14 @@
import './TitleBar.scss' import './TitleBar.scss'
function TitleBar() { interface TitleBarProps {
title?: string
}
function TitleBar({ title }: TitleBarProps = {}) {
return ( return (
<div className="title-bar"> <div className="title-bar">
<img src="./logo.png" alt="WeFlow" className="title-logo" /> <img src="./logo.png" alt="WeFlow" className="title-logo" />
<span className="titles">WeFlow</span> <span className="titles">{title || 'WeFlow'}</span>
</div> </div>
) )
} }

View File

@@ -0,0 +1,132 @@
.chat-history-page {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--bg-primary);
.history-list {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
.status-msg {
text-align: center;
padding: 40px 20px;
color: var(--text-tertiary);
font-size: 14px;
&.error {
color: var(--danger);
}
&.empty {
color: var(--text-tertiary);
}
}
}
.history-item {
display: flex;
gap: 12px;
align-items: flex-start;
.avatar {
width: 40px;
height: 40px;
border-radius: 4px;
overflow: hidden;
flex-shrink: 0;
background: var(--bg-tertiary);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
font-size: 16px;
font-weight: 500;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
}
.content-wrapper {
flex: 1;
min-width: 0;
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
.sender {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.time {
font-size: 12px;
color: var(--text-tertiary);
flex-shrink: 0;
margin-left: 8px;
}
}
.bubble {
background: var(--bg-secondary);
padding: 10px 14px;
border-radius: 18px 18px 18px 4px;
word-wrap: break-word;
max-width: 100%;
display: inline-block;
&.image-bubble {
padding: 0;
background: transparent;
}
.text-content {
font-size: 14px;
line-height: 1.6;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-word;
}
.media-content {
img {
max-width: 100%;
max-height: 300px;
border-radius: 8px;
display: block;
}
.media-tip {
padding: 8px 12px;
color: var(--text-tertiary);
font-size: 13px;
}
}
.media-placeholder {
font-size: 14px;
color: var(--text-secondary);
padding: 4px 0;
}
}
}
}
}

View File

@@ -0,0 +1,250 @@
import { useEffect, useState } from 'react'
import { useParams, useLocation } from 'react-router-dom'
import { ChatRecordItem } from '../types/models'
import TitleBar from '../components/TitleBar'
import './ChatHistoryPage.scss'
export default function ChatHistoryPage() {
const params = 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[] = []
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 = () => {
const sessionId = params.sessionId || ''
const messageId = params.messageId || ''
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.content) {
records = parseChatHistory(msg.content) || []
}
if (records && records.length > 0) {
setRecordList(records)
const match = /<title>(.*?)<\/title>/.exec(msg.content || '')
if (match) setTitle(match[1])
} else {
setError('暂时无法解析这条聊天记录')
}
} else {
setError(result.error || '获取消息失败')
}
} catch (e) {
console.error(e)
setError('加载详情失败')
} finally {
setLoading(false)
}
}
loadData()
}, [params.sessionId, params.messageId, location.pathname])
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 <div className="text-content">{item.datadesc || ''}</div>
}
if (item.datatype === 3) {
// 图片
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'
const placeholder = document.createElement('div')
placeholder.className = 'media-tip'
placeholder.textContent = '图片无法加载'
target.parentElement?.appendChild(placeholder)
}}
/>
</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 className="avatar-placeholder">
{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

@@ -834,92 +834,93 @@
// 链接卡片消息样式 // 链接卡片消息样式
.link-message { .link-message {
cursor: pointer; width: 280px;
background: var(--card-bg); background: var(--card-bg);
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
border: 1px solid var(--border-color); cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
max-width: 300px; border: 1px solid var(--border-color);
margin-top: 4px;
&:hover { &:hover {
background: var(--bg-hover); background: var(--bg-hover);
transform: translateY(-1px); border-color: var(--primary);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
} }
.link-header { .link-header {
padding: 10px 12px 6px;
display: flex; display: flex;
align-items: flex-start; gap: 8px;
padding: 12px;
gap: 12px;
}
.link-content {
flex: 1;
min-width: 0;
}
.link-title { .link-title {
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
color: var(--text-primary); color: var(--text-primary);
margin-bottom: 4px; line-height: 1.4;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
line-clamp: 2; line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
line-height: 1.4; flex: 1;
} }
}
.link-body {
padding: 6px 12px 10px;
display: flex;
gap: 10px;
.link-desc { .link-desc {
font-size: 12px; font-size: 12px;
color: var(--text-secondary); color: var(--text-secondary);
line-height: 1.4;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 3;
line-clamp: 2; line-clamp: 3;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
line-height: 1.4; flex: 1;
opacity: 0.8;
} }
.link-icon { .link-thumb {
width: 48px;
height: 48px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0; flex-shrink: 0;
width: 40px;
height: 40px;
background: var(--bg-tertiary); background: var(--bg-tertiary);
border-radius: 6px; }
.link-thumb-placeholder {
width: 48px;
height: 48px;
border-radius: 4px;
background: var(--bg-tertiary);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: var(--text-secondary); flex-shrink: 0;
color: var(--text-tertiary);
svg { svg {
opacity: 0.8; opacity: 0.5;
}
} }
} }
} }
// 适配发送出去的消息中的链接卡片 // 适配发送出去的消息中的链接卡片
.message-bubble.sent .link-message { .message-bubble.sent .link-message {
background: rgba(255, 255, 255, 0.1); background: var(--card-bg);
border-color: rgba(255, 255, 255, 0.2); border: 1px solid var(--border-color);
.link-title {
color: var(--text-primary);
}
.link-title,
.link-desc { .link-desc {
color: #fff; color: var(--text-secondary);
}
.link-icon {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
&:hover {
background: rgba(255, 255, 255, 0.2);
} }
} }
@@ -2171,3 +2172,303 @@
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
} }
// 名片消息
.card-message {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--bg-tertiary);
border-radius: 8px;
min-width: 200px;
.card-icon {
flex-shrink: 0;
color: var(--primary);
}
.card-info {
flex: 1;
min-width: 0;
}
.card-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 2px;
}
.card-label {
font-size: 12px;
color: var(--text-tertiary);
}
}
// 通话消息
.call-message {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
color: var(--text-secondary);
font-size: 13px;
svg {
flex-shrink: 0;
}
}
// 文件消息
// 文件消息
.file-message {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--bg-tertiary);
border-radius: 8px;
min-width: 220px;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: var(--bg-hover);
}
.file-icon {
flex-shrink: 0;
color: var(--primary);
}
.file-info {
flex: 1;
min-width: 0;
}
.file-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-meta {
font-size: 12px;
color: var(--text-tertiary);
}
}
// 发送的文件消息样式
.message-bubble.sent .file-message {
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.1);
.file-name {
color: #333;
}
.file-meta {
color: #999;
}
}
// 聊天记录消息 - 复用 link-message 基础样式
.chat-record-message {
cursor: pointer;
.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;
}
}
// 小程序消息
.miniapp-message {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--bg-tertiary);
border-radius: 8px;
min-width: 200px;
.miniapp-icon {
flex-shrink: 0;
color: var(--primary);
}
.miniapp-info {
flex: 1;
min-width: 0;
}
.miniapp-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.miniapp-label {
font-size: 12px;
color: var(--text-tertiary);
}
}
// 转账消息卡片
.transfer-message {
width: 240px;
background: linear-gradient(135deg, #f59e42 0%, #f5a742 100%);
border-radius: 12px;
padding: 14px 16px;
display: flex;
gap: 12px;
align-items: center;
cursor: default;
&.received {
background: linear-gradient(135deg, #b8b8b8 0%, #a8a8a8 100%);
}
.transfer-icon {
flex-shrink: 0;
svg {
width: 32px;
height: 32px;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1));
}
}
.transfer-info {
flex: 1;
color: white;
.transfer-amount {
font-size: 18px;
font-weight: 600;
margin-bottom: 2px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.transfer-memo {
font-size: 13px;
margin-bottom: 8px;
opacity: 0.95;
}
.transfer-label {
font-size: 12px;
opacity: 0.85;
}
}
}
// 发送消息中的特殊消息类型适配(除了文件和转账)
.message-bubble.sent {
.card-message,
.chat-record-message,
.miniapp-message {
background: rgba(255, 255, 255, 0.15);
.card-name,
.miniapp-title,
.source-name {
color: white;
}
.card-label,
.miniapp-label,
.chat-record-item,
.chat-record-meta-line,
.chat-record-desc {
color: rgba(255, 255, 255, 0.8);
}
.card-icon,
.miniapp-icon,
.chat-record-icon {
color: white;
}
.chat-record-more {
color: rgba(255, 255, 255, 0.9);
}
}
.call-message {
color: rgba(255, 255, 255, 0.9);
svg {
color: white;
}
}
}

View File

@@ -22,6 +22,15 @@ function isSystemMessage(localType: number): boolean {
return SYSTEM_MESSAGE_TYPES.includes(localType) return SYSTEM_MESSAGE_TYPES.includes(localType)
} }
// 格式化文件大小
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
}
interface ChatPageProps { interface ChatPageProps {
// 保留接口以备将来扩展 // 保留接口以备将来扩展
} }
@@ -1476,6 +1485,9 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
const isImage = message.localType === 3 const isImage = message.localType === 3
const isVideo = message.localType === 43 const isVideo = message.localType === 43
const isVoice = message.localType === 34 const isVoice = message.localType === 34
const isCard = message.localType === 42
const isCall = message.localType === 50
const isType49 = message.localType === 49
const isSent = message.isSend === 1 const isSent = message.isSend === 1
const [senderAvatarUrl, setSenderAvatarUrl] = useState<string | undefined>(undefined) const [senderAvatarUrl, setSenderAvatarUrl] = useState<string | undefined>(undefined)
const [senderName, setSenderName] = useState<string | undefined>(undefined) const [senderName, setSenderName] = useState<string | undefined>(undefined)
@@ -2438,6 +2450,268 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
) )
} }
// 名片消息
if (isCard) {
const cardName = message.cardNickname || message.cardUsername || '未知联系人'
return (
<div className="card-message">
<div className="card-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</div>
<div className="card-info">
<div className="card-name">{cardName}</div>
<div className="card-label"></div>
</div>
</div>
)
}
// 通话消息
if (isCall) {
return (
<div className="call-message">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
</svg>
<span>{message.parsedContent || '[通话]'}</span>
</div>
)
}
// 链接消息 (AppMessage)
const isAppMsg = message.rawContent?.includes('<appmsg') || (message.parsedContent && message.parsedContent.includes('<appmsg'))
if (isAppMsg) {
let title = '链接'
let desc = ''
let url = ''
let appMsgType = ''
try {
const content = message.rawContent || message.parsedContent || ''
// 简单清理 XML 前缀(如 wxid:
const xmlContent = content.substring(content.indexOf('<msg>'))
const parser = new DOMParser()
const doc = parser.parseFromString(xmlContent, 'text/xml')
title = doc.querySelector('title')?.textContent || '链接'
desc = doc.querySelector('des')?.textContent || ''
url = doc.querySelector('url')?.textContent || ''
appMsgType = doc.querySelector('appmsg > type')?.textContent || doc.querySelector('type')?.textContent || ''
} catch (e) {
console.error('解析 AppMsg 失败:', e)
}
// 聊天记录 (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') {
const fileName = message.fileName || title || '文件'
const fileSize = message.fileSize
const fileExt = message.fileExt || fileName.split('.').pop()?.toLowerCase() || ''
// 根据扩展名选择图标
const getFileIcon = () => {
const archiveExts = ['zip', 'rar', '7z', 'tar', 'gz', 'bz2']
if (archiveExts.includes(fileExt)) {
return (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
)
}
return (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" />
<polyline points="13 2 13 9 20 9" />
</svg>
)
}
return (
<div className="file-message">
<div className="file-icon">
{getFileIcon()}
</div>
<div className="file-info">
<div className="file-name" title={fileName}>{fileName}</div>
<div className="file-meta">
{fileSize ? formatFileSize(fileSize) : ''}
</div>
</div>
</div>
)
}
// 转账消息 (type=2000)
if (appMsgType === '2000') {
try {
const content = message.rawContent || message.content || message.parsedContent || ''
// 添加调试日志
console.log('[Transfer Debug] Raw content:', content.substring(0, 500))
const parser = new DOMParser()
const doc = parser.parseFromString(content, 'text/xml')
const feedesc = doc.querySelector('feedesc')?.textContent || ''
const payMemo = doc.querySelector('pay_memo')?.textContent || ''
const paysubtype = doc.querySelector('paysubtype')?.textContent || '1'
console.log('[Transfer Debug] Parsed:', { feedesc, payMemo, paysubtype, title })
// paysubtype: 1=待收款, 3=已收款
const isReceived = paysubtype === '3'
// 如果 feedesc 为空,使用 title 作为降级
const displayAmount = feedesc || title || '微信转账'
return (
<div className={`transfer-message ${isReceived ? 'received' : ''}`}>
<div className="transfer-icon">
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
<circle cx="20" cy="20" r="18" stroke="white" strokeWidth="2" />
<path d="M12 20h16M20 12l8 8-8 8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<div className="transfer-info">
<div className="transfer-amount">{displayAmount}</div>
{payMemo && <div className="transfer-memo">{payMemo}</div>}
<div className="transfer-label">{isReceived ? '已收款' : '微信转账'}</div>
</div>
</div>
)
} catch (e) {
console.error('[Transfer Debug] Parse error:', e)
// 解析失败时的降级处理
const feedesc = title || '微信转账'
return (
<div className="transfer-message">
<div className="transfer-icon">
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
<circle cx="20" cy="20" r="18" stroke="white" strokeWidth="2" />
<path d="M12 20h16M20 12l8 8-8 8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<div className="transfer-info">
<div className="transfer-amount">{feedesc}</div>
<div className="transfer-label"></div>
</div>
</div>
)
}
}
// 小程序 (type=33/36)
if (appMsgType === '33' || appMsgType === '36') {
return (
<div className="miniapp-message">
<div className="miniapp-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
</svg>
</div>
<div className="miniapp-info">
<div className="miniapp-title">{title}</div>
<div className="miniapp-label"></div>
</div>
</div>
)
}
// 有 URL 的链接消息
if (url) {
return (
<div
className="link-message"
onClick={(e) => {
e.stopPropagation()
if (window.electronAPI?.shell?.openExternal) {
window.electronAPI.shell.openExternal(url)
} else {
window.open(url, '_blank')
}
}}
>
<div className="link-header">
<div className="link-title" title={title}>{title}</div>
</div>
<div className="link-body">
<div className="link-desc" title={desc}>{desc}</div>
<div className="link-thumb-placeholder">
<Link size={24} />
</div>
</div>
</div>
)
}
}
// 表情包消息 // 表情包消息
if (isEmoji) { if (isEmoji) {
// ... (keep existing emoji logic) // ... (keep existing emoji logic)
@@ -2492,67 +2766,6 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
) )
} }
// 解析引用消息Links / App Messages
// localType: 21474836529 corresponds to AppMessage which often contains links
if (isLinkMessage) {
try {
// 清理内容:移除可能的 wxid 前缀,找到 XML 起始位置
let contentToParse = message.rawContent || message.parsedContent || '';
const xmlStartIndex = contentToParse.indexOf('<');
if (xmlStartIndex >= 0) {
contentToParse = contentToParse.substring(xmlStartIndex);
}
// 处理 HTML 转义字符
if (contentToParse.includes('&lt;')) {
contentToParse = contentToParse
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'");
}
const parser = new DOMParser();
const doc = parser.parseFromString(contentToParse, "text/xml");
const appMsg = doc.querySelector('appmsg');
if (appMsg) {
const title = doc.querySelector('title')?.textContent || '未命名链接';
const des = doc.querySelector('des')?.textContent || '无描述';
const url = doc.querySelector('url')?.textContent || '';
return (
<div
className="link-message"
onClick={(e) => {
e.stopPropagation();
if (url) {
// 优先使用 electron 接口打开外部浏览器
if (window.electronAPI?.shell?.openExternal) {
window.electronAPI.shell.openExternal(url);
} else {
window.open(url, '_blank');
}
}
}}
>
<div className="link-header">
<div className="link-content">
<div className="link-title" title={title}>{title}</div>
<div className="link-desc" title={des}>{des}</div>
</div>
<div className="link-icon">
<Link size={24} />
</div>
</div>
</div>
);
}
} catch (e) {
console.error('Failed to parse app message', e);
}
}
// 普通消息 // 普通消息
return <div className="bubble-content">{renderTextWithEmoji(cleanMessageContent(message.parsedContent))}</div> return <div className="bubble-content">{renderTextWithEmoji(cleanMessageContent(message.parsedContent))}</div>
} }

View File

@@ -41,11 +41,12 @@ export const MESSAGE_TYPE_LABELS: Record<number, string> = {
244813135921: '文本', 244813135921: '文本',
3: '图片', 3: '图片',
34: '语音', 34: '语音',
42: '名片',
43: '视频', 43: '视频',
47: '表情', 47: '表情',
48: '位置', 48: '位置',
49: '链接/文件', 49: '链接/文件',
42: '名片', 50: '通话',
10000: '系统消息', 10000: '系统消息',
} }

View File

@@ -11,6 +11,7 @@ export interface ElectronAPI {
setTitleBarOverlay: (options: { symbolColor: string }) => void setTitleBarOverlay: (options: { symbolColor: string }) => void
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise<void> openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise<void>
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void> resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean>
} }
config: { config: {
get: (key: string) => Promise<unknown> get: (key: string) => Promise<unknown>
@@ -106,6 +107,7 @@ export interface ElectronAPI {
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }> getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }>
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
execQuery: (kind: string, path: string | null, sql: string) => Promise<{ success: boolean; rows?: any[]; error?: string }> execQuery: (kind: string, path: string | null, sql: string) => Promise<{ success: boolean; rows?: any[]; error?: string }>
getMessage: (sessionId: string, localId: number) => Promise<{ success: boolean; message?: Message; error?: string }>
} }
image: { image: {

View File

@@ -53,8 +53,44 @@ export interface Message {
// 引用消息 // 引用消息
quotedContent?: string quotedContent?: string
quotedSender?: string quotedSender?: string
// Type 49 细分字段
linkTitle?: string // 链接/文件标题
linkUrl?: string // 链接 URL
linkThumb?: string // 链接缩略图
fileName?: string // 文件名
fileSize?: number // 文件大小
fileExt?: string // 文件扩展名
xmlType?: string // XML 中的 type 字段
// 名片消息
cardUsername?: string // 名片的微信ID
cardNickname?: string // 名片的昵称
// 聊天记录
chatRecordTitle?: string // 聊天记录标题
chatRecordList?: ChatRecordItem[] // 聊天记录列表
} }
// 聊天记录项
export interface ChatRecordItem {
datatype: number // 消息类型
sourcename: string // 发送者
sourcetime: string // 时间
sourceheadurl?: string // 发送者头像
datadesc?: string // 内容描述
datatitle?: string // 标题
fileext?: string // 文件扩展名
datasize?: number // 文件大小
messageuuid?: string // 消息UUID
dataurl?: string // 数据URL
datathumburl?: string // 缩略图URL
datacdnurl?: string // CDN URL
aeskey?: string // AES密钥
md5?: string // MD5
imgheight?: number // 图片高度
imgwidth?: number // 图片宽度
duration?: number // 时长(毫秒)
}
// 分析数据 // 分析数据
export interface AnalyticsData { export interface AnalyticsData {
totalMessages: number totalMessages: number