mirror of
https://github.com/ILoveBingLu/CipherTalk.git
synced 2026-05-23 04:53:24 +08:00
同步以前的更新
This commit is contained in:
@@ -33,8 +33,8 @@
|
||||
|
||||
.image-preview-close {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
top: 48px;
|
||||
right: 48px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Vendored
+43
-3
@@ -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<{
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface Message {
|
||||
quotedContent?: string
|
||||
quotedSender?: string
|
||||
quotedImageMd5?: string
|
||||
quotedEmojiMd5?: string
|
||||
quotedEmojiCdnUrl?: string
|
||||
// 视频相关
|
||||
videoMd5?: string
|
||||
videoDuration?: number // 视频时长(秒)
|
||||
|
||||
+88
-13
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'))
|
||||
}
|
||||
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'))
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join('') : text
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user