mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
364 lines
13 KiB
TypeScript
364 lines
13 KiB
TypeScript
import { useState, useEffect } from 'react'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import { Calendar, Loader2, Sparkles, Users } from 'lucide-react'
|
||
import {
|
||
finishBackgroundTask,
|
||
isBackgroundTaskCancelRequested,
|
||
registerBackgroundTask,
|
||
updateBackgroundTask
|
||
} from '../services/backgroundTaskMonitor'
|
||
import './AnnualReportPage.scss'
|
||
|
||
type YearOption = number | 'all'
|
||
type YearsLoadPayload = {
|
||
years?: number[]
|
||
done: boolean
|
||
error?: string
|
||
canceled?: boolean
|
||
strategy?: 'cache' | 'native' | 'hybrid'
|
||
phase?: 'cache' | 'native' | 'scan' | 'done'
|
||
statusText?: string
|
||
nativeElapsedMs?: number
|
||
scanElapsedMs?: number
|
||
totalElapsedMs?: number
|
||
switched?: boolean
|
||
nativeTimedOut?: boolean
|
||
}
|
||
|
||
const formatLoadElapsed = (ms: number) => {
|
||
const totalSeconds = Math.max(0, ms) / 1000
|
||
if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s`
|
||
const minutes = Math.floor(totalSeconds / 60)
|
||
const seconds = Math.floor(totalSeconds % 60)
|
||
return `${minutes}m ${String(seconds).padStart(2, '0')}s`
|
||
}
|
||
|
||
function AnnualReportPage() {
|
||
const navigate = useNavigate()
|
||
const [availableYears, setAvailableYears] = useState<number[]>([])
|
||
const [selectedYear, setSelectedYear] = useState<YearOption | null>(null)
|
||
const [selectedPairYear, setSelectedPairYear] = useState<YearOption | null>(null)
|
||
const [isLoading, setIsLoading] = useState(true)
|
||
const [isLoadingMoreYears, setIsLoadingMoreYears] = useState(false)
|
||
const [hasYearsLoadFinished, setHasYearsLoadFinished] = useState(false)
|
||
const [loadStrategy, setLoadStrategy] = useState<'cache' | 'native' | 'hybrid'>('native')
|
||
const [loadPhase, setLoadPhase] = useState<'cache' | 'native' | 'scan' | 'done'>('native')
|
||
const [loadStatusText, setLoadStatusText] = useState('准备加载年份数据...')
|
||
const [nativeElapsedMs, setNativeElapsedMs] = useState(0)
|
||
const [scanElapsedMs, setScanElapsedMs] = useState(0)
|
||
const [totalElapsedMs, setTotalElapsedMs] = useState(0)
|
||
const [hasSwitchedStrategy, setHasSwitchedStrategy] = useState(false)
|
||
const [nativeTimedOut, setNativeTimedOut] = useState(false)
|
||
const [isGenerating, setIsGenerating] = useState(false)
|
||
const [loadError, setLoadError] = useState<string | null>(null)
|
||
|
||
useEffect(() => {
|
||
let disposed = false
|
||
let taskId = ''
|
||
let uiTaskId = ''
|
||
|
||
const applyLoadPayload = (payload: YearsLoadPayload) => {
|
||
if (uiTaskId) {
|
||
updateBackgroundTask(uiTaskId, {
|
||
detail: payload.statusText || '正在加载可用年份',
|
||
progressText: payload.done
|
||
? '已完成'
|
||
: `${Array.isArray(payload.years) ? payload.years.length : 0} 个年份`
|
||
})
|
||
}
|
||
if (payload.strategy) setLoadStrategy(payload.strategy)
|
||
if (payload.phase) setLoadPhase(payload.phase)
|
||
if (typeof payload.statusText === 'string' && payload.statusText) setLoadStatusText(payload.statusText)
|
||
if (typeof payload.nativeElapsedMs === 'number' && Number.isFinite(payload.nativeElapsedMs)) {
|
||
setNativeElapsedMs(Math.max(0, payload.nativeElapsedMs))
|
||
}
|
||
if (typeof payload.scanElapsedMs === 'number' && Number.isFinite(payload.scanElapsedMs)) {
|
||
setScanElapsedMs(Math.max(0, payload.scanElapsedMs))
|
||
}
|
||
if (typeof payload.totalElapsedMs === 'number' && Number.isFinite(payload.totalElapsedMs)) {
|
||
setTotalElapsedMs(Math.max(0, payload.totalElapsedMs))
|
||
}
|
||
if (typeof payload.switched === 'boolean') setHasSwitchedStrategy(payload.switched)
|
||
if (typeof payload.nativeTimedOut === 'boolean') setNativeTimedOut(payload.nativeTimedOut)
|
||
|
||
const years = Array.isArray(payload.years) ? payload.years : []
|
||
if (years.length > 0) {
|
||
setAvailableYears(years)
|
||
setSelectedYear((prev) => {
|
||
if (prev === 'all') return prev
|
||
if (typeof prev === 'number' && years.includes(prev)) return prev
|
||
return years[0]
|
||
})
|
||
setSelectedPairYear((prev) => {
|
||
if (prev === 'all') return prev
|
||
if (typeof prev === 'number' && years.includes(prev)) return prev
|
||
return years[0]
|
||
})
|
||
setIsLoading(false)
|
||
}
|
||
|
||
if (payload.error && !payload.canceled) {
|
||
setLoadError(payload.error || '加载年度数据失败')
|
||
}
|
||
|
||
if (payload.done) {
|
||
setIsLoading(false)
|
||
setIsLoadingMoreYears(false)
|
||
setHasYearsLoadFinished(true)
|
||
setLoadPhase('done')
|
||
if (uiTaskId) {
|
||
finishBackgroundTask(uiTaskId, payload.canceled ? 'canceled' : 'completed', {
|
||
detail: payload.canceled
|
||
? '年度报告年份加载已停止'
|
||
: `年度报告年份加载完成,共 ${years.length} 个年份`,
|
||
progressText: payload.canceled ? '已停止' : `${years.length} 个年份`
|
||
})
|
||
}
|
||
} else {
|
||
setIsLoadingMoreYears(true)
|
||
setHasYearsLoadFinished(false)
|
||
}
|
||
}
|
||
|
||
const stopListen = window.electronAPI.annualReport.onAvailableYearsProgress((payload) => {
|
||
if (disposed) return
|
||
if (taskId && payload.taskId !== taskId) return
|
||
if (!taskId) taskId = payload.taskId
|
||
applyLoadPayload(payload)
|
||
})
|
||
|
||
const startLoad = async () => {
|
||
uiTaskId = registerBackgroundTask({
|
||
sourcePage: 'annualReport',
|
||
title: '年度报告年份加载',
|
||
detail: '准备使用原生快速模式加载年份',
|
||
progressText: '初始化',
|
||
cancelable: true,
|
||
onCancel: async () => {
|
||
if (taskId) {
|
||
await window.electronAPI.annualReport.cancelAvailableYearsLoad(taskId)
|
||
}
|
||
}
|
||
})
|
||
setIsLoading(true)
|
||
setIsLoadingMoreYears(true)
|
||
setHasYearsLoadFinished(false)
|
||
setLoadStrategy('native')
|
||
setLoadPhase('native')
|
||
setLoadStatusText('准备使用原生快速模式加载年份...')
|
||
setNativeElapsedMs(0)
|
||
setScanElapsedMs(0)
|
||
setTotalElapsedMs(0)
|
||
setHasSwitchedStrategy(false)
|
||
setNativeTimedOut(false)
|
||
setLoadError(null)
|
||
try {
|
||
const startResult = await window.electronAPI.annualReport.startAvailableYearsLoad()
|
||
if (!startResult.success || !startResult.taskId) {
|
||
finishBackgroundTask(uiTaskId, 'failed', {
|
||
detail: startResult.error || '加载年度数据失败'
|
||
})
|
||
setLoadError(startResult.error || '加载年度数据失败')
|
||
setIsLoading(false)
|
||
setIsLoadingMoreYears(false)
|
||
return
|
||
}
|
||
taskId = startResult.taskId
|
||
if (startResult.snapshot) {
|
||
applyLoadPayload(startResult.snapshot)
|
||
}
|
||
} catch (e) {
|
||
console.error(e)
|
||
finishBackgroundTask(uiTaskId, 'failed', {
|
||
detail: String(e)
|
||
})
|
||
setLoadError(String(e))
|
||
setIsLoading(false)
|
||
setIsLoadingMoreYears(false)
|
||
}
|
||
}
|
||
|
||
void startLoad()
|
||
|
||
return () => {
|
||
disposed = true
|
||
stopListen()
|
||
}
|
||
}, [])
|
||
|
||
const handleGenerateReport = async () => {
|
||
if (selectedYear === null) return
|
||
setIsGenerating(true)
|
||
try {
|
||
const yearParam = selectedYear === 'all' ? 0 : selectedYear
|
||
navigate(`/annual-report/view?year=${yearParam}`)
|
||
} catch (e) {
|
||
console.error('生成报告失败:', e)
|
||
} finally {
|
||
setIsGenerating(false)
|
||
}
|
||
}
|
||
|
||
const handleGenerateDualReport = () => {
|
||
if (selectedPairYear === null) return
|
||
const yearParam = selectedPairYear === 'all' ? 0 : selectedPairYear
|
||
navigate(`/dual-report?year=${yearParam}`)
|
||
}
|
||
|
||
if (isLoading && availableYears.length === 0) {
|
||
return (
|
||
<div className="annual-report-page">
|
||
<Loader2 size={32} className="spin" style={{ color: 'var(--text-tertiary)' }} />
|
||
<p style={{ color: 'var(--text-tertiary)', marginTop: 16 }}>正在准备年度报告...</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (availableYears.length === 0 && !isLoadingMoreYears) {
|
||
return (
|
||
<div className="annual-report-page">
|
||
<Calendar size={64} style={{ color: 'var(--text-tertiary)', opacity: 0.5 }} />
|
||
<h2 style={{ fontSize: 20, fontWeight: 600, color: 'var(--text-primary)', margin: '16px 0 8px' }}>暂无聊天记录</h2>
|
||
<p style={{ color: 'var(--text-tertiary)', margin: 0 }}>
|
||
{loadError || '请先解密数据库后再生成年度报告'}
|
||
</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const yearOptions: YearOption[] = availableYears.length > 0
|
||
? ['all', ...availableYears]
|
||
: []
|
||
|
||
const getYearLabel = (value: YearOption | null) => {
|
||
if (!value) return ''
|
||
return value === 'all' ? '全部时间' : `${value} 年`
|
||
}
|
||
|
||
const loadedYearCount = availableYears.length
|
||
const isYearStatusComplete = hasYearsLoadFinished
|
||
const strategyLabel = getStrategyLabel({ loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut })
|
||
const renderYearLoadStatus = () => (
|
||
<div className={`year-load-status ${isYearStatusComplete ? 'complete' : 'loading'}`}>
|
||
{isYearStatusComplete ? (
|
||
<>全部年份已加载完毕</>
|
||
) : (
|
||
<>
|
||
更多年份加载中<span className="dot-ellipsis" aria-hidden="true">...</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
|
||
return (
|
||
<div className="annual-report-page">
|
||
<Sparkles size={32} className="header-icon" />
|
||
<h1 className="page-title">年度报告</h1>
|
||
<p className="page-desc">选择年份,回顾你在微信里的点点滴滴</p>
|
||
|
||
<div className="report-sections">
|
||
<section className="report-section">
|
||
<div className="section-header">
|
||
<div>
|
||
<h2 className="section-title">总年度报告</h2>
|
||
<p className="section-desc">包含所有会话与消息</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="year-grid-with-status">
|
||
<div className="year-grid">
|
||
{yearOptions.map(option => (
|
||
<div
|
||
key={option}
|
||
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''}`}
|
||
onClick={() => setSelectedYear(option)}
|
||
>
|
||
<span className="year-number">{option === 'all' ? '全部' : option}</span>
|
||
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
className="generate-btn"
|
||
onClick={handleGenerateReport}
|
||
disabled={!selectedYear || isGenerating}
|
||
>
|
||
{isGenerating ? (
|
||
<>
|
||
<Loader2 size={20} className="spin" />
|
||
<span>正在生成...</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Sparkles size={20} />
|
||
<span>生成 {getYearLabel(selectedYear)} 年度报告</span>
|
||
</>
|
||
)}
|
||
</button>
|
||
</section>
|
||
|
||
<section className="report-section">
|
||
<div className="section-header">
|
||
<div>
|
||
<h2 className="section-title">双人年度报告</h2>
|
||
<p className="section-desc">选择一位好友,只看你们的私聊</p>
|
||
</div>
|
||
<div className="section-badge">
|
||
<Users size={16} />
|
||
<span>私聊</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="year-grid-with-status">
|
||
<div className="year-grid">
|
||
{yearOptions.map(option => (
|
||
<div
|
||
key={`pair-${option}`}
|
||
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedPairYear === option ? 'selected' : ''}`}
|
||
onClick={() => setSelectedPairYear(option)}
|
||
>
|
||
<span className="year-number">{option === 'all' ? '全部' : option}</span>
|
||
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
className="generate-btn secondary"
|
||
onClick={handleGenerateDualReport}
|
||
disabled={!selectedPairYear}
|
||
>
|
||
<Users size={20} />
|
||
<span>选择好友并生成报告</span>
|
||
</button>
|
||
<p className="section-hint">从聊天排行中选择好友生成双人报告</p>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function getStrategyLabel(params: {
|
||
loadStrategy: 'cache' | 'native' | 'hybrid'
|
||
loadPhase: 'cache' | 'native' | 'scan' | 'done'
|
||
hasYearsLoadFinished: boolean
|
||
hasSwitchedStrategy: boolean
|
||
nativeTimedOut: boolean
|
||
}): string {
|
||
const { loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut } = params
|
||
if (loadStrategy === 'cache') return '缓存模式(快速)'
|
||
if (hasYearsLoadFinished) {
|
||
if (loadStrategy === 'native') return '原生快速模式'
|
||
if (hasSwitchedStrategy || nativeTimedOut) return '混合策略(原生→扫表)'
|
||
return '扫表兼容模式'
|
||
}
|
||
if (loadPhase === 'native') return '原生快速模式(优先)'
|
||
if (loadPhase === 'scan') return '扫表兼容模式(回退)'
|
||
return '混合策略'
|
||
}
|
||
|
||
export default AnnualReportPage
|