From bc0671440c7282ec9e3ef69da01a220c65f934ef Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Wed, 25 Feb 2026 17:07:47 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=B6=88=E6=81=AF=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/chatService.ts | 153 ++++++++- src/pages/ChatPage.scss | 524 ++++++++++++++++++++++++++++++- src/pages/ChatPage.tsx | 328 ++++++++++++++++++- src/types/models.ts | 16 + 4 files changed, 999 insertions(+), 22 deletions(-) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 5d90f93..eda6e7a 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -84,9 +84,25 @@ export interface Message { appMsgLocationLabel?: string finderNickname?: string finderUsername?: string + finderCoverUrl?: string + finderAvatar?: string + finderDuration?: number + // 位置消息 + locationLat?: number + locationLng?: number + locationPoiname?: string + locationLabel?: string + // 音乐消息 + musicAlbumUrl?: string + musicUrl?: string + // 礼物消息 + giftImageUrl?: string + giftWish?: string + giftPrice?: string // 名片消息 cardUsername?: string // 名片的微信ID cardNickname?: string // 名片的昵称 + cardAvatarUrl?: string // 名片头像 URL // 转账消息 transferPayerUsername?: string // 转账付款人 transferReceiverUsername?: string // 转账收款人 @@ -744,15 +760,15 @@ class ChatService { } const batchSize = Math.max(1, limit || this.messageBatchDefault) - + // 使用互斥锁保护游标状态访问 while (this.messageCursorMutex) { await new Promise(resolve => setTimeout(resolve, 1)) } this.messageCursorMutex = true - + let state = this.messageCursors.get(sessionId) - + // 只在以下情况重新创建游标: // 1. 没有游标状态 // 2. offset 为 0 (重新加载会话) @@ -789,7 +805,7 @@ class ChatService { state = { cursor: cursorResult.cursor, fetched: 0, batchSize, startTime, endTime, ascending } this.messageCursors.set(sessionId, state) this.messageCursorMutex = false - + // 如果需要跳过消息(offset > 0),逐批获取但不返回 // 注意:仅在 offset === 0 时重建游标最安全; // 当 startTime/endTime 变化导致重建时,offset 应由前端重置为 0 @@ -890,7 +906,7 @@ class ChatService { // 群聊消息:senderUsername 是群成员,需要检查 _db_path 或上下文 // 单聊消息:senderUsername 应该是 sessionId 或自己 const isGroupChat = sessionId.includes('@chatroom') - + if (isGroupChat) { // 群聊消息暂不验证(因为 senderUsername 是群成员,不是 sessionId) return true @@ -927,7 +943,7 @@ class ChatService { state.fetched += rows.length this.messageCursorMutex = false - + this.messageCacheService.set(sessionId, filtered) return { success: true, messages: filtered, hasMore } } catch (e) { @@ -1246,9 +1262,22 @@ class ChatService { let appMsgLocationLabel: string | undefined let finderNickname: string | undefined let finderUsername: string | undefined + let finderCoverUrl: string | undefined + let finderAvatar: string | undefined + let finderDuration: number | undefined + let locationLat: number | undefined + let locationLng: number | undefined + let locationPoiname: string | undefined + let locationLabel: string | undefined + let musicAlbumUrl: string | undefined + let musicUrl: string | undefined + let giftImageUrl: string | undefined + let giftWish: string | undefined + let giftPrice: string | undefined // 名片消息 let cardUsername: string | undefined let cardNickname: string | undefined + let cardAvatarUrl: string | undefined // 转账消息 let transferPayerUsername: string | undefined let transferReceiverUsername: string | undefined @@ -1286,6 +1315,15 @@ class ChatService { const cardInfo = this.parseCardInfo(content) cardUsername = cardInfo.username cardNickname = cardInfo.nickname + cardAvatarUrl = cardInfo.avatarUrl + } else if (localType === 48 && content) { + // 位置消息 + const latStr = this.extractXmlAttribute(content, 'location', 'x') || this.extractXmlAttribute(content, 'location', 'latitude') + const lngStr = this.extractXmlAttribute(content, 'location', 'y') || this.extractXmlAttribute(content, 'location', 'longitude') + if (latStr) { const v = parseFloat(latStr); if (Number.isFinite(v)) locationLat = v } + if (lngStr) { const v = parseFloat(lngStr); if (Number.isFinite(v)) locationLng = v } + locationLabel = this.extractXmlAttribute(content, 'location', 'label') || this.extractXmlValue(content, 'label') || undefined + locationPoiname = this.extractXmlAttribute(content, 'location', 'poiname') || this.extractXmlValue(content, 'poiname') || undefined } else if ((localType === 49 || localType === 8589934592049) && content) { // Type 49 消息(链接、文件、小程序、转账等),8589934592049 也是转账类型 const type49Info = this.parseType49Message(content) @@ -1327,6 +1365,18 @@ class ChatService { appMsgLocationLabel = appMsgLocationLabel || type49Info.appMsgLocationLabel finderNickname = finderNickname || type49Info.finderNickname finderUsername = finderUsername || type49Info.finderUsername + finderCoverUrl = finderCoverUrl || type49Info.finderCoverUrl + finderAvatar = finderAvatar || type49Info.finderAvatar + finderDuration = finderDuration ?? type49Info.finderDuration + locationLat = locationLat ?? type49Info.locationLat + locationLng = locationLng ?? type49Info.locationLng + locationPoiname = locationPoiname || type49Info.locationPoiname + locationLabel = locationLabel || type49Info.locationLabel + musicAlbumUrl = musicAlbumUrl || type49Info.musicAlbumUrl + musicUrl = musicUrl || type49Info.musicUrl + giftImageUrl = giftImageUrl || type49Info.giftImageUrl + giftWish = giftWish || type49Info.giftWish + giftPrice = giftPrice || type49Info.giftPrice chatRecordTitle = chatRecordTitle || type49Info.chatRecordTitle chatRecordList = chatRecordList || type49Info.chatRecordList transferPayerUsername = transferPayerUsername || type49Info.transferPayerUsername @@ -1372,8 +1422,21 @@ class ChatService { appMsgLocationLabel, finderNickname, finderUsername, + finderCoverUrl, + finderAvatar, + finderDuration, + locationLat, + locationLng, + locationPoiname, + locationLabel, + musicAlbumUrl, + musicUrl, + giftImageUrl, + giftWish, + giftPrice, cardUsername, cardNickname, + cardAvatarUrl, transferPayerUsername, transferReceiverUsername, chatRecordTitle, @@ -1874,7 +1937,7 @@ class ChatService { * 解析名片消息 * 格式: */ - private parseCardInfo(content: string): { username?: string; nickname?: string } { + private parseCardInfo(content: string): { username?: string; nickname?: string; avatarUrl?: string } { try { if (!content) return {} @@ -1884,7 +1947,11 @@ class ChatService { // 提取 nickname const nickname = this.extractXmlAttribute(content, 'msg', 'nickname') || undefined - return { username, nickname } + // 提取头像 + const avatarUrl = this.extractXmlAttribute(content, 'msg', 'bigheadimgurl') || + this.extractXmlAttribute(content, 'msg', 'smallheadimgurl') || undefined + + return { username, nickname, avatarUrl } } catch (e) { console.error('[ChatService] 名片解析失败:', e) return {} @@ -1911,6 +1978,19 @@ class ChatService { appMsgLocationLabel?: string finderNickname?: string finderUsername?: string + finderCoverUrl?: string + finderAvatar?: string + finderDuration?: number + locationLat?: number + locationLng?: number + locationPoiname?: string + locationLabel?: string + musicAlbumUrl?: string + musicUrl?: string + giftImageUrl?: string + giftWish?: string + giftPrice?: string + cardAvatarUrl?: string fileName?: string fileSize?: number fileExt?: string @@ -1965,14 +2045,10 @@ class ChatService { this.extractXmlValue(content, 'findernickname') || this.extractXmlValue(content, 'finder_nickname') const normalized = content.toLowerCase() - const isFinder = - xmlType === '51' || - normalized.includes(' 0) result.finderDuration = d + } + } + + // 位置经纬度 + if (isLocation) { + const latAttr = this.extractXmlAttribute(content, 'location', 'x') || this.extractXmlAttribute(content, 'location', 'latitude') + const lngAttr = this.extractXmlAttribute(content, 'location', 'y') || this.extractXmlAttribute(content, 'location', 'longitude') + if (latAttr) { const v = parseFloat(latAttr); if (Number.isFinite(v)) result.locationLat = v } + if (lngAttr) { const v = parseFloat(lngAttr); if (Number.isFinite(v)) result.locationLng = v } + result.locationPoiname = this.extractXmlAttribute(content, 'location', 'poiname') || locationLabel || undefined + result.locationLabel = this.extractXmlAttribute(content, 'location', 'label') || undefined + } + + // 音乐专辑封面 + if (isMusic) { + const albumUrl = this.extractXmlValue(content, 'songalbumurl') + if (albumUrl) result.musicAlbumUrl = albumUrl + result.musicUrl = musicUrl || dataUrl || url || undefined + } + + // 礼物消息 + const isGift = xmlType === '115' + if (isGift) { + result.giftWish = this.extractXmlValue(content, 'wishmessage') || undefined + result.giftImageUrl = this.extractXmlValue(content, 'skuimgurl') || undefined + result.giftPrice = this.extractXmlValue(content, 'skuprice') || undefined + } + if (isFinder) { result.appMsgKind = 'finder' } else if (isRedPacket) { result.appMsgKind = 'red-packet' + } else if (isGift) { + result.appMsgKind = 'gift' } else if (isLocation) { result.appMsgKind = 'location' } else if (isMusic) { @@ -4286,6 +4406,7 @@ class ChatService { const cardInfo = this.parseCardInfo(rawContent) msg.cardUsername = cardInfo.username msg.cardNickname = cardInfo.nickname + msg.cardAvatarUrl = cardInfo.avatarUrl } if (rawContent && (rawContent.includes('
- - - - + {cardAvatar ? ( + + ) : ( + + + + + )}
{cardName}
+ {message.cardUsername && message.cardUsername !== message.cardNickname && ( +
微信号: {message.cardUsername}
+ )}
个人名片
@@ -3972,7 +3980,319 @@ function MessageBubble({ ) } + // 位置消息 + if (message.localType === 48) { + const raw = message.rawContent || '' + const poiname = raw.match(/poiname="([^"]*)"/)?.[1] || message.locationPoiname || '位置' + const label = raw.match(/label="([^"]*)"/)?.[1] || message.locationLabel || '' + const lat = parseFloat(raw.match(/x="([^"]*)"/)?.[1] || String(message.locationLat || 0)) + const lng = parseFloat(raw.match(/y="([^"]*)"/)?.[1] || String(message.locationLng || 0)) + const mapTileUrl = (lat && lng) + ? `https://restapi.amap.com/v3/staticmap?location=${lng},${lat}&zoom=15&size=280*100&markers=mid,,A:${lng},${lat}&key=e1dedc6bfbb8413ab2185e7a0e21f0a1` + : '' + return ( +
window.electronAPI.shell.openExternal(`https://uri.amap.com/marker?position=${lng},${lat}&name=${encodeURIComponent(poiname || label)}`)}> +
+
+ + + + +
+
+ {poiname &&
{poiname}
} + {label &&
{label}
} +
+
+ {mapTileUrl && ( +
+ 地图 +
+ )} +
+ ) + } + // 链接消息 (AppMessage) + const appMsgRichPreview = (() => { + const rawXml = message.rawContent || '' + if (!rawXml || (!rawXml.includes(' { + if (doc) return doc + try { + const start = rawXml.indexOf('') + const xml = start >= 0 ? rawXml.slice(start) : rawXml + doc = new DOMParser().parseFromString(xml, 'text/xml') + } catch { + doc = null + } + return doc + } + const q = (selector: string) => getDoc()?.querySelector(selector)?.textContent?.trim() || '' + + const xmlType = message.xmlType || q('appmsg > type') || q('type') + const title = message.linkTitle || q('title') || cleanMessageContent(message.parsedContent) || 'Card' + const desc = message.appMsgDesc || q('des') + const url = message.linkUrl || q('url') + const thumbUrl = message.linkThumb || message.appMsgThumbUrl || q('thumburl') || q('cdnthumburl') || q('cover') || q('coverurl') + const musicUrl = message.appMsgMusicUrl || message.appMsgDataUrl || q('musicurl') || q('playurl') || q('dataurl') || q('lowurl') + const sourceName = message.appMsgSourceName || q('sourcename') + const appName = message.appMsgAppName || q('appname') + const sourceUsername = message.appMsgSourceUsername || q('sourceusername') + const finderName = + message.finderNickname || + message.finderUsername || + q('findernickname') || + q('finder_nickname') || + q('finderusername') || + q('finder_username') + + const lower = rawXml.toLowerCase() + + const kind = message.appMsgKind || ( + (xmlType === '2001' || lower.includes('hongbao')) ? 'red-packet' + : (xmlType === '115' ? 'gift' + : ((xmlType === '33' || xmlType === '36') ? 'miniapp' + : (((xmlType === '5' || xmlType === '49') && (sourceUsername.startsWith('gh_') || !!sourceName || appName.includes('公众号'))) ? 'official-link' + : (xmlType === '51' ? 'finder' + : (xmlType === '3' ? 'music' + : ((xmlType === '5' || xmlType === '49') ? 'link' // Fallback for standard links + : (!!musicUrl ? 'music' : ''))))))) + ) + + if (!kind) return null + + // 对视频号提取真实标题,避免出现 "当前版本不支持该内容" + let displayTitle = title + if (kind === 'finder' && title.includes('不支持')) { + displayTitle = desc || '' + } + + const openExternal = (e: React.MouseEvent, nextUrl?: string) => { + if (!nextUrl) return + e.stopPropagation() + if (window.electronAPI?.shell?.openExternal) { + window.electronAPI.shell.openExternal(nextUrl) + } else { + window.open(nextUrl, '_blank') + } + } + + const metaLabel = + kind === 'red-packet' ? '红包' + : kind === 'finder' ? (finderName || '视频号') + : kind === 'location' ? '位置' + : kind === 'music' ? (sourceName || appName || '音乐') + : (sourceName || appName || (sourceUsername.startsWith('gh_') ? '公众号' : '')) + + const renderCard = (cardKind: string, clickableUrl?: string) => ( +
openExternal(e, clickableUrl) : undefined} + title={clickableUrl} + > +
+
{title}
+ {metaLabel ?
{metaLabel}
: null} +
+
+
+ {desc ?
{desc}
: null} +
+ {thumbUrl ? ( + + ) : ( +
{cardKind.slice(0, 2).toUpperCase()}
+ )} +
+
+ ) + + if (kind === 'red-packet') { + // 专属红包卡片 + const greeting = (() => { + try { + const d = getDoc() + if (!d) return '' + return d.querySelector('receivertitle')?.textContent?.trim() || + d.querySelector('sendertitle')?.textContent?.trim() || '' + } catch { return '' } + })() + return ( +
+
+ + + + + ¥ + +
+
+
{greeting || '恭喜发财,大吉大利'}
+
微信红包
+
+
+ ) + } + + if (kind === 'gift') { + // 礼物卡片 + const giftImg = message.giftImageUrl || thumbUrl + const giftWish = message.giftWish || title || '送你一份心意' + const giftPriceRaw = message.giftPrice + const giftPriceYuan = giftPriceRaw ? (parseInt(giftPriceRaw) / 100).toFixed(2) : '' + return ( +
+ {giftImg && } +
+
{giftWish}
+ {giftPriceYuan &&
¥{giftPriceYuan}
} +
微信礼物
+
+
+ ) + } + + if (kind === 'finder') { + // 视频号专属卡片 + const coverUrl = message.finderCoverUrl || thumbUrl + const duration = message.finderDuration + const authorName = finderName || '' + const authorAvatar = message.finderAvatar + const fmtDuration = duration ? `${Math.floor(duration / 60)}:${String(duration % 60).padStart(2, '0')}` : '' + return ( +
openExternal(e, url) : undefined}> +
+ {coverUrl ? ( + + ) : ( +
+ + + +
+ )} + {fmtDuration && {fmtDuration}} +
+
+
{displayTitle || '视频号视频'}
+
+ {authorAvatar && } + {authorName || '视频号'} +
+
+
+ ) + } + + + + if (kind === 'music') { + // 音乐专属卡片 + const albumUrl = message.musicAlbumUrl || thumbUrl + const playUrl = message.musicUrl || musicUrl || url + const songTitle = title || '未知歌曲' + const artist = desc || '' + const appLabel = sourceName || appName || '' + return ( +
openExternal(e, playUrl) : undefined}> +
+ {albumUrl ? ( + + ) : ( + + + + )} +
+
+
{songTitle}
+ {artist &&
{artist}
} + {appLabel &&
{appLabel}
} +
+
+ ) + } + + if (kind === 'official-link') { + const authorAvatar = q('publisher > headimg') || q('brand_info > headimgurl') || q('appmsg > avatar') || message.cardAvatarUrl + const authorName = q('publisher > nickname') || sourceName || appName || '公众号' + const coverPic = q('mmreader > category > item > cover') || thumbUrl + const digest = q('mmreader > category > item > digest') || desc + const articleTitle = q('mmreader > category > item > title') || title + + return ( +
openExternal(e, url) : undefined}> +
+ {authorAvatar ? ( + + ) : ( +
+ + + + +
+ )} + {authorName} +
+
+ {coverPic ? ( +
+ +
{articleTitle}
+
+ ) : ( +
{articleTitle}
+ )} + {digest &&
{digest}
} +
+
+ ) + } + + if (kind === 'link') return renderCard('link', url || undefined) + if (kind === 'card') return renderCard('card', url || undefined) + if (kind === 'miniapp') { + return ( +
+
+ + + +
+
+
{title}
+
{metaLabel || '小程序'}
+
+ {thumbUrl ? ( + + ) : null} +
+ ) + } + return null + })() + + if (appMsgRichPreview) { + return appMsgRichPreview + } + const isAppMsg = message.rawContent?.includes('