731 lines
29 KiB
TypeScript
731 lines
29 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react'
|
|
import { ChevronLeft, ChevronRight, Clock, FileText, CheckSquare, Settings, Search, Trash2, BookOpen, Monitor, Code, GitBranch } from 'lucide-react'
|
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Area, AreaChart, BarChart, Bar } from 'recharts'
|
|
import { getDayTimeData, getWeekTimeData, getMonthTimeData, getYearTimeData } from './api'
|
|
import type { WeekTimeData } from './api'
|
|
import { WEEK_DAYS } from '@shared/constants'
|
|
import type { DayTimeData, MonthTimeData, YearTimeData, TabType, TabSummary, DaySummary } from '@shared/types/time'
|
|
import { formatDurationShort, getWeekStart } from '@shared/utils/date'
|
|
import { ViewMode } from './types'
|
|
|
|
const formatDuration = (seconds: number): string => {
|
|
return formatDurationShort(seconds * 1000)
|
|
}
|
|
|
|
const formatDateDisplay = (dateStr: string): string => {
|
|
const [year, month, day] = dateStr.split('-')
|
|
const date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day))
|
|
const weekDay = WEEK_DAYS[date.getDay()]
|
|
return `${parseInt(month)}月${parseInt(day)}日 ${weekDay}`
|
|
}
|
|
|
|
const getTabIcon = (tabType: TabType): React.ReactNode => {
|
|
switch (tabType) {
|
|
case 'markdown':
|
|
return <FileText size={16} />
|
|
case 'todo':
|
|
return <CheckSquare size={16} />
|
|
case 'settings':
|
|
return <Settings size={16} />
|
|
case 'search':
|
|
return <Search size={16} />
|
|
case 'recycle-bin':
|
|
return <Trash2 size={16} />
|
|
case 'weread':
|
|
return <BookOpen size={16} />
|
|
case 'remote':
|
|
return <Monitor size={16} />
|
|
case 'remote-desktop':
|
|
return <Monitor size={16} />
|
|
case 'remote-git':
|
|
return <GitBranch size={16} />
|
|
case 'pydemos':
|
|
return <Code size={16} />
|
|
default:
|
|
return <FileText size={16} />
|
|
}
|
|
}
|
|
|
|
const getTabColor = (tabType: TabType): string => {
|
|
switch (tabType) {
|
|
case 'markdown':
|
|
return 'text-blue-500'
|
|
case 'todo':
|
|
return 'text-green-500'
|
|
case 'settings':
|
|
return 'text-gray-500'
|
|
case 'search':
|
|
return 'text-purple-500'
|
|
case 'recycle-bin':
|
|
return 'text-red-500'
|
|
case 'weread':
|
|
return 'text-orange-500'
|
|
case 'time-tracking':
|
|
return 'text-cyan-500'
|
|
case 'remote':
|
|
return 'text-indigo-500'
|
|
case 'remote-desktop':
|
|
return 'text-blue-600'
|
|
case 'remote-git':
|
|
return 'text-teal-500'
|
|
case 'pydemos':
|
|
return 'text-yellow-500'
|
|
default:
|
|
return 'text-gray-400'
|
|
}
|
|
}
|
|
|
|
const getTabDisplayName = (tabType: TabType, fileName: string): string => {
|
|
if (tabType === 'remote') {
|
|
if (fileName === 'remote-tab') {
|
|
return '远程'
|
|
}
|
|
if (fileName.startsWith('remote-git-')) {
|
|
return '远程Git'
|
|
}
|
|
if (fileName.startsWith('remote-')) {
|
|
return '远程桌面'
|
|
}
|
|
return '远程'
|
|
}
|
|
if (tabType === 'remote-desktop') {
|
|
if (fileName.startsWith('remote-git-')) {
|
|
return '远程Git'
|
|
}
|
|
const match = fileName.match(/device=([^&]+)/)
|
|
const deviceName = match ? decodeURIComponent(match[1]) : ''
|
|
if (deviceName) {
|
|
return `远程桌面-${deviceName}`
|
|
}
|
|
return '远程桌面'
|
|
}
|
|
if (tabType === 'remote-git') {
|
|
const match = fileName.match(/device=([^&]+)/)
|
|
const deviceName = match ? decodeURIComponent(match[1]) : ''
|
|
if (deviceName) {
|
|
return `远程Git-${deviceName}`
|
|
}
|
|
return '远程Git'
|
|
}
|
|
return fileName
|
|
}
|
|
|
|
interface HourlyData {
|
|
hour: string
|
|
duration: number
|
|
}
|
|
|
|
const getHourlyUsageData = (sessions: Array<{ startTime: string; endTime?: string; duration: number; tabRecords?: Array<{ focusedPeriods: Array<{ start: string; end: string }> }> }>): HourlyData[] => {
|
|
const hourlyMap: Record<number, number> = {}
|
|
|
|
for (let i = 0; i < 24; i++) {
|
|
hourlyMap[i] = 0
|
|
}
|
|
|
|
sessions.forEach(session => {
|
|
if (session.tabRecords) {
|
|
session.tabRecords.forEach(record => {
|
|
record.focusedPeriods.forEach(period => {
|
|
const [startH] = period.start.split(':').map(Number)
|
|
const [endH, endM, endS] = period.end.split(':').map(Number)
|
|
|
|
const startHour = startH
|
|
const endHour = endH
|
|
|
|
if (startHour === endHour) {
|
|
const [_, startM, startS] = period.start.split(':').map(Number)
|
|
const startSeconds = startM * 60 + startS
|
|
const endSeconds = endM * 60 + endS
|
|
hourlyMap[startHour] += (endSeconds - startSeconds)
|
|
} else {
|
|
for (let h = startHour; h <= endHour; h++) {
|
|
if (h === startHour) {
|
|
const [_, startM, startS] = period.start.split(':').map(Number)
|
|
hourlyMap[h] += (60 - startM) * 60 - startS
|
|
} else if (h === endHour) {
|
|
hourlyMap[h] += endM * 60 + endS
|
|
} else {
|
|
hourlyMap[h] += 3600
|
|
}
|
|
}
|
|
}
|
|
})
|
|
})
|
|
} else {
|
|
const startTime = new Date(session.startTime)
|
|
const endTime = session.endTime ? new Date(session.endTime) : new Date()
|
|
const startHour = startTime.getHours()
|
|
const endHour = endTime.getHours()
|
|
const startMinute = startTime.getMinutes()
|
|
const startSecond = startTime.getSeconds()
|
|
const endMinute = endTime.getMinutes()
|
|
const endSecond = endTime.getSeconds()
|
|
|
|
if (startHour === endHour) {
|
|
const duration = session.duration
|
|
hourlyMap[startHour] += duration
|
|
} else if (startHour < endHour) {
|
|
for (let h = startHour; h <= endHour && h < 24; h++) {
|
|
if (h === startHour) {
|
|
const remainingMinutes = 60 - startMinute
|
|
const remainingSeconds = remainingMinutes * 60 - startSecond
|
|
hourlyMap[h] += remainingSeconds
|
|
} else if (h === endHour) {
|
|
hourlyMap[h] += endMinute * 60 + endSecond
|
|
} else {
|
|
hourlyMap[h] += 3600
|
|
}
|
|
}
|
|
} else {
|
|
for (let h = startHour; h < 24; h++) {
|
|
if (h === startHour) {
|
|
const remainingMinutes = 60 - startMinute
|
|
const remainingSeconds = remainingMinutes * 60 - startSecond
|
|
hourlyMap[h] += remainingSeconds
|
|
} else {
|
|
hourlyMap[h] += 3600
|
|
}
|
|
}
|
|
for (let h = 0; h <= endHour; h++) {
|
|
if (h === endHour) {
|
|
hourlyMap[h] += endMinute * 60 + endSecond
|
|
} else {
|
|
hourlyMap[h] += 3600
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
return Object.entries(hourlyMap)
|
|
.map(([hour, duration]) => ({
|
|
hour: `${hour.padStart(2, '0')}:00`,
|
|
duration
|
|
}))
|
|
.sort((a, b) => parseInt(a.hour) - parseInt(b.hour))
|
|
}
|
|
|
|
export const TimeTrackingPage: React.FC = () => {
|
|
const [viewMode, setViewMode] = useState<ViewMode>('day')
|
|
const [currentDate, setCurrentDate] = useState(new Date())
|
|
const [dayData, setDayData] = useState<DayTimeData | null>(null)
|
|
const [weekData, setWeekData] = useState<WeekTimeData | null>(null)
|
|
const [monthData, setMonthData] = useState<MonthTimeData | null>(null)
|
|
const [yearData, setYearData] = useState<YearTimeData | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
const loadData = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const year = currentDate.getFullYear()
|
|
const month = currentDate.getMonth() + 1
|
|
const day = currentDate.getDate()
|
|
|
|
if (viewMode === 'day') {
|
|
const dateStr = `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`
|
|
const data = await getDayTimeData(dateStr)
|
|
setDayData(data)
|
|
} else if (viewMode === 'week') {
|
|
const weekStart = getWeekStart(currentDate)
|
|
const startDate = `${weekStart.getFullYear()}-${(weekStart.getMonth() + 1).toString().padStart(2, '0')}-${weekStart.getDate().toString().padStart(2, '0')}`
|
|
const data = await getWeekTimeData(startDate)
|
|
setWeekData(data)
|
|
} else if (viewMode === 'month') {
|
|
const data = await getMonthTimeData(`${year}-${month.toString().padStart(2, '0')}`)
|
|
setMonthData(data)
|
|
} else if (viewMode === 'year') {
|
|
const data = await getYearTimeData(year)
|
|
setYearData(data)
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load time data:', error)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [viewMode, currentDate])
|
|
|
|
useEffect(() => {
|
|
loadData()
|
|
}, [loadData])
|
|
|
|
const handlePrev = () => {
|
|
const newDate = new Date(currentDate)
|
|
if (viewMode === 'day') {
|
|
newDate.setDate(newDate.getDate() - 1)
|
|
} else if (viewMode === 'week') {
|
|
newDate.setDate(newDate.getDate() - 7)
|
|
} else if (viewMode === 'month') {
|
|
newDate.setMonth(newDate.getMonth() - 1)
|
|
} else if (viewMode === 'year') {
|
|
newDate.setFullYear(newDate.getFullYear() - 1)
|
|
}
|
|
setCurrentDate(newDate)
|
|
}
|
|
|
|
const handleNext = () => {
|
|
const newDate = new Date(currentDate)
|
|
if (viewMode === 'day') {
|
|
newDate.setDate(newDate.getDate() + 1)
|
|
} else if (viewMode === 'week') {
|
|
newDate.setDate(newDate.getDate() + 7)
|
|
} else if (viewMode === 'month') {
|
|
newDate.setMonth(newDate.getMonth() + 1)
|
|
} else if (viewMode === 'year') {
|
|
newDate.setFullYear(newDate.getFullYear() + 1)
|
|
}
|
|
setCurrentDate(newDate)
|
|
}
|
|
|
|
const getTitle = (): string => {
|
|
const year = currentDate.getFullYear()
|
|
const month = currentDate.getMonth() + 1
|
|
const day = currentDate.getDate()
|
|
|
|
if (viewMode === 'day') {
|
|
return `${year}年${month}月${day}日`
|
|
} else if (viewMode === 'week') {
|
|
const weekStart = getWeekStart(currentDate)
|
|
const weekEnd = new Date(weekStart)
|
|
weekEnd.setDate(weekEnd.getDate() + 6)
|
|
const startMonth = weekStart.getMonth() + 1
|
|
const startDay = weekStart.getDate()
|
|
const endMonth = weekEnd.getMonth() + 1
|
|
const endDay = weekEnd.getDate()
|
|
if (startMonth === endMonth) {
|
|
return `${startMonth}月${startDay}日 - ${endDay}日`
|
|
} else {
|
|
return `${startMonth}月${startDay}日 - ${endMonth}月${endDay}日`
|
|
}
|
|
} else if (viewMode === 'month') {
|
|
return `${year}年${month}月`
|
|
} else if (viewMode === 'year') {
|
|
return `${year}年`
|
|
}
|
|
return ''
|
|
}
|
|
|
|
const renderDayView = () => {
|
|
if (!dayData) return null
|
|
|
|
const tabSummaries: [string, TabSummary][] = Object.entries(dayData.tabSummary)
|
|
.sort((a, b) => b[1].totalDuration - a[1].totalDuration)
|
|
|
|
const hourlyData = getHourlyUsageData(dayData.sessions)
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm border border-gray-200 dark:border-gray-700">
|
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">今日概览</h3>
|
|
<div className="flex justify-center">
|
|
<div className="text-center p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg min-w-[200px]">
|
|
<div className="text-3xl font-bold text-gray-800 dark:text-gray-200">
|
|
{formatDuration(dayData.totalDuration)}
|
|
</div>
|
|
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1">总使用时长</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{dayData.totalDuration > 0 && (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm border border-gray-200 dark:border-gray-700">
|
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">使用时段分布</h3>
|
|
<div className="h-64">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<AreaChart data={hourlyData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
|
<defs>
|
|
<linearGradient id="colorDuration" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
|
|
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.2} />
|
|
<XAxis
|
|
dataKey="hour"
|
|
tick={{ fontSize: 12, fill: '#9ca3af' }}
|
|
tickLine={false}
|
|
axisLine={{ stroke: '#374151', opacity: 0.3 }}
|
|
interval={0}
|
|
angle={-45}
|
|
textAnchor="end"
|
|
height={50}
|
|
/>
|
|
<YAxis
|
|
tick={{ fontSize: 12, fill: '#9ca3af' }}
|
|
tickLine={false}
|
|
axisLine={{ stroke: '#374151', opacity: 0.3 }}
|
|
tickFormatter={(value) => `${Math.floor(value / 60)}m`}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: '#1f2937',
|
|
border: 'none',
|
|
borderRadius: '8px',
|
|
color: '#f3f4f6'
|
|
}}
|
|
labelStyle={{ color: '#9ca3af' }}
|
|
formatter={(value: number) => [formatDuration(value), '使用时长']}
|
|
/>
|
|
<Area
|
|
type="monotone"
|
|
dataKey="duration"
|
|
stroke="#3b82f6"
|
|
strokeWidth={2}
|
|
fillOpacity={1}
|
|
fill="url(#colorDuration)"
|
|
/>
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{tabSummaries.length > 0 && (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm border border-gray-200 dark:border-gray-700">
|
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">标签页使用统计</h3>
|
|
<div className="space-y-3">
|
|
{tabSummaries.map(([key, summary]) => {
|
|
const percentage = dayData.totalDuration > 0
|
|
? Math.round((summary.totalDuration / dayData.totalDuration) * 100)
|
|
: 0
|
|
return (
|
|
<div key={key} className="flex items-center gap-3">
|
|
<div className={getTabColor(summary.tabType)}>
|
|
{getTabIcon(summary.tabType)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium text-gray-800 dark:text-gray-200 truncate">
|
|
{getTabDisplayName(summary.tabType, summary.fileName)}
|
|
</span>
|
|
<span className="text-sm text-gray-500 dark:text-gray-400 ml-2 shrink-0">
|
|
{formatDuration(summary.totalDuration)} ({percentage}%)
|
|
</span>
|
|
</div>
|
|
<div className="mt-1 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-blue-500 rounded-full transition-all duration-300"
|
|
style={{ width: `${percentage}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{dayData.sessions.length === 0 && (
|
|
<div className="text-center text-gray-500 dark:text-gray-400 py-12">
|
|
暂无使用记录
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const renderWeekView = () => {
|
|
if (!weekData) return null
|
|
|
|
const weekStart = getWeekStart(currentDate)
|
|
const chartData = weekData.days.map((dayData, index) => {
|
|
const date = new Date(weekStart)
|
|
date.setDate(date.getDate() + index)
|
|
return {
|
|
day: WEEK_DAYS[date.getDay()],
|
|
date: `${date.getMonth() + 1}月${date.getDate()}日`,
|
|
duration: dayData.totalDuration,
|
|
durationStr: formatDuration(dayData.totalDuration)
|
|
}
|
|
})
|
|
|
|
const maxDuration = Math.max(...chartData.map(d => d.duration), 1)
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm border border-gray-200 dark:border-gray-700">
|
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">本周概览</h3>
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
|
<div className="text-2xl font-bold text-gray-800 dark:text-gray-200">
|
|
{formatDuration(weekData.totalDuration)}
|
|
</div>
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">总时长</div>
|
|
</div>
|
|
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
|
<div className="text-2xl font-bold text-gray-800 dark:text-gray-200">
|
|
{weekData.activeDays}
|
|
</div>
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">活跃天数</div>
|
|
</div>
|
|
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
|
<div className="text-2xl font-bold text-gray-800 dark:text-gray-200">
|
|
{formatDuration(weekData.averageDaily)}
|
|
</div>
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">日均时长</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{weekData.totalDuration > 0 && (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm border border-gray-200 dark:border-gray-700">
|
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">每日使用时长</h3>
|
|
<div className="h-64">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
|
<defs>
|
|
<linearGradient id="colorWeekBar" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.8} />
|
|
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0.3} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.2} />
|
|
<XAxis
|
|
dataKey="day"
|
|
tick={{ fontSize: 12, fill: '#9ca3af' }}
|
|
tickLine={false}
|
|
axisLine={{ stroke: '#374151', opacity: 0.3 }}
|
|
/>
|
|
<YAxis
|
|
tick={{ fontSize: 12, fill: '#9ca3af' }}
|
|
tickLine={false}
|
|
axisLine={{ stroke: '#374151', opacity: 0.3 }}
|
|
tickFormatter={(value) => formatDuration(value)}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: '#1f2937',
|
|
border: 'none',
|
|
borderRadius: '8px',
|
|
color: '#f3f4f6'
|
|
}}
|
|
labelStyle={{ color: '#9ca3af' }}
|
|
formatter={(value: number) => [formatDuration(value), '使用时长']}
|
|
labelFormatter={(_, payload) => payload?.[0]?.payload?.date || ''}
|
|
/>
|
|
<Bar
|
|
dataKey="duration"
|
|
fill="url(#colorWeekBar)"
|
|
radius={[4, 4, 0, 0]}
|
|
/>
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm border border-gray-200 dark:border-gray-700">
|
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">每日明细</h3>
|
|
<div className="space-y-2">
|
|
{chartData.map((data, index) => (
|
|
<div key={index} className="flex items-center justify-between py-2 border-b border-gray-100 dark:border-gray-700 last:border-0">
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-sm font-medium text-gray-800 dark:text-gray-200 w-12">
|
|
{data.day}
|
|
</span>
|
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
|
{data.date}
|
|
</span>
|
|
</div>
|
|
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
|
{data.durationStr}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{weekData.totalDuration === 0 && (
|
|
<div className="text-center text-gray-500 dark:text-gray-400 py-12">
|
|
暂无使用记录
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const renderMonthView = () => {
|
|
if (!monthData) return null
|
|
|
|
const days: [string, DaySummary][] = Object.entries(monthData.days)
|
|
.sort((a, b) => parseInt(a[0]) - parseInt(b[0]))
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm border border-gray-200 dark:border-gray-700">
|
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">本月概览</h3>
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
|
<div className="text-2xl font-bold text-gray-800 dark:text-gray-200">
|
|
{formatDuration(monthData.monthlyTotal)}
|
|
</div>
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">总时长</div>
|
|
</div>
|
|
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
|
<div className="text-2xl font-bold text-gray-800 dark:text-gray-200">
|
|
{monthData.activeDays}
|
|
</div>
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">活跃天数</div>
|
|
</div>
|
|
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
|
<div className="text-2xl font-bold text-gray-800 dark:text-gray-200">
|
|
{formatDuration(monthData.averageDaily)}
|
|
</div>
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">日均时长</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{days.length > 0 && (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm border border-gray-200 dark:border-gray-700">
|
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">每日统计</h3>
|
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
|
{days.map(([day, summary]) => {
|
|
const year = currentDate.getFullYear()
|
|
const month = currentDate.getMonth() + 1
|
|
const dateStr = `${year}-${month.toString().padStart(2, '0')}-${day}`
|
|
return (
|
|
<div key={day} className="flex items-center justify-between py-2 border-b border-gray-100 dark:border-gray-700 last:border-0">
|
|
<span className="text-sm text-gray-600 dark:text-gray-300">
|
|
{formatDateDisplay(dateStr)}
|
|
</span>
|
|
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
|
{formatDuration(summary.totalDuration)}
|
|
</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{days.length === 0 && (
|
|
<div className="text-center text-gray-500 dark:text-gray-400 py-12">
|
|
暂无使用记录
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const renderYearView = () => {
|
|
if (!yearData) return null
|
|
|
|
const months: [string, { totalDuration: number; activeDays: number }][] = Object.entries(yearData.months)
|
|
.sort((a, b) => parseInt(a[0]) - parseInt(b[0]))
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm border border-gray-200 dark:border-gray-700">
|
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">本年概览</h3>
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
|
<div className="text-2xl font-bold text-gray-800 dark:text-gray-200">
|
|
{formatDuration(yearData.yearlyTotal)}
|
|
</div>
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">总时长</div>
|
|
</div>
|
|
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
|
<div className="text-2xl font-bold text-gray-800 dark:text-gray-200">
|
|
{yearData.totalActiveDays}
|
|
</div>
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">活跃天数</div>
|
|
</div>
|
|
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
|
<div className="text-2xl font-bold text-gray-800 dark:text-gray-200">
|
|
{formatDuration(yearData.averageDaily)}
|
|
</div>
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">日均时长</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{months.length > 0 && (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm border border-gray-200 dark:border-gray-700">
|
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">每月统计</h3>
|
|
<div className="space-y-2">
|
|
{months.map(([month, summary]) => (
|
|
<div key={month} className="flex items-center justify-between py-2 border-b border-gray-100 dark:border-gray-700 last:border-0">
|
|
<span className="text-sm text-gray-600 dark:text-gray-300">
|
|
{currentDate.getFullYear()}年{parseInt(month)}月
|
|
</span>
|
|
<div className="flex items-center gap-4">
|
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
|
{summary.activeDays} 天
|
|
</span>
|
|
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
|
{formatDuration(summary.totalDuration)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{months.length === 0 && (
|
|
<div className="text-center text-gray-500 dark:text-gray-400 py-12">
|
|
暂无使用记录
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-4xl mx-auto w-full p-6">
|
|
<h1 className="text-2xl font-bold text-gray-800 dark:text-gray-200 mb-8 flex items-center gap-2">
|
|
<Clock className="w-8 h-8" />
|
|
时间统计
|
|
</h1>
|
|
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div className="flex items-center gap-2">
|
|
{(['day', 'week', 'month', 'year'] as ViewMode[]).map((mode) => (
|
|
<button
|
|
key={mode}
|
|
onClick={() => setViewMode(mode)}
|
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${viewMode === mode
|
|
? 'bg-gray-800 dark:bg-gray-200 text-white dark:text-gray-800'
|
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
|
}`}
|
|
>
|
|
{mode === 'day' ? '日' : mode === 'week' ? '周' : mode === 'month' ? '月' : '年'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={handlePrev}
|
|
className="p-1.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
|
>
|
|
<ChevronLeft size={18} />
|
|
</button>
|
|
<span className="text-base font-medium text-gray-700 dark:text-gray-300 min-w-[100px] text-center">
|
|
{getTitle()}
|
|
</span>
|
|
<button
|
|
onClick={handleNext}
|
|
className="p-1.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
|
>
|
|
<ChevronRight size={18} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="text-center text-gray-500 dark:text-gray-400 py-8">
|
|
加载中...
|
|
</div>
|
|
) : (
|
|
<div className="max-w-2xl mx-auto pb-8">
|
|
{viewMode === 'day' && renderDayView()}
|
|
{viewMode === 'week' && renderWeekView()}
|
|
{viewMode === 'month' && renderMonthView()}
|
|
{viewMode === 'year' && renderYearView()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|