feat: 一些适配

This commit is contained in:
Forrest
2026-01-29 21:25:36 +08:00
parent 26fbfd2c98
commit f3994a1a72
14 changed files with 1580 additions and 242 deletions

View File

@@ -18,6 +18,7 @@ import ExportPage from './pages/ExportPage'
import VideoWindow from './pages/VideoWindow'
import SnsPage from './pages/SnsPage'
import ContactsPage from './pages/ContactsPage'
import ChatHistoryPage from './pages/ChatHistoryPage'
import { useAppStore } from './stores/appStore'
import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
@@ -49,6 +50,7 @@ function App() {
const isAgreementWindow = location.pathname === '/agreement-window'
const isOnboardingWindow = location.pathname === '/onboarding-window'
const isVideoPlayerWindow = location.pathname === '/video-player-window'
const isChatHistoryWindow = location.pathname.startsWith('/chat-history/')
const [themeHydrated, setThemeHydrated] = useState(false)
// 锁定状态
@@ -298,6 +300,11 @@ function App() {
return <VideoWindow />
}
// 独立聊天记录窗口
if (isChatHistoryWindow) {
return <ChatHistoryPage />
}
// 主窗口 - 完整布局
return (
<div className="app-container">
@@ -392,6 +399,7 @@ function App() {
<Route path="/export" element={<ExportPage />} />
<Route path="/sns" element={<SnsPage />} />
<Route path="/contacts" element={<ContactsPage />} />
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
</Routes>
</RouteGuard>
</main>

View File

@@ -1,10 +1,14 @@
import './TitleBar.scss'
function TitleBar() {
interface TitleBarProps {
title?: string
}
function TitleBar({ title }: TitleBarProps = {}) {
return (
<div className="title-bar">
<img src="./logo.png" alt="WeFlow" className="title-logo" />
<span className="titles">WeFlow</span>
<span className="titles">{title || 'WeFlow'}</span>
</div>
)
}

View File

@@ -0,0 +1,132 @@
.chat-history-page {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--bg-primary);
.history-list {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
.status-msg {
text-align: center;
padding: 40px 20px;
color: var(--text-tertiary);
font-size: 14px;
&.error {
color: var(--danger);
}
&.empty {
color: var(--text-tertiary);
}
}
}
.history-item {
display: flex;
gap: 12px;
align-items: flex-start;
.avatar {
width: 40px;
height: 40px;
border-radius: 4px;
overflow: hidden;
flex-shrink: 0;
background: var(--bg-tertiary);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
font-size: 16px;
font-weight: 500;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
}
.content-wrapper {
flex: 1;
min-width: 0;
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
.sender {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.time {
font-size: 12px;
color: var(--text-tertiary);
flex-shrink: 0;
margin-left: 8px;
}
}
.bubble {
background: var(--bg-secondary);
padding: 10px 14px;
border-radius: 18px 18px 18px 4px;
word-wrap: break-word;
max-width: 100%;
display: inline-block;
&.image-bubble {
padding: 0;
background: transparent;
}
.text-content {
font-size: 14px;
line-height: 1.6;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-word;
}
.media-content {
img {
max-width: 100%;
max-height: 300px;
border-radius: 8px;
display: block;
}
.media-tip {
padding: 8px 12px;
color: var(--text-tertiary);
font-size: 13px;
}
}
.media-placeholder {
font-size: 14px;
color: var(--text-secondary);
padding: 4px 0;
}
}
}
}
}

View File

@@ -0,0 +1,250 @@
import { useEffect, useState } from 'react'
import { useParams, useLocation } from 'react-router-dom'
import { ChatRecordItem } from '../types/models'
import TitleBar from '../components/TitleBar'
import './ChatHistoryPage.scss'
export default function ChatHistoryPage() {
const params = useParams<{ sessionId: string; messageId: string }>()
const location = useLocation()
const [recordList, setRecordList] = useState<ChatRecordItem[]>([])
const [loading, setLoading] = useState(true)
const [title, setTitle] = useState('聊天记录')
const [error, setError] = useState('')
// 简单的 XML 标签内容提取
const extractXmlValue = (xml: string, tag: string): string => {
const match = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`).exec(xml)
return match ? match[1] : ''
}
// 简单的 HTML 实体解码
const decodeHtmlEntities = (text?: string): string | undefined => {
if (!text) return text
return text
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
}
// 前端兜底解析合并转发聊天记录
const parseChatHistory = (content: string): ChatRecordItem[] | undefined => {
try {
const type = extractXmlValue(content, 'type')
if (type !== '19') return undefined
const match = /<recorditem>[\s\S]*?<!\[CDATA\[([\s\S]*?)\]\]>[\s\S]*?<\/recorditem>/.exec(content)
if (!match) return undefined
const innerXml = match[1]
const items: ChatRecordItem[] = []
const itemRegex = /<dataitem\s+(.*?)>([\s\S]*?)<\/dataitem>/g
let itemMatch: RegExpExecArray | null
while ((itemMatch = itemRegex.exec(innerXml)) !== null) {
const attrs = itemMatch[1]
const body = itemMatch[2]
const datatypeMatch = /datatype="(\d+)"/.exec(attrs)
const datatype = datatypeMatch ? parseInt(datatypeMatch[1]) : 0
const sourcename = extractXmlValue(body, 'sourcename')
const sourcetime = extractXmlValue(body, 'sourcetime')
const sourceheadurl = extractXmlValue(body, 'sourceheadurl')
const datadesc = extractXmlValue(body, 'datadesc')
const datatitle = extractXmlValue(body, 'datatitle')
const fileext = extractXmlValue(body, 'fileext')
const datasize = parseInt(extractXmlValue(body, 'datasize') || '0')
const messageuuid = extractXmlValue(body, 'messageuuid')
const dataurl = extractXmlValue(body, 'dataurl')
const datathumburl = extractXmlValue(body, 'datathumburl') || extractXmlValue(body, 'thumburl')
const datacdnurl = extractXmlValue(body, 'datacdnurl') || extractXmlValue(body, 'cdnurl')
const aeskey = extractXmlValue(body, 'aeskey') || extractXmlValue(body, 'qaeskey')
const md5 = extractXmlValue(body, 'md5') || extractXmlValue(body, 'datamd5')
const imgheight = parseInt(extractXmlValue(body, 'imgheight') || '0')
const imgwidth = parseInt(extractXmlValue(body, 'imgwidth') || '0')
const duration = parseInt(extractXmlValue(body, 'duration') || '0')
items.push({
datatype,
sourcename,
sourcetime,
sourceheadurl,
datadesc: decodeHtmlEntities(datadesc),
datatitle: decodeHtmlEntities(datatitle),
fileext,
datasize,
messageuuid,
dataurl: decodeHtmlEntities(dataurl),
datathumburl: decodeHtmlEntities(datathumburl),
datacdnurl: decodeHtmlEntities(datacdnurl),
aeskey: decodeHtmlEntities(aeskey),
md5,
imgheight,
imgwidth,
duration
})
}
return items.length > 0 ? items : undefined
} catch (e) {
console.error('前端解析聊天记录失败:', e)
return undefined
}
}
// 统一从路由参数或 pathname 中解析 sessionId / messageId
const getIds = () => {
const sessionId = params.sessionId || ''
const messageId = params.messageId || ''
if (sessionId && messageId) {
return { sid: sessionId, mid: messageId }
}
// 独立窗口场景下没有 Route 包裹,用 pathname 手动解析
const match = /^\/chat-history\/([^/]+)\/([^/]+)/.exec(location.pathname)
if (match) {
return { sid: match[1], mid: match[2] }
}
return { sid: '', mid: '' }
}
useEffect(() => {
const loadData = async () => {
const { sid, mid } = getIds()
if (!sid || !mid) {
setError('无效的聊天记录链接')
setLoading(false)
return
}
try {
const result = await window.electronAPI.chat.getMessage(sid, parseInt(mid, 10))
if (result.success && result.message) {
const msg = result.message
// 优先使用后端解析好的列表
let records: ChatRecordItem[] | undefined = msg.chatRecordList
// 如果后端没有解析到,则在前端兜底解析一次
if ((!records || records.length === 0) && msg.content) {
records = parseChatHistory(msg.content) || []
}
if (records && records.length > 0) {
setRecordList(records)
const match = /<title>(.*?)<\/title>/.exec(msg.content || '')
if (match) setTitle(match[1])
} else {
setError('暂时无法解析这条聊天记录')
}
} else {
setError(result.error || '获取消息失败')
}
} catch (e) {
console.error(e)
setError('加载详情失败')
} finally {
setLoading(false)
}
}
loadData()
}, [params.sessionId, params.messageId, location.pathname])
return (
<div className="chat-history-page">
<TitleBar title={title} />
<div className="history-list">
{loading ? (
<div className="status-msg">...</div>
) : error ? (
<div className="status-msg error">{error}</div>
) : recordList.length === 0 ? (
<div className="status-msg empty"></div>
) : (
recordList.map((item, i) => (
<HistoryItem key={i} item={item} />
))
)}
</div>
</div>
)
}
function HistoryItem({ item }: { item: ChatRecordItem }) {
// sourcetime 在合并转发里有两种格式:
// 1) 时间戳(秒) 2) 已格式化的字符串 "2026-01-21 09:56:46"
let time = ''
if (item.sourcetime) {
if (/^\d+$/.test(item.sourcetime)) {
time = new Date(parseInt(item.sourcetime, 10) * 1000).toLocaleString()
} else {
time = item.sourcetime
}
}
const renderContent = () => {
if (item.datatype === 1) {
// 文本消息
return <div className="text-content">{item.datadesc || ''}</div>
}
if (item.datatype === 3) {
// 图片
const src = item.datathumburl || item.datacdnurl
if (src) {
return (
<div className="media-content">
<img
src={src}
alt="图片"
referrerPolicy="no-referrer"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
const placeholder = document.createElement('div')
placeholder.className = 'media-tip'
placeholder.textContent = '图片无法加载'
target.parentElement?.appendChild(placeholder)
}}
/>
</div>
)
}
return <div className="media-placeholder">[]</div>
}
if (item.datatype === 43) {
return <div className="media-placeholder">[] {item.datatitle}</div>
}
if (item.datatype === 34) {
return <div className="media-placeholder">[] {item.duration ? (item.duration / 1000).toFixed(0) + '"' : ''}</div>
}
// Fallback
return <div className="text-content">{item.datadesc || item.datatitle || '[不支持的消息类型]'}</div>
}
return (
<div className="history-item">
<div className="avatar">
{item.sourceheadurl ? (
<img src={item.sourceheadurl} alt="" referrerPolicy="no-referrer" />
) : (
<div className="avatar-placeholder">
{item.sourcename?.slice(0, 1)}
</div>
)}
</div>
<div className="content-wrapper">
<div className="header">
<span className="sender">{item.sourcename || '未知发送者'}</span>
<span className="time">{time}</span>
</div>
<div className={`bubble ${item.datatype === 3 ? 'image-bubble' : ''}`}>
{renderContent()}
</div>
</div>
</div>
)
}

View File

@@ -834,92 +834,93 @@
// 链接卡片消息样式
.link-message {
cursor: pointer;
width: 280px;
background: var(--card-bg);
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border-color);
cursor: pointer;
transition: all 0.2s ease;
max-width: 300px;
margin-top: 4px;
border: 1px solid var(--border-color);
&:hover {
background: var(--bg-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border-color: var(--primary);
}
.link-header {
padding: 10px 12px 6px;
display: flex;
align-items: flex-start;
padding: 12px;
gap: 12px;
gap: 8px;
.link-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
flex: 1;
}
}
.link-content {
flex: 1;
min-width: 0;
}
.link-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 4px;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
}
.link-desc {
font-size: 12px;
color: var(--text-secondary);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
opacity: 0.8;
}
.link-icon {
flex-shrink: 0;
width: 40px;
height: 40px;
background: var(--bg-tertiary);
border-radius: 6px;
.link-body {
padding: 6px 12px 10px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
gap: 10px;
svg {
opacity: 0.8;
.link-desc {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
flex: 1;
}
.link-thumb {
width: 48px;
height: 48px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
background: var(--bg-tertiary);
}
.link-thumb-placeholder {
width: 48px;
height: 48px;
border-radius: 4px;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--text-tertiary);
svg {
opacity: 0.5;
}
}
}
}
// 适配发送出去的消息中的链接卡片
.message-bubble.sent .link-message {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
background: var(--card-bg);
border: 1px solid var(--border-color);
.link-title {
color: var(--text-primary);
}
.link-title,
.link-desc {
color: #fff;
}
.link-icon {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
&:hover {
background: rgba(255, 255, 255, 0.2);
color: var(--text-secondary);
}
}
@@ -2170,4 +2171,304 @@
.spin {
animation: spin 1s linear infinite;
}
}
}
// 名片消息
.card-message {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--bg-tertiary);
border-radius: 8px;
min-width: 200px;
.card-icon {
flex-shrink: 0;
color: var(--primary);
}
.card-info {
flex: 1;
min-width: 0;
}
.card-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 2px;
}
.card-label {
font-size: 12px;
color: var(--text-tertiary);
}
}
// 通话消息
.call-message {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
color: var(--text-secondary);
font-size: 13px;
svg {
flex-shrink: 0;
}
}
// 文件消息
// 文件消息
.file-message {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--bg-tertiary);
border-radius: 8px;
min-width: 220px;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: var(--bg-hover);
}
.file-icon {
flex-shrink: 0;
color: var(--primary);
}
.file-info {
flex: 1;
min-width: 0;
}
.file-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-meta {
font-size: 12px;
color: var(--text-tertiary);
}
}
// 发送的文件消息样式
.message-bubble.sent .file-message {
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.1);
.file-name {
color: #333;
}
.file-meta {
color: #999;
}
}
// 聊天记录消息 - 复用 link-message 基础样式
.chat-record-message {
cursor: pointer;
.link-header {
padding-bottom: 4px;
}
.chat-record-preview {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
}
.chat-record-meta-line {
font-size: 11px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-record-list {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 70px;
overflow: hidden;
}
.chat-record-item {
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.source-name {
color: var(--text-primary);
font-weight: 500;
margin-right: 4px;
}
.chat-record-more {
font-size: 12px;
color: var(--primary);
}
.chat-record-desc {
font-size: 12px;
color: var(--text-secondary);
}
.chat-record-icon {
width: 40px;
height: 40px;
border-radius: 10px;
background: var(--primary-gradient);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
flex-shrink: 0;
}
}
// 小程序消息
.miniapp-message {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--bg-tertiary);
border-radius: 8px;
min-width: 200px;
.miniapp-icon {
flex-shrink: 0;
color: var(--primary);
}
.miniapp-info {
flex: 1;
min-width: 0;
}
.miniapp-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.miniapp-label {
font-size: 12px;
color: var(--text-tertiary);
}
}
// 转账消息卡片
.transfer-message {
width: 240px;
background: linear-gradient(135deg, #f59e42 0%, #f5a742 100%);
border-radius: 12px;
padding: 14px 16px;
display: flex;
gap: 12px;
align-items: center;
cursor: default;
&.received {
background: linear-gradient(135deg, #b8b8b8 0%, #a8a8a8 100%);
}
.transfer-icon {
flex-shrink: 0;
svg {
width: 32px;
height: 32px;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1));
}
}
.transfer-info {
flex: 1;
color: white;
.transfer-amount {
font-size: 18px;
font-weight: 600;
margin-bottom: 2px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.transfer-memo {
font-size: 13px;
margin-bottom: 8px;
opacity: 0.95;
}
.transfer-label {
font-size: 12px;
opacity: 0.85;
}
}
}
// 发送消息中的特殊消息类型适配(除了文件和转账)
.message-bubble.sent {
.card-message,
.chat-record-message,
.miniapp-message {
background: rgba(255, 255, 255, 0.15);
.card-name,
.miniapp-title,
.source-name {
color: white;
}
.card-label,
.miniapp-label,
.chat-record-item,
.chat-record-meta-line,
.chat-record-desc {
color: rgba(255, 255, 255, 0.8);
}
.card-icon,
.miniapp-icon,
.chat-record-icon {
color: white;
}
.chat-record-more {
color: rgba(255, 255, 255, 0.9);
}
}
.call-message {
color: rgba(255, 255, 255, 0.9);
svg {
color: white;
}
}
}

View File

@@ -22,6 +22,15 @@ function isSystemMessage(localType: number): boolean {
return SYSTEM_MESSAGE_TYPES.includes(localType)
}
// 格式化文件大小
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
}
interface ChatPageProps {
// 保留接口以备将来扩展
}
@@ -1476,6 +1485,9 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
const isImage = message.localType === 3
const isVideo = message.localType === 43
const isVoice = message.localType === 34
const isCard = message.localType === 42
const isCall = message.localType === 50
const isType49 = message.localType === 49
const isSent = message.isSend === 1
const [senderAvatarUrl, setSenderAvatarUrl] = useState<string | undefined>(undefined)
const [senderName, setSenderName] = useState<string | undefined>(undefined)
@@ -2438,6 +2450,268 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
)
}
// 名片消息
if (isCard) {
const cardName = message.cardNickname || message.cardUsername || '未知联系人'
return (
<div className="card-message">
<div className="card-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</div>
<div className="card-info">
<div className="card-name">{cardName}</div>
<div className="card-label"></div>
</div>
</div>
)
}
// 通话消息
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>
)
}
// 链接消息 (AppMessage)
const isAppMsg = message.rawContent?.includes('<appmsg') || (message.parsedContent && message.parsedContent.includes('<appmsg'))
if (isAppMsg) {
let title = '链接'
let desc = ''
let url = ''
let appMsgType = ''
try {
const content = message.rawContent || message.parsedContent || ''
// 简单清理 XML 前缀(如 wxid:
const xmlContent = content.substring(content.indexOf('<msg>'))
const parser = new DOMParser()
const doc = parser.parseFromString(xmlContent, 'text/xml')
title = doc.querySelector('title')?.textContent || '链接'
desc = doc.querySelector('des')?.textContent || ''
url = doc.querySelector('url')?.textContent || ''
appMsgType = doc.querySelector('appmsg > type')?.textContent || doc.querySelector('type')?.textContent || ''
} catch (e) {
console.error('解析 AppMsg 失败:', e)
}
// 聊天记录 (type=19)
if (appMsgType === '19') {
const recordList = message.chatRecordList || []
const displayTitle = title || '群聊的聊天记录'
const metaText =
recordList.length > 0
? `${recordList.length} 条聊天记录`
: desc || '聊天记录'
const previewItems = recordList.slice(0, 4)
return (
<div
className="link-message chat-record-message"
onClick={(e) => {
e.stopPropagation()
// 打开聊天记录窗口
window.electronAPI.window.openChatHistoryWindow(session.username, message.localId)
}}
title="点击查看详细聊天记录"
>
<div className="link-header">
<div className="link-title" title={displayTitle}>
{displayTitle}
</div>
</div>
<div className="link-body">
<div className="chat-record-preview">
{previewItems.length > 0 ? (
<>
<div className="chat-record-meta-line" title={metaText}>
{metaText}
</div>
<div className="chat-record-list">
{previewItems.map((item, i) => (
<div key={i} className="chat-record-item">
<span className="source-name">
{item.sourcename ? `${item.sourcename}: ` : ''}
</span>
{item.datadesc || item.datatitle || '[媒体消息]'}
</div>
))}
{recordList.length > previewItems.length && (
<div className="chat-record-more"> {recordList.length - previewItems.length} </div>
)}
</div>
</>
) : (
<div className="chat-record-desc">
{desc || '点击打开查看完整聊天记录'}
</div>
)}
</div>
<div className="chat-record-icon">
<MessageSquare size={18} />
</div>
</div>
</div>
)
}
// 文件消息 (type=6)
if (appMsgType === '6') {
const fileName = message.fileName || title || '文件'
const fileSize = message.fileSize
const fileExt = message.fileExt || fileName.split('.').pop()?.toLowerCase() || ''
// 根据扩展名选择图标
const getFileIcon = () => {
const archiveExts = ['zip', 'rar', '7z', 'tar', 'gz', 'bz2']
if (archiveExts.includes(fileExt)) {
return (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
)
}
return (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" />
<polyline points="13 2 13 9 20 9" />
</svg>
)
}
return (
<div className="file-message">
<div className="file-icon">
{getFileIcon()}
</div>
<div className="file-info">
<div className="file-name" title={fileName}>{fileName}</div>
<div className="file-meta">
{fileSize ? formatFileSize(fileSize) : ''}
</div>
</div>
</div>
)
}
// 转账消息 (type=2000)
if (appMsgType === '2000') {
try {
const content = message.rawContent || message.content || message.parsedContent || ''
// 添加调试日志
console.log('[Transfer Debug] Raw content:', content.substring(0, 500))
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'
console.log('[Transfer Debug] Parsed:', { feedesc, payMemo, paysubtype, title })
// paysubtype: 1=待收款, 3=已收款
const isReceived = paysubtype === '3'
// 如果 feedesc 为空,使用 title 作为降级
const displayAmount = feedesc || title || '微信转账'
return (
<div className={`transfer-message ${isReceived ? 'received' : ''}`}>
<div className="transfer-icon">
<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 20h16M20 12l8 8-8 8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<div className="transfer-info">
<div className="transfer-amount">{displayAmount}</div>
{payMemo && <div className="transfer-memo">{payMemo}</div>}
<div className="transfer-label">{isReceived ? '已收款' : '微信转账'}</div>
</div>
</div>
)
} catch (e) {
console.error('[Transfer Debug] Parse error:', e)
// 解析失败时的降级处理
const feedesc = title || '微信转账'
return (
<div className="transfer-message">
<div className="transfer-icon">
<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 20h16M20 12l8 8-8 8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<div className="transfer-info">
<div className="transfer-amount">{feedesc}</div>
<div className="transfer-label"></div>
</div>
</div>
)
}
}
// 小程序 (type=33/36)
if (appMsgType === '33' || appMsgType === '36') {
return (
<div className="miniapp-message">
<div className="miniapp-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
</svg>
</div>
<div className="miniapp-info">
<div className="miniapp-title">{title}</div>
<div className="miniapp-label"></div>
</div>
</div>
)
}
// 有 URL 的链接消息
if (url) {
return (
<div
className="link-message"
onClick={(e) => {
e.stopPropagation()
if (window.electronAPI?.shell?.openExternal) {
window.electronAPI.shell.openExternal(url)
} else {
window.open(url, '_blank')
}
}}
>
<div className="link-header">
<div className="link-title" title={title}>{title}</div>
</div>
<div className="link-body">
<div className="link-desc" title={desc}>{desc}</div>
<div className="link-thumb-placeholder">
<Link size={24} />
</div>
</div>
</div>
)
}
}
// 表情包消息
if (isEmoji) {
// ... (keep existing emoji logic)
@@ -2492,67 +2766,6 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
)
}
// 解析引用消息Links / App Messages
// localType: 21474836529 corresponds to AppMessage which often contains links
if (isLinkMessage) {
try {
// 清理内容:移除可能的 wxid 前缀,找到 XML 起始位置
let contentToParse = message.rawContent || message.parsedContent || '';
const xmlStartIndex = contentToParse.indexOf('<');
if (xmlStartIndex >= 0) {
contentToParse = contentToParse.substring(xmlStartIndex);
}
// 处理 HTML 转义字符
if (contentToParse.includes('&lt;')) {
contentToParse = contentToParse
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'");
}
const parser = new DOMParser();
const doc = parser.parseFromString(contentToParse, "text/xml");
const appMsg = doc.querySelector('appmsg');
if (appMsg) {
const title = doc.querySelector('title')?.textContent || '未命名链接';
const des = doc.querySelector('des')?.textContent || '无描述';
const url = doc.querySelector('url')?.textContent || '';
return (
<div
className="link-message"
onClick={(e) => {
e.stopPropagation();
if (url) {
// 优先使用 electron 接口打开外部浏览器
if (window.electronAPI?.shell?.openExternal) {
window.electronAPI.shell.openExternal(url);
} else {
window.open(url, '_blank');
}
}
}}
>
<div className="link-header">
<div className="link-content">
<div className="link-title" title={title}>{title}</div>
<div className="link-desc" title={des}>{des}</div>
</div>
<div className="link-icon">
<Link size={24} />
</div>
</div>
</div>
);
}
} catch (e) {
console.error('Failed to parse app message', e);
}
}
// 普通消息
return <div className="bubble-content">{renderTextWithEmoji(cleanMessageContent(message.parsedContent))}</div>
}

View File

@@ -41,11 +41,12 @@ export const MESSAGE_TYPE_LABELS: Record<number, string> = {
244813135921: '文本',
3: '图片',
34: '语音',
42: '名片',
43: '视频',
47: '表情',
48: '位置',
49: '链接/文件',
42: '名片',
50: '通话',
10000: '系统消息',
}

View File

@@ -11,6 +11,7 @@ export interface ElectronAPI {
setTitleBarOverlay: (options: { symbolColor: string }) => void
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise<void>
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean>
}
config: {
get: (key: string) => Promise<unknown>
@@ -106,6 +107,7 @@ export interface ElectronAPI {
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }>
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
execQuery: (kind: string, path: string | null, sql: string) => Promise<{ success: boolean; rows?: any[]; error?: string }>
getMessage: (sessionId: string, localId: number) => Promise<{ success: boolean; message?: Message; error?: string }>
}
image: {

View File

@@ -53,8 +53,44 @@ export interface Message {
// 引用消息
quotedContent?: string
quotedSender?: string
// Type 49 细分字段
linkTitle?: string // 链接/文件标题
linkUrl?: string // 链接 URL
linkThumb?: string // 链接缩略图
fileName?: string // 文件名
fileSize?: number // 文件大小
fileExt?: string // 文件扩展名
xmlType?: string // XML 中的 type 字段
// 名片消息
cardUsername?: string // 名片的微信ID
cardNickname?: string // 名片的昵称
// 聊天记录
chatRecordTitle?: string // 聊天记录标题
chatRecordList?: ChatRecordItem[] // 聊天记录列表
}
// 聊天记录项
export interface ChatRecordItem {
datatype: number // 消息类型
sourcename: string // 发送者
sourcetime: string // 时间
sourceheadurl?: string // 发送者头像
datadesc?: string // 内容描述
datatitle?: string // 标题
fileext?: string // 文件扩展名
datasize?: number // 文件大小
messageuuid?: string // 消息UUID
dataurl?: string // 数据URL
datathumburl?: string // 缩略图URL
datacdnurl?: string // CDN URL
aeskey?: string // AES密钥
md5?: string // MD5
imgheight?: number // 图片高度
imgwidth?: number // 图片宽度
duration?: number // 时长(毫秒)
}
// 分析数据
export interface AnalyticsData {
totalMessages: number