新增启动页面;修复转发表情包无法索引的问题;修复群回复中消息溢出错误;修复群消息中消息类型判定错误

This commit is contained in:
cc
2026-02-26 19:40:26 +08:00
parent 1c6e14acb4
commit 4a09b682b2
13 changed files with 779 additions and 30 deletions

View File

@@ -2243,6 +2243,18 @@
.quoted-text {
color: var(--text-secondary);
white-space: pre-wrap;
.quoted-type-label {
font-style: italic;
opacity: 0.8;
}
.quoted-emoji-image {
width: 40px;
height: 40px;
vertical-align: middle;
object-fit: contain;
}
}
}
@@ -2897,7 +2909,6 @@
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
color: var(--text-secondary);
font-size: 13px;

View File

@@ -2780,6 +2780,31 @@ const voiceTranscriptCache = new Map<string, string>()
const senderAvatarCache = new Map<string, { avatarUrl?: string; displayName?: string }>()
const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>()
// 引用消息中的动画表情组件
function QuotedEmoji({ cdnUrl, md5 }: { cdnUrl: string; md5?: string }) {
const cacheKey = md5 || cdnUrl
const [localPath, setLocalPath] = useState<string | undefined>(() => emojiDataUrlCache.get(cacheKey))
const [loading, setLoading] = useState(false)
const [error, setError] = useState(false)
useEffect(() => {
if (localPath || loading || error) return
setLoading(true)
window.electronAPI.chat.downloadEmoji(cdnUrl, md5).then((result: { success: boolean; localPath?: string }) => {
if (result.success && result.localPath) {
emojiDataUrlCache.set(cacheKey, result.localPath)
setLocalPath(result.localPath)
} else {
setError(true)
}
}).catch(() => setError(true)).finally(() => setLoading(false))
}, [cdnUrl, md5, cacheKey, localPath, loading, error])
if (error || (!loading && !localPath)) return <span className="quoted-type-label">[]</span>
if (loading) return <span className="quoted-type-label">[]</span>
return <img src={localPath} alt="动画表情" className="quoted-emoji-image" />
}
// 消息气泡组件
function MessageBubble({
message,
@@ -2901,7 +2926,7 @@ function MessageBubble({
// 从缓存获取表情包 data URL
const cacheKey = message.emojiMd5 || message.emojiCdnUrl || ''
const [emojiLocalPath, setEmojiLocalPath] = useState<string | undefined>(
() => emojiDataUrlCache.get(cacheKey)
() => emojiDataUrlCache.get(cacheKey) || message.emojiLocalPath
)
const imageCacheKey = message.imageMd5 || message.imageDatName || `local:${message.localId}`
const [imageLocalPath, setImageLocalPath] = useState<string | undefined>(
@@ -3036,10 +3061,15 @@ function MessageBubble({
// 自动下载表情包
useEffect(() => {
if (emojiLocalPath) return
// 后端已从本地缓存找到文件(转发表情包无 CDN URL 的情况)
if (isEmoji && message.emojiLocalPath && !emojiLocalPath) {
setEmojiLocalPath(message.emojiLocalPath)
return
}
if (isEmoji && message.emojiCdnUrl && !emojiLoading && !emojiError) {
downloadEmoji()
}
}, [isEmoji, message.emojiCdnUrl, emojiLocalPath, emojiLoading, emojiError])
}, [isEmoji, message.emojiCdnUrl, message.emojiLocalPath, emojiLocalPath, emojiLoading, emojiError])
const requestImageDecrypt = useCallback(async (forceUpdate = false, silent = false) => {
if (!isImage) return
@@ -3971,11 +4001,13 @@ function MessageBubble({
// 通话消息
if (isCall) {
return (
<div className="call-message">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
</svg>
<span>{message.parsedContent || '[通话]'}</span>
<div className="bubble-content">
<div className="call-message">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
</svg>
<span>{message.parsedContent || '[通话]'}</span>
</div>
</div>
)
}
@@ -4043,11 +4075,39 @@ function MessageBubble({
const replyText = q('title') || cleanMessageContent(message.parsedContent) || ''
const referContent = q('refermsg > content') || ''
const referSender = q('refermsg > displayname') || ''
const referType = q('refermsg > type') || ''
// 根据被引用消息类型渲染对应内容
const renderReferContent = () => {
// 动画表情:解析嵌套 XML 提取 cdnurl 渲染
if (referType === '47') {
try {
const innerDoc = new DOMParser().parseFromString(referContent, 'text/xml')
const cdnUrl = innerDoc.querySelector('emoji')?.getAttribute('cdnurl') || ''
const md5 = innerDoc.querySelector('emoji')?.getAttribute('md5') || ''
if (cdnUrl) return <QuotedEmoji cdnUrl={cdnUrl} md5={md5} />
} catch { /* 解析失败降级 */ }
return <span className="quoted-type-label">[]</span>
}
// 各类型名称映射
const typeLabels: Record<string, string> = {
'3': '图片', '34': '语音', '43': '视频',
'49': '链接', '50': '通话', '10000': '系统消息', '10002': '撤回消息',
}
if (referType && typeLabels[referType]) {
return <span className="quoted-type-label">[{typeLabels[referType]}]</span>
}
// 普通文本或未知类型
return <>{renderTextWithEmoji(cleanMessageContent(referContent))}</>
}
return (
<div className="bubble-content">
<div className="quoted-message">
{referSender && <span className="quoted-sender">{referSender}</span>}
<span className="quoted-text">{renderTextWithEmoji(cleanMessageContent(referContent))}</span>
<span className="quoted-text">{renderReferContent()}</span>
</div>
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
</div>
@@ -4143,6 +4203,22 @@ function MessageBubble({
</div>
)
if (kind === 'quote') {
// 引用回复消息appMsgKind='quote'xmlType=57
const replyText = message.linkTitle || q('title') || cleanMessageContent(message.parsedContent) || ''
const referContent = message.quotedContent || q('refermsg > content') || ''
const referSender = message.quotedSender || q('refermsg > displayname') || ''
return (
<div className="bubble-content">
<div className="quoted-message">
{referSender && <span className="quoted-sender">{referSender}</span>}
<span className="quoted-text">{renderTextWithEmoji(cleanMessageContent(referContent))}</span>
</div>
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
</div>
)
}
if (kind === 'red-packet') {
// 专属红包卡片
const greeting = (() => {
@@ -4347,6 +4423,44 @@ function MessageBubble({
console.error('解析 AppMsg 失败:', e)
}
// 引用回复消息 (type=57),防止被误判为链接
if (appMsgType === '57') {
const replyText = parsedDoc?.querySelector('title')?.textContent?.trim() || cleanMessageContent(message.parsedContent) || ''
const referContent = parsedDoc?.querySelector('refermsg > content')?.textContent?.trim() || ''
const referSender = parsedDoc?.querySelector('refermsg > displayname')?.textContent?.trim() || ''
const referType = parsedDoc?.querySelector('refermsg > type')?.textContent?.trim() || ''
const renderReferContent2 = () => {
if (referType === '47') {
try {
const innerDoc = new DOMParser().parseFromString(referContent, 'text/xml')
const cdnUrl = innerDoc.querySelector('emoji')?.getAttribute('cdnurl') || ''
const md5 = innerDoc.querySelector('emoji')?.getAttribute('md5') || ''
if (cdnUrl) return <QuotedEmoji cdnUrl={cdnUrl} md5={md5} />
} catch { /* 解析失败降级 */ }
return <span className="quoted-type-label">[]</span>
}
const typeLabels: Record<string, string> = {
'3': '图片', '34': '语音', '43': '视频',
'49': '链接', '50': '通话', '10000': '系统消息', '10002': '撤回消息',
}
if (referType && typeLabels[referType]) {
return <span className="quoted-type-label">[{typeLabels[referType]}]</span>
}
return <>{renderTextWithEmoji(cleanMessageContent(referContent))}</>
}
return (
<div className="bubble-content">
<div className="quoted-message">
{referSender && <span className="quoted-sender">{referSender}</span>}
<span className="quoted-text">{renderReferContent2()}</span>
</div>
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
</div>
)
}
// 群公告消息 (type=87)
if (appMsgType === '87') {
const announcementText = textAnnouncement || desc || '群公告'
@@ -4579,7 +4693,7 @@ function MessageBubble({
if (isEmoji) {
// ... (keep existing emoji logic)
// 没有 cdnUrl 或加载失败,显示占位符
if (!message.emojiCdnUrl || emojiError) {
if ((!message.emojiCdnUrl && !message.emojiLocalPath) || emojiError) {
return (
<div className="emoji-unavailable">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">

View File

@@ -29,7 +29,7 @@
.blob-1 {
width: 400px;
height: 400px;
background: rgba(139, 115, 85, 0.25);
background: rgba(var(--primary-rgb), 0.25);
top: -100px;
left: -50px;
animation-duration: 25s;
@@ -38,7 +38,7 @@
.blob-2 {
width: 350px;
height: 350px;
background: rgba(139, 115, 85, 0.15);
background: rgba(var(--primary-rgb), 0.15);
bottom: -50px;
right: -50px;
animation-duration: 30s;
@@ -74,7 +74,7 @@
margin: 0 0 16px;
color: var(--text-primary);
letter-spacing: -2px;
background: linear-gradient(135deg, var(--text-primary) 0%, rgba(139, 115, 85, 0.8) 100%);
background: linear-gradient(135deg, var(--primary) 0%, rgba(var(--primary-rgb), 0.6) 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;

View File

@@ -939,8 +939,16 @@ function SettingsPage() {
<div className="theme-grid">
{themes.map((theme) => (
<div key={theme.id} className={`theme-card ${currentTheme === theme.id ? 'active' : ''}`} onClick={() => setTheme(theme.id)}>
<div className="theme-preview" style={{ background: effectiveMode === 'dark' ? 'linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%)' : `linear-gradient(135deg, ${theme.bgColor} 0%, ${theme.bgColor}dd 100%)` }}>
<div className="theme-accent" style={{ background: theme.primaryColor }} />
<div className="theme-preview" style={{
background: effectiveMode === 'dark'
? (theme.id === 'blossom-dream' ? 'linear-gradient(150deg, #151316 0%, #1A1620 50%, #131018 100%)' : 'linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%)')
: (theme.id === 'blossom-dream' ? `linear-gradient(150deg, ${theme.bgColor} 0%, #F8F2F8 45%, #F2F6FB 100%)` : `linear-gradient(135deg, ${theme.bgColor} 0%, ${theme.bgColor}dd 100%)`)
}}>
<div className="theme-accent" style={{
background: theme.accentColor
? `linear-gradient(135deg, ${theme.primaryColor} 0%, ${theme.accentColor} 100%)`
: theme.primaryColor
}} />
</div>
<div className="theme-info">
<span className="theme-name">{theme.name}</span>