import { useEffect, useState, useRef, useCallback, useMemo } from 'react' import { RefreshCw, Heart, Search, Calendar, User, X, Filter, Play, ImageIcon, Zap, Download, ChevronRight, AlertTriangle } from 'lucide-react' import { Avatar } from '../components/Avatar' import { ImagePreview } from '../components/ImagePreview' import JumpToDateDialog from '../components/JumpToDateDialog' import { LivePhotoIcon } from '../components/LivePhotoIcon' import './SnsPage.scss' interface SnsPost { id: string username: string nickname: string avatarUrl?: string createTime: number contentDesc: string type?: number media: { url: string thumb: string md5?: string token?: string key?: string encIdx?: string livePhoto?: { url: string thumb: string token?: string key?: string encIdx?: string } }[] likes: string[] comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[] rawXml?: string // 原始 XML 数据 } const MediaItem = ({ media, onPreview }: { media: any, onPreview: () => void }) => { const [error, setError] = useState(false); const { url, thumb, livePhoto } = media; const isLive = !!livePhoto; const targetUrl = thumb || url; const handleDownload = (e: React.MouseEvent) => { e.stopPropagation(); let downloadUrl = url; let downloadKey = media.key || ''; if (isLive && media.livePhoto) { downloadUrl = media.livePhoto.url; downloadKey = media.livePhoto.key || ''; } // TODO: 调用后端下载服务 // window.electronAPI.sns.download(downloadUrl, downloadKey); }; return (
setError(true)} /> {isLive && (
)}
); }; interface Contact { username: string displayName: string avatarUrl?: string } export default function SnsPage() { const [posts, setPosts] = useState([]) const [loading, setLoading] = useState(false) const [offset, setOffset] = useState(0) const [hasMore, setHasMore] = useState(true) const loadingRef = useRef(false) // 筛选与搜索状态 const [searchKeyword, setSearchKeyword] = useState('') const [selectedUsernames, setSelectedUsernames] = useState([]) const [isSidebarOpen, setIsSidebarOpen] = useState(true) // 联系人列表状态 const [contacts, setContacts] = useState([]) const [contactSearch, setContactSearch] = useState('') const [contactsLoading, setContactsLoading] = useState(false) const [showJumpDialog, setShowJumpDialog] = useState(false) const [jumpTargetDate, setJumpTargetDate] = useState(undefined) const [previewImage, setPreviewImage] = useState(null) const [debugPost, setDebugPost] = useState(null) const postsContainerRef = useRef(null) const [hasNewer, setHasNewer] = useState(false) const [loadingNewer, setLoadingNewer] = useState(false) const postsRef = useRef([]) const scrollAdjustmentRef = useRef(0) // 同步 posts 到 ref 供 loadPosts 使用 useEffect(() => { postsRef.current = posts }, [posts]) // 处理向上加载动态时的滚动位置保持 useEffect(() => { if (scrollAdjustmentRef.current !== 0 && postsContainerRef.current) { const container = postsContainerRef.current; const newHeight = container.scrollHeight; const diff = newHeight - scrollAdjustmentRef.current; if (diff > 0) { container.scrollTop += diff; } scrollAdjustmentRef.current = 0; } }, [posts]) const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => { const { reset = false, direction = 'older' } = options if (loadingRef.current) return loadingRef.current = true if (direction === 'newer') setLoadingNewer(true) else setLoading(true) try { const limit = 20 let startTs: number | undefined = undefined let endTs: number | undefined = undefined if (reset) { if (jumpTargetDate) { endTs = Math.floor(jumpTargetDate.getTime() / 1000) + 86399 } } else if (direction === 'newer') { const currentPosts = postsRef.current if (currentPosts.length > 0) { const topTs = currentPosts[0].createTime const result = await window.electronAPI.sns.getTimeline( limit, 0, selectedUsernames, searchKeyword, topTs + 1, undefined ); if (result.success && result.timeline && result.timeline.length > 0) { if (postsContainerRef.current) { scrollAdjustmentRef.current = postsContainerRef.current.scrollHeight; } const existingIds = new Set(currentPosts.map((p: SnsPost) => p.id)); const uniqueNewer = result.timeline.filter((p: SnsPost) => !existingIds.has(p.id)); if (uniqueNewer.length > 0) { setPosts(prev => [...uniqueNewer, ...prev]); } setHasNewer(result.timeline.length >= limit); } else { setHasNewer(false); } } setLoadingNewer(false); loadingRef.current = false; return; } else { const currentPosts = postsRef.current if (currentPosts.length > 0) { endTs = currentPosts[currentPosts.length - 1].createTime - 1 } } const result = await window.electronAPI.sns.getTimeline( limit, 0, selectedUsernames, searchKeyword, startTs, endTs ) if (result.success && result.timeline) { if (reset) { setPosts(result.timeline) setHasMore(result.timeline.length >= limit) // 探测上方是否还有新动态(利用 DLL 过滤,而非底层 SQL) const topTs = result.timeline[0]?.createTime || 0; if (topTs > 0) { const checkResult = await window.electronAPI.sns.getTimeline(1, 0, selectedUsernames, searchKeyword, topTs + 1, undefined); setHasNewer(!!(checkResult.success && checkResult.timeline && checkResult.timeline.length > 0)); } else { setHasNewer(false); } if (postsContainerRef.current) { postsContainerRef.current.scrollTop = 0 } } else { if (result.timeline.length > 0) { setPosts(prev => [...prev, ...result.timeline!]) } if (result.timeline.length < limit) { setHasMore(false) } } } } catch (error) { console.error('Failed to load SNS timeline:', error) } finally { setLoading(false) setLoadingNewer(false) loadingRef.current = false } }, [selectedUsernames, searchKeyword, jumpTargetDate]) // 获取联系人列表 const loadContacts = useCallback(async () => { setContactsLoading(true) try { const result = await window.electronAPI.chat.getSessions() if (result.success && result.sessions) { const systemAccounts = ['filehelper', 'fmessage', 'newsapp', 'weixin', 'qqmail', 'tmessage', 'floatbottle', 'medianote', 'brandsessionholder']; const initialContacts = result.sessions .filter((s: any) => { if (!s.username) return false; const u = s.username.toLowerCase(); if (u.includes('@chatroom') || u.endsWith('@chatroom') || u.endsWith('@openim')) return false; if (u.startsWith('gh_')) return false; if (systemAccounts.includes(u) || u.includes('helper') || u.includes('sessionholder')) return false; return true; }) .map((s: any) => ({ username: s.username, displayName: s.displayName || s.username, avatarUrl: s.avatarUrl })) setContacts(initialContacts) const usernames = initialContacts.map((c: { username: string }) => c.username) const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) if (enriched.success && enriched.contacts) { setContacts(prev => prev.map(c => { const extra = enriched.contacts![c.username] if (extra) { return { ...c, displayName: extra.displayName || c.displayName, avatarUrl: extra.avatarUrl || c.avatarUrl } } return c })) } } } catch (error) { console.error('Failed to load contacts:', error) } finally { setContactsLoading(false) } }, []) // 初始加载 useEffect(() => { const checkSchema = async () => { try { const schema = await window.electronAPI.chat.execQuery('sns', null, "PRAGMA table_info(SnsTimeLine)"); if (schema.success && schema.rows) { const columns = schema.rows.map((r: any) => r.name); } } catch (e) { console.error('[SnsPage] Failed to check schema:', e); } }; checkSchema(); loadContacts() }, [loadContacts]) useEffect(() => { const handleChange = () => { setPosts([]) setHasMore(true) setHasNewer(false) setSelectedUsernames([]) setSearchKeyword('') setJumpTargetDate(undefined) loadContacts() loadPosts({ reset: true }) } window.addEventListener('wxid-changed', handleChange as EventListener) return () => window.removeEventListener('wxid-changed', handleChange as EventListener) }, [loadContacts, loadPosts]) useEffect(() => { loadPosts({ reset: true }) }, [selectedUsernames, searchKeyword, jumpTargetDate]) const handleScroll = (e: React.UIEvent) => { const { scrollTop, clientHeight, scrollHeight } = e.currentTarget // 加载更旧的动态(触底) if (scrollHeight - scrollTop - clientHeight < 400 && hasMore && !loading && !loadingNewer) { loadPosts({ direction: 'older' }) } // 加载更新的动态(触顶触发) // 这里的阈值可以保留,但主要依赖下面的 handleWheel 捕获到顶后的上划 if (scrollTop < 10 && hasNewer && !loading && !loadingNewer) { loadPosts({ direction: 'newer' }) } } // 处理到顶后的手动上滚意图 const handleWheel = (e: React.WheelEvent) => { const container = postsContainerRef.current if (!container) return // deltaY < 0 表示向上滚,scrollTop === 0 表示已经在最顶端 if (e.deltaY < -20 && container.scrollTop <= 0 && hasNewer && !loading && !loadingNewer) { loadPosts({ direction: 'newer' }) } } const formatTime = (ts: number) => { const date = new Date(ts * 1000) const isCurrentYear = date.getFullYear() === new Date().getFullYear() return date.toLocaleString('zh-CN', { year: isCurrentYear ? undefined : 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) } const toggleUserSelection = (username: string) => { // 选择联系人时,如果当前有时间跳转,建议清除时间跳转以避免“跳到旧动态”的困惑 // 或者保持原样。根据用户反馈“乱跳”,我们在这里选择: // 如果用户选择了新的一个人,而之前有时间跳转,我们重置时间跳转到最新。 setJumpTargetDate(undefined); setSelectedUsernames(prev => { if (prev.includes(username)) { return prev.filter(u => u !== username) } else { return [...prev, username] } }) } const clearFilters = () => { setSearchKeyword('') setSelectedUsernames([]) setJumpTargetDate(undefined) } const filteredContacts = contacts.filter(c => c.displayName.toLowerCase().includes(contactSearch.toLowerCase()) || c.username.toLowerCase().includes(contactSearch.toLowerCase()) ) return (

社交动态

由于技术限制,当前无法解密显示部分图片与视频等加密资源文件
{loadingNewer && (
正在检查更新的动态...
)} {!loadingNewer && hasNewer && (
loadPosts({ direction: 'newer' })}> 查看更新的动态
)} {posts.map((post, index) => { return (
{post.nickname}
{formatTime(post.createTime)}
{post.contentDesc &&
{post.contentDesc}
} {post.type === 15 ? (
视频动态
) : post.media.length > 0 && (
{post.media.map((m, idx) => ( setPreviewImage(m.url)} /> ))}
)}
{(post.likes.length > 0 || post.comments.length > 0) && (
{post.likes.length > 0 && (
{post.likes.join('、')}
)} {post.comments.length > 0 && (
{post.comments.map((c, idx) => (
{c.nickname} {c.refNickname && ( <> 回复 {c.refNickname} )} : {c.content}
))}
)}
)}
) })}
{loading &&
正在加载更多...
} {!hasMore && posts.length > 0 &&
已经到底啦
} {!loading && posts.length === 0 && (

未找到相关动态

{(selectedUsernames.length > 0 || searchKeyword) && ( )}
)}
{/* 侧边栏:过滤与搜索 (moved to right) */}
{previewImage && ( setPreviewImage(null)} /> )} { setShowJumpDialog(false) }} onSelect={(date) => { setJumpTargetDate(date) setShowJumpDialog(false) }} currentDate={jumpTargetDate || new Date()} /> {/* Debug Info Dialog */} {debugPost && (
setDebugPost(null)}>
e.stopPropagation()}>

原始数据 - {debugPost.nickname}

ℹ 基本信息

ID: {debugPost.id}
用户名: {debugPost.username}
昵称: {debugPost.nickname}
时间: {new Date(debugPost.createTime * 1000).toLocaleString()}
类型: {debugPost.type}

媒体信息 ({debugPost.media.length} 项)

{debugPost.media.map((media, idx) => (
媒体 {idx + 1}
URL: {media.url}
缩略图: {media.thumb}
{media.md5 && (
MD5: {media.md5}
)} {media.token && (
Token: {media.token}
)} {media.key && (
Key (解密密钥): {media.key}
)} {media.encIdx && (
Enc Index: {media.encIdx}
)} {media.livePhoto && (
Live Photo 视频部分:
视频 URL: {media.livePhoto.url}
视频缩略图: {media.livePhoto.thumb}
{media.livePhoto.token && (
视频 Token: {media.livePhoto.token}
)} {media.livePhoto.key && (
视频 Key: {media.livePhoto.key}
)}
)}
))}
{/* 原始 XML */} {debugPost.rawXml && (

原始 XML 数据

{(() => {
                                        // XML 缩进格式化
                                        let formatted = '';
                                        let indent = 0;
                                        const tab = '  ';
                                        const parts = debugPost.rawXml.split(/(<[^>]+>)/g).filter(p => p.trim());

                                        for (const part of parts) {
                                            if (!part.startsWith('<')) {
                                                if (part.trim()) formatted += part;
                                                continue;
                                            }

                                            if (part.startsWith('')) {
                                                formatted += '\n' + tab.repeat(indent) + part;
                                            } else {
                                                formatted += '\n' + tab.repeat(indent) + part;
                                                indent++;
                                            }
                                        }

                                        return formatted.trim();
                                    })()}
)}
)}
) }