Files
WeFlow/src/pages/SnsPage.tsx

911 lines
50 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'
interface Contact {
username: string
displayName: string
avatarUrl?: string
type?: 'friend' | 'former_friend' | 'sns_only'
}
export default function SnsPage() {
const [posts, setPosts] = useState<SnsPost[]>([])
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
const loadingRef = useRef(false)
// 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)
// UI states
const [showJumpDialog, setShowJumpDialog] = useState(false)
const [debugPost, setDebugPost] = useState<SnsPost | null>(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<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 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<string, Contact>()
// 好友和曾经的好友
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<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" onScroll={handleScroll} onWheel={handleWheel} ref={postsContainerRef}>
<div className="sns-feed-container">
<div className="feed-header">
<h2></h2>
<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 })
setTimeout(() => setRefreshSpin(false), 800)
}}
disabled={loading || loadingNewer}
className="icon-btn refresh-btn"
title="从头刷新"
>
<RefreshCw size={20} className={(loading || loadingNewer || refreshSpin) ? 'spinning' : ''} />
</button>
</div>
</div>
{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={(postId) => setPosts(prev => prev.filter(p => p.id !== postId))}
/>
))}
</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>
<SnsFilterPanel
searchKeyword={searchKeyword}
setSearchKeyword={setSearchKeyword}
jumpTargetDate={jumpTargetDate}
setJumpTargetDate={setJumpTargetDate}
onOpenJumpDialog={() => setShowJumpDialog(true)}
selectedUsernames={selectedUsernames}
setSelectedUsernames={setSelectedUsernames}
contacts={contacts}
contactSearch={contactSearch}
setContactSearch={setContactSearch}
loading={contactsLoading}
/>
{/* Dialogs and Overlays */}
<JumpToDateDialog
isOpen={showJumpDialog}
onClose={() => setShowJumpDialog(false)}
onSelect={(date) => {
setJumpTargetDate(date)
setShowJumpDialog(false)
}}
currentDate={jumpTargetDate || new Date()}
/>
{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>
</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">
<div className="export-toggle-row">
<div className="toggle-label">
<Image size={16} />
<span>/</span>
</div>
<button
className={`toggle-switch${exportMedia ? ' active' : ''}`}
onClick={() => !isExporting && setExportMedia(!exportMedia)}
disabled={isExporting}
>
<span className="toggle-knob" />
</button>
</div>
{exportMedia && (
<p className="export-media-hint"> media </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,
exportMedia,
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>
)
}