refactor(sns): move jump calendar to header

This commit is contained in:
aits2026
2026-03-06 15:44:24 +08:00
parent 1c89ee2797
commit 0599de372a
3 changed files with 136 additions and 221 deletions

View File

@@ -1,7 +1,6 @@
import React, { useEffect, useRef, useState, useCallback } from 'react' import React from 'react'
import { Search, Calendar, User, X, Loader2 } from 'lucide-react' import { Search, User, X, Loader2 } from 'lucide-react'
import { Avatar } from '../Avatar' import { Avatar } from '../Avatar'
import JumpToDatePopover from '../JumpToDatePopover'
interface Contact { interface Contact {
username: string username: string
@@ -20,8 +19,6 @@ interface ContactsCountProgress {
interface SnsFilterPanelProps { interface SnsFilterPanelProps {
searchKeyword: string searchKeyword: string
setSearchKeyword: (val: string) => void setSearchKeyword: (val: string) => void
jumpTargetDate?: Date
setJumpTargetDate: (date?: Date) => void
totalFriendsLabel?: string totalFriendsLabel?: string
selectedUsernames: string[] selectedUsernames: string[]
setSelectedUsernames: (val: string[]) => void setSelectedUsernames: (val: string[]) => void
@@ -35,8 +32,6 @@ interface SnsFilterPanelProps {
export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
searchKeyword, searchKeyword,
setSearchKeyword, setSearchKeyword,
jumpTargetDate,
setJumpTargetDate,
totalFriendsLabel, totalFriendsLabel,
selectedUsernames, selectedUsernames,
setSelectedUsernames, setSelectedUsernames,
@@ -46,104 +41,6 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
loading, loading,
contactsCountProgress contactsCountProgress
}) => { }) => {
const [showJumpPopover, setShowJumpPopover] = useState(false)
const [jumpPopoverDate, setJumpPopoverDate] = useState<Date>(jumpTargetDate || new Date())
const [jumpDateCounts, setJumpDateCounts] = useState<Record<string, number>>({})
const [jumpDateMessageDates, setJumpDateMessageDates] = useState<Set<string>>(new Set())
const [hasLoadedJumpDateCounts, setHasLoadedJumpDateCounts] = useState(false)
const [loadingJumpDateCounts, setLoadingJumpDateCounts] = useState(false)
const jumpCalendarWrapRef = useRef<HTMLDivElement | null>(null)
const jumpDateCountsCacheRef = useRef<Map<string, Record<string, number>>>(new Map())
const jumpDateRequestSeqRef = useRef(0)
useEffect(() => {
if (!showJumpPopover) return
const handleClickOutside = (event: MouseEvent) => {
if (!jumpCalendarWrapRef.current) return
if (jumpCalendarWrapRef.current.contains(event.target as Node)) return
setShowJumpPopover(false)
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showJumpPopover])
useEffect(() => {
if (showJumpPopover) return
setJumpPopoverDate(jumpTargetDate || new Date())
}, [jumpTargetDate, showJumpPopover])
const toMonthKey = useCallback((date: Date) => {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
}, [])
const toDateKey = useCallback((timestampSeconds: number) => {
const date = new Date(timestampSeconds * 1000)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}, [])
const applyJumpDateCounts = useCallback((counts: Record<string, number>) => {
setJumpDateCounts(counts)
setJumpDateMessageDates(new Set(Object.keys(counts)))
setHasLoadedJumpDateCounts(true)
}, [])
const loadJumpDateCounts = useCallback(async (monthDate: Date) => {
const monthKey = toMonthKey(monthDate)
const cached = jumpDateCountsCacheRef.current.get(monthKey)
if (cached) {
applyJumpDateCounts(cached)
setLoadingJumpDateCounts(false)
return
}
const requestSeq = ++jumpDateRequestSeqRef.current
setLoadingJumpDateCounts(true)
setHasLoadedJumpDateCounts(false)
const year = monthDate.getFullYear()
const month = monthDate.getMonth()
const monthStart = new Date(year, month, 1, 0, 0, 0, 0)
const monthEnd = new Date(year, month + 1, 0, 23, 59, 59, 999)
const startTime = Math.floor(monthStart.getTime() / 1000)
const endTime = Math.floor(monthEnd.getTime() / 1000)
const pageSize = 200
let offset = 0
const counts: Record<string, number> = {}
try {
while (true) {
const result = await window.electronAPI.sns.getTimeline(pageSize, offset, [], '', startTime, endTime)
if (!result?.success || !Array.isArray(result.timeline) || result.timeline.length === 0) {
break
}
result.timeline.forEach((post) => {
const key = toDateKey(Number(post.createTime || 0))
if (!key) return
counts[key] = (counts[key] || 0) + 1
})
if (result.timeline.length < pageSize) break
offset += pageSize
}
if (requestSeq !== jumpDateRequestSeqRef.current) return
jumpDateCountsCacheRef.current.set(monthKey, counts)
applyJumpDateCounts(counts)
} catch (error) {
console.error('加载朋友圈按日条数失败:', error)
if (requestSeq !== jumpDateRequestSeqRef.current) return
setJumpDateCounts({})
setJumpDateMessageDates(new Set())
setHasLoadedJumpDateCounts(true)
} finally {
if (requestSeq === jumpDateRequestSeqRef.current) {
setLoadingJumpDateCounts(false)
}
}
}, [applyJumpDateCounts, toDateKey, toMonthKey])
const filteredContacts = contacts.filter(c => const filteredContacts = contacts.filter(c =>
(c.displayName || '').toLowerCase().includes(contactSearch.toLowerCase()) || (c.displayName || '').toLowerCase().includes(contactSearch.toLowerCase()) ||
c.username.toLowerCase().includes(contactSearch.toLowerCase()) c.username.toLowerCase().includes(contactSearch.toLowerCase())
@@ -153,7 +50,6 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
if (selectedUsernames.includes(username)) { if (selectedUsernames.includes(username)) {
setSelectedUsernames(selectedUsernames.filter(u => u !== username)) setSelectedUsernames(selectedUsernames.filter(u => u !== username))
} else { } else {
setJumpTargetDate(undefined) // Reset date jump when selecting user
setSelectedUsernames([...selectedUsernames, username]) setSelectedUsernames([...selectedUsernames, username])
} }
} }
@@ -161,7 +57,6 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
const clearFilters = () => { const clearFilters = () => {
setSearchKeyword('') setSearchKeyword('')
setSelectedUsernames([]) setSelectedUsernames([])
setJumpTargetDate(undefined)
} }
const getEmptyStateText = () => { const getEmptyStateText = () => {
@@ -178,7 +73,7 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
<aside className="sns-filter-panel"> <aside className="sns-filter-panel">
<div className="filter-header"> <div className="filter-header">
<h3></h3> <h3></h3>
{(searchKeyword || jumpTargetDate || selectedUsernames.length > 0) && ( {(searchKeyword || selectedUsernames.length > 0) && (
<button className="reset-all-btn" onClick={clearFilters} title="重置所有筛选"> <button className="reset-all-btn" onClick={clearFilters} title="重置所有筛选">
<RefreshCw size={14} /> <RefreshCw size={14} />
</button> </button>
@@ -206,64 +101,6 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
)} )}
</div> </div>
</div> </div>
{/* Date Widget */}
<div className="filter-widget date-widget">
<div className="date-widget-row">
<div className="widget-header">
<Calendar size={14} />
<span></span>
</div>
<div className="jump-calendar-anchor" ref={jumpCalendarWrapRef}>
<button
className={`date-picker-trigger ${jumpTargetDate ? 'active' : ''}`}
onClick={() => {
if (!showJumpPopover) {
const nextDate = jumpTargetDate || new Date()
setJumpPopoverDate(nextDate)
void loadJumpDateCounts(nextDate)
}
setShowJumpPopover(prev => !prev)
}}
>
<span className="date-text">
{jumpTargetDate
? jumpTargetDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
: '选择日期...'}
</span>
{jumpTargetDate && (
<div
className="clear-date-btn"
onClick={(e) => {
e.stopPropagation()
setJumpTargetDate(undefined)
}}
>
<X size={12} />
</div>
)}
</button>
<JumpToDatePopover
isOpen={showJumpPopover}
currentDate={jumpPopoverDate}
onClose={() => setShowJumpPopover(false)}
onMonthChange={(date) => {
setJumpPopoverDate(date)
void loadJumpDateCounts(date)
}}
onSelect={(date) => {
setJumpPopoverDate(date)
setJumpTargetDate(date)
}}
messageDates={jumpDateMessageDates}
hasLoadedMessageDates={hasLoadedJumpDateCounts}
messageDateCounts={jumpDateCounts}
loadingDateCounts={loadingJumpDateCounts}
/>
</div>
</div>
</div>
{/* Contact Widget */} {/* Contact Widget */}
<div className="filter-widget contact-widget"> <div className="filter-widget contact-widget">
<div className="widget-header"> <div className="widget-header">

View File

@@ -1074,26 +1074,10 @@
} }
} }
/* Date Widget */
.date-widget-row {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.date-widget .widget-header {
margin-bottom: 0;
flex-shrink: 0;
min-width: 72px;
}
.jump-calendar-anchor { .jump-calendar-anchor {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
flex: 1;
min-width: 0;
isolation: isolate; isolation: isolate;
z-index: 20; z-index: 20;
@@ -1102,43 +1086,6 @@
} }
} }
.date-picker-trigger {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-tertiary);
border: 1px solid transparent;
border-radius: var(--sns-border-radius-sm);
padding: 12px;
cursor: pointer;
transition: all 0.2s;
font-size: 13px;
color: var(--text-secondary);
&:hover {
background: var(--bg-primary);
border-color: var(--primary);
}
&.active {
background: rgba(var(--primary-rgb), 0.08);
border-color: var(--primary);
color: var(--primary);
font-weight: 500;
}
.clear-date-btn {
padding: 4px;
display: flex;
color: var(--primary);
&:hover {
transform: scale(1.1);
}
}
}
/* Contact Widget - Refactored */ /* Contact Widget - Refactored */
.contact-widget { .contact-widget {
display: flex; display: flex;

View File

@@ -6,6 +6,7 @@ import { SnsPostItem } from '../components/Sns/SnsPostItem'
import { SnsFilterPanel } from '../components/Sns/SnsFilterPanel' import { SnsFilterPanel } from '../components/Sns/SnsFilterPanel'
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog' import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
import type { ContactSnsTimelineTarget } from '../components/Sns/contactSnsTimeline' import type { ContactSnsTimelineTarget } from '../components/Sns/contactSnsTimeline'
import JumpToDatePopover from '../components/JumpToDatePopover'
import * as configService from '../services/config' import * as configService from '../services/config'
const SNS_PAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000 const SNS_PAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000
@@ -119,6 +120,12 @@ export default function SnsPage() {
// UI states // UI states
const [debugPost, setDebugPost] = useState<SnsPost | null>(null) const [debugPost, setDebugPost] = useState<SnsPost | null>(null)
const [authorTimelineTarget, setAuthorTimelineTarget] = useState<ContactSnsTimelineTarget | null>(null) const [authorTimelineTarget, setAuthorTimelineTarget] = useState<ContactSnsTimelineTarget | null>(null)
const [showJumpPopover, setShowJumpPopover] = useState(false)
const [jumpPopoverDate, setJumpPopoverDate] = useState<Date>(jumpTargetDate || new Date())
const [jumpDateCounts, setJumpDateCounts] = useState<Record<string, number>>({})
const [jumpDateMessageDates, setJumpDateMessageDates] = useState<Set<string>>(new Set())
const [hasLoadedJumpDateCounts, setHasLoadedJumpDateCounts] = useState(false)
const [loadingJumpDateCounts, setLoadingJumpDateCounts] = useState(false)
// 导出相关状态 // 导出相关状态
const [showExportDialog, setShowExportDialog] = useState(false) const [showExportDialog, setShowExportDialog] = useState(false)
@@ -142,6 +149,7 @@ export default function SnsPage() {
const [triggerMessage, setTriggerMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null) const [triggerMessage, setTriggerMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const postsContainerRef = useRef<HTMLDivElement>(null) const postsContainerRef = useRef<HTMLDivElement>(null)
const jumpCalendarWrapRef = useRef<HTMLDivElement | null>(null)
const [hasNewer, setHasNewer] = useState(false) const [hasNewer, setHasNewer] = useState(false)
const [loadingNewer, setLoadingNewer] = useState(false) const [loadingNewer, setLoadingNewer] = useState(false)
const postsRef = useRef<SnsPost[]>([]) const postsRef = useRef<SnsPost[]>([])
@@ -157,6 +165,8 @@ export default function SnsPage() {
const contactsLoadTokenRef = useRef(0) const contactsLoadTokenRef = useRef(0)
const contactsCountHydrationTokenRef = useRef(0) const contactsCountHydrationTokenRef = useRef(0)
const contactsCountBatchTimerRef = useRef<number | null>(null) const contactsCountBatchTimerRef = useRef<number | null>(null)
const jumpDateCountsCacheRef = useRef<Map<string, Record<string, number>>>(new Map())
const jumpDateRequestSeqRef = useRef(0)
// Sync posts ref // Sync posts ref
useEffect(() => { useEffect(() => {
@@ -180,6 +190,21 @@ export default function SnsPage() {
useEffect(() => { useEffect(() => {
jumpTargetDateRef.current = jumpTargetDate jumpTargetDateRef.current = jumpTargetDate
}, [jumpTargetDate]) }, [jumpTargetDate])
useEffect(() => {
if (!showJumpPopover) {
setJumpPopoverDate(jumpTargetDate || new Date())
}
}, [jumpTargetDate, showJumpPopover])
useEffect(() => {
if (!showJumpPopover) return
const handleClickOutside = (event: MouseEvent) => {
if (!jumpCalendarWrapRef.current) return
if (jumpCalendarWrapRef.current.contains(event.target as Node)) return
setShowJumpPopover(false)
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showJumpPopover])
// 在 DOM 更新后、浏览器绘制前同步调整滚动位置,防止向上加载时页面跳动 // 在 DOM 更新后、浏览器绘制前同步调整滚动位置,防止向上加载时页面跳动
useLayoutEffect(() => { useLayoutEffect(() => {
const snapshot = scrollAdjustmentRef.current; const snapshot = scrollAdjustmentRef.current;
@@ -221,6 +246,78 @@ export default function SnsPage() {
return Math.max(0, Math.floor(numeric)) return Math.max(0, Math.floor(numeric))
}, []) }, [])
const toMonthKey = useCallback((date: Date) => {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
}, [])
const toDateKey = useCallback((timestampSeconds: number) => {
const date = new Date(timestampSeconds * 1000)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}, [])
const applyJumpDateCounts = useCallback((counts: Record<string, number>) => {
setJumpDateCounts(counts)
setJumpDateMessageDates(new Set(Object.keys(counts)))
setHasLoadedJumpDateCounts(true)
}, [])
const loadJumpDateCounts = useCallback(async (monthDate: Date) => {
const monthKey = toMonthKey(monthDate)
const cached = jumpDateCountsCacheRef.current.get(monthKey)
if (cached) {
applyJumpDateCounts(cached)
setLoadingJumpDateCounts(false)
return
}
const requestSeq = ++jumpDateRequestSeqRef.current
setLoadingJumpDateCounts(true)
setHasLoadedJumpDateCounts(false)
const year = monthDate.getFullYear()
const month = monthDate.getMonth()
const monthStart = new Date(year, month, 1, 0, 0, 0, 0)
const monthEnd = new Date(year, month + 1, 0, 23, 59, 59, 999)
const startTime = Math.floor(monthStart.getTime() / 1000)
const endTime = Math.floor(monthEnd.getTime() / 1000)
const pageSize = 200
let offset = 0
const counts: Record<string, number> = {}
try {
while (true) {
const result = await window.electronAPI.sns.getTimeline(pageSize, offset, [], '', startTime, endTime)
if (!result?.success || !Array.isArray(result.timeline) || result.timeline.length === 0) {
break
}
result.timeline.forEach((post) => {
const key = toDateKey(Number(post.createTime || 0))
if (!key) return
counts[key] = (counts[key] || 0) + 1
})
if (result.timeline.length < pageSize) break
offset += pageSize
}
if (requestSeq !== jumpDateRequestSeqRef.current) return
jumpDateCountsCacheRef.current.set(monthKey, counts)
applyJumpDateCounts(counts)
} catch (error) {
console.error('加载朋友圈按日条数失败:', error)
if (requestSeq !== jumpDateRequestSeqRef.current) return
setJumpDateCounts({})
setJumpDateMessageDates(new Set())
setHasLoadedJumpDateCounts(true)
} finally {
if (requestSeq === jumpDateRequestSeqRef.current) {
setLoadingJumpDateCounts(false)
}
}
}, [applyJumpDateCounts, toDateKey, toMonthKey])
const compareContactsForRanking = useCallback((a: Contact, b: Contact): number => { const compareContactsForRanking = useCallback((a: Contact, b: Contact): number => {
const aReady = a.postCountStatus === 'ready' const aReady = a.postCountStatus === 'ready'
const bReady = b.postCountStatus === 'ready' const bReady = b.postCountStatus === 'ready'
@@ -985,6 +1082,42 @@ export default function SnsPage() {
</div> </div>
</div> </div>
<div className="header-actions"> <div className="header-actions">
<div className="jump-calendar-anchor" ref={jumpCalendarWrapRef}>
<button
type="button"
className={`icon-btn ${showJumpPopover ? 'active' : ''}`}
title={jumpTargetDate
? jumpTargetDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
: '时间跳转'}
onClick={() => {
if (!showJumpPopover) {
const nextDate = jumpTargetDate || new Date()
setJumpPopoverDate(nextDate)
void loadJumpDateCounts(nextDate)
}
setShowJumpPopover(prev => !prev)
}}
>
<Calendar size={20} />
</button>
<JumpToDatePopover
isOpen={showJumpPopover}
currentDate={jumpPopoverDate}
onClose={() => setShowJumpPopover(false)}
onMonthChange={(date) => {
setJumpPopoverDate(date)
void loadJumpDateCounts(date)
}}
onSelect={(date) => {
setJumpPopoverDate(date)
setJumpTargetDate(date)
}}
messageDates={jumpDateMessageDates}
hasLoadedMessageDates={hasLoadedJumpDateCounts}
messageDateCounts={jumpDateCounts}
loadingDateCounts={loadingJumpDateCounts}
/>
</div>
<button <button
onClick={async () => { onClick={async () => {
setTriggerMessage(null) setTriggerMessage(null)
@@ -1110,8 +1243,6 @@ export default function SnsPage() {
<SnsFilterPanel <SnsFilterPanel
searchKeyword={searchKeyword} searchKeyword={searchKeyword}
setSearchKeyword={setSearchKeyword} setSearchKeyword={setSearchKeyword}
jumpTargetDate={jumpTargetDate}
setJumpTargetDate={setJumpTargetDate}
totalFriendsLabel={ totalFriendsLabel={
overviewStatsStatus === 'loading' overviewStatsStatus === 'loading'
? '统计中' ? '统计中'