import { useEffect, useState, useRef, useCallback } from 'react' import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Users, Info, ChevronLeft, ChevronRight } from 'lucide-react' import { ImagePreview } from '../components/ImagePreview' import JumpToDateDialog from '../components/JumpToDateDialog' import './SnsPage.scss' import { SnsPost } from '../types/sns' import { SnsPostItem } from '../components/Sns/SnsPostItem' import { SnsFilterPanel } from '../components/Sns/SnsFilterPanel' interface Contact { username: string displayName: string avatarUrl?: string } export default function SnsPage() { const [posts, setPosts] = useState([]) const [loading, setLoading] = useState(false) const [hasMore, setHasMore] = useState(true) const loadingRef = useRef(false) // Filter states const [searchKeyword, setSearchKeyword] = useState('') const [selectedUsernames, setSelectedUsernames] = useState([]) const [jumpTargetDate, setJumpTargetDate] = useState(undefined) // Contacts state const [contacts, setContacts] = useState([]) const [contactSearch, setContactSearch] = useState('') const [contactsLoading, setContactsLoading] = useState(false) // UI states const [showJumpDialog, setShowJumpDialog] = useState(false) const [previewImage, setPreviewImage] = useState<{ src: string, isVideo?: boolean, liveVideoPath?: string } | null>(null) const [debugPost, setDebugPost] = useState(null) // 导出相关状态 const [showExportDialog, setShowExportDialog] = useState(false) const [exportFormat, setExportFormat] = useState<'json' | 'html'>('html') const [exportFolder, setExportFolder] = useState('') const [exportMedia, setExportMedia] = useState(false) const [exportDateRange, setExportDateRange] = useState<{ start: string; end: string }>({ start: '', end: '' }) const [isExporting, setIsExporting] = useState(false) const [exportProgress, setExportProgress] = useState<{ current: number; total: number; status: string } | null>(null) const [exportResult, setExportResult] = useState<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string } | null>(null) const [refreshSpin, setRefreshSpin] = useState(false) const [calendarPicker, setCalendarPicker] = useState<{ field: 'start' | 'end'; month: Date } | null>(null) const postsContainerRef = useRef(null) const [hasNewer, setHasNewer] = useState(false) const [loadingNewer, setLoadingNewer] = useState(false) const postsRef = useRef([]) const scrollAdjustmentRef = useRef(0) // Sync posts ref useEffect(() => { postsRef.current = posts }, [posts]) // Maintain scroll position when loading newer 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 jumping to date, set endTs to end of that day 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 { // Loading older 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, // default undefined endTs ) if (result.success && result.timeline) { if (reset) { setPosts(result.timeline) setHasMore(result.timeline.length >= limit) // Check for newer items above topTs 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]) // Load Contacts 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) } }, []) // Initial Load & Listeners useEffect(() => { loadContacts() }, [loadContacts]) useEffect(() => { const handleChange = () => { // wxid changed, reset everything 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(() => { const timer = setTimeout(() => { loadPosts({ reset: true }) }, 500) return () => clearTimeout(timer) }, [selectedUsernames, searchKeyword, jumpTargetDate, loadPosts]) const handleScroll = (e: React.UIEvent) => { const { scrollTop, clientHeight, scrollHeight } = e.currentTarget if (scrollHeight - scrollTop - clientHeight < 400 && hasMore && !loading && !loadingNewer) { loadPosts({ direction: 'older' }) } if (scrollTop < 10 && hasNewer && !loading && !loadingNewer) { loadPosts({ direction: 'newer' }) } } const handleWheel = (e: React.WheelEvent) => { const container = postsContainerRef.current if (!container) return if (e.deltaY < -20 && container.scrollTop <= 0 && hasNewer && !loading && !loadingNewer) { loadPosts({ direction: 'newer' }) } } return (

朋友圈

{loadingNewer && (
正在检查更新的动态...
)} {!loadingNewer && hasNewer && (
loadPosts({ direction: 'newer' })}> 有新动态,点击查看
)}
{posts.map(post => ( setPreviewImage({ src, isVideo, liveVideoPath })} onDebug={(p) => setDebugPost(p)} /> ))}
{loading && posts.length === 0 && (
正在加载朋友圈...
)} {loading && posts.length > 0 && (
正在加载更多...
)} {!hasMore && posts.length > 0 && (
已经到底啦
)} {!loading && posts.length === 0 && (

未找到相关动态

{(selectedUsernames.length > 0 || searchKeyword || jumpTargetDate) && ( )}
)}
setShowJumpDialog(true)} selectedUsernames={selectedUsernames} setSelectedUsernames={setSelectedUsernames} contacts={contacts} contactSearch={contactSearch} setContactSearch={setContactSearch} loading={contactsLoading} /> {/* Dialogs and Overlays */} {previewImage && ( setPreviewImage(null)} /> )} setShowJumpDialog(false)} onSelect={(date) => { setJumpTargetDate(date) setShowJumpDialog(false) }} currentDate={jumpTargetDate || new Date()} /> {debugPost && (
setDebugPost(null)}>
e.stopPropagation()}>

原始数据

                                {JSON.stringify(debugPost, null, 2)}
                            
)} {/* 导出对话框 */} {showExportDialog && (
!isExporting && setShowExportDialog(false)}>
e.stopPropagation()}>

导出朋友圈

{/* 筛选条件提示 */} {(selectedUsernames.length > 0 || searchKeyword) && (
筛选导出 {searchKeyword && 关键词: "{searchKeyword}"} {selectedUsernames.length > 0 && ( {selectedUsernames.length} 个联系人 (同步自侧栏筛选) )}
)} {!exportResult ? ( <> {/* 格式选择 */}
{/* 输出路径 */}
{/* 时间范围 */}
{ if (!isExporting) setCalendarPicker(prev => prev?.field === 'start' ? null : { field: 'start', month: exportDateRange.start ? new Date(exportDateRange.start) : new Date() }) }}> {exportDateRange.start || '开始日期'} {exportDateRange.start && ( { e.stopPropagation(); setExportDateRange(prev => ({ ...prev, start: '' })) }} /> )}
{ if (!isExporting) setCalendarPicker(prev => prev?.field === 'end' ? null : { field: 'end', month: exportDateRange.end ? new Date(exportDateRange.end) : new Date() }) }}> {exportDateRange.end || '结束日期'} {exportDateRange.end && ( { e.stopPropagation(); setExportDateRange(prev => ({ ...prev, end: '' })) }} /> )}
{/* 媒体导出 */}
导出媒体文件(图片/视频)
{exportMedia && (

媒体文件将保存到输出目录的 media 子目录中,可能需要较长时间

)}
{/* 同步提示 */}
将同步主页面的联系人范围筛选及关键词搜索
{/* 进度条 */} {isExporting && exportProgress && (
0 ? `${Math.round((exportProgress.current / exportProgress.total) * 100)}%` : '100%' }} />
{exportProgress.status}
)} {/* 操作按钮 */}
) : ( /* 导出结果 */
{exportResult.success ? ( <>

导出成功

共导出 {exportResult.postCount} 条动态{exportResult.mediaCount ? `,${exportResult.mediaCount} 个媒体文件` : ''}

) : ( <>

导出失败

{exportResult.error}

)}
)}
)} {/* 日期选择弹窗 */} {calendarPicker && (
setCalendarPicker(null)}>
e.stopPropagation()}>

选择{calendarPicker.field === 'start' ? '开始' : '结束'}日期

{calendarPicker.month.getFullYear()}年{calendarPicker.month.getMonth() + 1}月
{['日', '一', '二', '三', '四', '五', '六'].map(d =>
{d}
)}
{(() => { const y = calendarPicker.month.getFullYear() const m = calendarPicker.month.getMonth() const firstDay = new Date(y, m, 1).getDay() const daysInMonth = new Date(y, m + 1, 0).getDate() const cells: (number | null)[] = [] for (let i = 0; i < firstDay; i++) cells.push(null) for (let i = 1; i <= daysInMonth; i++) cells.push(i) const today = new Date() return cells.map((day, i) => { if (day === null) return
const dateStr = `${y}-${String(m + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}` const isToday = day === today.getDate() && m === today.getMonth() && y === today.getFullYear() const currentVal = calendarPicker.field === 'start' ? exportDateRange.start : exportDateRange.end const isSelected = dateStr === currentVal return (
{ setExportDateRange(prev => ({ ...prev, [calendarPicker.field]: dateStr })) setCalendarPicker(null) }} >{day}
) }) })()}
)}
) }