feat: 完善微信小程序与红包消息解析

- 新增小程序和红包结构化消息及专用卡片
- 修复嵌套类型、拍一拍和表情包误判
- 补充常见 AppMsg 类型分类
This commit is contained in:
电摇小子
2026-07-28 03:20:11 +08:00
parent 7db845ac7e
commit 5ccabb3898
10 changed files with 502 additions and 27 deletions
+9 -2
View File
@@ -263,6 +263,7 @@ function App(): React.ReactElement {
const messageHistoryRef = React.useRef<Message[]>([])
const messagesRef = React.useRef<Message[]>([])
const messagePrefetchRef = React.useRef<Promise<void> | null>(null)
const pendingLiveRefreshMd5Ref = React.useRef('')
messagesRef.current = messages
React.useEffect(() => {
if (!reportNotice) return
@@ -922,6 +923,7 @@ function App(): React.ReactElement {
storeGroupMemberMeta(contact, cachedPage.groupSnapshot)
}
messageHistoryRef.current = cachedMsgs
pendingLiveRefreshMd5Ref.current = cachedPage.hit ? '' : contact.md5
setMessages(
applyGroupMemberMeta(
contact,
@@ -929,7 +931,9 @@ function App(): React.ReactElement {
)
)
const needsLivePage =
forceLive || (isDatabaseConnected && cachedMsgs.length < MESSAGE_PREFETCH_COUNT)
forceLive ||
(isDatabaseConnected &&
(!cachedPage.hit || cachedMsgs.length < MESSAGE_PREFETCH_COUNT))
if (!needsLivePage) {
setIsMessagesLoading(false)
if (contact.type === 'group' && cachedMsgs.length > 0 && !cachedPage.groupSnapshot) {
@@ -955,6 +959,7 @@ function App(): React.ReactElement {
})
if (selectedContactMd5Ref.current !== contact.md5) return
messageHistoryRef.current = msgs
pendingLiveRefreshMd5Ref.current = ''
const visibleMessages = applyGroupMemberMeta(
contact,
mergeSyntheticMessages(contact, msgs.slice(-INITIAL_MESSAGE_COUNT))
@@ -986,7 +991,9 @@ function App(): React.ReactElement {
}
React.useEffect(() => {
if (!isDatabaseConnected || !selectedContact || messages.length > 0) return
if (!isDatabaseConnected || !selectedContact) return
const needsPendingRefresh = pendingLiveRefreshMd5Ref.current === selectedContact.md5
if (messages.length > 0 && !needsPendingRefresh) return
// The first live page uses async native cursors, so a cache miss can be filled
// without blocking the Electron main thread.
void handleSelectContact(selectedContact)
+137
View File
@@ -2837,6 +2837,143 @@ body {
text-overflow: ellipsis;
}
.mini-program-message {
width: min(280px, 48vw);
overflow: hidden;
color: var(--wxex-text-primary);
}
.mini-program-title {
margin-bottom: 8px;
font-size: 14px;
line-height: 20px;
word-break: break-word;
}
.mini-program-description {
margin: -4px 0 8px;
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 18px;
}
.mini-program-preview .image-bubble,
.mini-program-preview .image-content {
width: 100%;
max-width: 100%;
max-height: 220px;
border-radius: 4px;
}
.mini-program-preview .image-content {
object-fit: cover;
}
.mini-program-inline-image {
display: block;
width: 100%;
max-height: 220px;
border-radius: 4px;
object-fit: cover;
cursor: zoom-in;
}
.mini-program-preview .image-content.image-fallback {
width: 64px;
height: 64px;
margin: 28px auto;
border-radius: 8px;
object-fit: cover;
}
.mini-program-preview .image-actions {
display: none;
}
.mini-program-icon {
display: block;
width: 52px;
height: 52px;
margin: 8px 0;
border-radius: 6px;
object-fit: cover;
}
.mini-program-footer {
display: flex;
align-items: center;
gap: 5px;
margin-top: 8px;
padding-top: 7px;
border-top: 1px solid var(--wxex-border);
color: var(--wxex-text-muted);
font-size: 11px;
line-height: 16px;
}
.red-packet-message {
width: min(280px, 48vw);
overflow: hidden;
border-radius: 6px;
background: #fa9d3b;
color: #fff;
}
.red-packet-message.is-clickable {
cursor: pointer;
}
.red-packet-main {
display: flex;
align-items: center;
gap: 12px;
min-height: 72px;
padding: 12px 14px;
}
.red-packet-icon {
display: grid;
width: 36px;
height: 36px;
flex: 0 0 36px;
place-items: center;
border: 2px solid rgba(255, 239, 170, 0.92);
border-radius: 50%;
color: #fff1a8;
font-size: 18px;
font-weight: 700;
}
.red-packet-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.red-packet-copy strong {
overflow: hidden;
font-size: 15px;
font-weight: 500;
line-height: 21px;
text-overflow: ellipsis;
white-space: nowrap;
}
.red-packet-copy small {
color: rgba(255, 255, 255, 0.82);
font-size: 12px;
line-height: 18px;
}
.red-packet-footer {
padding: 5px 14px;
background: #fff;
color: var(--wxex-text-muted);
font-size: 11px;
line-height: 16px;
}
.voip-message {
display: flex;
align-items: center;
+31 -4
View File
@@ -45,6 +45,7 @@ interface ImageBubbleProps {
imageDatName?: string
sessionId?: string
isThumb?: boolean
fallbackUrl?: string
onImageClick?: (imageUrl: string) => void
}
@@ -53,6 +54,7 @@ export function ImageBubble({
imageDatName,
sessionId,
isThumb = false,
fallbackUrl,
onImageClick
}: ImageBubbleProps): JSX.Element {
const initialCachedImage = getCachedImage(imageMd5, imageDatName)
@@ -61,11 +63,18 @@ export function ImageBubble({
const [upgrading, setUpgrading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isThumbnail, setIsThumbnail] = useState(Boolean(initialCachedImage?.isThumbnail))
const [usingFallback, setUsingFallback] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const loadImage = useCallback(async () => {
if (imageUrl || loading) return
if (!imageMd5 && !imageDatName) {
if (fallbackUrl) {
setImageUrl(fallbackUrl)
setUsingFallback(true)
setError(null)
return
}
setError('缺少图片标识')
return
}
@@ -79,17 +88,30 @@ export function ImageBubble({
isThumbnail: Boolean(result.isThumb)
})
setImageUrl(result.data)
setUsingFallback(false)
setIsThumbnail(Boolean(result.isThumb))
setError(null)
} else {
setError(result.error || '加载图片失败')
if (fallbackUrl) {
setImageUrl(fallbackUrl)
setUsingFallback(true)
setError(null)
} else {
setError(result.error || '加载图片失败')
}
}
} catch {
setError('加载图片失败')
if (fallbackUrl) {
setImageUrl(fallbackUrl)
setUsingFallback(true)
setError(null)
} else {
setError('加载图片失败')
}
} finally {
setLoading(false)
}
}, [imageMd5, imageDatName, sessionId, isThumb, imageUrl, loading])
}, [fallbackUrl, imageMd5, imageDatName, sessionId, isThumb, imageUrl, loading])
useEffect(() => {
if (imageUrl || loading || error) return
@@ -141,6 +163,7 @@ export function ImageBubble({
isThumbnail: Boolean(result.isThumb)
})
setImageUrl(result.data)
setUsingFallback(false)
setIsThumbnail(Boolean(result.isThumb))
setError(null)
onImageClick?.(result.data)
@@ -184,7 +207,11 @@ export function ImageBubble({
return (
<div className="image-bubble image-loaded" onClick={handleClick}>
<img src={imageUrl} alt="图片" className="image-content" />
<img
src={imageUrl}
alt="图片"
className={`image-content ${usingFallback ? 'image-fallback' : ''}`}
/>
{(upgrading || isThumbnail) && (
<div className="image-quality-badge">{upgrading ? '正在查找原图' : '缩略图'}</div>
)}
@@ -24,6 +24,16 @@ export function RichMessageBubble({
return <CardBubble data={contentData} />
case 'share':
return <ShareBubble data={contentData} />
case 'miniProgram':
return (
<MiniProgramBubble
data={contentData}
sessionId={sessionId}
onImageClick={onImageClick}
/>
)
case 'redPacket':
return <RedPacketBubble data={contentData} />
case 'voip':
return <VoipBubble data={contentData} />
case 'sticker':
@@ -117,6 +127,83 @@ function ShareBubble({ data }: { data: Extract<ParsedContent, { type: 'share' }>
)
}
function MiniProgramBubble({
data,
sessionId,
onImageClick
}: {
data: Extract<ParsedContent, { type: 'miniProgram' }>
sessionId?: string
onImageClick?: (imageUrl: string) => void
}): JSX.Element {
return (
<div className="mini-program-message">
<div className="mini-program-title">{data.title}</div>
{data.description && <div className="mini-program-description">{data.description}</div>}
{data.thumbDataUrl ? (
<div className="mini-program-preview">
<img
className="mini-program-inline-image"
src={data.thumbDataUrl}
alt={data.title}
onClick={() => onImageClick?.(data.thumbDataUrl || '')}
/>
</div>
) : data.thumbMd5 ? (
<div className="mini-program-preview">
<ImageBubble
imageMd5={data.thumbMd5}
imageDatName={data.thumbDatName}
sessionId={sessionId}
isThumb
fallbackUrl={data.iconUrl}
onImageClick={onImageClick}
/>
</div>
) : data.iconUrl ? (
<img
className="mini-program-icon"
src={data.iconUrl}
alt=""
referrerPolicy="no-referrer"
/>
) : null}
<div className="mini-program-footer">
<span aria-hidden></span>
<span>{data.appName || '小程序'}</span>
</div>
</div>
)
}
function RedPacketBubble({
data
}: {
data: Extract<ParsedContent, { type: 'redPacket' }>
}): JSX.Element {
const handleClick = (): void => {
if (data.url) window.open(data.url, '_blank')
}
return (
<div
className={`red-packet-message ${data.url ? 'is-clickable' : ''}`}
onClick={data.url ? handleClick : undefined}
>
<div className="red-packet-main">
<span className="red-packet-icon" aria-hidden>
¥
</span>
<span className="red-packet-copy">
<strong>{data.title}</strong>
<small>{data.description || '恭喜发财,大吉大利'}</small>
</span>
</div>
<div className="red-packet-footer"></div>
</div>
)
}
function VoipBubble({ data }: { data: Extract<ParsedContent, { type: 'voip' }> }): JSX.Element {
const { status, roomType, duration } = data
const isVideo = roomType === 1
@@ -16,7 +16,22 @@ interface MessageBubbleProps {
onImageClick: (imageUrl: string) => void
}
const RICH_MESSAGE_TYPES = ['名片', '位置', '分享消息', '引用消息', '通话', '表情包', '系统消息']
const RICH_MESSAGE_TYPES = [
'名片',
'位置',
'分享消息',
'小程序',
'微信红包',
'公众号链接',
'文件',
'文件发送中',
'视频号',
'转账',
'引用消息',
'通话',
'表情包',
'系统消息'
]
export function MessageBubble({
message,