同步以前的更新

This commit is contained in:
ILoveBingLu
2026-02-28 21:29:40 +08:00
parent 2a7405b3f9
commit 7ee09ee6de
28 changed files with 3381 additions and 514 deletions
+2 -2
View File
@@ -33,8 +33,8 @@
.image-preview-close {
position: fixed;
top: 20px;
right: 20px;
top: 48px;
right: 48px;
width: 44px;
height: 44px;
border-radius: 50%;
+12 -11
View File
@@ -1,3 +1,4 @@
//更新说明!!!
import { Package, Image, Mic, Filter, Send, Aperture } from 'lucide-react'
import './WhatsNewModal.scss'
@@ -11,13 +12,13 @@ function WhatsNewModal({ onClose, version }: WhatsNewModalProps) {
{
icon: <Package size={20} />,
title: '优化',
desc: '修复并优化部分内容。'
desc: '别管优化了什么,反正是优化了好多,记不清了。'
},
{
icon: <Image size={20} />,
title: '聊天内图片',
desc: '支持查看谷歌标准实况图片(iOS端与大疆等实况图片,发送后实况暂不支持)。'
}
// {
// icon: <Image size={20} />,
// title: '聊天内图片',
// desc: '支持查看谷歌标准实况图片(iOS端与大疆等实况图片,发送后实况暂不支持)。'
// }
// {
// icon: <Mic size={20} />,
// title: '语音导出',
@@ -28,11 +29,11 @@ function WhatsNewModal({ onClose, version }: WhatsNewModalProps) {
// title: '分类导出',
// desc: '导出时可按群聊或个人聊天筛选,支持日期范围过滤。'
// }
// {
// icon: <Aperture size={20} />,
// title: '朋友圈',
// desc: '新增朋友圈功能!'
// }
{
icon: <Aperture size={20} />,
title: '朋友圈',
desc: '评论内的表情包已完成解密!'
}
]
const handleTelegram = () => {
+498
View File
@@ -624,6 +624,8 @@
border: 1px solid var(--border-color);
cursor: default;
line-height: 1.4;
display: flex;
align-items: center;
}
.quote-text {
@@ -642,6 +644,16 @@
font-weight: 500;
opacity: 0.9;
}
.quote-image-thumb {
width: 36px;
height: 36px;
object-fit: cover;
border-radius: 4px;
cursor: pointer;
flex-shrink: 0;
margin-left: 8px;
}
}
.time-divider,
@@ -1577,6 +1589,16 @@
// 群聊发送者名称
// 链接/分享消息卡片
.card-badge {
margin-left: auto;
font-size: 10px;
color: var(--text-tertiary);
background: var(--bg-tertiary);
padding: 1px 6px;
border-radius: 8px;
white-space: nowrap;
}
.link-message {
width: 280px;
background: var(--card-bg);
@@ -1652,6 +1674,143 @@
}
}
}
&--cover {
width: 200px;
}
.link-cover {
height: 260px;
overflow: hidden;
img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
}
.link-source {
padding: 6px 12px 8px;
font-size: 11px;
color: var(--text-tertiary);
border-top: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 4px;
.link-source-avatar {
width: 16px;
height: 16px;
border-radius: 50%;
}
}
}
// 小程序消息卡片
.miniprogram-card {
width: 240px;
background: var(--card-bg);
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border-color);
.miniprogram-header {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 12px 4px;
.miniprogram-icon {
width: 18px;
height: 18px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.miniprogram-icon-placeholder {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--bg-tertiary);
flex-shrink: 0;
}
.miniprogram-name {
font-size: 12px;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.miniprogram-title {
padding: 4px 12px 8px;
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;
}
.miniprogram-cover {
overflow: hidden;
.miniprogram-cover-img {
width: calc(100% - 24px);
margin: 0 12px 8px;
display: block;
object-fit: cover;
border-radius: 6px;
}
.miniprogram-cover-placeholder {
width: calc(100% - 24px);
height: 100px;
margin: 0 12px 8px;
border-radius: 6px;
background: var(--bg-tertiary);
}
.miniprogram-cover-icon {
width: calc(100% - 24px);
height: 100px;
margin: 0 12px 8px;
border-radius: 6px;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
img {
width: 40px;
height: 40px;
border-radius: 8px;
object-fit: cover;
}
}
}
.miniprogram-footer {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border-top: 1px solid var(--border-color);
font-size: 11px;
color: var(--text-tertiary);
.miniprogram-logo {
opacity: 0.6;
}
}
}
// 适配发送/接收样式(跟随主题色)
@@ -1871,6 +2030,345 @@
}
}
// 红包卡片
.hongbao-message {
width: 240px;
background: linear-gradient(135deg, #e25b4a 0%, #c94535 100%);
border-radius: 12px;
padding: 14px 16px;
display: flex;
gap: 12px;
align-items: center;
cursor: default;
.hongbao-icon {
flex-shrink: 0;
svg {
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1));
}
}
.hongbao-info {
flex: 1;
color: white;
.hongbao-greeting {
font-size: 15px;
font-weight: 500;
margin-bottom: 6px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.hongbao-label {
font-size: 12px;
opacity: 0.8;
}
}
}
.gift-message {
width: 240px;
background: linear-gradient(135deg, #f7a8b8 0%, #e88fa0 100%);
border-radius: 12px;
padding: 14px 16px;
cursor: default;
.gift-img {
width: 100%;
border-radius: 8px;
margin-bottom: 10px;
object-fit: cover;
}
.gift-info {
color: white;
.gift-wish {
font-size: 15px;
font-weight: 500;
margin-bottom: 4px;
}
.gift-name {
font-size: 12px;
opacity: 0.9;
margin-bottom: 2px;
}
.gift-price {
font-size: 13px;
font-weight: 600;
margin-bottom: 4px;
}
.gift-label {
font-size: 12px;
opacity: 0.7;
}
}
}
.music-message {
width: 240px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
display: flex;
overflow: hidden;
cursor: pointer;
&:hover {
opacity: 0.85;
}
.music-cover {
width: 80px;
align-self: stretch;
flex-shrink: 0;
background: var(--hover-bg);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.music-info {
flex: 1;
min-width: 0;
overflow: hidden;
padding: 10px;
.music-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.music-artist {
font-size: 12px;
color: var(--text-secondary);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.music-source {
font-size: 11px;
color: var(--text-tertiary);
margin-top: 2px;
}
}
}
.contact-card-message {
width: 240px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 12px;
display: flex;
align-items: center;
gap: 10px;
position: relative;
.contact-card-avatar {
width: 40px;
height: 40px;
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
background: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.contact-card-info {
flex: 1;
min-width: 0;
.contact-card-name {
font-size: 14px;
font-weight: 500;
}
.contact-card-detail {
font-size: 11px;
color: var(--text-tertiary);
margin-top: 2px;
}
}
.contact-card-badge {
position: absolute;
bottom: 0;
left: 0;
right: 0;
text-align: center;
font-size: 10px;
color: var(--text-tertiary);
border-top: 1px solid var(--border-color);
padding: 4px 0;
border-radius: 0 0 12px 12px;
}
padding-bottom: 28px;
}
.location-message {
width: 240px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
overflow: hidden;
cursor: pointer;
.location-text {
padding: 12px;
display: flex;
gap: 8px;
}
.location-icon {
flex-shrink: 0;
color: #e25b4a;
margin-top: 2px;
}
.location-info {
flex: 1;
min-width: 0;
.location-name {
font-size: 14px;
font-weight: 500;
margin-bottom: 2px;
}
.location-label {
font-size: 11px;
color: var(--text-tertiary);
line-height: 1.4;
}
}
.location-map {
position: relative;
height: 100px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
[data-mode="dark"] & {
filter: invert(1) hue-rotate(180deg) brightness(0.9) contrast(0.9);
}
}
.location-pin {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -90%);
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
}
}
}
// 视频号卡片
.channel-video-card {
width: 200px;
background: var(--card-bg);
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border-color);
position: relative;
.channel-video-cover {
position: relative;
width: 100%;
height: 260px;
background: #000;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.channel-video-cover-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #666;
}
.channel-video-duration {
position: absolute;
bottom: 6px;
right: 6px;
background: rgba(0, 0, 0, 0.6);
color: white;
font-size: 11px;
padding: 1px 5px;
border-radius: 3px;
}
}
.channel-video-info {
padding: 8px 10px;
.channel-video-title {
font-size: 13px;
color: var(--text-primary);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 4px;
}
.channel-video-author {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-secondary);
.channel-video-avatar {
width: 16px;
height: 16px;
border-radius: 50%;
}
}
}
}
// 群公告消息
.announcement-message {
display: flex;
+382 -19
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { createPortal } from 'react-dom'
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Image as ImageIcon, Play, Video, Copy, ZoomIn, CheckSquare, Check, Edit, Link, Sparkles, FileText, FileArchive, Users, Mic, CheckCircle, XCircle } from 'lucide-react'
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Image as ImageIcon, Play, Video, Copy, ZoomIn, CheckSquare, Check, Edit, Link, Sparkles, FileText, FileArchive, Users, Mic, CheckCircle, XCircle, Download, Phone, Aperture, MapPin, UserRound } from 'lucide-react'
import { useChatStore } from '../stores/chatStore'
import { useUpdateStatusStore } from '../stores/updateStatusStore'
import ChatBackground from '../components/ChatBackground'
@@ -1388,6 +1388,15 @@ function ChatPage(_props: ChatPageProps) {
>
<RefreshCw size={18} className={isRefreshingMessages || isUpdating ? 'spin' : ''} />
</button>
{!isGroupChat(currentSession.username) && (
<button
className="icon-btn moments-btn"
onClick={() => window.electronAPI.window.openMomentsWindow(currentSession.username)}
title="查看朋友圈"
>
<Aperture size={18} />
</button>
)}
<button
className="icon-btn ai-summary-btn"
onClick={() => {
@@ -2361,6 +2370,81 @@ const videoInfoCache = new Map<string, {
// 最后一次增量更新时间戳
let lastIncrementalUpdateTime = 0
// 视频号卡片组件
function ChannelVideoCard({ info }: { info: { title: string; author: string; avatar?: string; thumbUrl?: string; coverUrl?: string; duration?: number } }) {
return (
<div className="channel-video-card">
<div className="channel-video-cover">
{info.coverUrl || info.thumbUrl ? (
<img src={info.coverUrl || info.thumbUrl} alt="" />
) : (
<div className="channel-video-cover-placeholder"><Video size={24} /></div>
)}
{info.duration && (
<span className="channel-video-duration">{Math.floor(info.duration / 60)}:{String(info.duration % 60).padStart(2, '0')}</span>
)}
</div>
<div className="channel-video-info">
<div className="channel-video-title">{info.title}</div>
<div className="channel-video-author">
{info.avatar && <img src={info.avatar} alt="" className="channel-video-avatar" />}
<span>{info.author}</span>
<span className="card-badge"></span>
</div>
</div>
</div>
)
}
function LinkThumb({ imageMd5, sessionId }: { imageMd5: string; sessionId: string }) {
const [src, setSrc] = useState('')
useEffect(() => {
let cancelled = false
window.electronAPI.image.decrypt({ sessionId, imageMd5 }).then(r => {
if (!cancelled && r.success && r.localPath) setSrc('file://' + r.localPath)
})
return () => { cancelled = true }
}, [imageMd5, sessionId])
if (!src) return <div className="link-thumb-placeholder"><Link size={24} /></div>
return <img className="link-thumb" src={src} alt="" />
}
function MiniProgramThumb({ imageMd5, sessionId, fallbackUrl, iconUrl }: { imageMd5: string; sessionId: string; fallbackUrl?: string; iconUrl?: string }) {
const [src, setSrc] = useState('')
const [failed, setFailed] = useState(false)
useEffect(() => {
let cancelled = false
window.electronAPI.image.decrypt({ sessionId, imageMd5 }).then(r => {
if (cancelled) return
if (r.success && r.localPath) setSrc('file://' + r.localPath)
else setFailed(true)
}).catch(() => { if (!cancelled) setFailed(true) })
return () => { cancelled = true }
}, [imageMd5, sessionId])
const imgSrc = src || (failed ? fallbackUrl : '')
if (imgSrc) return <img className="miniprogram-cover-img" src={imgSrc} alt="" referrerPolicy="no-referrer" />
if (failed && iconUrl) return <div className="miniprogram-cover-icon"><img src={iconUrl} alt="" referrerPolicy="no-referrer" /></div>
if (failed) return <div className="miniprogram-cover-placeholder" />
return null
}
function LinkSource({ username, name, badge }: { username: string; name: string; badge?: string }) {
const [avatar, setAvatar] = useState('')
useEffect(() => {
if (!username) return
window.electronAPI.chat.getContactAvatar(username).then(r => {
if (r?.avatarUrl) setAvatar(r.avatarUrl)
})
}, [username])
return (
<div className="link-source">
{avatar && <img className="link-source-avatar" src={avatar} alt="" referrerPolicy="no-referrer" />}
<span>{name}</span>
{badge && <span className="card-badge">{badge}</span>}
</div>
)
}
// 消息气泡组件
function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, hasImageKey, onContextMenu, isSelected, quoteStyle = 'default' }: {
message: Message;
@@ -2442,6 +2526,12 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
() => quotedImageCacheKey ? imageDataUrlCache.get(quotedImageCacheKey) : undefined
)
// 引用表情包缓存
const quotedEmojiCacheKey = message.quotedEmojiMd5 || ''
const [quotedEmojiLocalPath, setQuotedEmojiLocalPath] = useState<string | undefined>(
() => quotedEmojiCacheKey ? emojiDataUrlCache.get(quotedEmojiCacheKey) : undefined
)
const formatTime = (timestamp: number): string => {
const date = new Date(timestamp * 1000)
return date.toLocaleDateString('zh-CN', {
@@ -3107,6 +3197,28 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
enqueueDecrypt(doDecrypt)
}, [message.quotedImageMd5, quotedImageLocalPath, session.username])
// 引用表情包自动下载
useEffect(() => {
if (!message.quotedEmojiMd5 && !message.quotedEmojiCdnUrl) return
if (quotedEmojiLocalPath) return
const cdnUrl = message.quotedEmojiCdnUrl || ''
const md5 = message.quotedEmojiMd5 || ''
// 先检查缓存
if (md5 && emojiDataUrlCache.has(md5)) {
setQuotedEmojiLocalPath(emojiDataUrlCache.get(md5))
return
}
window.electronAPI.chat.downloadEmoji(cdnUrl, md5).then((result: any) => {
if (result.success && result.localPath) {
if (md5) emojiDataUrlCache.set(md5, result.localPath)
setQuotedEmojiLocalPath(result.localPath)
}
}).catch(() => {})
}, [message.quotedEmojiMd5, message.quotedEmojiCdnUrl, quotedEmojiLocalPath])
if (isSystem) {
// 系统类消息:包含“拍一拍”等 appmsg(type=62)
let systemText = message.parsedContent || '[系统消息]'
@@ -3150,11 +3262,11 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
if (hasQuote && quoteStyle === 'default') {
return (
<div className="bubble-content">
<div className="quoted-message">
<div className="quoted-message" onClick={(quotedImageLocalPath || quotedEmojiLocalPath) ? (e) => { e.stopPropagation(); window.electronAPI.window.openImageViewerWindow((quotedImageLocalPath || quotedEmojiLocalPath)!) } : undefined} style={(quotedImageLocalPath || quotedEmojiLocalPath) ? { cursor: 'pointer' } : undefined}>
<div className="quoted-message-content">
<div className="quoted-text-container">
{message.quotedSender && <span className="quoted-sender">{message.quotedSender}</span>}
<span className="quoted-text">{message.quotedContent}</span>
<span className="quoted-text">{(quotedImageLocalPath || quotedEmojiLocalPath) ? null : message.quotedContent}</span>
</div>
{quotedImageLocalPath && (
<div className="quoted-image-container">
@@ -3162,10 +3274,15 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
src={quotedImageLocalPath}
alt="引用图片"
className="quoted-image-thumb"
onClick={(e) => {
e.stopPropagation()
window.electronAPI.window.openImageViewerWindow(quotedImageLocalPath)
}}
/>
</div>
)}
{!quotedImageLocalPath && quotedEmojiLocalPath && (
<div className="quoted-image-container">
<img
src={quotedEmojiLocalPath}
alt="表情"
className="quoted-image-thumb"
/>
</div>
)}
@@ -3197,10 +3314,9 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
src={imageLocalPath}
alt="图片"
className="image-message"
onClick={async () => {
const liveVideo = await requestImageDecrypt(true)
onClick={() => {
if (imageLocalPath) {
window.electronAPI.window.openImageViewerWindow(imageLocalPath, liveVideo || imageLiveVideoPath)
window.electronAPI.window.openImageViewerWindow(imageLocalPath, imageLiveVideoPath)
}
}}
onLoad={() => setImageError(false)}
@@ -3514,6 +3630,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
let appMsgType = ''
let isPat = false
let textAnnouncement = ''
let cdnthumbmd5 = ''
let sourcedisplayname = ''
let sourceusername = ''
let coverPicUrl = ''
try {
const content = message.rawContent || message.parsedContent || ''
@@ -3529,8 +3649,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
appMsgType = doc.querySelector('appmsg > type')?.textContent || doc.querySelector('type')?.textContent || ''
isPat = appMsgType === '62' || Boolean(doc.querySelector('patinfo'))
textAnnouncement = doc.querySelector('textannouncement')?.textContent || ''
// 尝试获取缩略图 (这里只是简单的解析,实际上可能需要解密或者下载)
// 暂时只显示占位符,或者如果 url 是图片则显示
cdnthumbmd5 = doc.querySelector('cdnthumbmd5')?.textContent || ''
sourcedisplayname = doc.querySelector('sourcedisplayname')?.textContent || ''
sourceusername = doc.querySelector('sourceusername')?.textContent || ''
coverPicUrl = doc.querySelector('coverpicimageurl')?.textContent || ''
} catch (e) {
console.error('解析 AppMsg 失败:', e)
}
@@ -3743,6 +3865,175 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
}
}
// 红包消息 (type=2001)
if (appMsgType === '2001') {
try {
const content = message.rawContent || message.parsedContent || ''
const xmlStr = content.includes('<msg>') ? content.substring(content.indexOf('<msg>')) : content
const parser = new DOMParser()
const doc = parser.parseFromString(xmlStr, 'text/xml')
const greeting = doc.querySelector('receivertitle')?.textContent || doc.querySelector('sendertitle')?.textContent || ''
return (
<div className="hongbao-message">
<div className="hongbao-icon">
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
<rect x="4" y="6" width="32" height="28" rx="4" fill="white" fillOpacity="0.3" />
<rect x="4" y="6" width="32" height="14" rx="4" fill="white" fillOpacity="0.2" />
<circle cx="20" cy="20" r="6" fill="white" fillOpacity="0.4" />
<text x="20" y="24" textAnchor="middle" fill="white" fontSize="12" fontWeight="bold">¥</text>
</svg>
</div>
<div className="hongbao-info">
<div className="hongbao-greeting">{greeting || '恭喜发财,大吉大利'}</div>
<div className="hongbao-label"></div>
</div>
</div>
)
} catch {
return <div className="bubble-content"><MessageContent content={message.parsedContent} /></div>
}
}
// 微信礼物 (type=115)
if (appMsgType === '115') {
try {
const content = message.rawContent || ''
const xmlStr = content.includes('<msg>') ? content.substring(content.indexOf('<msg>')) : content
const parser = new DOMParser()
const doc = parser.parseFromString(xmlStr, 'text/xml')
const wish = doc.querySelector('wishmessage')?.textContent || '送你一份心意'
const skutitle = doc.querySelector('skutitle')?.textContent || ''
const skuimg = doc.querySelector('skuimgurl')?.textContent || ''
const skuprice = doc.querySelector('skuprice')?.textContent || ''
const priceYuan = skuprice ? (parseInt(skuprice) / 100).toFixed(2) : ''
return (
<div className="gift-message">
{skuimg && <img className="gift-img" src={skuimg} alt="" referrerPolicy="no-referrer" />}
<div className="gift-info">
<div className="gift-wish">{wish}</div>
{skutitle && <div className="gift-name">{skutitle}</div>}
{priceYuan && <div className="gift-price">¥{priceYuan}</div>}
<div className="gift-label"></div>
</div>
</div>
)
} catch {
return <div className="bubble-content"><MessageContent content={message.parsedContent} /></div>
}
}
// 音乐分享 (type=3)
if (appMsgType === '3') {
try {
const content = message.rawContent || ''
const xmlStr = content.includes('<msg>') ? content.substring(content.indexOf('<msg>')) : content
const parser = new DOMParser()
const doc = parser.parseFromString(xmlStr, 'text/xml')
const title = doc.querySelector('title')?.textContent || ''
const des = doc.querySelector('des')?.textContent || ''
const url = doc.querySelector('url')?.textContent || ''
const albumUrl = doc.querySelector('songalbumurl')?.textContent || ''
const appname = doc.querySelector('appname')?.textContent || ''
return (
<div className="music-message" onClick={() => url && window.electronAPI.shell.openExternal(url)}>
<div className="music-cover">
{albumUrl ? <img src={albumUrl} alt="" referrerPolicy="no-referrer" /> : <Play size={24} />}
</div>
<div className="music-info">
<div className="music-title">{title || '未知歌曲'}</div>
{des && <div className="music-artist">{des}</div>}
{appname && <div className="music-source">{appname}</div>}
</div>
</div>
)
} catch {
return <div className="bubble-content"><MessageContent content={message.parsedContent} /></div>
}
}
// 视频号消息 (type=51)
if (appMsgType === '51') {
try {
const content = message.rawContent || message.parsedContent || ''
const xmlStr = content.includes('<msg>') ? content.substring(content.indexOf('<msg>')) : content
const p = new DOMParser()
const d = p.parseFromString(xmlStr, 'text/xml')
const finder = d.querySelector('finderFeed')
if (finder) {
const getCDATA = (tag: string) => finder.querySelector(tag)?.textContent?.trim() || ''
const media = finder.querySelector('mediaList media')
const getMediaCDATA = (tag: string) => media?.querySelector(tag)?.textContent?.trim() || ''
const channelInfo = {
title: getCDATA('desc') || '视频号视频',
author: getCDATA('nickname'),
avatar: getCDATA('avatar'),
thumbUrl: getMediaCDATA('thumbUrl'),
coverUrl: getMediaCDATA('coverUrl'),
duration: parseInt(getMediaCDATA('videoPlayDuration')) || undefined,
}
return <ChannelVideoCard info={channelInfo} />
}
} catch (e) {
// fallthrough to generic link
}
}
// 小程序消息 (type=33 或 type=36)
if (appMsgType === '33' || appMsgType === '36') {
try {
const content = message.rawContent || message.parsedContent || ''
const xmlStr = content.includes('<msg>') ? content.substring(content.indexOf('<msg>')) : content
const p = new DOMParser()
const d = p.parseFromString(xmlStr, 'text/xml')
const weappinfo = d.querySelector('weappinfo')
const weappiconurl = weappinfo?.querySelector('weappiconurl')?.textContent?.trim() || ''
const thumbRawUrl = weappinfo?.querySelector('weapppagethumbrawurl')?.textContent?.trim() || ''
return (
<div className="miniprogram-card">
<div className="miniprogram-header">
{weappiconurl ? (
<img className="miniprogram-icon" src={weappiconurl} alt="" referrerPolicy="no-referrer" />
) : (
<div className="miniprogram-icon-placeholder" />
)}
<span className="miniprogram-name">{sourcedisplayname || '小程序'}</span>
</div>
<div className="miniprogram-title">{title}</div>
<div className="miniprogram-cover">
{cdnthumbmd5 && session ? (
<MiniProgramThumb imageMd5={cdnthumbmd5} sessionId={session.username} fallbackUrl={thumbRawUrl} iconUrl={weappiconurl} />
) : thumbRawUrl ? (
<img className="miniprogram-cover-img" src={thumbRawUrl} alt="" referrerPolicy="no-referrer" />
) : weappiconurl ? (
<div className="miniprogram-cover-icon"><img src={weappiconurl} alt="" referrerPolicy="no-referrer" /></div>
) : (
<div className="miniprogram-cover-placeholder" />
)}
</div>
<div className="miniprogram-footer">
<svg className="miniprogram-logo" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="7" cy="12" r="3" /><circle cx="17" cy="12" r="3" /><path d="M10 12h4" /></svg>
<span></span>
</div>
</div>
)
} catch (e) {
// fallthrough to generic link
}
}
if (url && coverPicUrl && appMsgType === '5') {
return (
<div className="link-message link-message--cover" onClick={(e) => { e.stopPropagation(); window.electronAPI.window.openBrowserWindow(url, title) }}>
<div className="link-cover">
<img src={coverPicUrl} alt="" referrerPolicy="no-referrer" />
</div>
<div className="link-header"><span className="link-title">{title}</span></div>
{sourcedisplayname ? <LinkSource username={sourceusername} name={sourcedisplayname} badge="公众号图文" /> : <div className="link-source"><span className="card-badge"></span></div>}
</div>
)
}
if (url) {
return (
<div
@@ -3758,15 +4049,83 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
</div>
<div className="link-body">
<div className="link-desc">{desc}</div>
<div className="link-thumb-placeholder">
<Link size={24} />
</div>
{cdnthumbmd5 && session ? (
<LinkThumb imageMd5={cdnthumbmd5} sessionId={session.username} />
) : (
<div className="link-thumb-placeholder"><Link size={24} /></div>
)}
</div>
{sourcedisplayname && <LinkSource username={sourceusername} name={sourcedisplayname} badge="公众号文章" />}
</div>
)
}
}
// 名片消息
if (message.localType === 42) {
const raw = message.rawContent || ''
const nickname = raw.match(/nickname="([^"]*)"/)?.[1] || '未知'
const avatar = raw.match(/bigheadimgurl="([^"]*)"/)?.[1] || raw.match(/smallheadimgurl="([^"]*)"/)?.[1]
const alias = raw.match(/alias="([^"]*)"/)?.[1]
const province = raw.match(/province="([^"]*)"/)?.[1]
return (
<div className="contact-card-message">
<div className="contact-card-avatar">
{avatar ? <img src={avatar} alt="" referrerPolicy="no-referrer" /> : <UserRound size={24} />}
</div>
<div className="contact-card-info">
<div className="contact-card-name">{nickname}</div>
{(alias || province) && <div className="contact-card-detail">{[alias, province].filter(Boolean).join(' · ')}</div>}
</div>
<div className="contact-card-badge"></div>
</div>
)
}
// 位置消息
if (message.localType === 48) {
const raw = message.rawContent || ''
const poiname = raw.match(/poiname="([^"]*)"/)?.[1] || ''
const label = raw.match(/label="([^"]*)"/)?.[1] || ''
const lat = parseFloat(raw.match(/x="([^"]*)"/)?.[1] || '0')
const lng = parseFloat(raw.match(/y="([^"]*)"/)?.[1] || '0')
const zoom = 15
const n = Math.pow(2, zoom)
const tileX = Math.floor((lng + 180) / 360 * n)
const tileY = Math.floor((1 - Math.log(Math.tan(lat * Math.PI / 180) + 1 / Math.cos(lat * Math.PI / 180)) / Math.PI) / 2 * n)
const tileUrl = `https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x=${tileX}&y=${tileY}&z=${zoom}`
return (
<div className="location-message" onClick={() => window.electronAPI.shell.openExternal(`https://uri.amap.com/marker?position=${lng},${lat}&name=${encodeURIComponent(poiname || label)}`)}>
<div className="location-text">
<MapPin size={16} className="location-icon" />
<div className="location-info">
{poiname && <div className="location-name">{poiname}</div>}
{label && <div className="location-label">{label}</div>}
</div>
</div>
{lat !== 0 && lng !== 0 && (
<div className="location-map">
<img src={tileUrl} alt="" referrerPolicy="no-referrer" />
<div className="location-pin"><MapPin size={20} fill="#e25b4a" color="#fff" /></div>
</div>
)}
</div>
)
}
// 通话消息
if (message.localType === 50) {
const raw = message.rawContent || ''
const isVideoCall = /<room_type>0<\/room_type>/.test(raw)
const Icon = isVideoCall ? Video : Phone
return (
<div className="bubble-content" style={{ display: 'flex', alignItems: 'center', gap: 6, flexDirection: isSent ? 'row-reverse' : 'row' }}>
<Icon size={16} style={{ transform: isSent ? 'scaleX(-1)' : undefined }} />
<span>{message.parsedContent}</span>
</div>
)
}
// 调试非文本类型的未适配消息
if (message.localType !== 1) {
console.log('[ChatPage] 未适配的消息:', message)
@@ -3823,20 +4182,24 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
{/* 引用消息 - 移至下方,单行显示 */}
{hasQuote && quoteStyle === 'wechat' && (
<div className="bubble-quote">
<div className="quote-content">
<div className="quote-content" onClick={(quotedImageLocalPath || quotedEmojiLocalPath) ? (e) => { e.stopPropagation(); window.electronAPI.window.openImageViewerWindow((quotedImageLocalPath || quotedEmojiLocalPath)!) } : undefined} style={(quotedImageLocalPath || quotedEmojiLocalPath) ? { cursor: 'pointer' } : undefined}>
<span className="quote-text">
{(() => {
// 尝试获取引用发送者:优先使用字段值,否则尝试从 xml 解析
let sender = message.quotedSender
if (!sender && message.rawContent) {
const match = message.rawContent.match(/<displayname>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/displayname>/)
if (match) sender = match[1]
}
return sender ? <span className="quote-sender">{sender}: </span> : null
})()}
{message.quotedContent}
{(quotedImageLocalPath || quotedEmojiLocalPath) ? null : message.quotedContent}
</span>
{quotedImageLocalPath && (
<img src={quotedImageLocalPath} alt="" className="quote-image-thumb" />
)}
{!quotedImageLocalPath && quotedEmojiLocalPath && (
<img src={quotedEmojiLocalPath} alt="表情" className="quote-image-thumb" />
)}
</div>
</div>
)}
+56 -34
View File
@@ -67,21 +67,11 @@
gap: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
&::before,
&::after {
&::before {
content: attr(data-tooltip);
position: absolute;
left: 50%;
transform: translateX(-50%);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1000;
}
// Tooltip Bubble (Downward)
&::before {
content: attr(data-tooltip);
top: 100%;
margin-top: 8px;
background: rgba(20, 20, 20, 0.9);
@@ -94,35 +84,22 @@
font-weight: normal;
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
// Tooltip Arrow (Pointing Up)
&::after {
content: "";
top: 100%;
margin-top: 3px;
width: 0;
height: 0;
border-width: 0 5px 5px 5px;
border-style: solid;
border-color: transparent transparent rgba(20, 20, 20, 0.9) transparent;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1000;
}
&:hover {
background: rgba(var(--primary-rgb, 139, 115, 85), 0.2);
transform: translateY(1px); // Subtle push down in title bar
transform: translateY(1px);
&::before {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(4px);
}
&::after {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(0);
}
}
&.active {
@@ -157,6 +134,15 @@
background: var(--border-color);
margin: 0 4px;
}
.image-counter {
font-size: 12px;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
min-width: 36px;
text-align: center;
}
}
}
@@ -202,10 +188,10 @@
left: 0;
width: 100%;
height: 100%;
object-fit: fill; // Fill the wrapper (which is sized by img)
pointer-events: none; // Passthrough mouse events to wrapper/img for drag
object-fit: fill;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s ease-in-out; // Smooth transition
transition: opacity 0.3s ease-in-out;
z-index: 2;
&.visible {
@@ -213,6 +199,42 @@
}
}
}
.nav-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 10;
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(8px);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease, background 0.2s ease;
&:hover {
background: rgba(0, 0, 0, 0.55);
}
}
.nav-prev {
left: 12px;
}
.nav-next {
right: 12px;
}
&:hover .nav-btn {
opacity: 1;
}
}
}
+76 -19
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom'
import { ZoomIn, ZoomOut, RotateCw, RotateCcw } from 'lucide-react'
import { ZoomIn, ZoomOut, RotateCw, RotateCcw, ChevronLeft, ChevronRight } from 'lucide-react'
import { LivePhotoIcon } from '../components/LivePhotoIcon'
import './ImageWindow.scss'
@@ -9,6 +9,16 @@ export default function ImageWindow() {
const [searchParams] = useSearchParams()
const imagePath = searchParams.get('imagePath')
const liveVideoPath = searchParams.get('liveVideoPath')
// 图片列表导航状态
const [imageList, setImageList] = useState<Array<{ imagePath: string; liveVideoPath?: string }>>([])
const [currentIndex, setCurrentIndex] = useState(0)
const activeImage = imageList.length > 0 ? imageList[currentIndex] : null
const currentImagePath = activeImage?.imagePath || imagePath
// 多图模式下只用列表中的 liveVideoPath,不回退到 URL 参数,避免非实况图也显示实况按钮
const currentLiveVideoPath = imageList.length > 0 ? activeImage?.liveVideoPath : liveVideoPath
const [scale, setScale] = useState(1)
const [rotation, setRotation] = useState(0)
const [position, setPosition] = useState({ x: 0, y: 0 })
@@ -48,7 +58,7 @@ export default function ImageWindow() {
// 播放 Live Photo
const handlePlayLiveVideo = useCallback(() => {
if (liveVideoPath && !isPlayingLive) {
if (currentLiveVideoPath && !isPlayingLive) {
setIsPlayingLive(true)
// 播放视频
if (videoRef.current) {
@@ -56,7 +66,7 @@ export default function ImageWindow() {
videoRef.current.play()
}
}
}, [liveVideoPath, isPlayingLive])
}, [currentLiveVideoPath, isPlayingLive])
// 视频真正开始播放(画面就绪)
const handleVideoPlaying = useCallback(() => {
@@ -72,6 +82,32 @@ export default function ImageWindow() {
}, 300)
}, [])
// 监听主进程发送的图片列表
useEffect(() => {
const cleanup = window.electronAPI?.window?.onImageListUpdate?.((data) => {
setImageList(data.imageList)
setCurrentIndex(data.currentIndex)
})
return () => cleanup?.()
}, [])
// 导航函数
const canGoPrev = imageList.length > 0 && currentIndex > 0
const canGoNext = imageList.length > 0 && currentIndex < imageList.length - 1
const goToImage = useCallback((newIndex: number) => {
if (newIndex < 0 || newIndex >= imageList.length) return
setCurrentIndex(newIndex)
setScale(1)
setRotation(0)
setPosition({ x: 0, y: 0 })
setIsPlayingLive(false)
setIsVideoVisible(false)
}, [imageList.length])
const goPrev = useCallback(() => { if (canGoPrev) goToImage(currentIndex - 1) }, [canGoPrev, currentIndex, goToImage])
const goNext = useCallback(() => { if (canGoNext) goToImage(currentIndex + 1) }, [canGoNext, currentIndex, goToImage])
// 监听窗口大小变化
useEffect(() => {
if (!viewportRef.current) return
@@ -113,20 +149,18 @@ export default function ImageWindow() {
setNaturalSize({ width: naturalWidth, height: naturalHeight })
// 请求调整窗口大小
// 目标:让视口内容区域(viewport)能容纳图片
// viewport = window - titlebar(40px)
const desiredWidth = naturalWidth
const desiredHeight = naturalHeight + 40 // +40px TitleBar
// 调用主进程调整窗口
// @ts-ignore
window.electronAPI?.window?.resizeContent?.(desiredWidth, desiredHeight)
// 多图模式下不调整窗口大小,避免切换时窗口跳动
if (imageList.length <= 1) {
const desiredWidth = naturalWidth
const desiredHeight = naturalHeight + 40
// @ts-ignore
window.electronAPI?.window?.resizeContent?.(desiredWidth, desiredHeight)
}
// 重置缩放和位置
setScale(1)
setPosition({ x: 0, y: 0 })
}, [])
}, [imageList.length])
// Use a ref to access latest state in event listeners without re-binding
const metaRef = useRef({
@@ -319,18 +353,20 @@ export default function ImageWindow() {
if (e.key === '-') handleZoomOut()
if (e.key === 'r' || e.key === 'R') handleRotate()
if (e.key === '0') handleReset()
if (e.key === ' ' && liveVideoPath) {
if (e.key === ' ' && currentLiveVideoPath) {
e.preventDefault()
handlePlayLiveVideo()
}
if (e.key === 'ArrowLeft') { e.preventDefault(); goPrev() }
if (e.key === 'ArrowRight') { e.preventDefault(); goNext() }
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleReset, liveVideoPath, isPlayingLive, handlePlayLiveVideo])
}, [handleReset, currentLiveVideoPath, isPlayingLive, handlePlayLiveVideo, goPrev, goNext])
const hasLiveVideo = !!liveVideoPath
const hasLiveVideo = !!currentLiveVideoPath
if (!imagePath) {
if (!currentImagePath) {
return (
<div className="image-window-empty">
<span></span>
@@ -372,6 +408,12 @@ export default function ImageWindow() {
<div className="divider"></div>
<button onClick={handleRotateCcw} title="逆时针旋转"><RotateCcw size={16} /></button>
<button onClick={handleRotate} title="顺时针旋转 (R)"><RotateCw size={16} /></button>
{imageList.length > 1 && (
<>
<div className="divider"></div>
<span className="image-counter">{currentIndex + 1} / {imageList.length}</span>
</>
)}
</div>
</div>
@@ -389,7 +431,7 @@ export default function ImageWindow() {
}}
>
<img
src={imagePath}
src={currentImagePath}
alt="Preview"
className={isPannable ? 'pannable' : ''}
onLoad={handleImageLoad}
@@ -399,7 +441,7 @@ export default function ImageWindow() {
{hasLiveVideo && isPlayingLive && (
<video
ref={videoRef}
src={liveVideoPath || ''}
src={currentLiveVideoPath || ''}
className={`live-video ${isVideoVisible ? 'visible' : ''}`}
autoPlay
// muted={false} // Default is unmuted, explicit false for clarity
@@ -408,6 +450,21 @@ export default function ImageWindow() {
/>
)}
</div>
{imageList.length > 1 && (
<>
{canGoPrev && (
<button className="nav-btn nav-prev" onClick={goPrev}>
<ChevronLeft size={28} />
</button>
)}
{canGoNext && (
<button className="nav-btn nav-next" onClick={goNext}>
<ChevronRight size={28} />
</button>
)}
</>
)}
</div>
</div>
)
+112 -69
View File
@@ -402,16 +402,7 @@
position: relative;
}
.sns-notice-banner {
background: rgba(255, 152, 0, 0.1);
color: #ff9800;
padding: 8px 16px;
font-size: 12px;
display: flex;
align-items: center;
gap: 8px;
border-bottom: 1px solid rgba(255, 152, 0, 0.2);
}
.moments-content {
flex: 1;
@@ -660,7 +651,7 @@
&.media-count-2,
&.media-count-4 {
grid-template-columns: repeat(2, 1fr);
max-width: 70%;
max-width: 50%;
}
&.media-count-3,
@@ -670,6 +661,7 @@
&.media-count-8,
&.media-count-9 {
grid-template-columns: repeat(3, 1fr);
max-width: 60%;
}
.media-item {
@@ -756,32 +748,18 @@
}
}
// Video Decrypting Overlay
.video-loading-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
color: white;
gap: 12px;
z-index: 5;
// 骨架屏:用于加载中和视口外的占位,带 shimmer 动画
.media-skeleton {
width: 100%;
height: 100%;
min-height: 80px;
border-radius: 8px;
.spin-icon {
animation: spin 1s linear infinite;
opacity: 0.9;
}
span {
font-size: 13px;
font-weight: 500;
letter-spacing: 0.5px;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}
background: linear-gradient(90deg,
var(--bg-tertiary) 25%,
var(--bg-secondary, rgba(128, 128, 128, 0.12)) 50%,
var(--bg-tertiary) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s ease-in-out infinite;
}
// Frosted glass live photo icon (bottom right)
@@ -827,6 +805,36 @@
min-height: 100px;
}
}
.download-btn-overlay {
position: absolute;
top: 6px;
right: 6px;
width: 28px;
height: 28px;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: all 0.2s ease;
cursor: pointer;
z-index: 4;
&:hover {
background: rgba(0, 0, 0, 0.5);
transform: scale(1.1);
}
}
&:hover .download-btn-overlay {
opacity: 1;
}
}
.video-badge-container,
@@ -841,36 +849,6 @@
gap: 4px;
}
.download-btn-overlay {
position: absolute;
top: 6px;
right: 6px;
width: 28px;
height: 28px;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: all 0.2s ease;
cursor: pointer;
z-index: 4;
&:hover {
background: rgba(0, 0, 0, 0.5);
transform: scale(1.1);
}
}
&:hover .download-btn-overlay {
opacity: 1;
}
}
.post-share-card {
@@ -1176,32 +1154,87 @@
align-items: center;
gap: 8px;
.export-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
margin: 0;
border-radius: 999px;
border: none;
background: var(--primary);
color: white;
font-size: 12px;
font-weight: 500;
line-height: 1;
cursor: pointer;
transition: filter 0.15s ease;
&:hover {
filter: brightness(1.15);
}
}
.divider {
width: 1px;
height: 16px;
background: var(--border-color);
}
button {
background: none;
border: none;
color: var(--text-primary); // Make sure it follows theme
color: var(--text-primary);
cursor: pointer;
padding: 6px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
&:hover {
background: var(--hover-bg); // Use theme variable
background: var(--hover-bg);
}
&.active {
background: var(--active-bg, rgba(0, 0, 0, 0.1));
color: var(--accent-color);
}
// 自定义 tooltip
&[data-tooltip] {
&::after {
content: attr(data-tooltip);
position: absolute;
top: calc(100% + 6px);
left: 50%;
transform: translateX(-50%) translateY(4px);
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease, transform 0.15s ease;
background: var(--tooltip-bg, rgba(0, 0, 0, 0.75));
color: var(--tooltip-color, #fff);
z-index: 999;
}
&:hover::after {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
}
}
.icon-btn {
width: 30px;
height: 30px;
}
}
// Modal and dialogs
.modal-overlay {
@@ -1624,4 +1657,14 @@
}
}
}
}
@keyframes skeleton-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
+265 -67
View File
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'
import { Loader2, RefreshCw, Search, Calendar, User, X, Filter, AlertTriangle, Play, Download, Heart, Copy, Link, Music, FileDown } from 'lucide-react'
import { ImagePreview } from '../components/ImagePreview'
import { LivePhotoIcon } from '../components/LivePhotoIcon'
import { parseWechatEmoji } from '../utils/wechatEmoji'
import { parseWechatEmoji, parseWechatEmojiHtml } from '../utils/wechatEmoji'
import TitleBar from '../components/TitleBar'
import JumpToDateDialog from '../components/JumpToDateDialog'
import DateRangePicker from '../components/DateRangePicker'
@@ -86,8 +86,11 @@ const formatXml = (xml: string) => {
}
}
// 缓存已解密的媒体路径,用于构建图片列表传给查看器
const mediaPathCache = new Map<string, { imagePath: string; liveVideoPath?: string }>()
// 媒体项组件
const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: any; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void; onMediaDeleted?: () => void }) => {
const MediaItem = ({ media, isSingle, allMedia, onPreview, onMediaDeleted }: { media: any; isSingle?: boolean; allMedia?: any[]; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void; onMediaDeleted?: () => void }) => {
const [error, setError] = useState(false)
const [deleted, setDeleted] = useState(false)
const [thumbSrc, setThumbSrc] = useState<string>('')
@@ -97,12 +100,42 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: any; onPreview
const [isVisible, setIsVisible] = useState(false)
const imgRef = useRef<HTMLDivElement>(null)
const { url, thumb, livePhoto } = media
const { url, thumb, livePhoto, width: rawWidth, height: rawHeight } = media
const isLive = !!livePhoto
const targetUrl = thumb || url
const isVideo = url && isVideoUrl(url)
// 骨架屏宽高比和具体尺寸(用于单图模式,防止 max-content width 退化为 0
let skeletonStyle: React.CSSProperties | undefined = undefined
if (rawWidth && rawHeight && rawWidth > 0 && rawHeight > 0) {
if (isSingle) {
// 单图模式下,根据原始宽高和最大容器限制计算展示宽高
const MAX_W = 260
const MAX_H = 400
let w = rawWidth
let h = rawHeight
if (w > MAX_W) {
h = h * (MAX_W / w)
w = MAX_W
}
if (h > MAX_H) {
w = w * (MAX_H / h)
h = MAX_H
}
skeletonStyle = { width: w, height: h }
} else {
skeletonStyle = { aspectRatio: `${rawWidth} / ${rawHeight}` }
}
} else {
// 缺失宽高参数时的 fallback 骨架屏,确保一定有占位
if (isSingle) {
skeletonStyle = { width: 260, height: 260 }
} else {
skeletonStyle = { aspectRatio: '1 / 1' }
}
}
// Intersection Observer 懒加载
useEffect(() => {
if (!imgRef.current) return
@@ -224,21 +257,24 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: any; onPreview
if (cancelled) return
if (result.success) {
let resolvedPath = ''
if (result.localPath) {
// 使用本地文件路径(file:// 协议)
const localUrl = result.localPath.startsWith('file:')
resolvedPath = result.localPath.startsWith('file:')
? result.localPath
: `file://${result.localPath.replace(/\\/g, '/')}`
setThumbSrc(localUrl)
} else if (result.dataUrl) {
// 回退:使用 base64 dataUrl
setThumbSrc(result.dataUrl)
resolvedPath = result.dataUrl
} else if (result.videoPath) {
// 兼容:某些情况下可能返回 videoPath
const localUrl = result.videoPath.startsWith('file:')
resolvedPath = result.videoPath.startsWith('file:')
? result.videoPath
: `file://${result.videoPath.replace(/\\/g, '/')}`
setThumbSrc(localUrl)
}
if (resolvedPath) {
setThumbSrc(resolvedPath)
mediaPathCache.set(targetUrl, { imagePath: resolvedPath })
}
} else {
setDeleted(true)
@@ -252,16 +288,23 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: any; onPreview
}).then((res: any) => {
if (cancelled) return
if (res.success) {
let liveLocalUrl = ''
if (res.videoPath) {
const localUrl = res.videoPath.startsWith('file:')
liveLocalUrl = res.videoPath.startsWith('file:')
? res.videoPath
: `file://${res.videoPath.replace(/\\/g, '/')}`
setLiveVideoPath(localUrl)
} else if (res.localPath) {
const localUrl = res.localPath.startsWith('file:')
liveLocalUrl = res.localPath.startsWith('file:')
? res.localPath
: `file://${res.localPath.replace(/\\/g, '/')}`
setLiveVideoPath(localUrl)
}
if (liveLocalUrl) {
setLiveVideoPath(liveLocalUrl)
// 更新缓存中的 liveVideoPath
const cached = mediaPathCache.get(targetUrl)
if (cached) {
cached.liveVideoPath = liveLocalUrl
}
}
}
}).catch((e: any) => { })
@@ -313,10 +356,27 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: any; onPreview
// 图片:使用图片查看器窗口
if (thumbSrc) {
const localPath = thumbSrc.replace(/^file:\/\//, '')
// 如果是 Live Photo,传递视频路径(即使还没加载完也传递,让查看器知道这是 Live Photo)
const liveVideoLocalPath = isLive && liveVideoPath ? liveVideoPath.replace(/^file:\/\//, '') : undefined
await window.electronAPI.window.openImageViewerWindow(localPath, liveVideoLocalPath)
// 从缓存构建同一条动态的图片列表
let imageList: Array<{ imagePath: string; liveVideoPath?: string }> | undefined
if (allMedia && allMedia.length > 1) {
const list: Array<{ imagePath: string; liveVideoPath?: string }> = []
for (const m of allMedia) {
if (m.url && isVideoUrl(m.url)) continue // 跳过视频
const key = m.thumb || m.url
const cached = mediaPathCache.get(key)
if (cached) {
list.push({
imagePath: cached.imagePath.replace(/^file:\/\//, ''),
liveVideoPath: cached.liveVideoPath?.replace(/^file:\/\//, '')
})
}
}
if (list.length > 1) imageList = list
}
await window.electronAPI.window.openImageViewerWindow(localPath, liveVideoLocalPath, imageList)
}
}
} catch (error) {
@@ -328,7 +388,11 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: any; onPreview
if (deleted) {
return (
<div ref={imgRef} className="media-item deleted-media">
<div
ref={imgRef}
className="media-item deleted-media"
style={skeletonStyle}
>
<div className="deleted-placeholder">
<AlertTriangle size={24} />
<span></span>
@@ -341,17 +405,12 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: any; onPreview
<div
ref={imgRef}
className={`media-item ${error ? 'error' : ''} ${isVideo && isDecrypting ? 'decrypting' : ''}`}
style={!displaySrc && skeletonStyle ? skeletonStyle : undefined}
onClick={handleClick}
>
{!isVisible ? (
<div className="media-placeholder">
<Loader2 size={20} className="spin" />
</div>
) : isVideo && isDecrypting ? (
<div className="video-loading-overlay">
<RefreshCw size={24} className="spin-icon" />
<span>...</span>
</div>
// 尚未进入视口:骨架屏占位(保持宽高比)
<div className="media-skeleton" />
) : displaySrc ? (
<img
src={displaySrc}
@@ -366,9 +425,8 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: any; onPreview
<span></span>
</div>
) : (
<div className="media-placeholder loading-state">
<Loader2 size={24} className="spin" />
</div>
// 可见但仍在加载中/解密中:shimmer 骨架屏
<div className="media-skeleton" />
)}
{isVisible && isVideo && !isDecrypting && (
@@ -469,6 +527,77 @@ const ShareThumb = ({ shareInfo }: { shareInfo: SnsShareInfo }) => {
)
}
// 表情包内存缓存:url/encryptUrl → file:// 本地路径
const emojiCache = new Map<string, string>()
// 评论表情包组件(先查内存缓存,再查本地文件,最后才下载)
const CommentEmoji = ({ emoji, onPreview }: {
emoji: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }
onPreview?: (src: string) => void
}) => {
const cacheKey = emoji.encryptUrl || emoji.url
const [localSrc, setLocalSrc] = useState<string>(() => emojiCache.get(cacheKey) || '')
useEffect(() => {
if (!cacheKey) return
if (emojiCache.has(cacheKey)) {
setLocalSrc(emojiCache.get(cacheKey)!)
return
}
let cancelled = false
const load = async () => {
try {
const res = await window.electronAPI.sns.downloadEmoji({
url: emoji.url,
encryptUrl: emoji.encryptUrl,
aesKey: emoji.aesKey
})
if (cancelled) return
if (res.success && res.localPath) {
const fileUrl = res.localPath.startsWith('file:')
? res.localPath
: `file://${res.localPath.replace(/\\/g, '/')}`
emojiCache.set(cacheKey, fileUrl)
setLocalSrc(fileUrl)
}
} catch (e) {
// 静默失败
}
}
load()
return () => { cancelled = true }
}, [cacheKey])
if (!localSrc) return null
return (
<img
src={localSrc}
alt="emoji"
className="comment-custom-emoji"
draggable={false}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onPreview?.(localSrc)
}}
style={{
width: Math.min(emoji.width || 36, 30),
height: Math.min(emoji.height || 36, 30),
verticalAlign: 'middle',
marginLeft: 2,
borderRadius: 6,
cursor: 'pointer',
pointerEvents: 'auto',
position: 'relative',
zIndex: 5
}}
/>
)
}
// 朋友圈长文折叠展开组件
const ExpandableText = ({ content }: { content: string }) => {
const [isExpanded, setIsExpanded] = useState(false)
@@ -522,11 +651,15 @@ function MomentsWindow() {
// 筛选状态
const [searchKeyword, setSearchKeyword] = useState('')
const [selectedUsernames, setSelectedUsernames] = useState<string[]>([])
const [selectedUsernames, setSelectedUsernames] = useState<string[]>(() => {
const p = new URLSearchParams(window.location.search)
const u = p.get('filterUsername')
return u ? [u] : []
})
const [jumpTargetDate, setJumpTargetDate] = useState<Date | undefined>(undefined)
const [contacts, setContacts] = useState<Contact[]>([])
const [contactSearch, setContactSearch] = useState('')
const [isSidebarOpen, setIsSidebarOpen] = useState(true)
const [isSidebarOpen, setIsSidebarOpen] = useState(() => !new URLSearchParams(window.location.search).get('filterUsername'))
const [showJumpDialog, setShowJumpDialog] = useState(false)
// 其他状态
@@ -549,6 +682,14 @@ function MomentsWindow() {
const sentinelRef = useRef<HTMLDivElement>(null)
const isInitialLoad = useRef(true)
// 监听已有窗口收到的筛选消息
useEffect(() => {
const cleanup = window.electronAPI?.window?.onMomentsFilterUser?.((username: string) => {
setSelectedUsernames([username])
})
return () => cleanup?.()
}, [])
// 处理滚动,当有新筛选项时回滚到顶部
useEffect(() => {
if (!isInitialLoad.current) {
@@ -746,13 +887,18 @@ function MomentsWindow() {
if (exporting) return
try {
// 弹出保存对话框
const result = await window.electronAPI.dialog.saveFile({
title: '导出朋友圈',
defaultPath: `朋友圈导出_${new Date().toISOString().slice(0, 10)}.html`,
filters: [{ name: 'HTML', extensions: ['html'] }]
// 弹出选择目录对话框
const result = await window.electronAPI.dialog.openFile({
title: '选择导出目录',
properties: ['openDirectory']
})
if (!result || result.canceled || !result.filePath) return
if (!result || result.canceled || !result.filePaths || result.filePaths.length === 0) return
// 在选择的目录下创建带时间戳的子文件夹
const now = new Date()
const ts = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`
const exportDirName = `朋友圈_${ts}`
const exportDir = `${result.filePaths[0].replace(/\\/g, '/')}/${exportDirName}`
setExporting(true)
setExportProgress({ current: 0, total: 0, status: '正在获取动态数据...' })
@@ -793,32 +939,58 @@ function MomentsWindow() {
// 第二阶段:如果需要图片/视频,批量下载到同级 media 目录
const imageCache = new Map<string, string>() // url -> 相对路径 (media/xxx.jpg)
const avatarMap = new Map<string, string>() // username -> 相对路径 (media/avatar_xxx.jpg)
const emojiCache = new Map<string, string>() // url/encryptUrl -> 相对路径
const htmlDir = result.filePath.replace(/\\/g, '/').replace(/\/[^/]+$/, '')
const htmlDir = exportDir
const mediaDir = `${htmlDir}/media`
if (exportOptions.includeImages) {
// 收集所有媒体头像
const allMediaUrls: { url: string; key?: string; type: 'media' | 'avatar'; username?: string }[] = []
// 收集所有媒体头像和表情包
const allMediaUrls: { url: string; key?: string; type: 'media' | 'avatar' | 'emoji'; username?: string; md5?: string; encryptUrl?: string; aesKey?: string }[] = []
// 1. 媒体
// 1. 媒体(使用 md5 或 URL 作为唯一标识去重)
const mediaUrlSet = new Set<string>()
for (const p of allPosts) {
if (p.media) {
for (const m of p.media) {
const thumbUrl = m.thumb || m.url
if (thumbUrl) {
allMediaUrls.push({ url: thumbUrl, key: m.key, type: 'media' })
if (thumbUrl && !mediaUrlSet.has(thumbUrl)) {
mediaUrlSet.add(thumbUrl)
allMediaUrls.push({ url: thumbUrl, key: m.key, type: 'media', md5: m.md5 })
}
// 普通视频
if (m.url && isVideoUrl(m.url) && m.url !== thumbUrl) {
allMediaUrls.push({ url: m.url, key: m.key, type: 'media' })
if (m.url && isVideoUrl(m.url) && m.url !== thumbUrl && !mediaUrlSet.has(m.url)) {
mediaUrlSet.add(m.url)
allMediaUrls.push({ url: m.url, key: m.key, type: 'media', md5: m.md5 })
}
// 实况照片视频
if (m.livePhoto && m.livePhoto.url) {
if (m.livePhoto && m.livePhoto.url && !mediaUrlSet.has(m.livePhoto.url)) {
mediaUrlSet.add(m.livePhoto.url)
allMediaUrls.push({ url: m.livePhoto.url, key: m.livePhoto.key || m.key, type: 'media' })
}
}
}
// 收集评论中的表情包
if (p.comments) {
for (const c of p.comments) {
if (c.emojis) {
for (const emoji of c.emojis) {
const emojiId = emoji.md5 || emoji.encryptUrl || emoji.url
if (emojiId && !mediaUrlSet.has(emojiId)) {
mediaUrlSet.add(emojiId)
allMediaUrls.push({
type: 'emoji',
url: emoji.url,
encryptUrl: emoji.encryptUrl,
aesKey: emoji.aesKey,
md5: emoji.md5
})
}
}
}
}
}
}
// 2. 唯一头像
@@ -844,13 +1016,24 @@ function MomentsWindow() {
url: item.url,
key: item.key,
outputDir: htmlDir,
index: globalIdx
index: globalIdx,
md5: item.md5,
isAvatar: item.type === 'avatar',
username: item.username,
isEmoji: item.type === 'emoji',
encryptUrl: item.encryptUrl,
aesKey: item.aesKey
})
if (res.success && res.fileName) {
if (item.type === 'media') {
imageCache.set(item.url, `media/${res.fileName}`)
} else if (item.username) {
} else if (item.type === 'avatar' && item.username) {
avatarMap.set(item.username, `media/${res.fileName}`)
} else if (item.type === 'emoji') {
const cacheKey = item.encryptUrl || item.url
if (cacheKey) {
emojiCache.set(cacheKey, `media/${res.fileName}`)
}
}
}
} catch (e) {
@@ -937,11 +1120,21 @@ function MomentsWindow() {
let commentsHtml = ''
if (p.comments && p.comments.length > 0) {
const items = p.comments.map((c: any) => {
const items = await Promise.all(p.comments.map(async (c: any) => {
const reply = c.refNickname ? `<span class="re">回复</span><b>${escHtml(c.refNickname)}</b>` : ''
return `<div class="cmt"><b>${escHtml(c.nickname)}</b>${reply}${escHtml(c.content)}</div>`
}).join('')
commentsHtml = `<div class="interactions${p.likes.length > 0 ? ' cmt-border' : ''}"><div class="cmts">${items}</div></div>`
let emojiHtml = ''
if (c.emojis && c.emojis.length > 0) {
emojiHtml = c.emojis.map((emoji: any) => {
const cacheKey = emoji.encryptUrl || emoji.url
const fileUrl = imageCache.get(cacheKey) || emojiCache.get(cacheKey) || '' // 对于导出的表情包,我们可能需要使用对应的路径,由于表情包独立下载可能没存到导出的媒体库,先保留内存路径
if (!fileUrl) return ''
return `<img src="${escHtml(fileUrl)}" style="width: ${Math.min(emoji.width || 36, 48)}px; height: ${Math.min(emoji.height || 36, 48)}px; vertical-align: middle; margin-left: 2px; border-radius: 6px; cursor: pointer; pointer-events: auto;" onclick="openLightbox('${escHtml(fileUrl)}')" />`
}).join('')
}
const cContentHtml = await parseWechatEmojiHtml(c.content)
return `<div class="cmt"><b>${escHtml(c.nickname)}</b>${reply}${cContentHtml}${emojiHtml}</div>`
}))
commentsHtml = `<div class="interactions${p.likes.length > 0 ? ' cmt-border' : ''}"><div class="cmts">${items.join('')}</div></div>`
}
const avatarFile = p.username ? avatarMap.get(p.username) : null
@@ -949,6 +1142,8 @@ function MomentsWindow() {
? `<div class="avatar"><img src="${avatarFile}" alt=""></div>`
: `<div class="avatar">${escHtml(p.nickname[0] || '?')}</div>`
const pContentHtml = await parseWechatEmojiHtml(p.contentDesc)
postsHtml += `
<div class="post">
${avatarHtml}
@@ -957,7 +1152,7 @@ function MomentsWindow() {
<span class="nick">${escHtml(p.nickname)}</span>
<span class="tm">${formatDate(p.createTime)}</span>
</div>
${p.contentDesc ? `<div class="txt">${escHtml(p.contentDesc)}</div>` : ''}
${p.contentDesc ? `<div class="txt">${pContentHtml}</div>` : ''}
${mediaHtml}
${shareHtml}
${likesHtml}
@@ -1153,15 +1348,15 @@ document.querySelectorAll('.vi video').forEach(function(v) {
</body>
</html>`
// 通过 IPC 直接写入用户选择的路径
// 写入 index.html 到导出目录
const htmlFilePath = `${exportDir}/index.html`
setExportProgress({ current: allPosts.length, total: allPosts.length, status: '正在写入文件...' })
const writeResult = await window.electronAPI.sns.writeExportFile(result.filePath, fullHtml)
const writeResult = await window.electronAPI.sns.writeExportFile(htmlFilePath, fullHtml)
if (writeResult.success) {
setExportProgress({ current: allPosts.length, total: allPosts.length, status: '导出完成!' })
// 导出完成后打开文件所在目录
const dir = result.filePath.replace(/\\/g, '/').replace(/\/[^/]+$/, '')
window.electronAPI.shell.openPath(dir)
// 导出完成后打开导出目录
window.electronAPI.shell.openPath(exportDir)
} else {
setExportProgress({ current: 0, total: 0, status: `写入失败: ${writeResult.error}` })
}
@@ -1192,14 +1387,19 @@ document.querySelectorAll('.vi video').forEach(function(v) {
title="朋友圈"
rightContent={
<div className="title-actions">
<button className="export-btn" onClick={() => setShowExportOptions(true)}>
<FileDown size={14} />
<span></span>
</button>
<div className="divider"></div>
<button
className={`icon-btn ${isSidebarOpen ? 'active' : ''}`}
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
title={isSidebarOpen ? "收起筛选" : "打开筛选"}
data-tooltip={isSidebarOpen ? "收起筛选" : "打开筛选"}
>
<Filter size={16} />
</button>
<button onClick={() => loadPosts({ reset: true })} disabled={isLoading} className="refresh-btn" title="刷新">
<button onClick={() => loadPosts({ reset: true })} disabled={isLoading} className="refresh-btn" data-tooltip="刷新">
<RefreshCw size={16} className={isLoading ? 'spinning' : ''} />
</button>
</div>
@@ -1303,21 +1503,12 @@ document.querySelectorAll('.vi video').forEach(function(v) {
<RefreshCw size={14} />
</button>
<button className="export-btn" onClick={() => setShowExportOptions(true)}>
<FileDown size={14} />
</button>
</div>
</aside>
{/* 主内容区 */}
<div className="moments-main">
<div className="moments-content-wrapper">
<div className="sns-notice-banner">
<AlertTriangle size={16} />
<span></span>
</div>
<div className="moments-content custom-scrollbar">
{isLoading ? (
<div className="moments-loading">
@@ -1388,6 +1579,8 @@ document.querySelectorAll('.vi video').forEach(function(v) {
<MediaItem
key={idx}
media={m}
isSingle={post.media.length === 1}
allMedia={post.media}
onPreview={(src, isVideo, liveVideoPath) => setPreviewImage({ src, isVideo, liveVideoPath })}
onMediaDeleted={[1, 54].includes(post.type ?? 0) ? () => setDeletedPostIds(prev => new Set(prev).add(post.id)) : undefined}
/>
@@ -1461,7 +1654,12 @@ document.querySelectorAll('.vi video').forEach(function(v) {
</>
)}
<span className="comment-separator">: </span>
<span className="comment-content">{parseWechatEmoji(comment.content)}</span>
<span className="comment-content">
{comment.content && parseWechatEmoji(comment.content)}
{comment.emojis && comment.emojis.map((emoji: any, eidx: number) => (
<CommentEmoji key={eidx} emoji={emoji} onPreview={(src) => setPreviewImage({ src })} />
))}
</span>
</div>
))}
</div>
+55 -63
View File
@@ -1168,18 +1168,10 @@ function SettingsPage() {
}
setIsGettingImageKey(true)
setImageKeyStatus('正在检查微信进程...')
setImageKeyStatus('正在从缓存目录扫描图片密钥...')
try {
const isRunning = await window.electronAPI.wxKey.isWeChatRunning()
if (!isRunning) {
showMessage('请先启动微信并登录', false)
setImageKeyStatus('')
setIsGettingImageKey(false)
return
}
// 构建用户目录路径
// 构建用户目录路径(用于 wxid 匹配)
const userDir = `${dbPath}\\${wxid}`
const removeListener = window.electronAPI.imageKey.onProgress((msg) => {
@@ -1227,12 +1219,12 @@ function SettingsPage() {
const [isDownloadingWhisperModel, setIsDownloadingWhisperModel] = useState(false)
const [whisperDownloadProgress, setWhisperDownloadProgress] = useState(0)
const [useWhisperGpu, setUseWhisperGpu] = useState(false)
// GPU 组件状态
const [gpuComponentsStatus, setGpuComponentsStatus] = useState<{ installed: boolean; missingFiles?: string[]; gpuDir?: string } | null>(null)
const [isDownloadingGpuComponents, setIsDownloadingGpuComponents] = useState(false)
const [gpuDownloadProgress, setGpuDownloadProgress] = useState({ overallProgress: 0, currentFile: '' })
// ========== STT 模式切换 ==========
const [sttMode, setSttMode] = useState<'cpu' | 'gpu'>('cpu')
@@ -1245,12 +1237,12 @@ function SettingsPage() {
checkGpuComponents()
}
}, [activeTab])
const loadSttMode = async () => {
const savedMode = await window.electronAPI.config.get('sttMode') as 'cpu' | 'gpu' | undefined
setSttMode(savedMode || 'cpu')
}
const handleSttModeChange = async (mode: 'cpu' | 'gpu') => {
setSttMode(mode)
await window.electronAPI.config.set('sttMode', mode)
@@ -1414,7 +1406,7 @@ function SettingsPage() {
const handleDownloadGpuComponents = async () => {
if (isDownloadingGpuComponents) return
// 检查是否设置了缓存目录
if (!cachePath) {
showMessage('请先设置缓存目录', false)
@@ -1462,14 +1454,14 @@ function SettingsPage() {
<div className="tab-content">
{/* STT 模式切换器 */}
<div className="theme-mode-toggle" style={{ marginBottom: '2rem' }}>
<button
className={`mode-btn ${sttMode === 'cpu' ? 'active' : ''}`}
<button
className={`mode-btn ${sttMode === 'cpu' ? 'active' : ''}`}
onClick={() => handleSttModeChange('cpu')}
>
<Layers size={16} /> CPU
</button>
<button
className={`mode-btn ${sttMode === 'gpu' ? 'active' : ''}`}
<button
className={`mode-btn ${sttMode === 'gpu' ? 'active' : ''}`}
onClick={() => handleSttModeChange('gpu')}
>
<Zap size={16} /> GPU
@@ -1627,9 +1619,9 @@ function SettingsPage() {
</p>
{/* GPU 状态卡片 */}
<div className="gpu-status-card" style={{
padding: '1rem',
background: 'var(--bg-secondary)',
<div className="gpu-status-card" style={{
padding: '1rem',
background: 'var(--bg-secondary)',
borderRadius: '12px',
marginBottom: '1.5rem',
border: '1px solid var(--border-color)'
@@ -1656,9 +1648,9 @@ function SettingsPage() {
</div>
{/* GPU 组件状态 */}
<div className="gpu-components-card" style={{
padding: '1.25rem',
background: 'var(--bg-secondary)',
<div className="gpu-components-card" style={{
padding: '1.25rem',
background: 'var(--bg-secondary)',
borderRadius: '12px',
marginBottom: '1.5rem',
border: '1px solid var(--border-color)',
@@ -1666,10 +1658,10 @@ function SettingsPage() {
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.75rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div style={{
width: '32px',
height: '32px',
borderRadius: '8px',
<div style={{
width: '32px',
height: '32px',
borderRadius: '8px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
@@ -1680,11 +1672,11 @@ function SettingsPage() {
<strong style={{ fontSize: '15px' }}>GPU </strong>
</div>
{gpuComponentsStatus?.installed ? (
<span style={{
fontSize: '13px',
color: 'var(--success-color)',
display: 'flex',
alignItems: 'center',
<span style={{
fontSize: '13px',
color: 'var(--success-color)',
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.75rem',
background: 'var(--success-bg)',
@@ -1694,11 +1686,11 @@ function SettingsPage() {
<CheckCircle size={16} />
</span>
) : (
<span style={{
fontSize: '13px',
color: 'var(--warning-color)',
display: 'flex',
alignItems: 'center',
<span style={{
fontSize: '13px',
color: 'var(--warning-color)',
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.75rem',
background: 'var(--warning-bg)',
@@ -1709,11 +1701,11 @@ function SettingsPage() {
</span>
)}
</div>
{gpuComponentsStatus?.installed ? (
<div style={{
padding: '0.75rem',
background: 'var(--bg-tertiary)',
<div style={{
padding: '0.75rem',
background: 'var(--bg-tertiary)',
borderRadius: '8px',
fontSize: '13px',
color: 'var(--text-secondary)',
@@ -1726,9 +1718,9 @@ function SettingsPage() {
</div>
) : (
<>
<div style={{
padding: '0.75rem',
background: 'var(--bg-tertiary)',
<div style={{
padding: '0.75rem',
background: 'var(--bg-tertiary)',
borderRadius: '8px',
marginBottom: '1rem'
}}>
@@ -1743,18 +1735,18 @@ function SettingsPage() {
</div>
{isDownloadingGpuComponents ? (
<div>
<div style={{
marginBottom: '0.75rem',
fontSize: '13px',
<div style={{
marginBottom: '0.75rem',
fontSize: '13px',
color: 'var(--text-primary)',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: '0.5rem'
}}>
<div className="spinner" style={{
width: '14px',
height: '14px',
<div className="spinner" style={{
width: '14px',
height: '14px',
border: '2px solid var(--border-color)',
borderTopColor: 'var(--primary-color)',
borderRadius: '50%',
@@ -1762,14 +1754,14 @@ function SettingsPage() {
}} />
{gpuDownloadProgress.currentFile}
</div>
<div style={{
background: 'var(--bg-tertiary)',
borderRadius: '8px',
<div style={{
background: 'var(--bg-tertiary)',
borderRadius: '8px',
overflow: 'hidden',
height: '8px',
position: 'relative'
}}>
<div style={{
<div style={{
width: `${gpuDownloadProgress.overallProgress}%`,
height: '100%',
background: 'linear-gradient(90deg, #667eea 0%, #764ba2 100%)',
@@ -1788,10 +1780,10 @@ function SettingsPage() {
}} />
</div>
</div>
<div style={{
marginTop: '0.75rem',
fontSize: '13px',
textAlign: 'center',
<div style={{
marginTop: '0.75rem',
fontSize: '13px',
textAlign: 'center',
color: 'var(--text-secondary)',
fontWeight: 500
}}>
@@ -1799,11 +1791,11 @@ function SettingsPage() {
</div>
</div>
) : (
<button
<button
className="btn-primary"
onClick={handleDownloadGpuComponents}
style={{
width: '100%',
style={{
width: '100%',
padding: '0.75rem 1rem',
borderRadius: '9999px',
display: 'flex',
+4 -5
View File
@@ -663,11 +663,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
<h3></h3>
<p></p>
<ul className="info-list">
<li>"自动获取"</li>
<li></li>
<li></li>
<li></li>
<li></li>
<li>"自动获取"</li>
<li></li>
<li> wxid </li>
<li></li>
</ul>
</div>
)}
+43 -3
View File
@@ -1,6 +1,11 @@
import type { ChatSession, Message, Contact, ContactInfo } from './models'
import type { SummaryResult } from './ai'
export interface ImageListItem {
imagePath: string
liveVideoPath?: string
}
export interface ElectronAPI {
window: {
minimize: () => void
@@ -9,7 +14,8 @@ export interface ElectronAPI {
splashReady: () => void
onSplashFadeOut?: (callback: () => void) => () => void
openChatWindow: () => Promise<boolean>
openMomentsWindow: () => Promise<boolean>
openMomentsWindow: (filterUsername?: string) => Promise<boolean>
onMomentsFilterUser: (callback: (username: string) => void) => () => void
openGroupAnalyticsWindow: () => Promise<boolean>
openAnnualReportWindow: (year: number) => Promise<boolean>
openAgreementWindow: () => Promise<boolean>
@@ -19,12 +25,13 @@ export interface ElectronAPI {
isChatWindowOpen: () => Promise<boolean>
closeChatWindow: () => Promise<boolean>
setTitleBarOverlay: (options: { symbolColor: string }) => void
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => Promise<void>
openImageViewerWindow: (imagePath: string, liveVideoPath?: string, imageList?: ImageListItem[]) => Promise<void>
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise<void>
openBrowserWindow: (url: string, title?: string) => Promise<void>
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
openAISummaryWindow: (sessionId: string, sessionName: string) => Promise<boolean>
openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean>
onImageListUpdate: (callback: (data: { imageList: ImageListItem[], currentIndex: number }) => void) => () => void
}
config: {
get: (key: string) => Promise<unknown>
@@ -192,6 +199,34 @@ export interface ElectronAPI {
error?: string
md5?: string
}>
parseChannelVideo: (content: string) => Promise<{
success: boolean
error?: string
videoInfo?: {
objectId: string
title: string
author: string
avatar?: string
videoUrl: string
thumbUrl?: string
coverUrl?: string
duration?: number
width?: number
height?: number
}
}>
downloadChannelVideo: (videoInfo: any, key?: string) => Promise<{
success: boolean
filePath?: string
error?: string
needsKey?: boolean
}>
onDownloadProgress: (callback: (progress: {
objectId: string
downloaded: number
total: number
percentage: number
}) => void) => () => void
}
imageKey: {
getImageKeys: (userDir: string) => Promise<{ success: boolean; xorKey?: number; aesKey?: string; error?: string }>
@@ -316,8 +351,13 @@ export interface ElectronAPI {
success: boolean
error?: string
}>
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => Promise<{
success: boolean
localPath?: string
error?: string
}>
writeExportFile: (filePath: string, content: string) => Promise<{ success: boolean; error?: string }>
saveMediaToDir: (params: { url: string; key?: string | number; outputDir: string; index: number }) => Promise<{ success: boolean; fileName?: string; error?: string }>
saveMediaToDir: (params: { url: string; key?: string | number; outputDir: string; index: number; md5?: string; isAvatar?: boolean; username?: string; isEmoji?: boolean; encryptUrl?: string; aesKey?: string }) => Promise<{ success: boolean; fileName?: string; error?: string }>
}
analytics: {
getOverallStatistics: () => Promise<{
+2
View File
@@ -55,6 +55,8 @@ export interface Message {
quotedContent?: string
quotedSender?: string
quotedImageMd5?: string
quotedEmojiMd5?: string
quotedEmojiCdnUrl?: string
// 视频相关
videoMd5?: string
videoDuration?: number // 视频时长(秒)
+88 -13
View File
@@ -1,8 +1,8 @@
import React from 'react'
import { getEmojiPath, hasEmoji, type EmojiName } from 'wechat-emojis'
// 微信表情名称到图片的映射正则
const emojiPattern = /\[([^\]]+)\]/g
// 微信表情名称到图片的映射正则(不使用模块级带 g 标志的正则,避免 async 函数中 lastIndex 被并发修改)
const EMOJI_PATTERN_SOURCE = '\\[([^\\]]+)\\]'
/**
* URL
@@ -20,24 +20,22 @@ function getEmojiUrl(name: string): string | null {
*/
export function parseWechatEmoji(text: string): React.ReactNode {
if (!text) return text
const parts: React.ReactNode[] = []
let lastIndex = 0
let match: RegExpExecArray | null
// 重置正则
emojiPattern.lastIndex = 0
const emojiPattern = new RegExp(EMOJI_PATTERN_SOURCE, 'g')
while ((match = emojiPattern.exec(text)) !== null) {
const emojiName = match[1]
// 检查是否是有效的微信表情
if (hasEmoji(emojiName)) {
// 添加表情前的文本
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index))
}
// 添加表情图片
const emojiUrl = getEmojiUrl(emojiName)
if (emojiUrl) {
@@ -54,16 +52,16 @@ export function parseWechatEmoji(text: string): React.ReactNode {
// 如果获取路径失败,保留原文本
parts.push(match[0])
}
lastIndex = match.index + match[0].length
}
}
// 添加剩余文本
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex))
}
return parts.length > 0 ? parts : text
}
@@ -72,10 +70,87 @@ export function parseWechatEmoji(text: string): React.ReactNode {
*/
export function hasWechatEmoji(text: string): boolean {
if (!text) return false
emojiPattern.lastIndex = 0
const emojiPattern = new RegExp(EMOJI_PATTERN_SOURCE, 'g')
let match: RegExpExecArray | null
while ((match = emojiPattern.exec(text)) !== null) {
if (hasEmoji(match[1])) return true
}
return false
}
// 缓存 base64 数据,避免重复 fetch
const emojiBase64Cache = new Map<string, string>()
/**
* [xxx] Base64 HTML (线访)
*/
export async function parseWechatEmojiHtml(text: string): Promise<string> {
if (!text) return text
const parts: string[] = []
let lastIndex = 0
let match: RegExpExecArray | null
const emojiPattern = new RegExp(EMOJI_PATTERN_SOURCE, 'g')
while ((match = emojiPattern.exec(text)) !== null) {
const emojiName = match[1]
if (hasEmoji(emojiName)) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'))
}
const relativePath = getEmojiPath(emojiName as EmojiName)
if (relativePath) {
// 构建完整的 URL,支持热更新 dev (/) 和打包后 prod (./)
// 使用 encodeURI 处理中文名文件 (如 "失望.png")
const baseUrl = import.meta.env.BASE_URL || '/'
const rawPath = `${baseUrl}wechat-emojis/${relativePath.replace('assets/', '')}`
const emojiUri = encodeURI(rawPath.replace(/\/\//g, '/'))
let base64 = emojiBase64Cache.get(emojiUri)
if (!base64) {
try {
const res = await fetch(emojiUri)
const contentType = res.headers.get('content-type') || ''
// 确保请求成功,并且返回的是图片而不是 HTML 回退页面
if (res.ok && contentType.includes('image')) {
const blob = await res.blob()
base64 = await new Promise<string>((resolve) => {
const reader = new FileReader()
reader.onloadend = () => resolve(reader.result as string)
reader.readAsDataURL(blob)
})
if (base64) {
emojiBase64Cache.set(emojiUri, base64)
}
} else {
console.warn(`[Emoji] Failed to fetch: ${emojiUri}, status: ${res.status}, type: ${contentType}`)
}
} catch (e) {
console.error(`[Emoji] Fetch error for: ${emojiUri}`, e)
}
}
if (base64) {
parts.push(`<img src="${base64}" alt="[${emojiName}]" title="${emojiName}" style="width: 20px; height: 20px; vertical-align: text-bottom; margin: 0 2px;" loading="lazy" />`)
} else {
// 如果获取失败,保留纯文本,避免渲染出损坏的图片造成“出现两个表情(破图+文字)”的情况
parts.push(`[${emojiName}]`)
}
} else {
parts.push(`[${emojiName}]`)
}
lastIndex = match.index + match[0].length
}
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'))
}
return parts.length > 0 ? parts.join('') : text
}