import { useEffect, useLayoutEffect, useState, useRef, useCallback } from 'react' import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Users, Info, ChevronLeft, ChevronRight, Shield, ShieldOff } from 'lucide-react' 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 type?: 'friend' | 'former_friend' | 'sns_only' } 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 [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 [showYearMonthPicker, setShowYearMonthPicker] = useState(false) // 触发器相关状态 const [showTriggerDialog, setShowTriggerDialog] = useState(false) const [triggerInstalled, setTriggerInstalled] = useState(null) const [triggerLoading, setTriggerLoading] = useState(false) const [triggerMessage, setTriggerMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null) const postsContainerRef = useRef(null) const [hasNewer, setHasNewer] = useState(false) const [loadingNewer, setLoadingNewer] = useState(false) const postsRef = useRef([]) const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null) // Sync posts ref useEffect(() => { postsRef.current = posts }, [posts]) // 在 DOM 更新后、浏览器绘制前同步调整滚动位置,防止向上加载时页面跳动 useLayoutEffect(() => { const snapshot = scrollAdjustmentRef.current; if (snapshot && postsContainerRef.current) { const container = postsContainerRef.current; const addedHeight = container.scrollHeight - snapshot.scrollHeight; if (addedHeight > 0) { container.scrollTop = snapshot.scrollTop + addedHeight; } scrollAdjustmentRef.current = null; } }, [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 = { scrollHeight: postsContainerRef.current.scrollHeight, scrollTop: postsContainerRef.current.scrollTop }; } 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].sort((a, b) => b.createTime - a.createTime)); } 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!].sort((a, b) => b.createTime - a.createTime)) } 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(合并好友+曾经好友+朋友圈发布者,enrichSessionsContactInfo 补充头像) const loadContacts = useCallback(async () => { setContactsLoading(true) try { // 并行获取联系人列表和朋友圈发布者列表 const [contactsResult, snsResult] = await Promise.all([ window.electronAPI.chat.getContacts(), window.electronAPI.sns.getSnsUsernames() ]) // 以联系人为基础,按 username 去重 const contactMap = new Map() // 好友和曾经的好友 if (contactsResult.success && contactsResult.contacts) { for (const c of contactsResult.contacts) { if (c.type === 'friend' || c.type === 'former_friend') { contactMap.set(c.username, { username: c.username, displayName: c.displayName, avatarUrl: c.avatarUrl, type: c.type === 'former_friend' ? 'former_friend' : 'friend' }) } } } // 朋友圈发布者(补充不在联系人列表中的用户) if (snsResult.success && snsResult.usernames) { for (const u of snsResult.usernames) { if (!contactMap.has(u)) { contactMap.set(u, { username: u, displayName: u, type: 'sns_only' }) } } } const allUsernames = Array.from(contactMap.keys()) // 用 enrichSessionsContactInfo 统一补充头像和显示名 if (allUsernames.length > 0) { const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(allUsernames) if (enriched.success && enriched.contacts) { for (const [username, extra] of Object.entries(enriched.contacts) as [string, { displayName?: string; avatarUrl?: string }][]) { const c = contactMap.get(username) if (c) { c.displayName = extra.displayName || c.displayName c.avatarUrl = extra.avatarUrl || c.avatarUrl } } } } setContacts(Array.from(contactMap.values())) } 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 => ( { if (isVideo) { void window.electronAPI.window.openVideoPlayerWindow(src) } else { void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined) } }} onDebug={(p) => setDebugPost(p)} onDelete={(postId) => setPosts(prev => prev.filter(p => p.id !== postId))} /> ))}
{loading && posts.length === 0 && (
正在加载朋友圈...
)} {loading && posts.length > 0 && (
正在加载更多...
)} {!hasMore && posts.length > 0 && (
{ selectedUsernames.length === 1 && contacts.find(c => c.username === selectedUsernames[0])?.type === 'former_friend' ? '在时间的长河里刻舟求剑' : '或许过往已无可溯洄,但好在还有可以与你相遇的明天' }
)} {!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 */} setShowJumpDialog(false)} onSelect={(date) => { setJumpTargetDate(date) setShowJumpDialog(false) }} currentDate={jumpTargetDate || new Date()} /> {debugPost && (
setDebugPost(null)}>
e.stopPropagation()}>

原始数据

                                {JSON.stringify(debugPost, null, 2)}
                            
)} {/* 朋友圈防删除插件对话框 */} {showTriggerDialog && (
{ setShowTriggerDialog(false); setTriggerMessage(null) }}>
e.stopPropagation()}> {/* 顶部图标区 */}
{triggerLoading ? : triggerInstalled ? : }
朋友圈防删除
{triggerLoading ? '检查中…' : triggerInstalled ? '已启用' : '未启用'}
{/* 说明 */}
启用后,WeFlow将拦截朋友圈删除操作
已同步的动态不会从本地数据库中消失
新的动态仍可正常同步。
{/* 操作反馈 */} {triggerMessage && (
{triggerMessage.type === 'success' ? : } {triggerMessage.text}
)} {/* 操作按钮 */}
{!triggerInstalled ? ( ) : ( )}
)} {/* 导出对话框 */} {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); setShowYearMonthPicker(false) }}>
e.stopPropagation()}>

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

setShowYearMonthPicker(!showYearMonthPicker)}> {calendarPicker.month.getFullYear()}年{calendarPicker.month.getMonth() + 1}月
{showYearMonthPicker ? (
{calendarPicker.month.getFullYear()}年
{['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => ( ))}
) : ( <>
{['日', '一', '二', '三', '四', '五', '六'].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}
) }) })()}
)}
)}
) }