Files
WeFlow/src/pages/SnsPage.tsx

1496 lines
78 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
import type { ContactSnsTimelineTarget } from '../components/Sns/contactSnsTimeline'
import * as configService from '../services/config'
const SNS_PAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000
const SNS_PAGE_CACHE_POST_LIMIT = 200
const SNS_PAGE_CACHE_SCOPE_FALLBACK = '__default__'
const CONTACT_COUNT_SORT_DEBOUNCE_MS = 200
const CONTACT_COUNT_BATCH_SIZE = 10
type ContactPostCountStatus = 'idle' | 'loading' | 'ready'
interface Contact {
username: string
displayName: string
avatarUrl?: string
type?: 'friend' | 'former_friend' | 'sns_only'
lastSessionTimestamp?: number
postCount?: number
postCountStatus?: ContactPostCountStatus
}
interface ContactsCountProgress {
resolved: number
total: number
running: boolean
}
interface SnsOverviewStats {
totalPosts: number
totalFriends: number
myPosts: number | null
earliestTime: number | null
latestTime: number | null
}
type OverviewStatsStatus = 'loading' | 'ready' | 'error'
export default function SnsPage() {
const [posts, setPosts] = useState<SnsPost[]>([])
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
const loadingRef = useRef(false)
const [overviewStats, setOverviewStats] = useState<SnsOverviewStats>({
totalPosts: 0,
totalFriends: 0,
myPosts: null,
earliestTime: null,
latestTime: null
})
const [overviewStatsStatus, setOverviewStatsStatus] = useState<OverviewStatsStatus>('loading')
// Filter states
const [searchKeyword, setSearchKeyword] = useState('')
const [selectedUsernames, setSelectedUsernames] = useState<string[]>([])
const [jumpTargetDate, setJumpTargetDate] = useState<Date | undefined>(undefined)
// Contacts state
const [contacts, setContacts] = useState<Contact[]>([])
const [contactSearch, setContactSearch] = useState('')
const [contactsLoading, setContactsLoading] = useState(false)
const [contactsCountProgress, setContactsCountProgress] = useState<ContactsCountProgress>({
resolved: 0,
total: 0,
running: false
})
// UI states
const [showJumpDialog, setShowJumpDialog] = useState(false)
const [debugPost, setDebugPost] = useState<SnsPost | null>(null)
const [authorTimelineTarget, setAuthorTimelineTarget] = useState<ContactSnsTimelineTarget | null>(null)
// 导出相关状态
const [showExportDialog, setShowExportDialog] = useState(false)
const [exportFormat, setExportFormat] = useState<'json' | 'html' | 'arkmejson'>('html')
const [exportFolder, setExportFolder] = useState('')
const [exportImages, setExportImages] = useState(false)
const [exportLivePhotos, setExportLivePhotos] = useState(false)
const [exportVideos, setExportVideos] = 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<boolean | null>(null)
const [triggerLoading, setTriggerLoading] = useState(false)
const [triggerMessage, setTriggerMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const postsContainerRef = useRef<HTMLDivElement>(null)
const [hasNewer, setHasNewer] = useState(false)
const [loadingNewer, setLoadingNewer] = useState(false)
const postsRef = useRef<SnsPost[]>([])
const contactsRef = useRef<Contact[]>([])
const overviewStatsRef = useRef<SnsOverviewStats>(overviewStats)
const overviewStatsStatusRef = useRef<OverviewStatsStatus>(overviewStatsStatus)
const selectedUsernamesRef = useRef<string[]>(selectedUsernames)
const searchKeywordRef = useRef(searchKeyword)
const jumpTargetDateRef = useRef<Date | undefined>(jumpTargetDate)
const cacheScopeKeyRef = useRef('')
const snsUserPostCountsCacheScopeKeyRef = useRef('')
const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
const contactsLoadTokenRef = useRef(0)
const contactsCountHydrationTokenRef = useRef(0)
const contactsCountBatchTimerRef = useRef<number | null>(null)
// Sync posts ref
useEffect(() => {
postsRef.current = posts
}, [posts])
useEffect(() => {
contactsRef.current = contacts
}, [contacts])
useEffect(() => {
overviewStatsRef.current = overviewStats
}, [overviewStats])
useEffect(() => {
overviewStatsStatusRef.current = overviewStatsStatus
}, [overviewStatsStatus])
useEffect(() => {
selectedUsernamesRef.current = selectedUsernames
}, [selectedUsernames])
useEffect(() => {
searchKeywordRef.current = searchKeyword
}, [searchKeyword])
useEffect(() => {
jumpTargetDateRef.current = jumpTargetDate
}, [jumpTargetDate])
// 在 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 formatDateOnly = (timestamp: number | null): string => {
if (!timestamp || timestamp <= 0) return '--'
const date = new Date(timestamp * 1000)
if (Number.isNaN(date.getTime())) return '--'
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 decodeHtmlEntities = (text: string): string => {
if (!text) return ''
return text
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
.trim()
}
const normalizePostCount = useCallback((value: unknown): number => {
const numeric = Number(value)
if (!Number.isFinite(numeric)) return 0
return Math.max(0, Math.floor(numeric))
}, [])
const compareContactsForRanking = useCallback((a: Contact, b: Contact): number => {
const aReady = a.postCountStatus === 'ready'
const bReady = b.postCountStatus === 'ready'
if (aReady && bReady) {
const countDiff = normalizePostCount(b.postCount) - normalizePostCount(a.postCount)
if (countDiff !== 0) return countDiff
} else if (aReady !== bReady) {
return aReady ? -1 : 1
}
const tsDiff = Number(b.lastSessionTimestamp || 0) - Number(a.lastSessionTimestamp || 0)
if (tsDiff !== 0) return tsDiff
return (a.displayName || a.username).localeCompare((b.displayName || b.username), 'zh-Hans-CN')
}, [normalizePostCount])
const sortContactsForRanking = useCallback((input: Contact[]): Contact[] => {
return [...input].sort(compareContactsForRanking)
}, [compareContactsForRanking])
const isDefaultViewNow = useCallback(() => {
return selectedUsernamesRef.current.length === 0 && !searchKeywordRef.current.trim() && !jumpTargetDateRef.current
}, [])
const ensureSnsCacheScopeKey = useCallback(async () => {
if (cacheScopeKeyRef.current) return cacheScopeKeyRef.current
const wxid = (await configService.getMyWxid())?.trim() || SNS_PAGE_CACHE_SCOPE_FALLBACK
const scopeKey = `sns_page:${wxid}`
cacheScopeKeyRef.current = scopeKey
return scopeKey
}, [])
const ensureSnsUserPostCountsCacheScopeKey = useCallback(async () => {
if (snsUserPostCountsCacheScopeKeyRef.current) return snsUserPostCountsCacheScopeKeyRef.current
const [wxidRaw, dbPathRaw] = await Promise.all([
configService.getMyWxid(),
configService.getDbPath()
])
const wxid = String(wxidRaw || '').trim()
const dbPath = String(dbPathRaw || '').trim()
const scopeKey = (dbPath || wxid)
? `${dbPath}::${wxid}`
: 'default'
snsUserPostCountsCacheScopeKeyRef.current = scopeKey
return scopeKey
}, [])
const persistSnsPageCache = useCallback(async (patch?: { posts?: SnsPost[]; overviewStats?: SnsOverviewStats }) => {
if (!isDefaultViewNow()) return
try {
const scopeKey = await ensureSnsCacheScopeKey()
if (!scopeKey) return
const existingCache = await configService.getSnsPageCache(scopeKey)
let postsToStore = patch?.posts ?? postsRef.current
if (!patch?.posts && postsToStore.length === 0) {
if (existingCache && Array.isArray(existingCache.posts) && existingCache.posts.length > 0) {
postsToStore = existingCache.posts as SnsPost[]
}
}
const overviewToStore = patch?.overviewStats
?? (overviewStatsStatusRef.current === 'ready'
? overviewStatsRef.current
: existingCache?.overviewStats ?? overviewStatsRef.current)
await configService.setSnsPageCache(scopeKey, {
overviewStats: overviewToStore,
posts: postsToStore.slice(0, SNS_PAGE_CACHE_POST_LIMIT)
})
} catch (error) {
console.error('Failed to persist SNS page cache:', error)
}
}, [ensureSnsCacheScopeKey, isDefaultViewNow])
const hydrateSnsPageCache = useCallback(async () => {
try {
const scopeKey = await ensureSnsCacheScopeKey()
const cached = await configService.getSnsPageCache(scopeKey)
if (!cached) return
if (Date.now() - cached.updatedAt > SNS_PAGE_CACHE_TTL_MS) return
const cachedOverview = cached.overviewStats
if (cachedOverview) {
const cachedTotalPosts = Math.max(0, Number(cachedOverview.totalPosts || 0))
const cachedTotalFriends = Math.max(0, Number(cachedOverview.totalFriends || 0))
const hasCachedPosts = Array.isArray(cached.posts) && cached.posts.length > 0
const hasOverviewData = cachedTotalPosts > 0 || cachedTotalFriends > 0
setOverviewStats({
totalPosts: cachedTotalPosts,
totalFriends: cachedTotalFriends,
myPosts: typeof cachedOverview.myPosts === 'number' && Number.isFinite(cachedOverview.myPosts) && cachedOverview.myPosts >= 0
? Math.floor(cachedOverview.myPosts)
: null,
earliestTime: cachedOverview.earliestTime ?? null,
latestTime: cachedOverview.latestTime ?? null
})
// 只有明确有统计值(或确实无帖子)时才把缓存视为 ready避免历史异常 0 卡住显示。
setOverviewStatsStatus(hasOverviewData || !hasCachedPosts ? 'ready' : 'loading')
}
if (Array.isArray(cached.posts) && cached.posts.length > 0) {
const cachedPosts = cached.posts
.filter((raw): raw is SnsPost => {
if (!raw || typeof raw !== 'object') return false
const row = raw as Record<string, unknown>
return typeof row.id === 'string' && typeof row.createTime === 'number'
})
.slice(0, SNS_PAGE_CACHE_POST_LIMIT)
.sort((a, b) => b.createTime - a.createTime)
if (cachedPosts.length > 0) {
setPosts(cachedPosts)
setHasMore(true)
setHasNewer(false)
}
}
} catch (error) {
console.error('Failed to hydrate SNS page cache:', error)
}
}, [ensureSnsCacheScopeKey])
const loadOverviewStats = useCallback(async () => {
setOverviewStatsStatus('loading')
try {
const statsResult = await window.electronAPI.sns.getExportStats()
if (!statsResult.success || !statsResult.data) {
throw new Error(statsResult.error || '获取朋友圈统计失败')
}
const totalPosts = Math.max(0, Number(statsResult.data.totalPosts || 0))
const totalFriends = Math.max(0, Number(statsResult.data.totalFriends || 0))
const myPosts = (typeof statsResult.data.myPosts === 'number' && Number.isFinite(statsResult.data.myPosts) && statsResult.data.myPosts >= 0)
? Math.floor(statsResult.data.myPosts)
: null
let earliestTime: number | null = null
let latestTime: number | null = null
if (totalPosts > 0) {
const [latestResult, earliestResult] = await Promise.all([
window.electronAPI.sns.getTimeline(1, 0),
window.electronAPI.sns.getTimeline(1, Math.max(totalPosts - 1, 0))
])
const latestTs = Number(latestResult.timeline?.[0]?.createTime || 0)
const earliestTs = Number(earliestResult.timeline?.[0]?.createTime || 0)
if (latestResult.success && Number.isFinite(latestTs) && latestTs > 0) {
latestTime = Math.floor(latestTs)
}
if (earliestResult.success && Number.isFinite(earliestTs) && earliestTs > 0) {
earliestTime = Math.floor(earliestTs)
}
}
const nextOverviewStats = {
totalPosts,
totalFriends,
myPosts,
earliestTime,
latestTime
}
setOverviewStats(nextOverviewStats)
setOverviewStatsStatus('ready')
void persistSnsPageCache({ overviewStats: nextOverviewStats })
} catch (error) {
console.error('Failed to load SNS overview stats:', error)
setOverviewStatsStatus('error')
}
}, [persistSnsPageCache])
const renderOverviewStats = () => {
if (overviewStatsStatus === 'error') {
return (
<button type="button" className="feed-stats-retry" onClick={() => { void loadOverviewStats() }}>
</button>
)
}
if (overviewStatsStatus === 'loading') {
return '统计中...'
}
return `${overviewStats.totalPosts} ${formatDateOnly(overviewStats.earliestTime)} ~ ${formatDateOnly(overviewStats.latestTime)} ${overviewStats.totalFriends} 位好友`
}
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) {
const merged = [...uniqueNewer, ...currentPosts].sort((a, b) => b.createTime - a.createTime)
setPosts(merged);
void persistSnsPageCache({ posts: merged })
}
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)
void persistSnsPageCache({ posts: 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) {
const merged = [...postsRef.current, ...result.timeline!].sort((a, b) => b.createTime - a.createTime)
setPosts(merged)
void persistSnsPageCache({ posts: merged })
}
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
}
}, [jumpTargetDate, persistSnsPageCache, searchKeyword, selectedUsernames])
const stopContactsCountHydration = useCallback((resetProgress = false) => {
contactsCountHydrationTokenRef.current += 1
if (contactsCountBatchTimerRef.current) {
window.clearTimeout(contactsCountBatchTimerRef.current)
contactsCountBatchTimerRef.current = null
}
if (resetProgress) {
setContactsCountProgress({
resolved: 0,
total: 0,
running: false
})
} else {
setContactsCountProgress((prev) => ({ ...prev, running: false }))
}
}, [])
const hydrateContactPostCounts = useCallback(async (
usernames: string[],
options?: { force?: boolean; readyUsernames?: Set<string> }
) => {
const force = options?.force === true
const targets = usernames
.map((username) => String(username || '').trim())
.filter(Boolean)
stopContactsCountHydration(true)
if (targets.length === 0) return
const readySet = options?.readyUsernames || new Set(
contactsRef.current
.filter((contact) => contact.postCountStatus === 'ready' && typeof contact.postCount === 'number')
.map((contact) => contact.username)
)
const pendingTargets = force ? targets : targets.filter((username) => !readySet.has(username))
const runToken = ++contactsCountHydrationTokenRef.current
const totalTargets = targets.length
const targetSet = new Set(pendingTargets)
if (pendingTargets.length > 0) {
setContacts((prev) => {
let changed = false
const next = prev.map((contact) => {
if (!targetSet.has(contact.username)) return contact
if (contact.postCountStatus === 'loading' && typeof contact.postCount !== 'number') return contact
changed = true
return {
...contact,
postCount: force ? undefined : contact.postCount,
postCountStatus: 'loading' as ContactPostCountStatus
}
})
return changed ? sortContactsForRanking(next) : prev
})
}
const preResolved = Math.max(0, totalTargets - pendingTargets.length)
setContactsCountProgress({
resolved: preResolved,
total: totalTargets,
running: pendingTargets.length > 0
})
if (pendingTargets.length === 0) return
let normalizedCounts: Record<string, number> = {}
try {
const result = await window.electronAPI.sns.getUserPostCounts()
if (runToken !== contactsCountHydrationTokenRef.current) return
if (result.success && result.counts) {
normalizedCounts = Object.fromEntries(
Object.entries(result.counts).map(([username, value]) => [username, normalizePostCount(value)])
)
void (async () => {
try {
const scopeKey = await ensureSnsUserPostCountsCacheScopeKey()
await configService.setExportSnsUserPostCountsCache(scopeKey, normalizedCounts)
} catch (cacheError) {
console.error('Failed to persist SNS user post counts cache:', cacheError)
}
})()
}
} catch (error) {
console.error('Failed to load contact post counts:', error)
}
let resolved = preResolved
let cursor = 0
const applyBatch = () => {
if (runToken !== contactsCountHydrationTokenRef.current) return
const batch = pendingTargets.slice(cursor, cursor + CONTACT_COUNT_BATCH_SIZE)
if (batch.length === 0) {
setContactsCountProgress({
resolved: totalTargets,
total: totalTargets,
running: false
})
contactsCountBatchTimerRef.current = null
return
}
const batchSet = new Set(batch)
setContacts((prev) => {
let changed = false
const next = prev.map((contact) => {
if (!batchSet.has(contact.username)) return contact
const nextCount = normalizePostCount(normalizedCounts[contact.username])
if (contact.postCountStatus === 'ready' && contact.postCount === nextCount) return contact
changed = true
return {
...contact,
postCount: nextCount,
postCountStatus: 'ready' as ContactPostCountStatus
}
})
return changed ? sortContactsForRanking(next) : prev
})
resolved += batch.length
cursor += batch.length
setContactsCountProgress({
resolved,
total: totalTargets,
running: resolved < totalTargets
})
if (cursor < totalTargets) {
contactsCountBatchTimerRef.current = window.setTimeout(applyBatch, CONTACT_COUNT_SORT_DEBOUNCE_MS)
} else {
contactsCountBatchTimerRef.current = null
}
}
applyBatch()
}, [normalizePostCount, sortContactsForRanking, stopContactsCountHydration])
// Load Contacts先按最近会话显示联系人再异步统计朋友圈条数并增量排序
const loadContacts = useCallback(async () => {
const requestToken = ++contactsLoadTokenRef.current
stopContactsCountHydration(true)
setContactsLoading(true)
try {
const snsPostCountsScopeKey = await ensureSnsUserPostCountsCacheScopeKey()
const [cachedPostCountsItem, cachedContactsItem, cachedAvatarItem] = await Promise.all([
configService.getExportSnsUserPostCountsCache(snsPostCountsScopeKey),
configService.getContactsListCache(snsPostCountsScopeKey),
configService.getContactsAvatarCache(snsPostCountsScopeKey)
])
const cachedPostCounts = cachedPostCountsItem?.counts || {}
const cachedAvatarMap = cachedAvatarItem?.avatars || {}
const cachedContacts = (cachedContactsItem?.contacts || [])
.filter((contact) => contact.type === 'friend' || contact.type === 'former_friend')
.map((contact) => {
const cachedCount = cachedPostCounts[contact.username]
const hasCachedCount = typeof cachedCount === 'number' && Number.isFinite(cachedCount)
return {
username: contact.username,
displayName: contact.displayName || contact.username,
avatarUrl: cachedAvatarMap[contact.username]?.avatarUrl,
type: (contact.type === 'former_friend' ? 'former_friend' : 'friend') as 'friend' | 'former_friend',
lastSessionTimestamp: 0,
postCount: hasCachedCount ? Math.max(0, Math.floor(cachedCount)) : undefined,
postCountStatus: hasCachedCount ? 'ready' as ContactPostCountStatus : 'idle' as ContactPostCountStatus
}
})
if (requestToken !== contactsLoadTokenRef.current) return
if (cachedContacts.length > 0) {
const cachedContactsSorted = sortContactsForRanking(cachedContacts)
setContacts(cachedContactsSorted)
setContactsLoading(false)
const cachedReadyCount = cachedContactsSorted.filter(contact => contact.postCountStatus === 'ready').length
setContactsCountProgress({
resolved: cachedReadyCount,
total: cachedContactsSorted.length,
running: cachedReadyCount < cachedContactsSorted.length
})
}
const [contactsResult, sessionsResult] = await Promise.all([
window.electronAPI.chat.getContacts(),
window.electronAPI.chat.getSessions()
])
const contactMap = new Map<string, Contact>()
const sessionTimestampMap = new Map<string, number>()
if (sessionsResult.success && Array.isArray(sessionsResult.sessions)) {
for (const session of sessionsResult.sessions) {
const username = String(session?.username || '').trim()
if (!username) continue
const ts = Math.max(
Number(session?.sortTimestamp || 0),
Number(session?.lastTimestamp || 0)
)
const prevTs = Number(sessionTimestampMap.get(username) || 0)
if (ts > prevTs) {
sessionTimestampMap.set(username, ts)
}
}
}
if (contactsResult.success && contactsResult.contacts) {
for (const c of contactsResult.contacts) {
if (c.type === 'friend' || c.type === 'former_friend') {
const cachedCount = cachedPostCounts[c.username]
const hasCachedCount = typeof cachedCount === 'number' && Number.isFinite(cachedCount)
contactMap.set(c.username, {
username: c.username,
displayName: c.displayName,
avatarUrl: c.avatarUrl,
type: c.type === 'former_friend' ? 'former_friend' : 'friend',
lastSessionTimestamp: Number(sessionTimestampMap.get(c.username) || 0),
postCount: hasCachedCount ? Math.max(0, Math.floor(cachedCount)) : undefined,
postCountStatus: hasCachedCount ? 'ready' : 'idle'
})
}
}
}
let contactsList = sortContactsForRanking(Array.from(contactMap.values()))
if (requestToken !== contactsLoadTokenRef.current) return
setContacts(contactsList)
const readyUsernames = new Set(
contactsList
.filter((contact) => contact.postCountStatus === 'ready' && typeof contact.postCount === 'number')
.map((contact) => contact.username)
)
void hydrateContactPostCounts(
contactsList.map(contact => contact.username),
{ readyUsernames }
)
const allUsernames = contactsList.map(c => c.username)
// 用 enrichSessionsContactInfo 统一补充头像和显示名
if (allUsernames.length > 0) {
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(allUsernames)
if (enriched.success && enriched.contacts) {
contactsList = contactsList.map((contact) => {
const extra = enriched.contacts?.[contact.username]
if (!extra) return contact
return {
...contact,
displayName: extra.displayName || contact.displayName,
avatarUrl: extra.avatarUrl || contact.avatarUrl
}
})
if (requestToken !== contactsLoadTokenRef.current) return
setContacts((prev) => {
const prevMap = new Map(prev.map((contact) => [contact.username, contact]))
const merged = contactsList.map((contact) => {
const previous = prevMap.get(contact.username)
return {
...contact,
lastSessionTimestamp: previous?.lastSessionTimestamp ?? contact.lastSessionTimestamp,
postCount: previous?.postCount,
postCountStatus: previous?.postCountStatus ?? contact.postCountStatus
}
})
return sortContactsForRanking(merged)
})
}
}
} catch (error) {
if (requestToken !== contactsLoadTokenRef.current) return
console.error('Failed to load contacts:', error)
stopContactsCountHydration(true)
} finally {
if (requestToken === contactsLoadTokenRef.current) {
setContactsLoading(false)
}
}
}, [ensureSnsUserPostCountsCacheScopeKey, hydrateContactPostCounts, sortContactsForRanking, stopContactsCountHydration])
const closeAuthorTimeline = useCallback(() => {
setAuthorTimelineTarget(null)
}, [])
const openAuthorTimeline = useCallback((post: SnsPost) => {
setAuthorTimelineTarget({
username: post.username,
displayName: decodeHtmlEntities(post.nickname || '') || post.username,
avatarUrl: post.avatarUrl
})
}, [decodeHtmlEntities])
const handlePostDelete = useCallback((postId: string, username: string) => {
setPosts(prev => {
const next = prev.filter(p => p.id !== postId)
void persistSnsPageCache({ posts: next })
return next
})
void loadOverviewStats()
}, [loadOverviewStats, persistSnsPageCache])
// Initial Load & Listeners
useEffect(() => {
void hydrateSnsPageCache()
loadContacts()
loadOverviewStats()
}, [hydrateSnsPageCache, loadContacts, loadOverviewStats])
useEffect(() => {
return () => {
contactsCountHydrationTokenRef.current += 1
if (contactsCountBatchTimerRef.current) {
window.clearTimeout(contactsCountBatchTimerRef.current)
contactsCountBatchTimerRef.current = null
}
}
}, [])
useEffect(() => {
const handleChange = () => {
cacheScopeKeyRef.current = ''
snsUserPostCountsCacheScopeKeyRef.current = ''
// wxid changed, reset everything
stopContactsCountHydration(true)
setContacts([])
setPosts([]); setHasMore(true); setHasNewer(false);
setSelectedUsernames([]); setSearchKeyword(''); setJumpTargetDate(undefined);
void hydrateSnsPageCache()
loadContacts();
loadOverviewStats();
loadPosts({ reset: true });
}
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, [hydrateSnsPageCache, loadContacts, loadOverviewStats, loadPosts, stopContactsCountHydration])
useEffect(() => {
const timer = setTimeout(() => {
loadPosts({ reset: true })
}, 500)
return () => clearTimeout(timer)
}, [selectedUsernames, searchKeyword, jumpTargetDate, loadPosts])
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
const container = postsContainerRef.current
if (!container) return
if (e.deltaY < -20 && container.scrollTop <= 0 && hasNewer && !loading && !loadingNewer) {
loadPosts({ direction: 'newer' })
}
}
return (
<div className="sns-page-layout">
<div className="sns-main-viewport">
<div className="sns-feed-container">
<div className="feed-header">
<div className="feed-header-main">
<h2></h2>
<div className={`feed-stats-line ${overviewStatsStatus}`}>
{renderOverviewStats()}
</div>
</div>
<div className="header-actions">
<button
onClick={async () => {
setTriggerMessage(null)
setShowTriggerDialog(true)
setTriggerLoading(true)
try {
const r = await window.electronAPI.sns.checkBlockDeleteTrigger()
setTriggerInstalled(r.success ? (r.installed ?? false) : false)
} catch {
setTriggerInstalled(false)
} finally {
setTriggerLoading(false)
}
}}
className="icon-btn"
title="朋友圈保护插件"
>
<Shield size={20} />
</button>
<button
onClick={() => {
setExportResult(null)
setExportProgress(null)
setExportDateRange({ start: '', end: '' })
setShowExportDialog(true)
}}
className="icon-btn export-btn"
title="导出朋友圈"
>
<Download size={20} />
</button>
<button
onClick={() => {
setRefreshSpin(true)
loadPosts({ reset: true })
loadOverviewStats()
setTimeout(() => setRefreshSpin(false), 800)
}}
disabled={loading || loadingNewer}
className="icon-btn refresh-btn"
title="从头刷新"
>
<RefreshCw size={20} className={(loading || loadingNewer || refreshSpin) ? 'spinning' : ''} />
</button>
</div>
</div>
<div className="sns-posts-scroll" onScroll={handleScroll} onWheel={handleWheel} ref={postsContainerRef}>
{loadingNewer && (
<div className="status-indicator loading-newer">
<RefreshCw size={16} className="spinning" />
<span>...</span>
</div>
)}
{!loadingNewer && hasNewer && (
<div className="status-indicator newer-hint" onClick={() => loadPosts({ direction: 'newer' })}>
</div>
)}
<div className="posts-list">
{posts.map(post => (
<SnsPostItem
key={post.id}
post={{ ...post, isProtected: triggerInstalled === true }}
onPreview={(src, isVideo, liveVideoPath) => {
if (isVideo) {
void window.electronAPI.window.openVideoPlayerWindow(src)
} else {
void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined)
}
}}
onDebug={(p) => setDebugPost(p)}
onDelete={handlePostDelete}
onOpenAuthorPosts={openAuthorTimeline}
/>
))}
</div>
{loading && posts.length === 0 && (
<div className="initial-loading">
<div className="loading-pulse">
<div className="pulse-circle"></div>
<span>...</span>
</div>
</div>
)}
{loading && posts.length > 0 && (
<div className="status-indicator loading-more">
<RefreshCw size={16} className="spinning" />
<span>...</span>
</div>
)}
{!hasMore && posts.length > 0 && (
<div className="status-indicator no-more">{
selectedUsernames.length === 1 &&
contacts.find(c => c.username === selectedUsernames[0])?.type === 'former_friend'
? '在时间的长河里刻舟求剑'
: '或许过往已无可溯洄,但好在还有可以与你相遇的明天'
}</div>
)}
{!loading && posts.length === 0 && (
<div className="no-results">
<div className="no-results-icon"><Search size={48} /></div>
<p></p>
{(selectedUsernames.length > 0 || searchKeyword || jumpTargetDate) && (
<button onClick={() => {
setSearchKeyword(''); setSelectedUsernames([]); setJumpTargetDate(undefined);
}} className="reset-inline">
</button>
)}
</div>
)}
</div>
</div>
</div>
<SnsFilterPanel
searchKeyword={searchKeyword}
setSearchKeyword={setSearchKeyword}
jumpTargetDate={jumpTargetDate}
setJumpTargetDate={setJumpTargetDate}
onOpenJumpDialog={() => setShowJumpDialog(true)}
selectedUsernames={selectedUsernames}
setSelectedUsernames={setSelectedUsernames}
contacts={contacts}
contactSearch={contactSearch}
setContactSearch={setContactSearch}
loading={contactsLoading}
contactsCountProgress={contactsCountProgress}
/>
{/* Dialogs and Overlays */}
<JumpToDateDialog
isOpen={showJumpDialog}
onClose={() => setShowJumpDialog(false)}
onSelect={(date) => {
setJumpTargetDate(date)
setShowJumpDialog(false)
}}
currentDate={jumpTargetDate || new Date()}
/>
<ContactSnsTimelineDialog
target={authorTimelineTarget}
onClose={closeAuthorTimeline}
isProtected={triggerInstalled === true}
onDeletePost={handlePostDelete}
/>
{debugPost && (
<div className="modal-overlay" onClick={() => setDebugPost(null)}>
<div className="debug-dialog" onClick={(e) => e.stopPropagation()}>
<div className="debug-dialog-header">
<h3></h3>
<button className="close-btn" onClick={() => setDebugPost(null)}>
<X size={20} />
</button>
</div>
<div className="debug-dialog-body">
<pre className="json-code">
{JSON.stringify(debugPost, null, 2)}
</pre>
</div>
</div>
</div>
)}
{/* 朋友圈防删除插件对话框 */}
{showTriggerDialog && (
<div className="modal-overlay" onClick={() => { setShowTriggerDialog(false); setTriggerMessage(null) }}>
<div className="sns-protect-dialog" onClick={(e) => e.stopPropagation()}>
<button className="close-btn sns-protect-close" onClick={() => { setShowTriggerDialog(false); setTriggerMessage(null) }}>
<X size={18} />
</button>
{/* 顶部图标区 */}
<div className="sns-protect-hero">
<div className={`sns-protect-icon-wrap ${triggerInstalled ? 'active' : ''}`}>
{triggerLoading
? <RefreshCw size={28} className="spinning" />
: triggerInstalled
? <Shield size={28} />
: <ShieldOff size={28} />
}
</div>
<div className="sns-protect-title"></div>
<div className={`sns-protect-status-badge ${triggerInstalled ? 'on' : 'off'}`}>
{triggerLoading ? '检查中…' : triggerInstalled ? '已启用' : '未启用'}
</div>
</div>
{/* 说明 */}
<div className="sns-protect-desc">
WeFlow将拦截朋友圈删除操作<br/><br/>
</div>
{/* 操作反馈 */}
{triggerMessage && (
<div className={`sns-protect-feedback ${triggerMessage.type}`}>
{triggerMessage.type === 'success' ? <CheckCircle size={14} /> : <AlertCircle size={14} />}
<span>{triggerMessage.text}</span>
</div>
)}
{/* 操作按钮 */}
<div className="sns-protect-actions">
{!triggerInstalled ? (
<button
className="sns-protect-btn primary"
disabled={triggerLoading}
onClick={async () => {
setTriggerLoading(true)
setTriggerMessage(null)
try {
const r = await window.electronAPI.sns.installBlockDeleteTrigger()
if (r.success) {
setTriggerInstalled(true)
setTriggerMessage({ type: 'success', text: r.alreadyInstalled ? '插件已存在,无需重复安装' : '已启用朋友圈防删除保护' })
} else {
setTriggerMessage({ type: 'error', text: r.error || '安装失败' })
}
} catch (e: any) {
setTriggerMessage({ type: 'error', text: e.message || String(e) })
} finally {
setTriggerLoading(false)
}
}}
>
<Shield size={15} />
</button>
) : (
<button
className="sns-protect-btn danger"
disabled={triggerLoading}
onClick={async () => {
setTriggerLoading(true)
setTriggerMessage(null)
try {
const r = await window.electronAPI.sns.uninstallBlockDeleteTrigger()
if (r.success) {
setTriggerInstalled(false)
setTriggerMessage({ type: 'success', text: '已关闭朋友圈防删除保护' })
} else {
setTriggerMessage({ type: 'error', text: r.error || '卸载失败' })
}
} catch (e: any) {
setTriggerMessage({ type: 'error', text: e.message || String(e) })
} finally {
setTriggerLoading(false)
}
}}
>
<ShieldOff size={15} />
</button>
)}
</div>
</div>
</div>
)}
{/* 导出对话框 */}
{showExportDialog && (
<div className="modal-overlay" onClick={() => !isExporting && setShowExportDialog(false)}>
<div className="export-dialog" onClick={(e) => e.stopPropagation()}>
<div className="export-dialog-header">
<h3></h3>
<button className="close-btn" onClick={() => !isExporting && setShowExportDialog(false)} disabled={isExporting}>
<X size={20} />
</button>
</div>
<div className="export-dialog-body">
{/* 筛选条件提示 */}
{(selectedUsernames.length > 0 || searchKeyword) && (
<div className="export-filter-info">
<span className="filter-badge"></span>
{searchKeyword && <span className="filter-tag">: "{searchKeyword}"</span>}
{selectedUsernames.length > 0 && (
<span className="filter-tag">
<Users size={12} />
{selectedUsernames.length}
<span className="sync-hint"></span>
</span>
)}
</div>
)}
{!exportResult ? (
<>
{/* 格式选择 */}
<div className="export-section">
<label className="export-label"></label>
<div className="export-format-options">
<button
className={`format-option ${exportFormat === 'html' ? 'active' : ''}`}
onClick={() => setExportFormat('html')}
disabled={isExporting}
>
<FileText size={20} />
<span>HTML</span>
<small></small>
</button>
<button
className={`format-option ${exportFormat === 'json' ? 'active' : ''}`}
onClick={() => setExportFormat('json')}
disabled={isExporting}
>
<FileJson size={20} />
<span>JSON</span>
<small></small>
</button>
<button
className={`format-option ${exportFormat === 'arkmejson' ? 'active' : ''}`}
onClick={() => setExportFormat('arkmejson')}
disabled={isExporting}
>
<FileJson size={20} />
<span>ArkmeJSON</span>
<small></small>
</button>
</div>
</div>
{/* 输出路径 */}
<div className="export-section">
<label className="export-label"></label>
<div className="export-path-row">
<input
type="text"
value={exportFolder}
readOnly
placeholder="点击选择输出目录..."
className="export-path-input"
/>
<button
className="export-browse-btn"
onClick={async () => {
const result = await window.electronAPI.sns.selectExportDir()
if (!result.canceled && result.filePath) {
setExportFolder(result.filePath)
}
}}
disabled={isExporting}
>
<FolderOpen size={16} />
</button>
</div>
</div>
{/* 时间范围 */}
<div className="export-section">
<label className="export-label"><Calendar size={14} /> </label>
<div className="export-date-row">
<div className="date-picker-trigger" onClick={() => {
if (!isExporting) setCalendarPicker(prev => prev?.field === 'start' ? null : { field: 'start', month: exportDateRange.start ? new Date(exportDateRange.start) : new Date() })
}}>
<Calendar size={14} />
<span className={exportDateRange.start ? '' : 'placeholder'}>
{exportDateRange.start || '开始日期'}
</span>
{exportDateRange.start && (
<X size={12} className="clear-date" onClick={(e) => { e.stopPropagation(); setExportDateRange(prev => ({ ...prev, start: '' })) }} />
)}
</div>
<span className="date-separator"></span>
<div className="date-picker-trigger" onClick={() => {
if (!isExporting) setCalendarPicker(prev => prev?.field === 'end' ? null : { field: 'end', month: exportDateRange.end ? new Date(exportDateRange.end) : new Date() })
}}>
<Calendar size={14} />
<span className={exportDateRange.end ? '' : 'placeholder'}>
{exportDateRange.end || '结束日期'}
</span>
{exportDateRange.end && (
<X size={12} className="clear-date" onClick={(e) => { e.stopPropagation(); setExportDateRange(prev => ({ ...prev, end: '' })) }} />
)}
</div>
</div>
</div>
{/* 媒体导出 */}
<div className="export-section">
<label className="export-label">
<Image size={14} />
</label>
<div className="export-media-check-grid">
<label>
<input
type="checkbox"
checked={exportImages}
onChange={(e) => setExportImages(e.target.checked)}
disabled={isExporting}
/>
</label>
<label>
<input
type="checkbox"
checked={exportLivePhotos}
onChange={(e) => setExportLivePhotos(e.target.checked)}
disabled={isExporting}
/>
</label>
<label>
<input
type="checkbox"
checked={exportVideos}
onChange={(e) => setExportVideos(e.target.checked)}
disabled={isExporting}
/>
</label>
</div>
<p className="export-media-hint"></p>
</div>
{/* 同步提示 */}
<div className="export-sync-hint">
<Info size={14} />
<span></span>
</div>
{/* 进度条 */}
{isExporting && exportProgress && (
<div className="export-progress">
<div className="export-progress-bar">
<div
className="export-progress-fill"
style={{ width: exportProgress.total > 0 ? `${Math.round((exportProgress.current / exportProgress.total) * 100)}%` : '100%' }}
/>
</div>
<span className="export-progress-text">{exportProgress.status}</span>
</div>
)}
{/* 操作按钮 */}
<div className="export-actions">
<button
className="export-cancel-btn"
onClick={() => setShowExportDialog(false)}
disabled={isExporting}
>
</button>
<button
className="export-start-btn"
disabled={!exportFolder || isExporting}
onClick={async () => {
setIsExporting(true)
setExportProgress({ current: 0, total: 0, status: '准备导出...' })
setExportResult(null)
// 监听进度
const removeProgress = window.electronAPI.sns.onExportProgress((progress: any) => {
setExportProgress(progress)
})
try {
const result = await window.electronAPI.sns.exportTimeline({
outputDir: exportFolder,
format: exportFormat,
usernames: selectedUsernames.length > 0 ? selectedUsernames : undefined,
keyword: searchKeyword || undefined,
exportImages,
exportLivePhotos,
exportVideos,
startTime: exportDateRange.start ? Math.floor(new Date(exportDateRange.start).getTime() / 1000) : undefined,
endTime: exportDateRange.end ? Math.floor(new Date(exportDateRange.end + 'T23:59:59').getTime() / 1000) : undefined
})
setExportResult(result)
} catch (e: any) {
setExportResult({ success: false, error: e.message || String(e) })
} finally {
setIsExporting(false)
removeProgress()
}
}}
>
{isExporting ? '导出中...' : '开始导出'}
</button>
</div>
</>
) : (
/* 导出结果 */
<div className="export-result">
{exportResult.success ? (
<>
<div className="export-result-icon success">
<CheckCircle size={48} />
</div>
<h4></h4>
<p> {exportResult.postCount} {exportResult.mediaCount ? `${exportResult.mediaCount} 个媒体文件` : ''}</p>
<div className="export-result-actions">
<button
className="export-open-btn"
onClick={() => {
if (exportFolder) {
window.electronAPI.shell.openExternal(`file://${exportFolder}`)
}
}}
>
<FolderOpen size={16} />
</button>
<button
className="export-done-btn"
onClick={() => setShowExportDialog(false)}
>
</button>
</div>
</>
) : (
<>
<div className="export-result-icon error">
<AlertCircle size={48} />
</div>
<h4></h4>
<p className="error-text">{exportResult.error}</p>
<button
className="export-done-btn"
onClick={() => setExportResult(null)}
>
</button>
</>
)}
</div>
)}
</div>
</div>
</div>
)}
{/* 日期选择弹窗 */}
{calendarPicker && (
<div className="calendar-overlay" onClick={() => { setCalendarPicker(null); setShowYearMonthPicker(false) }}>
<div className="calendar-modal" onClick={e => e.stopPropagation()}>
<div className="calendar-header">
<div className="title-area">
<Calendar size={18} />
<h3>{calendarPicker.field === 'start' ? '开始' : '结束'}</h3>
</div>
<button className="close-btn" onClick={() => { setCalendarPicker(null); setShowYearMonthPicker(false) }}>
<X size={18} />
</button>
</div>
<div className="calendar-view">
<div className="calendar-nav">
<button className="nav-btn" onClick={() => setCalendarPicker(prev => prev ? { ...prev, month: new Date(prev.month.getFullYear(), prev.month.getMonth() - 1, 1) } : null)}>
<ChevronLeft size={18} />
</button>
<span className="current-month clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
{calendarPicker.month.getFullYear()}{calendarPicker.month.getMonth() + 1}
</span>
<button className="nav-btn" onClick={() => setCalendarPicker(prev => prev ? { ...prev, month: new Date(prev.month.getFullYear(), prev.month.getMonth() + 1, 1) } : null)}>
<ChevronRight size={18} />
</button>
</div>
{showYearMonthPicker ? (
<div className="year-month-picker">
<div className="year-selector">
<button className="nav-btn" onClick={() => setCalendarPicker(prev => prev ? { ...prev, month: new Date(prev.month.getFullYear() - 1, prev.month.getMonth(), 1) } : null)}>
<ChevronLeft size={16} />
</button>
<span className="year-label">{calendarPicker.month.getFullYear()}</span>
<button className="nav-btn" onClick={() => setCalendarPicker(prev => prev ? { ...prev, month: new Date(prev.month.getFullYear() + 1, prev.month.getMonth(), 1) } : null)}>
<ChevronRight size={16} />
</button>
</div>
<div className="month-grid">
{['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => (
<button
key={i}
className={`month-btn ${i === calendarPicker.month.getMonth() ? 'active' : ''}`}
onClick={() => {
setCalendarPicker(prev => prev ? { ...prev, month: new Date(prev.month.getFullYear(), i, 1) } : null)
setShowYearMonthPicker(false)
}}
>{name}</button>
))}
</div>
</div>
) : (
<>
<div className="calendar-weekdays">
{['日', '一', '二', '三', '四', '五', '六'].map(d => <div key={d} className="weekday">{d}</div>)}
</div>
<div className="calendar-days">
{(() => {
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 <div key={i} className="day-cell empty" />
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 (
<div
key={i}
className={`day-cell${isSelected ? ' selected' : ''}${isToday ? ' today' : ''}`}
onClick={() => {
setExportDateRange(prev => ({ ...prev, [calendarPicker.field]: dateStr }))
setCalendarPicker(null)
}}
>{day}</div>
)
})
})()}
</div>
</>
)}
</div>
<div className="quick-options">
<button onClick={() => {
if (calendarPicker.field === 'start') {
const d = new Date(); d.setMonth(d.getMonth() - 1)
setExportDateRange(prev => ({ ...prev, start: d.toISOString().split('T')[0] }))
} else {
setExportDateRange(prev => ({ ...prev, end: new Date().toISOString().split('T')[0] }))
}
setCalendarPicker(null)
}}>{calendarPicker.field === 'start' ? '一个月前' : '今天'}</button>
<button onClick={() => {
if (calendarPicker.field === 'start') {
const d = new Date(); d.setMonth(d.getMonth() - 3)
setExportDateRange(prev => ({ ...prev, start: d.toISOString().split('T')[0] }))
} else {
const d = new Date(); d.setMonth(d.getMonth() - 1)
setExportDateRange(prev => ({ ...prev, end: d.toISOString().split('T')[0] }))
}
setCalendarPicker(null)
}}>{calendarPicker.field === 'start' ? '三个月前' : '一个月前'}</button>
</div>
<div className="dialog-footer">
<button className="cancel-btn" onClick={() => { setCalendarPicker(null); setShowYearMonthPicker(false) }}></button>
</div>
</div>
</div>
)}
</div>
)
}