diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 9c06ed2..e6da68f 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -4670,6 +4670,8 @@ class ChatService { case '57': // 引用消息,title 就是回复的内容 return title + case '53': + return `[接龙] ${title.split(/\r?\n/).map(line => line.trim()).find(Boolean) || title}` case '2000': return `[转账] ${title}` case '2001': @@ -4699,6 +4701,8 @@ class ChatService { return '[链接]' case '87': return '[群公告]' + case '53': + return '[接龙]' default: return '[消息]' } @@ -5298,6 +5302,8 @@ class ChatService { const quoteInfo = this.parseQuoteMessage(content) result.quotedContent = quoteInfo.content result.quotedSender = quoteInfo.sender + } else if (xmlType === '53') { + result.appMsgKind = 'solitaire' } else if ((xmlType === '5' || xmlType === '49') && (sourceUsername?.startsWith('gh_') || appName?.includes('公众号') || sourceName)) { result.appMsgKind = 'official-link' } else if (url) { diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index d13458c..2717718 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -2119,6 +2119,7 @@ class ExportService { } return title || '[引用消息]' } + if (xmlType === '53') return title ? `[接龙] ${title.split(/\r?\n/).map(line => line.trim()).find(Boolean) || title}` : '[接龙]' if (xmlType === '5' || xmlType === '49') return title ? `[链接] ${title}` : '[链接]' // 有 title 就返回 title @@ -3220,6 +3221,8 @@ class ExportService { appMsgKind = 'announcement' } else if (xmlType === '57' || hasReferMsg || localType === 244813135921) { appMsgKind = 'quote' + } else if (xmlType === '53') { + appMsgKind = 'solitaire' } else if (xmlType === '5' || xmlType === '49') { appMsgKind = 'link' } else if (looksLikeAppMsg) { diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 22d2e56..60b99ee 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -2064,6 +2064,7 @@ .message-bubble .bubble-content:has(> .link-message), .message-bubble .bubble-content:has(> .card-message), .message-bubble .bubble-content:has(> .chat-record-message), +.message-bubble .bubble-content:has(> .solitaire-message), .message-bubble .bubble-content:has(> .official-message), .message-bubble .bubble-content:has(> .channel-video-card), .message-bubble .bubble-content:has(> .location-message) { @@ -3604,6 +3605,140 @@ } } +// 接龙消息 +.solitaire-message { + width: min(360px, 72vw); + max-width: 360px; + background: var(--card-inner-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; + cursor: pointer; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); + transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; + + &:hover { + background: var(--bg-hover); + border-color: var(--primary); + } + + .solitaire-header { + display: flex; + gap: 10px; + padding: 12px 14px 10px; + border-bottom: 1px solid var(--border-color); + } + + .solitaire-icon { + width: 30px; + height: 30px; + border-radius: 8px; + background: color-mix(in srgb, var(--primary) 12%, transparent); + color: var(--primary); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + .solitaire-heading { + min-width: 0; + flex: 1; + } + + .solitaire-title { + color: var(--text-primary); + font-size: 14px; + font-weight: 600; + line-height: 1.45; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .solitaire-meta { + margin-top: 2px; + color: var(--text-tertiary); + font-size: 12px; + line-height: 1.4; + } + + .solitaire-intro, + .solitaire-entry-list { + padding: 10px 14px; + border-bottom: 1px solid var(--border-color); + } + + .solitaire-intro { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.55; + } + + .solitaire-intro-line { + white-space: pre-wrap; + word-break: break-word; + } + + .solitaire-entry-list { + display: flex; + flex-direction: column; + gap: 7px; + } + + .solitaire-entry { + display: flex; + gap: 8px; + align-items: flex-start; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.45; + } + + .solitaire-entry-index { + width: 22px; + height: 22px; + border-radius: 8px; + background: var(--bg-tertiary); + color: var(--text-tertiary); + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 11px; + } + + .solitaire-entry-text { + min-width: 0; + flex: 1; + word-break: break-word; + } + + .solitaire-muted-line { + color: var(--text-tertiary); + font-size: 12px; + line-height: 1.45; + } + + .solitaire-footer { + padding: 8px 14px 10px; + color: var(--text-tertiary); + font-size: 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + + .solitaire-chevron { + transition: transform 0.2s ease; + } + + &.expanded .solitaire-chevron { + transform: rotate(180deg); + } +} + // 通话消息 .call-message { display: flex; diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 7c94600..7af1bc4 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -181,6 +181,51 @@ function buildChatRecordPreviewItems(recordList: ChatRecordItem[], maxVisible = ] } +interface SolitaireEntry { + index: string + text: string +} + +interface SolitaireContent { + title: string + introLines: string[] + entries: SolitaireEntry[] +} + +function parseSolitaireContent(rawTitle: string): SolitaireContent { + const lines = String(rawTitle || '') + .replace(/\r\n/g, '\n') + .split('\n') + .map(line => line.trim()) + .filter(Boolean) + + const title = lines[0] || '接龙' + const introLines: string[] = [] + const entries: SolitaireEntry[] = [] + let hasStartedEntries = false + + for (const line of lines.slice(1)) { + const entryMatch = /^(\d+)[..、]\s*(.+)$/.exec(line) + if (entryMatch) { + hasStartedEntries = true + entries.push({ + index: entryMatch[1], + text: entryMatch[2].trim() + }) + continue + } + + if (hasStartedEntries && entries.length > 0) { + const previous = entries[entries.length - 1] + previous.text = `${previous.text} ${line}`.trim() + } else { + introLines.push(line) + } + } + + return { title, introLines, entries } +} + function composeGlobalMsgSearchResults( seedMap: Map, authoritativeMap: Map @@ -7825,6 +7870,7 @@ function MessageBubble({ const [senderName, setSenderName] = useState(undefined) const [quotedSenderName, setQuotedSenderName] = useState(undefined) const [quoteLayout, setQuoteLayout] = useState('quote-top') + const [solitaireExpanded, setSolitaireExpanded] = useState(false) const senderProfileRequestSeqRef = useRef(0) const [emojiError, setEmojiError] = useState(false) const [emojiLoading, setEmojiLoading] = useState(false) @@ -9433,6 +9479,71 @@ function MessageBubble({ ) } + if (xmlType === '53' || message.appMsgKind === 'solitaire') { + const solitaireText = message.linkTitle || q('appmsg > title') || q('title') || cleanedParsedContent || '接龙' + const solitaire = parseSolitaireContent(solitaireText) + const previewEntries = solitaireExpanded ? solitaire.entries : solitaire.entries.slice(0, 3) + const hiddenEntryCount = Math.max(0, solitaire.entries.length - previewEntries.length) + const introLines = solitaireExpanded ? solitaire.introLines : solitaire.introLines.slice(0, 4) + const hasMoreIntro = !solitaireExpanded && solitaire.introLines.length > introLines.length + const countText = solitaire.entries.length > 0 ? `${solitaire.entries.length} 人参与` : '接龙消息' + + return ( +
{ + e.stopPropagation() + setSolitaireExpanded(value => !value) + }} + onKeyDown={isSelectionMode ? undefined : (e) => { + if (e.key !== 'Enter' && e.key !== ' ') return + e.preventDefault() + e.stopPropagation() + setSolitaireExpanded(value => !value) + }} + title={solitaireExpanded ? '点击收起接龙' : '点击展开接龙'} + > +
+ +
+
{solitaire.title}
+
{countText}
+
+
+ {introLines.length > 0 && ( +
+ {introLines.map((line, index) => ( +
{line}
+ ))} + {hasMoreIntro &&
...
} +
+ )} + {previewEntries.length > 0 ? ( +
+ {previewEntries.map(entry => ( +
+ {entry.index} + {entry.text} +
+ ))} + {hiddenEntryCount > 0 && ( +
还有 {hiddenEntryCount} 条...
+ )} +
+ ) : null} +
+ {solitaireExpanded ? '收起接龙' : '展开接龙'} + +
+
+ ) + } + const title = message.linkTitle || q('title') || cleanedParsedContent || 'Card' const desc = message.appMsgDesc || q('des') const url = message.linkUrl || q('url')