mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-28 07:25:53 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c9d0a39c3 | ||
|
|
a5777027b1 | ||
|
|
c3e911e6fa | ||
|
|
4d03110df2 | ||
|
|
8cb640f565 | ||
|
|
494bd4f539 | ||
|
|
38169691cd | ||
|
|
bd995bc736 | ||
|
|
6e05e74d5e | ||
|
|
d3a1db4efe | ||
|
|
a19f2a57c3 | ||
|
|
666a53f6ba | ||
|
|
b156a08f0d | ||
|
|
9c76aa2189 | ||
|
|
a54c95b6ac | ||
|
|
9cb0ada1b7 | ||
|
|
54378a132f | ||
|
|
4d1632a9b9 | ||
|
|
1eab835458 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -59,3 +59,4 @@ wcdb/
|
|||||||
*info
|
*info
|
||||||
概述.md
|
概述.md
|
||||||
chatlab-format.md
|
chatlab-format.md
|
||||||
|
*.bak
|
||||||
@@ -53,7 +53,7 @@ WeFlow 提供本地 HTTP API 服务,支持通过接口查询消息数据,可
|
|||||||
- **访问地址**:`http://127.0.0.1:5031`
|
- **访问地址**:`http://127.0.0.1:5031`
|
||||||
- **支持格式**:原始 JSON 或 [ChatLab](https://chatlab.fun/) 标准格式
|
- **支持格式**:原始 JSON 或 [ChatLab](https://chatlab.fun/) 标准格式
|
||||||
|
|
||||||
📖 完整接口文档:[docs/HTTP-API.md](docs/HTTP-API.md)
|
📖 完整接口文档:[点击查看](docs/HTTP-API.md)
|
||||||
|
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|||||||
@@ -864,6 +864,10 @@ function registerIpcHandlers() {
|
|||||||
return await chatService.getContactAvatar(username)
|
return await chatService.getContactAvatar(username)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('chat:resolveTransferDisplayNames', async (_, chatroomId: string, payerUsername: string, receiverUsername: string) => {
|
||||||
|
return await chatService.resolveTransferDisplayNames(chatroomId, payerUsername, receiverUsername)
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('chat:getContacts', async () => {
|
ipcMain.handle('chat:getContacts', async () => {
|
||||||
return await chatService.getContacts()
|
return await chatService.getContacts()
|
||||||
})
|
})
|
||||||
@@ -896,6 +900,9 @@ function registerIpcHandlers() {
|
|||||||
ipcMain.handle('chat:getVoiceData', async (_, sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => {
|
ipcMain.handle('chat:getVoiceData', async (_, sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => {
|
||||||
return chatService.getVoiceData(sessionId, msgId, createTime, serverId)
|
return chatService.getVoiceData(sessionId, msgId, createTime, serverId)
|
||||||
})
|
})
|
||||||
|
ipcMain.handle('chat:getAllVoiceMessages', async (_, sessionId: string) => {
|
||||||
|
return chatService.getAllVoiceMessages(sessionId)
|
||||||
|
})
|
||||||
ipcMain.handle('chat:resolveVoiceCache', async (_, sessionId: string, msgId: string) => {
|
ipcMain.handle('chat:resolveVoiceCache', async (_, sessionId: string, msgId: string) => {
|
||||||
return chatService.resolveVoiceCache(sessionId, msgId)
|
return chatService.resolveVoiceCache(sessionId, msgId)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.invoke('chat:getNewMessages', sessionId, minTime, limit),
|
ipcRenderer.invoke('chat:getNewMessages', sessionId, minTime, limit),
|
||||||
getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username),
|
getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username),
|
||||||
getContactAvatar: (username: string) => ipcRenderer.invoke('chat:getContactAvatar', username),
|
getContactAvatar: (username: string) => ipcRenderer.invoke('chat:getContactAvatar', username),
|
||||||
|
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) =>
|
||||||
|
ipcRenderer.invoke('chat:resolveTransferDisplayNames', chatroomId, payerUsername, receiverUsername),
|
||||||
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
|
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
|
||||||
downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5),
|
downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5),
|
||||||
getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId),
|
getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId),
|
||||||
@@ -139,6 +141,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
|
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
|
||||||
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
|
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
|
||||||
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),
|
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),
|
||||||
|
getAllVoiceMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId),
|
||||||
resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId),
|
resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId),
|
||||||
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
|
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
|
||||||
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => {
|
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => {
|
||||||
|
|||||||
@@ -1120,6 +1120,9 @@ class ChatService {
|
|||||||
// 名片消息
|
// 名片消息
|
||||||
let cardUsername: string | undefined
|
let cardUsername: string | undefined
|
||||||
let cardNickname: string | undefined
|
let cardNickname: string | undefined
|
||||||
|
// 转账消息
|
||||||
|
let transferPayerUsername: string | undefined
|
||||||
|
let transferReceiverUsername: string | undefined
|
||||||
// 聊天记录
|
// 聊天记录
|
||||||
let chatRecordTitle: string | undefined
|
let chatRecordTitle: string | undefined
|
||||||
let chatRecordList: Array<{
|
let chatRecordList: Array<{
|
||||||
@@ -1151,8 +1154,8 @@ class ChatService {
|
|||||||
const cardInfo = this.parseCardInfo(content)
|
const cardInfo = this.parseCardInfo(content)
|
||||||
cardUsername = cardInfo.username
|
cardUsername = cardInfo.username
|
||||||
cardNickname = cardInfo.nickname
|
cardNickname = cardInfo.nickname
|
||||||
} else if (localType === 49 && content) {
|
} else if ((localType === 49 || localType === 8589934592049) && content) {
|
||||||
// Type 49 消息(链接、文件、小程序、转账等)
|
// Type 49 消息(链接、文件、小程序、转账等),8589934592049 也是转账类型
|
||||||
const type49Info = this.parseType49Message(content)
|
const type49Info = this.parseType49Message(content)
|
||||||
xmlType = type49Info.xmlType
|
xmlType = type49Info.xmlType
|
||||||
linkTitle = type49Info.linkTitle
|
linkTitle = type49Info.linkTitle
|
||||||
@@ -1163,6 +1166,8 @@ class ChatService {
|
|||||||
fileExt = type49Info.fileExt
|
fileExt = type49Info.fileExt
|
||||||
chatRecordTitle = type49Info.chatRecordTitle
|
chatRecordTitle = type49Info.chatRecordTitle
|
||||||
chatRecordList = type49Info.chatRecordList
|
chatRecordList = type49Info.chatRecordList
|
||||||
|
transferPayerUsername = type49Info.transferPayerUsername
|
||||||
|
transferReceiverUsername = type49Info.transferReceiverUsername
|
||||||
} 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
|
||||||
@@ -1199,6 +1204,8 @@ class ChatService {
|
|||||||
xmlType,
|
xmlType,
|
||||||
cardUsername,
|
cardUsername,
|
||||||
cardNickname,
|
cardNickname,
|
||||||
|
transferPayerUsername,
|
||||||
|
transferReceiverUsername,
|
||||||
chatRecordTitle,
|
chatRecordTitle,
|
||||||
chatRecordList
|
chatRecordList
|
||||||
})
|
})
|
||||||
@@ -1663,6 +1670,8 @@ class ChatService {
|
|||||||
fileName?: string
|
fileName?: string
|
||||||
fileSize?: number
|
fileSize?: number
|
||||||
fileExt?: string
|
fileExt?: string
|
||||||
|
transferPayerUsername?: string
|
||||||
|
transferReceiverUsername?: string
|
||||||
chatRecordTitle?: string
|
chatRecordTitle?: string
|
||||||
chatRecordList?: Array<{
|
chatRecordList?: Array<{
|
||||||
datatype: number
|
datatype: number
|
||||||
@@ -1786,6 +1795,16 @@ class ChatService {
|
|||||||
} else if (feedesc) {
|
} else if (feedesc) {
|
||||||
result.linkTitle = feedesc
|
result.linkTitle = feedesc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 提取转账双方 wxid
|
||||||
|
const payerUsername = this.extractXmlValue(content, 'payer_username')
|
||||||
|
const receiverUsername = this.extractXmlValue(content, 'receiver_username')
|
||||||
|
if (payerUsername) {
|
||||||
|
result.transferPayerUsername = payerUsername
|
||||||
|
}
|
||||||
|
if (receiverUsername) {
|
||||||
|
result.transferReceiverUsername = receiverUsername
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2388,6 +2407,75 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析转账消息中的付款方和收款方显示名称
|
||||||
|
* 优先使用群昵称,群昵称为空时回退到微信昵称/备注
|
||||||
|
*/
|
||||||
|
async resolveTransferDisplayNames(
|
||||||
|
chatroomId: string,
|
||||||
|
payerUsername: string,
|
||||||
|
receiverUsername: string
|
||||||
|
): Promise<{ payerName: string; receiverName: string }> {
|
||||||
|
try {
|
||||||
|
const connectResult = await this.ensureConnected()
|
||||||
|
if (!connectResult.success) {
|
||||||
|
return { payerName: payerUsername, receiverName: receiverUsername }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是群聊,尝试获取群昵称
|
||||||
|
let groupNicknames: Record<string, string> = {}
|
||||||
|
if (chatroomId.endsWith('@chatroom')) {
|
||||||
|
const nickResult = await wcdbService.getGroupNicknames(chatroomId)
|
||||||
|
if (nickResult.success && nickResult.nicknames) {
|
||||||
|
groupNicknames = nickResult.nicknames
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前用户 wxid,用于识别"自己"
|
||||||
|
const myWxid = this.configService.get('myWxid')
|
||||||
|
const cleanedMyWxid = myWxid ? this.cleanAccountDirName(myWxid) : ''
|
||||||
|
|
||||||
|
// 解析付款方名称:自己 > 群昵称 > 备注 > 昵称 > alias > wxid
|
||||||
|
const resolveName = async (username: string): Promise<string> => {
|
||||||
|
// 特判:如果是当前用户自己(contact 表通常不包含自己)
|
||||||
|
if (myWxid && (username === myWxid || username === cleanedMyWxid)) {
|
||||||
|
// 先查群昵称中是否有自己
|
||||||
|
const myGroupNick = groupNicknames[username]
|
||||||
|
if (myGroupNick) return myGroupNick
|
||||||
|
// 尝试从缓存获取自己的昵称
|
||||||
|
const cached = this.avatarCache.get(username) || this.avatarCache.get(myWxid)
|
||||||
|
if (cached?.displayName) return cached.displayName
|
||||||
|
return '我'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先查群昵称
|
||||||
|
const groupNick = groupNicknames[username]
|
||||||
|
if (groupNick) return groupNick
|
||||||
|
|
||||||
|
// 再查联系人信息
|
||||||
|
const contact = await this.getContact(username)
|
||||||
|
if (contact) {
|
||||||
|
return contact.remark || contact.nickName || contact.alias || username
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:查缓存
|
||||||
|
const cached = this.avatarCache.get(username)
|
||||||
|
if (cached?.displayName) return cached.displayName
|
||||||
|
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
|
||||||
|
const [payerName, receiverName] = await Promise.all([
|
||||||
|
resolveName(payerUsername),
|
||||||
|
resolveName(receiverUsername)
|
||||||
|
])
|
||||||
|
|
||||||
|
return { payerName, receiverName }
|
||||||
|
} catch {
|
||||||
|
return { payerName: payerUsername, receiverName: receiverUsername }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前用户的头像 URL
|
* 获取当前用户的头像 URL
|
||||||
*/
|
*/
|
||||||
@@ -3551,6 +3639,67 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取某会话的所有语音消息(localType=34),用于批量转写
|
||||||
|
*/
|
||||||
|
async getAllVoiceMessages(sessionId: string): Promise<{ success: boolean; messages?: Message[]; error?: string }> {
|
||||||
|
try {
|
||||||
|
const connectResult = await this.ensureConnected()
|
||||||
|
if (!connectResult.success) {
|
||||||
|
return { success: false, error: connectResult.error || '数据库未连接' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取会话表信息
|
||||||
|
let tables = this.sessionTablesCache.get(sessionId)
|
||||||
|
if (!tables) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let allVoiceMessages: Message[] = []
|
||||||
|
|
||||||
|
for (const { tableName, dbPath } of tables) {
|
||||||
|
try {
|
||||||
|
const sql = `SELECT * FROM ${tableName} WHERE local_type = 34 ORDER BY create_time DESC`
|
||||||
|
const result = await wcdbService.execQuery('message', dbPath, sql)
|
||||||
|
if (result.success && result.rows && result.rows.length > 0) {
|
||||||
|
const mapped = this.mapRowsToMessages(result.rows as Record<string, any>[])
|
||||||
|
allVoiceMessages.push(...mapped)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[ChatService] 查询语音消息失败 (${dbPath}):`, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按 createTime 降序排序
|
||||||
|
allVoiceMessages.sort((a, b) => b.createTime - a.createTime)
|
||||||
|
|
||||||
|
// 去重
|
||||||
|
const seen = new Set<string>()
|
||||||
|
allVoiceMessages = allVoiceMessages.filter(msg => {
|
||||||
|
const key = `${msg.serverId}-${msg.localId}-${msg.createTime}-${msg.sortSeq}`
|
||||||
|
if (seen.has(key)) return false
|
||||||
|
seen.add(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`[ChatService] 共找到 ${allVoiceMessages.length} 条语音消息(去重后)`)
|
||||||
|
return { success: true, messages: allVoiceMessages }
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ChatService] 获取所有语音消息失败:', e)
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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. 尝试从缓存获取会话表信息
|
||||||
|
|||||||
@@ -348,6 +348,51 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从转账消息 XML 中提取并解析 "谁转账给谁" 描述
|
||||||
|
* @param content 原始消息内容 XML
|
||||||
|
* @param myWxid 当前用户 wxid
|
||||||
|
* @param groupNicknamesMap 群昵称映射
|
||||||
|
* @param getContactName 联系人名称解析函数
|
||||||
|
* @returns "A 转账给 B" 或 null
|
||||||
|
*/
|
||||||
|
private async resolveTransferDesc(
|
||||||
|
content: string,
|
||||||
|
myWxid: string,
|
||||||
|
groupNicknamesMap: Map<string, string>,
|
||||||
|
getContactName: (username: string) => Promise<string>
|
||||||
|
): Promise<string | null> {
|
||||||
|
const xmlType = this.extractXmlValue(content, 'type')
|
||||||
|
if (xmlType !== '2000') return null
|
||||||
|
|
||||||
|
const payerUsername = this.extractXmlValue(content, 'payer_username')
|
||||||
|
const receiverUsername = this.extractXmlValue(content, 'receiver_username')
|
||||||
|
if (!payerUsername || !receiverUsername) return null
|
||||||
|
|
||||||
|
const cleanedMyWxid = myWxid ? this.cleanAccountDirName(myWxid) : ''
|
||||||
|
|
||||||
|
const resolveName = async (username: string): Promise<string> => {
|
||||||
|
// 当前用户自己
|
||||||
|
if (myWxid && (username === myWxid || username === cleanedMyWxid)) {
|
||||||
|
const groupNick = groupNicknamesMap.get(username) || groupNicknamesMap.get(username.toLowerCase())
|
||||||
|
if (groupNick) return groupNick
|
||||||
|
return '我'
|
||||||
|
}
|
||||||
|
// 群昵称
|
||||||
|
const groupNick = groupNicknamesMap.get(username) || groupNicknamesMap.get(username.toLowerCase())
|
||||||
|
if (groupNick) return groupNick
|
||||||
|
// 联系人名称
|
||||||
|
return getContactName(username)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [payerName, receiverName] = await Promise.all([
|
||||||
|
resolveName(payerUsername),
|
||||||
|
resolveName(receiverUsername)
|
||||||
|
])
|
||||||
|
|
||||||
|
return `${payerName} 转账给 ${receiverName}`
|
||||||
|
}
|
||||||
|
|
||||||
private looksLikeBase64(s: string): boolean {
|
private looksLikeBase64(s: string): boolean {
|
||||||
if (s.length % 4 !== 0) return false
|
if (s.length % 4 !== 0) return false
|
||||||
return /^[A-Za-z0-9+/=]+$/.test(s)
|
return /^[A-Za-z0-9+/=]+$/.test(s)
|
||||||
@@ -2003,7 +2048,8 @@ class ExportService {
|
|||||||
phase: 'exporting'
|
phase: 'exporting'
|
||||||
})
|
})
|
||||||
|
|
||||||
const chatLabMessages: ChatLabMessage[] = allMessages.map(msg => {
|
const chatLabMessages: ChatLabMessage[] = []
|
||||||
|
for (const msg of allMessages) {
|
||||||
const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || {
|
const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || {
|
||||||
platformId: msg.senderUsername,
|
platformId: msg.senderUsername,
|
||||||
accountName: msg.senderUsername,
|
accountName: msg.senderUsername,
|
||||||
@@ -2024,6 +2070,22 @@ class ExportService {
|
|||||||
content = this.parseMessageContent(msg.content, msg.localType, sessionId, msg.createTime)
|
content = this.parseMessageContent(msg.content, msg.localType, sessionId, msg.createTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 转账消息:追加 "谁转账给谁" 信息
|
||||||
|
if (content && content.startsWith('[转账]') && msg.content) {
|
||||||
|
const transferDesc = await this.resolveTransferDesc(
|
||||||
|
msg.content,
|
||||||
|
cleanedMyWxid,
|
||||||
|
groupNicknamesMap,
|
||||||
|
async (username) => {
|
||||||
|
const info = await this.getContactInfo(username)
|
||||||
|
return info.displayName || username
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (transferDesc) {
|
||||||
|
content = content.replace('[转账]', `[转账] (${transferDesc})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const message: ChatLabMessage = {
|
const message: ChatLabMessage = {
|
||||||
sender: msg.senderUsername,
|
sender: msg.senderUsername,
|
||||||
accountName: memberInfo.accountName,
|
accountName: memberInfo.accountName,
|
||||||
@@ -2127,8 +2189,8 @@ class ExportService {
|
|||||||
message.chatRecords = chatRecords
|
message.chatRecords = chatRecords
|
||||||
}
|
}
|
||||||
|
|
||||||
return message
|
chatLabMessages.push(message)
|
||||||
})
|
}
|
||||||
|
|
||||||
const avatarMap = options.exportAvatars
|
const avatarMap = options.exportAvatars
|
||||||
? await this.exportAvatars(
|
? await this.exportAvatars(
|
||||||
@@ -2341,6 +2403,25 @@ class ExportService {
|
|||||||
content = this.parseMessageContent(msg.content, msg.localType)
|
content = this.parseMessageContent(msg.content, msg.localType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 转账消息:追加 "谁转账给谁" 信息
|
||||||
|
if (content && content.startsWith('[转账]') && msg.content) {
|
||||||
|
const transferDesc = await this.resolveTransferDesc(
|
||||||
|
msg.content,
|
||||||
|
cleanedMyWxid,
|
||||||
|
groupNicknamesMap,
|
||||||
|
async (username) => {
|
||||||
|
const c = await getContactCached(username)
|
||||||
|
if (c.success && c.contact) {
|
||||||
|
return c.contact.remark || c.contact.nickName || c.contact.alias || username
|
||||||
|
}
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (transferDesc) {
|
||||||
|
content = content.replace('[转账]', `[转账] (${transferDesc})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取发送者信息用于名称显示
|
// 获取发送者信息用于名称显示
|
||||||
const senderWxid = msg.senderUsername
|
const senderWxid = msg.senderUsername
|
||||||
const contact = await getContactCached(senderWxid)
|
const contact = await getContactCached(senderWxid)
|
||||||
@@ -2784,6 +2865,26 @@ class ExportService {
|
|||||||
voiceTranscriptMap.get(msg.localId)
|
voiceTranscriptMap.get(msg.localId)
|
||||||
))
|
))
|
||||||
|
|
||||||
|
// 转账消息:追加 "谁转账给谁" 信息
|
||||||
|
let enrichedContentValue = contentValue
|
||||||
|
if (contentValue.startsWith('[转账]') && msg.content) {
|
||||||
|
const transferDesc = await this.resolveTransferDesc(
|
||||||
|
msg.content,
|
||||||
|
cleanedMyWxid,
|
||||||
|
groupNicknamesMap,
|
||||||
|
async (username) => {
|
||||||
|
const c = await getContactCached(username)
|
||||||
|
if (c.success && c.contact) {
|
||||||
|
return c.contact.remark || c.contact.nickName || c.contact.alias || username
|
||||||
|
}
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (transferDesc) {
|
||||||
|
enrichedContentValue = contentValue.replace('[转账]', `[转账] (${transferDesc})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 调试日志
|
// 调试日志
|
||||||
if (msg.localType === 3 || msg.localType === 47) {
|
if (msg.localType === 3 || msg.localType === 47) {
|
||||||
}
|
}
|
||||||
@@ -2793,7 +2894,7 @@ class ExportService {
|
|||||||
if (useCompactColumns) {
|
if (useCompactColumns) {
|
||||||
worksheet.getCell(currentRow, 3).value = senderRole
|
worksheet.getCell(currentRow, 3).value = senderRole
|
||||||
worksheet.getCell(currentRow, 4).value = this.getMessageTypeName(msg.localType)
|
worksheet.getCell(currentRow, 4).value = this.getMessageTypeName(msg.localType)
|
||||||
worksheet.getCell(currentRow, 5).value = contentValue
|
worksheet.getCell(currentRow, 5).value = enrichedContentValue
|
||||||
} else {
|
} else {
|
||||||
worksheet.getCell(currentRow, 3).value = senderNickname
|
worksheet.getCell(currentRow, 3).value = senderNickname
|
||||||
worksheet.getCell(currentRow, 4).value = senderWxid
|
worksheet.getCell(currentRow, 4).value = senderWxid
|
||||||
@@ -2801,7 +2902,7 @@ class ExportService {
|
|||||||
worksheet.getCell(currentRow, 6).value = senderGroupNickname
|
worksheet.getCell(currentRow, 6).value = senderGroupNickname
|
||||||
worksheet.getCell(currentRow, 7).value = senderRole
|
worksheet.getCell(currentRow, 7).value = senderRole
|
||||||
worksheet.getCell(currentRow, 8).value = this.getMessageTypeName(msg.localType)
|
worksheet.getCell(currentRow, 8).value = this.getMessageTypeName(msg.localType)
|
||||||
worksheet.getCell(currentRow, 9).value = contentValue
|
worksheet.getCell(currentRow, 9).value = enrichedContentValue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置每个单元格的样式
|
// 设置每个单元格的样式
|
||||||
@@ -2948,6 +3049,11 @@ class ExportService {
|
|||||||
senderUsernames.add(sessionId)
|
senderUsernames.add(sessionId)
|
||||||
await this.preloadContacts(senderUsernames, contactCache)
|
await this.preloadContacts(senderUsernames, contactCache)
|
||||||
|
|
||||||
|
// 获取群昵称(用于转账描述等)
|
||||||
|
const groupNicknamesMap = isGroup
|
||||||
|
? await this.getGroupNicknamesForRoom(sessionId)
|
||||||
|
: new Map<string, string>()
|
||||||
|
|
||||||
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
|
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
|
||||||
|
|
||||||
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
||||||
@@ -3033,6 +3139,26 @@ class ExportService {
|
|||||||
voiceTranscriptMap.get(msg.localId)
|
voiceTranscriptMap.get(msg.localId)
|
||||||
))
|
))
|
||||||
|
|
||||||
|
// 转账消息:追加 "谁转账给谁" 信息
|
||||||
|
let enrichedContentValue = contentValue
|
||||||
|
if (contentValue.startsWith('[转账]') && msg.content) {
|
||||||
|
const transferDesc = await this.resolveTransferDesc(
|
||||||
|
msg.content,
|
||||||
|
cleanedMyWxid,
|
||||||
|
groupNicknamesMap,
|
||||||
|
async (username) => {
|
||||||
|
const c = await getContactCached(username)
|
||||||
|
if (c.success && c.contact) {
|
||||||
|
return c.contact.remark || c.contact.nickName || c.contact.alias || username
|
||||||
|
}
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (transferDesc) {
|
||||||
|
enrichedContentValue = contentValue.replace('[转账]', `[转账] (${transferDesc})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let senderRole: string
|
let senderRole: string
|
||||||
let senderWxid: string
|
let senderWxid: string
|
||||||
let senderNickname: string
|
let senderNickname: string
|
||||||
@@ -3067,7 +3193,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lines.push(`${this.formatTimestamp(msg.createTime)} '${senderRole}'`)
|
lines.push(`${this.formatTimestamp(msg.createTime)} '${senderRole}'`)
|
||||||
lines.push(contentValue)
|
lines.push(enrichedContentValue)
|
||||||
lines.push('')
|
lines.push('')
|
||||||
|
|
||||||
if ((i + 1) % 200 === 0) {
|
if ((i + 1) % 200 === 0) {
|
||||||
|
|||||||
@@ -200,18 +200,179 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量获取消息(循环游标直到满足 limit)
|
||||||
|
* 绕过 chatService 的单 batch 限制,直接操作 wcdbService 游标
|
||||||
|
*/
|
||||||
|
private async fetchMessagesBatch(
|
||||||
|
talker: string,
|
||||||
|
offset: number,
|
||||||
|
limit: number,
|
||||||
|
startTime: number,
|
||||||
|
endTime: number,
|
||||||
|
ascending: boolean
|
||||||
|
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
// 使用固定 batch 大小(与 limit 相同或最大 500)来减少循环次数
|
||||||
|
const batchSize = Math.min(limit, 500)
|
||||||
|
const beginTimestamp = startTime > 10000000000 ? Math.floor(startTime / 1000) : startTime
|
||||||
|
const endTimestamp = endTime > 10000000000 ? Math.floor(endTime / 1000) : endTime
|
||||||
|
|
||||||
|
const cursorResult = await wcdbService.openMessageCursor(talker, batchSize, ascending, beginTimestamp, endTimestamp)
|
||||||
|
if (!cursorResult.success || !cursorResult.cursor) {
|
||||||
|
return { success: false, error: cursorResult.error || '打开消息游标失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursor = cursorResult.cursor
|
||||||
|
try {
|
||||||
|
const allRows: Record<string, any>[] = []
|
||||||
|
let hasMore = true
|
||||||
|
let skipped = 0
|
||||||
|
|
||||||
|
// 循环获取消息,处理 offset 跳过 + limit 累积
|
||||||
|
while (allRows.length < limit && hasMore) {
|
||||||
|
const batch = await wcdbService.fetchMessageBatch(cursor)
|
||||||
|
if (!batch.success || !batch.rows || batch.rows.length === 0) {
|
||||||
|
hasMore = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = batch.rows
|
||||||
|
hasMore = batch.hasMore === true
|
||||||
|
|
||||||
|
// 处理 offset: 跳过前 N 条
|
||||||
|
if (skipped < offset) {
|
||||||
|
const remaining = offset - skipped
|
||||||
|
if (remaining >= rows.length) {
|
||||||
|
skipped += rows.length
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rows = rows.slice(remaining)
|
||||||
|
skipped = offset
|
||||||
|
}
|
||||||
|
|
||||||
|
allRows.push(...rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedRows = allRows.slice(0, limit)
|
||||||
|
const finalHasMore = hasMore || allRows.length > limit
|
||||||
|
const messages = this.mapRowsToMessagesSimple(trimmedRows)
|
||||||
|
return { success: true, messages, hasMore: finalHasMore }
|
||||||
|
} finally {
|
||||||
|
await wcdbService.closeMessageCursor(cursor)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[HttpService] fetchMessagesBatch error:', e)
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简单的行数据到 Message 映射(用于 API 输出)
|
||||||
|
*/
|
||||||
|
private mapRowsToMessagesSimple(rows: Record<string, any>[]): Message[] {
|
||||||
|
const myWxid = this.configService.get('myWxid') || ''
|
||||||
|
const messages: Message[] = []
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const content = this.getField(row, ['message_content', 'messageContent', 'content', 'msg_content', 'WCDB_CT_message_content']) || ''
|
||||||
|
const localType = parseInt(this.getField(row, ['local_type', 'localType', 'type', 'msg_type', 'WCDB_CT_local_type']) || '1', 10)
|
||||||
|
const isSendRaw = this.getField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'])
|
||||||
|
const senderUsername = this.getField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || ''
|
||||||
|
const createTime = parseInt(this.getField(row, ['create_time', 'createTime', 'msg_create_time', 'WCDB_CT_create_time']) || '0', 10)
|
||||||
|
const localId = parseInt(this.getField(row, ['local_id', 'localId', 'WCDB_CT_local_id', 'rowid']) || '0', 10)
|
||||||
|
const serverId = this.getField(row, ['server_id', 'serverId', 'WCDB_CT_server_id']) || ''
|
||||||
|
|
||||||
|
let isSend: number
|
||||||
|
if (isSendRaw !== null && isSendRaw !== undefined) {
|
||||||
|
isSend = parseInt(isSendRaw, 10)
|
||||||
|
} else if (senderUsername && myWxid) {
|
||||||
|
isSend = senderUsername.toLowerCase() === myWxid.toLowerCase() ? 1 : 0
|
||||||
|
} else {
|
||||||
|
isSend = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析消息内容中的特殊字段
|
||||||
|
let parsedContent = content
|
||||||
|
let xmlType: string | undefined
|
||||||
|
let linkTitle: string | undefined
|
||||||
|
let fileName: string | undefined
|
||||||
|
let emojiCdnUrl: string | undefined
|
||||||
|
let emojiMd5: string | undefined
|
||||||
|
let imageMd5: string | undefined
|
||||||
|
let videoMd5: string | undefined
|
||||||
|
let cardNickname: string | undefined
|
||||||
|
|
||||||
|
if (localType === 49 && content) {
|
||||||
|
// 提取 type 子标签
|
||||||
|
const typeMatch = /<type>(\d+)<\/type>/i.exec(content)
|
||||||
|
if (typeMatch) xmlType = typeMatch[1]
|
||||||
|
// 提取 title
|
||||||
|
const titleMatch = /<title>([^<]*)<\/title>/i.exec(content)
|
||||||
|
if (titleMatch) linkTitle = titleMatch[1]
|
||||||
|
// 提取文件名
|
||||||
|
const fnMatch = /<title>([^<]*)<\/title>/i.exec(content)
|
||||||
|
if (fnMatch) fileName = fnMatch[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localType === 47 && content) {
|
||||||
|
const cdnMatch = /cdnurl\s*=\s*"([^"]+)"/i.exec(content)
|
||||||
|
if (cdnMatch) emojiCdnUrl = cdnMatch[1]
|
||||||
|
const md5Match = /md5\s*=\s*"([^"]+)"/i.exec(content)
|
||||||
|
if (md5Match) emojiMd5 = md5Match[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push({
|
||||||
|
localId,
|
||||||
|
talker: '',
|
||||||
|
localType,
|
||||||
|
createTime,
|
||||||
|
sortSeq: createTime,
|
||||||
|
content: parsedContent,
|
||||||
|
isSend,
|
||||||
|
senderUsername,
|
||||||
|
serverId: serverId ? parseInt(serverId, 10) || 0 : 0,
|
||||||
|
rawContent: content,
|
||||||
|
parsedContent: content,
|
||||||
|
emojiCdnUrl,
|
||||||
|
emojiMd5,
|
||||||
|
imageMd5,
|
||||||
|
videoMd5,
|
||||||
|
xmlType,
|
||||||
|
linkTitle,
|
||||||
|
fileName,
|
||||||
|
cardNickname
|
||||||
|
} as Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从行数据中获取字段值(兼容多种字段名)
|
||||||
|
*/
|
||||||
|
private getField(row: Record<string, any>, keys: string[]): string | null {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (row[key] !== undefined && row[key] !== null) {
|
||||||
|
return String(row[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理消息查询
|
* 处理消息查询
|
||||||
* GET /api/v1/messages?talker=xxx&limit=100&start=20260101&chatlab=1
|
* GET /api/v1/messages?talker=xxx&limit=100&start=20260101&chatlab=1
|
||||||
*/
|
*/
|
||||||
private async handleMessages(url: URL, res: http.ServerResponse): Promise<void> {
|
private async handleMessages(url: URL, res: http.ServerResponse): Promise<void> {
|
||||||
const talker = url.searchParams.get('talker')
|
const talker = url.searchParams.get('talker')
|
||||||
const limit = parseInt(url.searchParams.get('limit') || '100', 10)
|
const limit = Math.min(parseInt(url.searchParams.get('limit') || '100', 10), 10000)
|
||||||
const offset = parseInt(url.searchParams.get('offset') || '0', 10)
|
const offset = parseInt(url.searchParams.get('offset') || '0', 10)
|
||||||
const startParam = url.searchParams.get('start')
|
const startParam = url.searchParams.get('start')
|
||||||
const endParam = url.searchParams.get('end')
|
const endParam = url.searchParams.get('end')
|
||||||
const chatlab = url.searchParams.get('chatlab') === '1'
|
const chatlab = url.searchParams.get('chatlab') === '1'
|
||||||
const format = url.searchParams.get('format') || (chatlab ? 'chatlab' : 'json')
|
const formatParam = url.searchParams.get('format')
|
||||||
|
const format = formatParam || (chatlab ? 'chatlab' : 'json')
|
||||||
|
|
||||||
if (!talker) {
|
if (!talker) {
|
||||||
this.sendError(res, 400, 'Missing required parameter: talker')
|
this.sendError(res, 400, 'Missing required parameter: talker')
|
||||||
@@ -222,8 +383,8 @@ class HttpService {
|
|||||||
const startTime = this.parseTimeParam(startParam)
|
const startTime = this.parseTimeParam(startParam)
|
||||||
const endTime = this.parseTimeParam(endParam, true)
|
const endTime = this.parseTimeParam(endParam, true)
|
||||||
|
|
||||||
// 获取消息
|
// 使用批量获取方法,绕过 chatService 的单 batch 限制
|
||||||
const result = await chatService.getMessages(talker, offset, limit, startTime, endTime, true)
|
const result = await this.fetchMessagesBatch(talker, offset, limit, startTime, endTime, true)
|
||||||
if (!result.success || !result.messages) {
|
if (!result.success || !result.messages) {
|
||||||
this.sendError(res, 500, result.error || 'Failed to get messages')
|
this.sendError(res, 500, result.error || 'Failed to get messages')
|
||||||
return
|
return
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "1.5.2",
|
"version": "1.5.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "1.5.2",
|
"version": "1.5.4",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.5.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "1.5.3",
|
"version": "1.5.4",
|
||||||
"description": "WeFlow",
|
"description": "WeFlow",
|
||||||
"main": "dist-electron/main.js",
|
"main": "dist-electron/main.js",
|
||||||
"author": "cc",
|
"author": "cc",
|
||||||
|
|||||||
@@ -2016,12 +2016,43 @@
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
|
user-select: text;
|
||||||
|
|
||||||
&.highlight {
|
&.highlight {
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .copy-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-list {
|
.table-list {
|
||||||
@@ -2458,6 +2489,13 @@
|
|||||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.transfer-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
opacity: 0.9;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
.transfer-memo {
|
.transfer-memo {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
@@ -2572,3 +2610,422 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 批量转写按钮
|
||||||
|
.batch-transcribe-btn {
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量转写模态框基础样式
|
||||||
|
.batch-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10000;
|
||||||
|
animation: batchFadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-modal-content {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: batchSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes batchFadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes batchSlideUp {
|
||||||
|
from { opacity: 0; transform: translateY(20px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量转写确认对话框
|
||||||
|
.batch-confirm-modal {
|
||||||
|
width: 480px;
|
||||||
|
max-width: 90vw;
|
||||||
|
|
||||||
|
.batch-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
svg { color: var(--primary-color); }
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-dates-list-wrap {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.batch-dates-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
|
||||||
|
.batch-dates-btn {
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-dates-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
max-height: 160px;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
li {
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-date-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
|
||||||
|
&:hover { background: var(--bg-hover); }
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
accent-color: var(--primary-color);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-date-label {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-date-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-info {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-warning {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: rgba(255, 152, 0, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 152, 0, 0.3);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: #ff9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
&.btn-secondary {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
&:hover { background: var(--border-color); }
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-primary, &.batch-transcribe-start-btn {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
&:hover { opacity: 0.9; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量转写进度对话框
|
||||||
|
.batch-progress-modal {
|
||||||
|
width: 420px;
|
||||||
|
max-width: 90vw;
|
||||||
|
|
||||||
|
.batch-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
svg { color: var(--primary-color); }
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
|
||||||
|
.progress-info {
|
||||||
|
.progress-text {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
.progress-percent {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 8px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--primary-color), var(--primary-color));
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-tip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量转写结果对话框
|
||||||
|
.batch-result-modal {
|
||||||
|
width: 420px;
|
||||||
|
max-width: 90vw;
|
||||||
|
|
||||||
|
.batch-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
svg { color: #4caf50; }
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
|
||||||
|
.result-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
|
||||||
|
svg { flex-shrink: 0; }
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.success {
|
||||||
|
svg { color: #4caf50; }
|
||||||
|
.value { color: #4caf50; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&.fail {
|
||||||
|
svg { color: #f44336; }
|
||||||
|
.value { color: #f44336; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-tip {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: rgba(255, 152, 0, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 152, 0, 0.3);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: #ff9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
&.btn-primary {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
&:hover { opacity: 0.9; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||||
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link } from 'lucide-react'
|
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, XCircle, Copy, Check } from 'lucide-react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { useChatStore } from '../stores/chatStore'
|
import { useChatStore } from '../stores/chatStore'
|
||||||
import type { ChatSession, Message } from '../types/models'
|
import type { ChatSession, Message } from '../types/models'
|
||||||
@@ -168,12 +168,25 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
const [showDetailPanel, setShowDetailPanel] = useState(false)
|
const [showDetailPanel, setShowDetailPanel] = useState(false)
|
||||||
const [sessionDetail, setSessionDetail] = useState<SessionDetail | null>(null)
|
const [sessionDetail, setSessionDetail] = useState<SessionDetail | null>(null)
|
||||||
const [isLoadingDetail, setIsLoadingDetail] = useState(false)
|
const [isLoadingDetail, setIsLoadingDetail] = useState(false)
|
||||||
|
const [copiedField, setCopiedField] = useState<string | null>(null)
|
||||||
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
|
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
|
||||||
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false)
|
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false)
|
||||||
const [hasInitialMessages, setHasInitialMessages] = useState(false)
|
const [hasInitialMessages, setHasInitialMessages] = useState(false)
|
||||||
const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false)
|
const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false)
|
||||||
const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null)
|
const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null)
|
||||||
|
|
||||||
|
// 批量语音转文字相关状态
|
||||||
|
const [isBatchTranscribing, setIsBatchTranscribing] = useState(false)
|
||||||
|
const [batchTranscribeProgress, setBatchTranscribeProgress] = useState({ current: 0, total: 0 })
|
||||||
|
const [showBatchConfirm, setShowBatchConfirm] = useState(false)
|
||||||
|
const [batchVoiceCount, setBatchVoiceCount] = useState(0)
|
||||||
|
const [batchVoiceMessages, setBatchVoiceMessages] = useState<Message[] | null>(null)
|
||||||
|
const [batchVoiceDates, setBatchVoiceDates] = useState<string[]>([])
|
||||||
|
const [batchSelectedDates, setBatchSelectedDates] = useState<Set<string>>(new Set())
|
||||||
|
const [showBatchProgress, setShowBatchProgress] = useState(false)
|
||||||
|
const [showBatchResult, setShowBatchResult] = useState(false)
|
||||||
|
const [batchResult, setBatchResult] = useState({ success: 0, fail: 0 })
|
||||||
|
|
||||||
// 联系人信息加载控制
|
// 联系人信息加载控制
|
||||||
const isEnrichingRef = useRef(false)
|
const isEnrichingRef = useRef(false)
|
||||||
const enrichCancelledRef = useRef(false)
|
const enrichCancelledRef = useRef(false)
|
||||||
@@ -231,6 +244,25 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
setShowDetailPanel(!showDetailPanel)
|
setShowDetailPanel(!showDetailPanel)
|
||||||
}, [showDetailPanel, currentSessionId, loadSessionDetail])
|
}, [showDetailPanel, currentSessionId, loadSessionDetail])
|
||||||
|
|
||||||
|
// 复制字段值到剪贴板
|
||||||
|
const handleCopyField = useCallback(async (text: string, field: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
setCopiedField(field)
|
||||||
|
setTimeout(() => setCopiedField(null), 1500)
|
||||||
|
} catch {
|
||||||
|
// fallback
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
textarea.value = text
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
setCopiedField(field)
|
||||||
|
setTimeout(() => setCopiedField(null), 1500)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
// 连接数据库
|
// 连接数据库
|
||||||
const connect = useCallback(async () => {
|
const connect = useCallback(async () => {
|
||||||
setConnecting(true)
|
setConnecting(true)
|
||||||
@@ -1183,6 +1215,155 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
setShowVoiceTranscribeDialog(true)
|
setShowVoiceTranscribeDialog(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// 批量语音转文字
|
||||||
|
const handleBatchTranscribe = useCallback(async () => {
|
||||||
|
if (!currentSessionId) return
|
||||||
|
const session = sessions.find(s => s.username === currentSessionId)
|
||||||
|
if (!session) {
|
||||||
|
alert('未找到当前会话')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isBatchTranscribing) return
|
||||||
|
|
||||||
|
const result = await window.electronAPI.chat.getAllVoiceMessages(currentSessionId)
|
||||||
|
if (!result.success || !result.messages) {
|
||||||
|
alert(`获取语音消息失败: ${result.error || '未知错误'}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const voiceMessages = result.messages
|
||||||
|
if (voiceMessages.length === 0) {
|
||||||
|
alert('当前会话没有语音消息')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateSet = new Set<string>()
|
||||||
|
voiceMessages.forEach(m => dateSet.add(new Date(m.createTime * 1000).toISOString().slice(0, 10)))
|
||||||
|
const sortedDates = Array.from(dateSet).sort((a, b) => b.localeCompare(a))
|
||||||
|
|
||||||
|
setBatchVoiceMessages(voiceMessages)
|
||||||
|
setBatchVoiceCount(voiceMessages.length)
|
||||||
|
setBatchVoiceDates(sortedDates)
|
||||||
|
setBatchSelectedDates(new Set(sortedDates))
|
||||||
|
setShowBatchConfirm(true)
|
||||||
|
}, [sessions, currentSessionId, isBatchTranscribing])
|
||||||
|
|
||||||
|
// 确认批量转写
|
||||||
|
const confirmBatchTranscribe = useCallback(async () => {
|
||||||
|
if (!currentSessionId) return
|
||||||
|
|
||||||
|
const selected = batchSelectedDates
|
||||||
|
if (selected.size === 0) {
|
||||||
|
alert('请至少选择一个日期')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = batchVoiceMessages
|
||||||
|
if (!messages || messages.length === 0) {
|
||||||
|
setShowBatchConfirm(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const voiceMessages = messages.filter(m =>
|
||||||
|
selected.has(new Date(m.createTime * 1000).toISOString().slice(0, 10))
|
||||||
|
)
|
||||||
|
if (voiceMessages.length === 0) {
|
||||||
|
alert('所选日期下没有语音消息')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowBatchConfirm(false)
|
||||||
|
setBatchVoiceMessages(null)
|
||||||
|
setBatchVoiceDates([])
|
||||||
|
setBatchSelectedDates(new Set())
|
||||||
|
|
||||||
|
const session = sessions.find(s => s.username === currentSessionId)
|
||||||
|
if (!session) return
|
||||||
|
|
||||||
|
setIsBatchTranscribing(true)
|
||||||
|
setShowBatchProgress(true)
|
||||||
|
setBatchTranscribeProgress({ current: 0, total: voiceMessages.length })
|
||||||
|
|
||||||
|
// 检查模型状态
|
||||||
|
const modelStatus = await window.electronAPI.whisper.getModelStatus()
|
||||||
|
if (!modelStatus?.exists) {
|
||||||
|
alert('SenseVoice 模型未下载,请先在设置中下载模型')
|
||||||
|
setIsBatchTranscribing(false)
|
||||||
|
setShowBatchProgress(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let successCount = 0
|
||||||
|
let failCount = 0
|
||||||
|
let completedCount = 0
|
||||||
|
const concurrency = 3
|
||||||
|
|
||||||
|
const transcribeOne = async (msg: Message) => {
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.chat.getVoiceTranscript(
|
||||||
|
session.username,
|
||||||
|
String(msg.localId),
|
||||||
|
msg.createTime
|
||||||
|
)
|
||||||
|
return { success: result.success }
|
||||||
|
} catch {
|
||||||
|
return { success: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < voiceMessages.length; i += concurrency) {
|
||||||
|
const batch = voiceMessages.slice(i, i + concurrency)
|
||||||
|
const results = await Promise.all(batch.map(msg => transcribeOne(msg)))
|
||||||
|
|
||||||
|
results.forEach(result => {
|
||||||
|
if (result.success) successCount++
|
||||||
|
else failCount++
|
||||||
|
completedCount++
|
||||||
|
setBatchTranscribeProgress({ current: completedCount, total: voiceMessages.length })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsBatchTranscribing(false)
|
||||||
|
setShowBatchProgress(false)
|
||||||
|
setBatchResult({ success: successCount, fail: failCount })
|
||||||
|
setShowBatchResult(true)
|
||||||
|
}, [sessions, currentSessionId, batchSelectedDates, batchVoiceMessages])
|
||||||
|
|
||||||
|
// 批量转写:按日期的消息数量
|
||||||
|
const batchCountByDate = useMemo(() => {
|
||||||
|
const map = new Map<string, number>()
|
||||||
|
if (!batchVoiceMessages) return map
|
||||||
|
batchVoiceMessages.forEach(m => {
|
||||||
|
const d = new Date(m.createTime * 1000).toISOString().slice(0, 10)
|
||||||
|
map.set(d, (map.get(d) || 0) + 1)
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
}, [batchVoiceMessages])
|
||||||
|
|
||||||
|
// 批量转写:选中日期对应的语音条数
|
||||||
|
const batchSelectedMessageCount = useMemo(() => {
|
||||||
|
if (!batchVoiceMessages) return 0
|
||||||
|
return batchVoiceMessages.filter(m =>
|
||||||
|
batchSelectedDates.has(new Date(m.createTime * 1000).toISOString().slice(0, 10))
|
||||||
|
).length
|
||||||
|
}, [batchVoiceMessages, batchSelectedDates])
|
||||||
|
|
||||||
|
const toggleBatchDate = useCallback((date: string) => {
|
||||||
|
setBatchSelectedDates(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(date)) next.delete(date)
|
||||||
|
else next.add(date)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
const selectAllBatchDates = useCallback(() => setBatchSelectedDates(new Set(batchVoiceDates)), [batchVoiceDates])
|
||||||
|
const clearAllBatchDates = useCallback(() => setBatchSelectedDates(new Set()), [])
|
||||||
|
|
||||||
|
const formatBatchDateLabel = useCallback((dateStr: string) => {
|
||||||
|
const [y, m, d] = dateStr.split('-').map(Number)
|
||||||
|
return `${y}年${m}月${d}日`
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`chat-page ${isResizing ? 'resizing' : ''}`}>
|
<div className={`chat-page ${isResizing ? 'resizing' : ''}`}>
|
||||||
{/* 左侧会话列表 */}
|
{/* 左侧会话列表 */}
|
||||||
@@ -1293,6 +1474,18 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
|
<button
|
||||||
|
className="icon-btn batch-transcribe-btn"
|
||||||
|
onClick={handleBatchTranscribe}
|
||||||
|
disabled={isBatchTranscribing || !currentSessionId}
|
||||||
|
title={isBatchTranscribing ? `批量转写中 (${batchTranscribeProgress.current}/${batchTranscribeProgress.total})` : '批量语音转文字'}
|
||||||
|
>
|
||||||
|
{isBatchTranscribing ? (
|
||||||
|
<Loader2 size={18} className="spin" />
|
||||||
|
) : (
|
||||||
|
<Mic size={18} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="icon-btn jump-to-time-btn"
|
className="icon-btn jump-to-time-btn"
|
||||||
onClick={() => setShowJumpDialog(true)}
|
onClick={() => setShowJumpDialog(true)}
|
||||||
@@ -1428,23 +1621,35 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
<Hash size={14} />
|
<Hash size={14} />
|
||||||
<span className="label">微信ID</span>
|
<span className="label">微信ID</span>
|
||||||
<span className="value">{sessionDetail.wxid}</span>
|
<span className="value">{sessionDetail.wxid}</span>
|
||||||
|
<button className="copy-btn" title="复制" onClick={() => handleCopyField(sessionDetail.wxid, 'wxid')}>
|
||||||
|
{copiedField === 'wxid' ? <Check size={12} /> : <Copy size={12} />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{sessionDetail.remark && (
|
{sessionDetail.remark && (
|
||||||
<div className="detail-item">
|
<div className="detail-item">
|
||||||
<span className="label">备注</span>
|
<span className="label">备注</span>
|
||||||
<span className="value">{sessionDetail.remark}</span>
|
<span className="value">{sessionDetail.remark}</span>
|
||||||
|
<button className="copy-btn" title="复制" onClick={() => handleCopyField(sessionDetail.remark!, 'remark')}>
|
||||||
|
{copiedField === 'remark' ? <Check size={12} /> : <Copy size={12} />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{sessionDetail.nickName && (
|
{sessionDetail.nickName && (
|
||||||
<div className="detail-item">
|
<div className="detail-item">
|
||||||
<span className="label">昵称</span>
|
<span className="label">昵称</span>
|
||||||
<span className="value">{sessionDetail.nickName}</span>
|
<span className="value">{sessionDetail.nickName}</span>
|
||||||
|
<button className="copy-btn" title="复制" onClick={() => handleCopyField(sessionDetail.nickName!, 'nickName')}>
|
||||||
|
{copiedField === 'nickName' ? <Check size={12} /> : <Copy size={12} />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{sessionDetail.alias && (
|
{sessionDetail.alias && (
|
||||||
<div className="detail-item">
|
<div className="detail-item">
|
||||||
<span className="label">微信号</span>
|
<span className="label">微信号</span>
|
||||||
<span className="value">{sessionDetail.alias}</span>
|
<span className="value">{sessionDetail.alias}</span>
|
||||||
|
<button className="copy-btn" title="复制" onClick={() => handleCopyField(sessionDetail.alias!, 'alias')}>
|
||||||
|
{copiedField === 'alias' ? <Check size={12} /> : <Copy size={12} />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1542,10 +1747,176 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 批量转写确认对话框 */}
|
||||||
|
{showBatchConfirm && createPortal(
|
||||||
|
<div className="batch-modal-overlay" onClick={() => setShowBatchConfirm(false)}>
|
||||||
|
<div className="batch-modal-content batch-confirm-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="batch-modal-header">
|
||||||
|
<Mic size={20} />
|
||||||
|
<h3>批量语音转文字</h3>
|
||||||
|
</div>
|
||||||
|
<div className="batch-modal-body">
|
||||||
|
<p>选择要转写的日期(仅显示有语音的日期),然后开始转写。</p>
|
||||||
|
{batchVoiceDates.length > 0 && (
|
||||||
|
<div className="batch-dates-list-wrap">
|
||||||
|
<div className="batch-dates-actions">
|
||||||
|
<button type="button" className="batch-dates-btn" onClick={selectAllBatchDates}>全选</button>
|
||||||
|
<button type="button" className="batch-dates-btn" onClick={clearAllBatchDates}>取消全选</button>
|
||||||
|
</div>
|
||||||
|
<ul className="batch-dates-list">
|
||||||
|
{batchVoiceDates.map(dateStr => {
|
||||||
|
const count = batchCountByDate.get(dateStr) ?? 0
|
||||||
|
const checked = batchSelectedDates.has(dateStr)
|
||||||
|
return (
|
||||||
|
<li key={dateStr}>
|
||||||
|
<label className="batch-date-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => toggleBatchDate(dateStr)}
|
||||||
|
/>
|
||||||
|
<span className="batch-date-label">{formatBatchDateLabel(dateStr)}</span>
|
||||||
|
<span className="batch-date-count">{count} 条语音</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="batch-info">
|
||||||
|
<div className="info-item">
|
||||||
|
<span className="label">已选:</span>
|
||||||
|
<span className="value">{batchSelectedDates.size} 天有语音,共 {batchSelectedMessageCount} 条语音</span>
|
||||||
|
</div>
|
||||||
|
<div className="info-item">
|
||||||
|
<span className="label">预计耗时:</span>
|
||||||
|
<span className="value">约 {Math.ceil(batchSelectedMessageCount * 2 / 60)} 分钟</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="batch-warning">
|
||||||
|
<AlertCircle size={16} />
|
||||||
|
<span>批量转写可能需要较长时间,转写过程中可以继续使用其他功能。已转写过的语音会自动跳过。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="batch-modal-footer">
|
||||||
|
<button className="btn-secondary" onClick={() => setShowBatchConfirm(false)}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button className="btn-primary batch-transcribe-start-btn" onClick={confirmBatchTranscribe}>
|
||||||
|
<Mic size={16} />
|
||||||
|
开始转写
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 批量转写进度对话框 */}
|
||||||
|
{showBatchProgress && createPortal(
|
||||||
|
<div className="batch-modal-overlay">
|
||||||
|
<div className="batch-modal-content batch-progress-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="batch-modal-header">
|
||||||
|
<Loader2 size={20} className="spin" />
|
||||||
|
<h3>正在转写...</h3>
|
||||||
|
</div>
|
||||||
|
<div className="batch-modal-body">
|
||||||
|
<div className="progress-info">
|
||||||
|
<div className="progress-text">
|
||||||
|
<span>已完成 {batchTranscribeProgress.current} / {batchTranscribeProgress.total} 条</span>
|
||||||
|
<span className="progress-percent">
|
||||||
|
{batchTranscribeProgress.total > 0
|
||||||
|
? Math.round((batchTranscribeProgress.current / batchTranscribeProgress.total) * 100)
|
||||||
|
: 0}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="progress-bar">
|
||||||
|
<div
|
||||||
|
className="progress-fill"
|
||||||
|
style={{
|
||||||
|
width: `${batchTranscribeProgress.total > 0
|
||||||
|
? (batchTranscribeProgress.current / batchTranscribeProgress.total) * 100
|
||||||
|
: 0}%`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="batch-tip">
|
||||||
|
<span>转写过程中可以继续使用其他功能</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 批量转写结果对话框 */}
|
||||||
|
{showBatchResult && createPortal(
|
||||||
|
<div className="batch-modal-overlay" onClick={() => setShowBatchResult(false)}>
|
||||||
|
<div className="batch-modal-content batch-result-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="batch-modal-header">
|
||||||
|
<CheckCircle size={20} />
|
||||||
|
<h3>转写完成</h3>
|
||||||
|
</div>
|
||||||
|
<div className="batch-modal-body">
|
||||||
|
<div className="result-summary">
|
||||||
|
<div className="result-item success">
|
||||||
|
<CheckCircle size={18} />
|
||||||
|
<span className="label">成功:</span>
|
||||||
|
<span className="value">{batchResult.success} 条</span>
|
||||||
|
</div>
|
||||||
|
{batchResult.fail > 0 && (
|
||||||
|
<div className="result-item fail">
|
||||||
|
<XCircle size={18} />
|
||||||
|
<span className="label">失败:</span>
|
||||||
|
<span className="value">{batchResult.fail} 条</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{batchResult.fail > 0 && (
|
||||||
|
<div className="result-tip">
|
||||||
|
<AlertCircle size={16} />
|
||||||
|
<span>部分语音转写失败,可能是语音文件损坏或网络问题</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="batch-modal-footer">
|
||||||
|
<button className="btn-primary" onClick={() => setShowBatchResult(false)}>
|
||||||
|
确定
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 全局语音播放管理器:同一时间只能播放一条语音
|
||||||
|
const globalVoiceManager = {
|
||||||
|
currentAudio: null as HTMLAudioElement | null,
|
||||||
|
currentStopCallback: null as (() => void) | null,
|
||||||
|
play(audio: HTMLAudioElement, onStop: () => void) {
|
||||||
|
// 停止当前正在播放的语音
|
||||||
|
if (this.currentAudio && this.currentAudio !== audio) {
|
||||||
|
this.currentAudio.pause()
|
||||||
|
this.currentAudio.currentTime = 0
|
||||||
|
this.currentStopCallback?.()
|
||||||
|
}
|
||||||
|
this.currentAudio = audio
|
||||||
|
this.currentStopCallback = onStop
|
||||||
|
},
|
||||||
|
stop(audio: HTMLAudioElement) {
|
||||||
|
if (this.currentAudio === audio) {
|
||||||
|
this.currentAudio = null
|
||||||
|
this.currentStopCallback = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
// 前端表情包缓存
|
// 前端表情包缓存
|
||||||
const emojiDataUrlCache = new Map<string, string>()
|
const emojiDataUrlCache = new Map<string, string>()
|
||||||
const imageDataUrlCache = new Map<string, string>()
|
const imageDataUrlCache = new Map<string, string>()
|
||||||
@@ -1601,6 +1972,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
|||||||
const [voiceWaveform, setVoiceWaveform] = useState<number[]>([])
|
const [voiceWaveform, setVoiceWaveform] = useState<number[]>([])
|
||||||
const voiceAutoDecryptTriggered = useRef(false)
|
const voiceAutoDecryptTriggered = useRef(false)
|
||||||
|
|
||||||
|
// 转账消息双方名称
|
||||||
|
const [transferPayerName, setTransferPayerName] = useState<string | undefined>(undefined)
|
||||||
|
const [transferReceiverName, setTransferReceiverName] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
// 视频相关状态
|
// 视频相关状态
|
||||||
const [videoLoading, setVideoLoading] = useState(false)
|
const [videoLoading, setVideoLoading] = useState(false)
|
||||||
const [videoInfo, setVideoInfo] = useState<{ videoUrl?: string; coverUrl?: string; thumbUrl?: string; exists: boolean } | null>(null)
|
const [videoInfo, setVideoInfo] = useState<{ videoUrl?: string; coverUrl?: string; thumbUrl?: string; exists: boolean } | null>(null)
|
||||||
@@ -1765,6 +2140,26 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
|||||||
}
|
}
|
||||||
}, [isGroupChat, isSent, message.senderUsername, myAvatarUrl])
|
}, [isGroupChat, isSent, message.senderUsername, myAvatarUrl])
|
||||||
|
|
||||||
|
// 解析转账消息的付款方和收款方显示名称
|
||||||
|
useEffect(() => {
|
||||||
|
const payerWxid = (message as any).transferPayerUsername
|
||||||
|
const receiverWxid = (message as any).transferReceiverUsername
|
||||||
|
if (!payerWxid && !receiverWxid) return
|
||||||
|
// 仅对转账消息类型处理
|
||||||
|
if (message.localType !== 49 && message.localType !== 8589934592049) return
|
||||||
|
|
||||||
|
window.electronAPI.chat.resolveTransferDisplayNames(
|
||||||
|
session.username,
|
||||||
|
payerWxid || '',
|
||||||
|
receiverWxid || ''
|
||||||
|
).then((result: { payerName: string; receiverName: string }) => {
|
||||||
|
if (result) {
|
||||||
|
setTransferPayerName(result.payerName)
|
||||||
|
setTransferReceiverName(result.receiverName)
|
||||||
|
}
|
||||||
|
}).catch(() => { })
|
||||||
|
}, [(message as any).transferPayerUsername, (message as any).transferReceiverUsername, session.username])
|
||||||
|
|
||||||
// 自动下载表情包
|
// 自动下载表情包
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (emojiLocalPath) return
|
if (emojiLocalPath) return
|
||||||
@@ -1985,6 +2380,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
|||||||
const handleEnded = () => {
|
const handleEnded = () => {
|
||||||
setIsVoicePlaying(false)
|
setIsVoicePlaying(false)
|
||||||
setVoiceCurrentTime(0)
|
setVoiceCurrentTime(0)
|
||||||
|
globalVoiceManager.stop(audio)
|
||||||
}
|
}
|
||||||
const handleTimeUpdate = () => {
|
const handleTimeUpdate = () => {
|
||||||
setVoiceCurrentTime(audio.currentTime)
|
setVoiceCurrentTime(audio.currentTime)
|
||||||
@@ -1999,6 +2395,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
|||||||
audio.addEventListener('loadedmetadata', handleLoadedMetadata)
|
audio.addEventListener('loadedmetadata', handleLoadedMetadata)
|
||||||
return () => {
|
return () => {
|
||||||
audio.pause()
|
audio.pause()
|
||||||
|
globalVoiceManager.stop(audio)
|
||||||
audio.removeEventListener('play', handlePlay)
|
audio.removeEventListener('play', handlePlay)
|
||||||
audio.removeEventListener('pause', handlePause)
|
audio.removeEventListener('pause', handlePause)
|
||||||
audio.removeEventListener('ended', handleEnded)
|
audio.removeEventListener('ended', handleEnded)
|
||||||
@@ -2433,6 +2830,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
|||||||
if (isVoicePlaying) {
|
if (isVoicePlaying) {
|
||||||
audio.pause()
|
audio.pause()
|
||||||
audio.currentTime = 0
|
audio.currentTime = 0
|
||||||
|
globalVoiceManager.stop(audio)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!voiceDataUrl) {
|
if (!voiceDataUrl) {
|
||||||
@@ -2467,6 +2865,11 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
|||||||
}
|
}
|
||||||
audio.src = source
|
audio.src = source
|
||||||
try {
|
try {
|
||||||
|
// 停止其他正在播放的语音,确保同一时间只播放一条
|
||||||
|
globalVoiceManager.play(audio, () => {
|
||||||
|
audio.pause()
|
||||||
|
audio.currentTime = 0
|
||||||
|
})
|
||||||
await audio.play()
|
await audio.play()
|
||||||
} catch {
|
} catch {
|
||||||
setVoiceError(true)
|
setVoiceError(true)
|
||||||
@@ -2623,6 +3026,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
|||||||
let url = ''
|
let url = ''
|
||||||
let appMsgType = ''
|
let appMsgType = ''
|
||||||
let textAnnouncement = ''
|
let textAnnouncement = ''
|
||||||
|
let parsedDoc: Document | null = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = message.rawContent || message.parsedContent || ''
|
const content = message.rawContent || message.parsedContent || ''
|
||||||
@@ -2630,13 +3034,13 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
|||||||
const xmlContent = content.substring(content.indexOf('<msg>'))
|
const xmlContent = content.substring(content.indexOf('<msg>'))
|
||||||
|
|
||||||
const parser = new DOMParser()
|
const parser = new DOMParser()
|
||||||
const doc = parser.parseFromString(xmlContent, 'text/xml')
|
parsedDoc = parser.parseFromString(xmlContent, 'text/xml')
|
||||||
|
|
||||||
title = doc.querySelector('title')?.textContent || '链接'
|
title = parsedDoc.querySelector('title')?.textContent || '链接'
|
||||||
desc = doc.querySelector('des')?.textContent || ''
|
desc = parsedDoc.querySelector('des')?.textContent || ''
|
||||||
url = doc.querySelector('url')?.textContent || ''
|
url = parsedDoc.querySelector('url')?.textContent || ''
|
||||||
appMsgType = doc.querySelector('appmsg > type')?.textContent || doc.querySelector('type')?.textContent || ''
|
appMsgType = parsedDoc.querySelector('appmsg > type')?.textContent || parsedDoc.querySelector('type')?.textContent || ''
|
||||||
textAnnouncement = doc.querySelector('textannouncement')?.textContent || ''
|
textAnnouncement = parsedDoc.querySelector('textannouncement')?.textContent || ''
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('解析 AppMsg 失败:', e)
|
console.error('解析 AppMsg 失败:', e)
|
||||||
}
|
}
|
||||||
@@ -2764,19 +3168,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
|||||||
// 转账消息 (type=2000)
|
// 转账消息 (type=2000)
|
||||||
if (appMsgType === '2000') {
|
if (appMsgType === '2000') {
|
||||||
try {
|
try {
|
||||||
const content = message.rawContent || message.content || message.parsedContent || ''
|
// 使用外层已解析好的 parsedDoc(已去除 wxid 前缀)
|
||||||
|
const feedesc = parsedDoc?.querySelector('feedesc')?.textContent || ''
|
||||||
// 添加调试日志
|
const payMemo = parsedDoc?.querySelector('pay_memo')?.textContent || ''
|
||||||
|
const paysubtype = parsedDoc?.querySelector('paysubtype')?.textContent || '1'
|
||||||
|
|
||||||
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'
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// paysubtype: 1=待收款, 3=已收款
|
// paysubtype: 1=待收款, 3=已收款
|
||||||
const isReceived = paysubtype === '3'
|
const isReceived = paysubtype === '3'
|
||||||
@@ -2784,16 +3179,29 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
|||||||
// 如果 feedesc 为空,使用 title 作为降级
|
// 如果 feedesc 为空,使用 title 作为降级
|
||||||
const displayAmount = feedesc || title || '微信转账'
|
const displayAmount = feedesc || title || '微信转账'
|
||||||
|
|
||||||
|
// 构建转账描述:A 转账给 B
|
||||||
|
const transferDesc = transferPayerName && transferReceiverName
|
||||||
|
? `${transferPayerName} 转账给 ${transferReceiverName}`
|
||||||
|
: undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`transfer-message ${isReceived ? 'received' : ''}`}>
|
<div className={`transfer-message ${isReceived ? 'received' : ''}`}>
|
||||||
<div className="transfer-icon">
|
<div className="transfer-icon">
|
||||||
|
{isReceived ? (
|
||||||
|
<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 20l6 6 10-12" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
|
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
|
||||||
<circle cx="20" cy="20" r="18" stroke="white" strokeWidth="2" />
|
<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" />
|
<path d="M12 20h16M20 12l8 8-8 8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="transfer-info">
|
<div className="transfer-info">
|
||||||
<div className="transfer-amount">{displayAmount}</div>
|
<div className="transfer-amount">{displayAmount}</div>
|
||||||
|
{transferDesc && <div className="transfer-desc">{transferDesc}</div>}
|
||||||
{payMemo && <div className="transfer-memo">{payMemo}</div>}
|
{payMemo && <div className="transfer-memo">{payMemo}</div>}
|
||||||
<div className="transfer-label">{isReceived ? '已收款' : '微信转账'}</div>
|
<div className="transfer-label">{isReceived ? '已收款' : '微信转账'}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
2
src/types/electron.d.ts
vendored
2
src/types/electron.d.ts
vendored
@@ -85,6 +85,7 @@ export interface ElectronAPI {
|
|||||||
}>
|
}>
|
||||||
getContact: (username: string) => Promise<Contact | null>
|
getContact: (username: string) => Promise<Contact | null>
|
||||||
getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null>
|
getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null>
|
||||||
|
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) => Promise<{ payerName: string; receiverName: string }>
|
||||||
getContacts: () => Promise<{
|
getContacts: () => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
contacts?: ContactInfo[]
|
contacts?: ContactInfo[]
|
||||||
@@ -111,6 +112,7 @@ export interface ElectronAPI {
|
|||||||
}>
|
}>
|
||||||
getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
|
getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||||
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => Promise<{ success: boolean; data?: string; error?: string }>
|
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||||
|
getAllVoiceMessages: (sessionId: string) => Promise<{ success: boolean; messages?: Message[]; error?: string }>
|
||||||
resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }>
|
resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }>
|
||||||
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
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export interface Message {
|
|||||||
fileSize?: number // 文件大小
|
fileSize?: number // 文件大小
|
||||||
fileExt?: string // 文件扩展名
|
fileExt?: string // 文件扩展名
|
||||||
xmlType?: string // XML 中的 type 字段
|
xmlType?: string // XML 中的 type 字段
|
||||||
|
// 转账消息
|
||||||
|
transferPayerUsername?: string // 转账付款方 wxid
|
||||||
|
transferReceiverUsername?: string // 转账收款方 wxid
|
||||||
// 名片消息
|
// 名片消息
|
||||||
cardUsername?: string // 名片的微信ID
|
cardUsername?: string // 名片的微信ID
|
||||||
cardNickname?: string // 名片的昵称
|
cardNickname?: string // 名片的昵称
|
||||||
|
|||||||
Reference in New Issue
Block a user