支持朋友圈导出

This commit is contained in:
cc
2026-02-20 21:50:02 +08:00
parent 6d74eb65ae
commit 9aee578707
7 changed files with 1597 additions and 12 deletions

View File

@@ -54,6 +54,12 @@
color: var(--text-primary);
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
}
.icon-btn {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
@@ -69,8 +75,14 @@
transform: scale(1.05);
}
&.spinning {
animation: spin 1s linear infinite;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.spinning {
animation: spin 0.8s linear infinite;
}
}
}
@@ -746,6 +758,53 @@
}
}
/* Initial Loading Animation */
.initial-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 120px 0;
.loading-pulse {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
.pulse-circle {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--primary, #576b95);
opacity: 0.25;
animation: pulse-ring 1.4s ease-in-out infinite;
}
span {
font-size: 14px;
color: var(--text-tertiary);
letter-spacing: 0.5px;
}
}
}
@keyframes pulse-ring {
0% {
transform: scale(0.6);
opacity: 0.15;
}
50% {
transform: scale(1.0);
opacity: 0.35;
}
100% {
transform: scale(0.6);
opacity: 0.15;
}
}
.no-results {
display: flex;
flex-direction: column;
@@ -872,4 +931,748 @@
opacity: 1;
transform: translateY(0);
}
}
/* =========================================
Export Dialog
========================================= */
.export-dialog {
background: rgba(255, 255, 255, 0.88);
border-radius: var(--sns-border-radius-lg);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
width: 480px;
max-width: 92vw;
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
overflow: hidden;
animation: slide-up-fade 0.3s cubic-bezier(0.16, 1, 0.3, 1);
.export-dialog-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: space-between;
h3 {
margin: 0;
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
.close-btn {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
padding: 6px;
border-radius: 6px;
display: flex;
&:hover {
background: rgba(0, 0, 0, 0.05);
color: var(--text-primary);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
}
.export-dialog-body {
padding: 20px;
display: flex;
flex-direction: column;
gap: 18px;
}
}
.export-filter-info {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 10px 14px;
background: var(--bg-tertiary);
border-radius: 8px;
border: 1px solid var(--border-color);
.filter-badge {
font-size: 12px;
font-weight: 600;
color: #fff;
background: var(--primary, #576b95);
padding: 2px 8px;
border-radius: 10px;
}
.filter-tag {
font-size: 13px;
color: var(--text-secondary);
background: var(--bg-primary);
padding: 2px 10px;
border-radius: 6px;
border: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 4px;
svg {
flex-shrink: 0;
}
.sync-hint {
font-size: 11px;
color: var(--text-tertiary);
font-style: italic;
}
}
}
.export-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.export-format-options {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
.format-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 14px 10px;
border-radius: 10px;
border: 2px solid var(--border-color);
background: var(--bg-primary);
cursor: pointer;
transition: all 0.2s;
span {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
small {
font-size: 11px;
color: var(--text-tertiary);
}
svg {
color: var(--text-tertiary);
}
&:hover:not(:disabled) {
border-color: var(--primary, #576b95);
background: var(--bg-tertiary);
}
&.active {
border-color: var(--primary, #576b95);
background: rgba(87, 107, 149, 0.08);
svg {
color: var(--primary, #576b95);
}
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
.export-path-row {
display: flex;
gap: 8px;
.export-path-input {
flex: 1;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
font-size: 13px;
outline: none;
cursor: default;
&::placeholder {
color: var(--text-tertiary);
}
}
.export-browse-btn {
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-tertiary);
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
transition: all 0.15s;
&:hover:not(:disabled) {
background: var(--primary, #576b95);
color: #fff;
border-color: var(--primary, #576b95);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
.export-date-row {
display: flex;
align-items: center;
gap: 8px;
.date-picker-trigger {
flex: 1;
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
min-height: 36px;
&:hover {
border-color: var(--primary, #576b95);
}
&>svg:first-child {
color: var(--text-tertiary);
flex-shrink: 0;
}
.placeholder {
color: var(--text-tertiary);
}
.clear-date {
margin-left: auto;
color: var(--text-tertiary);
cursor: pointer;
border-radius: 50%;
padding: 1px;
&:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
}
}
.date-separator {
font-size: 13px;
color: var(--text-tertiary);
flex-shrink: 0;
}
}
.calendar-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 2100;
animation: fadeIn 0.2s ease-out;
}
.calendar-modal {
background: var(--card-bg);
width: 340px;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
overflow: hidden;
animation: modalSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
.calendar-header {
padding: 18px 20px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border-color);
.title-area {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-primary);
svg {
color: var(--primary);
}
h3 {
font-size: 16px;
font-weight: 600;
margin: 0;
}
}
.close-btn {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
padding: 4px;
border-radius: 6px;
display: flex;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
}
.calendar-view {
padding: 20px;
.calendar-nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
.current-month {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.nav-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
border-color: var(--primary);
color: var(--primary);
}
}
}
.calendar-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
margin-bottom: 8px;
.weekday {
text-align: center;
font-size: 12px;
font-weight: 500;
color: var(--text-tertiary);
padding: 4px 0;
}
}
.calendar-days {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-template-rows: repeat(6, 36px);
gap: 4px;
.day-cell {
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: var(--text-primary);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
position: relative;
&.empty {
cursor: default;
}
&:not(.empty):hover {
background: var(--bg-hover);
}
&.selected {
background: var(--primary);
color: #fff;
font-weight: 600;
}
&.today:not(.selected) {
color: var(--primary);
font-weight: 600;
background: var(--primary-light);
}
}
}
}
.quick-options {
display: flex;
gap: 8px;
padding: 0 20px 16px;
button {
flex: 1;
padding: 8px;
font-size: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
color: var(--primary);
border-color: var(--primary);
}
}
}
.dialog-footer {
padding: 16px 20px;
display: flex;
gap: 12px;
background: var(--bg-secondary);
button {
flex: 1;
padding: 10px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.cancel-btn {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modalSlideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.export-label {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 4px;
svg {
flex-shrink: 0;
}
}
.export-toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
.toggle-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--text-primary);
svg {
color: var(--text-tertiary);
}
}
.toggle-switch {
width: 44px;
height: 24px;
border-radius: 12px;
border: none;
background: var(--bg-tertiary, #555);
cursor: pointer;
position: relative;
transition: background 0.25s;
padding: 0;
flex-shrink: 0;
&.active {
background: var(--primary, #576b95);
}
.toggle-knob {
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
transition: transform 0.25s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
&.active .toggle-knob {
transform: translateX(20px);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
.export-media-hint {
font-size: 12px;
color: var(--text-tertiary);
margin: 0;
padding-left: 24px;
line-height: 1.4;
}
.export-progress {
display: flex;
flex-direction: column;
gap: 6px;
.export-progress-bar {
height: 6px;
background: var(--bg-tertiary);
border-radius: 3px;
overflow: hidden;
.export-progress-fill {
height: 100%;
background: var(--primary, #576b95);
border-radius: 3px;
transition: width 0.3s ease;
}
}
.export-progress-text {
font-size: 12px;
color: var(--text-tertiary);
text-align: center;
}
}
.export-result {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
padding: 10px 0;
.export-result-icon {
&.success svg {
color: #52c41a;
}
&.error svg {
color: #ff4d4f;
}
}
h4 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
}
p {
margin: 0;
font-size: 14px;
color: var(--text-secondary);
line-height: 1.5;
&.error-text {
color: #ff4d4f;
word-break: break-all;
}
}
.export-result-actions {
display: flex;
gap: 10px;
margin-top: 8px;
}
.export-open-btn,
.export-done-btn {
padding: 8px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border: none;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.15s;
}
.export-open-btn {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
&:hover {
background: var(--bg-primary);
}
}
.export-done-btn {
background: var(--primary, #576b95);
color: #fff;
&:hover {
filter: brightness(1.1);
}
}
}
.export-sync-hint {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: var(--bg-secondary);
border-radius: 8px;
margin: 8px 0;
color: var(--text-tertiary);
font-size: 12px;
border: 1px dashed var(--border-color);
svg {
color: var(--primary);
flex-shrink: 0;
}
}
.export-actions {
display: flex;
gap: 12px;
margin-top: 24px;
button {
flex: 1;
height: 40px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.export-cancel-btn {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
&:hover {
background: var(--hover-bg);
color: var(--text-primary);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.export-start-btn {
background: var(--primary, #576b95);
border: none;
color: #fff;
box-shadow: 0 4px 12px rgba(87, 107, 149, 0.2);
&:hover:not(:disabled) {
filter: brightness(1.1);
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(87, 107, 149, 0.3);
}
&:active:not(:disabled) {
transform: translateY(0);
}
&:disabled {
background: var(--bg-tertiary);
color: var(--text-tertiary);
box-shadow: none;
cursor: not-allowed;
}
}
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState, useRef, useCallback } from 'react'
import { RefreshCw, Search, X, Download, FolderOpen } from 'lucide-react'
import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Users, Info, ChevronLeft, ChevronRight } from 'lucide-react'
import { ImagePreview } from '../components/ImagePreview'
import JumpToDateDialog from '../components/JumpToDateDialog'
import './SnsPage.scss'
@@ -34,6 +34,18 @@ export default function SnsPage() {
const [previewImage, setPreviewImage] = useState<{ src: string, isVideo?: boolean, liveVideoPath?: string } | null>(null)
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 postsContainerRef = useRef<HTMLDivElement>(null)
const [hasNewer, setHasNewer] = useState(false)
const [loadingNewer, setLoadingNewer] = useState(false)
@@ -257,12 +269,28 @@ export default function SnsPage() {
<h2></h2>
<div className="header-actions">
<button
onClick={() => loadPosts({ reset: true })}
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) ? 'spinning' : ''} />
<RefreshCw size={20} className={(loading || loadingNewer || refreshSpin) ? 'spinning' : ''} />
</button>
</div>
</div>
@@ -291,10 +319,21 @@ export default function SnsPage() {
))}
</div>
{loading && <div className="status-indicator loading-more">
<RefreshCw size={16} className="spinning" />
<span>...</span>
</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"></div>
@@ -367,6 +406,338 @@ export default function SnsPage() {
</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)}>
<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)}>
<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">
{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>
<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)}></button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -491,6 +491,18 @@ export interface ElectronAPI {
}>
debugResource: (url: string) => Promise<{ success: boolean; status?: number; headers?: any; error?: string }>
proxyImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; dataUrl?: string; error?: string }>
downloadImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; data?: any; contentType?: string; error?: string }>
exportTimeline: (options: {
outputDir: string
format: 'json' | 'html'
usernames?: string[]
keyword?: string
exportMedia?: boolean
startTime?: number
endTime?: number
}) => Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string }>
onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void
selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }>
}
llama: {
loadModel: (modelPath: string) => Promise<boolean>