mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
@@ -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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,17 +1235,35 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return '[消息]'
|
|
||||||
|
// 如果没有 title,根据 type 返回默认标签
|
||||||
|
switch (type) {
|
||||||
|
case '6':
|
||||||
|
return '[文件]'
|
||||||
|
case '19':
|
||||||
|
return '[聊天记录]'
|
||||||
|
case '33':
|
||||||
|
case '36':
|
||||||
|
return '[小程序]'
|
||||||
|
case '2000':
|
||||||
|
return '[转账]'
|
||||||
|
default:
|
||||||
|
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)
|
||||||
const tableStats = await wcdbService.getMessageTableStats(sessionId)
|
|
||||||
if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) {
|
if (!tables) {
|
||||||
return { success: false, error: '未找到会话消息表' }
|
// 缓存未命中,查询数据库
|
||||||
|
const tableStats = await wcdbService.getMessageTableStats(sessionId)
|
||||||
|
if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) {
|
||||||
|
return { success: false, error: '未找到会话消息表' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取表信息并缓存
|
||||||
|
tables = tableStats.tables
|
||||||
|
.map(t => ({
|
||||||
|
tableName: t.table_name || t.name,
|
||||||
|
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. 遍历表查找消息 (通常只有一个主表,但可能有归档)
|
// 2. 遍历表查找消息 (通常只有一个主表,但可能有归档)
|
||||||
for (const tableInfo of tableStats.tables) {
|
for (const { tableName, dbPath } of tables) {
|
||||||
const tableName = tableInfo.table_name || tableInfo.name
|
|
||||||
const dbPath = tableInfo.db_path
|
|
||||||
if (!tableName || !dbPath) continue
|
|
||||||
|
|
||||||
// 构造查询
|
// 构造查询
|
||||||
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)
|
||||||
|
|||||||
@@ -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,42 +899,71 @@ 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']
|
||||||
|
|
||||||
if (sessionId) {
|
// 遍历所有可能的缓存根路径
|
||||||
const sessionDir = join(root, this.sanitizeDirName(sessionId))
|
for (const root of allRoots) {
|
||||||
if (existsSync(sessionDir)) {
|
// 策略1: 新目录结构 Images/{sessionId}/{YYYY-MM}/{file}_hd.jpg
|
||||||
try {
|
if (sessionId) {
|
||||||
const sessionEntries = readdirSync(sessionDir)
|
const sessionDir = join(root, this.sanitizeDirName(sessionId))
|
||||||
for (const entry of sessionEntries) {
|
if (existsSync(sessionDir)) {
|
||||||
const timeDir = join(sessionDir, entry)
|
try {
|
||||||
if (!this.isDirectory(timeDir)) continue
|
const dateDirs = readdirSync(sessionDir, { withFileTypes: true })
|
||||||
const hit = this.findCachedOutputInDir(timeDir, normalizedKey, extensions, preferHd)
|
.filter(d => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name))
|
||||||
if (hit) return hit
|
.map(d => d.name)
|
||||||
}
|
.sort()
|
||||||
} catch {
|
.reverse() // 最新的日期优先
|
||||||
// ignore
|
|
||||||
|
for (const dateDir of dateDirs) {
|
||||||
|
const imageDir = join(sessionDir, dateDir)
|
||||||
|
const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd)
|
||||||
|
if (hit) return hit
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 新目录结构: Images/{normalizedKey}/{normalizedKey}_thumb.jpg 或 _hd.jpg
|
// 策略2: 遍历所有 sessionId 目录查找(如果没有指定 sessionId)
|
||||||
const imageDir = join(root, normalizedKey)
|
try {
|
||||||
if (existsSync(imageDir)) {
|
const sessionDirs = readdirSync(root, { withFileTypes: true })
|
||||||
const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd)
|
.filter(d => d.isDirectory())
|
||||||
if (hit) return hit
|
.map(d => d.name)
|
||||||
}
|
|
||||||
|
|
||||||
// 兼容旧的平铺结构
|
for (const session of sessionDirs) {
|
||||||
for (const ext of extensions) {
|
const sessionDir = join(root, session)
|
||||||
const candidate = join(root, `${cacheKey}${ext}`)
|
// 检查是否是日期目录结构
|
||||||
if (existsSync(candidate)) return candidate
|
try {
|
||||||
}
|
const subDirs = readdirSync(sessionDir, { withFileTypes: true })
|
||||||
for (const ext of extensions) {
|
.filter(d => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name))
|
||||||
const candidate = join(root, `${cacheKey}_t${ext}`)
|
.map(d => d.name)
|
||||||
if (existsSync(candidate)) return candidate
|
|
||||||
|
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) {
|
||||||
|
const candidate = join(root, `${cacheKey}${ext}`)
|
||||||
|
if (existsSync(candidate)) return candidate
|
||||||
|
}
|
||||||
|
for (const ext of extensions) {
|
||||||
|
const candidate = join(root, `${cacheKey}_t${ext}`)
|
||||||
|
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()
|
// 扫描所有可能的缓存根目录
|
||||||
try {
|
const allRoots = this.getAllCacheRoots()
|
||||||
this.indexCacheDir(root, 2, 0)
|
this.logInfo('开始索引缓存', { roots: allRoots.length })
|
||||||
} catch {
|
|
||||||
this.cacheIndexed = true
|
for (const root of allRoots) {
|
||||||
this.cacheIndexing = null
|
try {
|
||||||
resolve()
|
this.indexCacheDir(root, 3, 0) // 增加深度到3,支持 sessionId/YYYY-MM 结构
|
||||||
return
|
} catch (e) {
|
||||||
|
this.logError('索引目录失败', e, { root })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
console.warn(`[WCDB] InitProtection 失败 (${resPath}):`, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!protectionOk) {
|
||||||
|
console.warn('[WCDB] Core security check failed - 继续运行但可能不稳定')
|
||||||
|
this.writeLog('InitProtection 失败,继续运行')
|
||||||
|
// 不返回 false,允许继续运行
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('InitProtection symbol not found or failed:', e)
|
console.warn('InitProtection symbol not found:', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 定义类型
|
// 定义类型
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
132
src/pages/ChatHistoryPage.scss
Normal file
132
src/pages/ChatHistoryPage.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
250
src/pages/ChatHistoryPage.tsx
Normal file
250
src/pages/ChatHistoryPage.tsx
Normal 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(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 前端兜底解析合并转发聊天记录
|
||||||
|
const parseChatHistory = (content: string): ChatRecordItem[] | undefined => {
|
||||||
|
try {
|
||||||
|
const type = extractXmlValue(content, 'type')
|
||||||
|
if (type !== '19') return undefined
|
||||||
|
|
||||||
|
const match = /<recorditem>[\s\S]*?<!\[CDATA\[([\s\S]*?)\]\]>[\s\S]*?<\/recorditem>/.exec(content)
|
||||||
|
if (!match) return undefined
|
||||||
|
|
||||||
|
const innerXml = match[1]
|
||||||
|
const items: ChatRecordItem[] = []
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.4;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-content {
|
.link-body {
|
||||||
flex: 1;
|
padding: 6px 12px 10px;
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-title {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: 4px;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-desc {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
line-height: 1.4;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border-radius: 6px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
gap: 10px;
|
||||||
justify-content: center;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
|
|
||||||
svg {
|
.link-desc {
|
||||||
opacity: 0.8;
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-thumb {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-thumb-placeholder {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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('<')) {
|
|
||||||
contentToParse = contentToParse
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: '系统消息',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
2
src/types/electron.d.ts
vendored
2
src/types/electron.d.ts
vendored
@@ -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: {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user