"use client"; import React, { useMemo } from 'react'; import { Bar, BarChart, CartesianGrid, Cell, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import { CalendarDays, Clock3, Loader2, Sparkles, WandSparkles } from 'lucide-react'; import { ScreenTimeResponse } from '../types'; import { formatDuration, getLocalDateInputValue } from '../utils'; interface ScreenTimeOverviewChartProps { data: ScreenTimeResponse | null; unit: 'day' | 'week'; loading: boolean; refreshing: boolean; onSelectDate: (date: string, nextUnit?: 'day' | 'week') => void; onSpotlightAppClick: (processId: string) => void; } interface WeekChartDatum { date: string; label: string; totalDurationSeconds: number; mutedDuration: number; selectedRemainder: number; spotlight_0: number; spotlight_1: number; spotlight_2: number; isSelected: boolean; } interface HourChartDatum { hour: number; label: string; totalDurationSeconds: number; spotlight_0: number; spotlight_1: number; spotlight_2: number; otherDurationSeconds: number; } interface TooltipPayloadRow { payload: T; } interface RoundedBarShapeProps { fill?: string; x?: number; y?: number; width?: number; height?: number; payload?: Record; } const SPOTLIGHT_COLORS = ['#2f7cff', '#17c3d1', '#ff9f43']; const OTHER_COLOR = '#d4d7df'; const MUTED_BAR_COLOR = '#cfd3dc'; const WEEK_STACK_KEYS = ['mutedDuration', 'spotlight_0', 'spotlight_1', 'spotlight_2', 'selectedRemainder'] as const; const HOUR_STACK_KEYS = ['spotlight_0', 'spotlight_1', 'spotlight_2', 'otherDurationSeconds'] as const; const getNumericValue = (value: unknown) => { return typeof value === 'number' && Number.isFinite(value) ? value : 0; }; const getRoundedTopBarPath = (x: number, y: number, width: number, height: number, radius: number) => { const safeRadius = Math.max(0, Math.min(radius, width / 2, height)); return [ `M${x},${y + height}`, `L${x},${y + safeRadius}`, `Q${x},${y} ${x + safeRadius},${y}`, `L${x + width - safeRadius},${y}`, `Q${x + width},${y} ${x + width},${y + safeRadius}`, `L${x + width},${y + height}`, 'Z' ].join(' '); }; const createRoundedTopShape = ( dataKey: string, stackKeys: readonly string[], radius: number ) => { return ({ fill, x, y, width, height, payload }: RoundedBarShapeProps) => { if ( typeof x !== 'number' || typeof y !== 'number' || typeof width !== 'number' || typeof height !== 'number' || width <= 0 || height <= 0 ) { return null; } const currentValue = getNumericValue(payload?.[dataKey]); if (currentValue <= 0) { return null; } const currentIndex = stackKeys.indexOf(dataKey); const isTopMostSegment = currentIndex === -1 || stackKeys .slice(currentIndex + 1) .every((key) => getNumericValue(payload?.[key]) <= 0); if (!isTopMostSegment) { return ; } return ; }; }; const formatAxisDuration = (durationSeconds: number) => { if (durationSeconds <= 0) return '0'; if (durationSeconds >= 3600) { const hours = durationSeconds / 3600; return `${hours >= 10 ? Math.round(hours) : hours.toFixed(hours >= 2 ? 0 : 1)}h`; } return `${Math.round(durationSeconds / 60)}m`; }; const formatSelectedDateHeadline = (dateString: string) => { const [year, month, day] = dateString.split('-').map(Number); const target = new Date(year, month - 1, day); const today = getLocalDateInputValue(); const label = `${target.getMonth() + 1}月${target.getDate()}日`; if (dateString === today) { return `${label} 今天`; } return label; }; const formatUpdateTime = (value: string | null) => { if (!value) return '暂无记录'; const date = new Date(value); return `更新于 ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; }; const OverviewSkeleton = () => { return (
{[56, 88, 62, 76, 50, 68, 18].map((height, index) => (
))}
{Array.from({ length: 18 }).map((_, index) => (
))}
); }; function WeekTooltip({ active, payload }: { active?: boolean; payload?: Array>; }) { if (!active || !payload || payload.length === 0) return null; const datum = payload[0].payload; return (
{datum.date}
{formatDuration(datum.totalDurationSeconds)}
{datum.isSelected ? '当前选中日,点击其他日期可跳转' : '点击查看这一天'}
); } function HourTooltip({ active, payload, spotlightNames }: { active?: boolean; payload?: Array>; spotlightNames: string[]; }) { if (!active || !payload || payload.length === 0) return null; const datum = payload[0].payload; const rows = [0, 1, 2] .map((index) => ({ label: spotlightNames[index], value: datum[`spotlight_${index}` as keyof HourChartDatum] as number, color: SPOTLIGHT_COLORS[index] })) .filter((row) => row.label && row.value > 0); if (datum.otherDurationSeconds > 0) { rows.push({ label: '其他', value: datum.otherDurationSeconds, color: OTHER_COLOR }); } return (
{datum.label}
总计 {formatDuration(datum.totalDurationSeconds)}
{rows.map((row) => (
{row.label}
{formatDuration(row.value)}
))}
); } export default function ScreenTimeOverviewChart({ data, unit, loading, refreshing, onSelectDate, onSpotlightAppClick }: ScreenTimeOverviewChartProps) { const weekChartData = useMemo(() => { if (!data) return [] as WeekChartDatum[]; const spotlightTotal = data.overview.selectedDay.spotlightApps .reduce((sum, app) => sum + app.durationSeconds, 0); return data.overview.week.days.map((day) => ({ date: day.date, label: day.weekdayLabel, totalDurationSeconds: day.totalDurationSeconds, mutedDuration: day.isSelected ? 0 : day.totalDurationSeconds, selectedRemainder: day.isSelected ? Math.max(day.totalDurationSeconds - spotlightTotal, 0) : 0, spotlight_0: day.isSelected ? (data.overview.selectedDay.spotlightApps[0]?.durationSeconds ?? 0) : 0, spotlight_1: day.isSelected ? (data.overview.selectedDay.spotlightApps[1]?.durationSeconds ?? 0) : 0, spotlight_2: day.isSelected ? (data.overview.selectedDay.spotlightApps[2]?.durationSeconds ?? 0) : 0, isSelected: day.isSelected })); }, [data]); const hourChartData = useMemo(() => { if (!data) return [] as HourChartDatum[]; const spotlightApps = data.overview.selectedDay.spotlightApps; return data.overview.selectedDay.hours.map((hour) => ({ hour: hour.hour, label: hour.label, totalDurationSeconds: hour.totalDurationSeconds, spotlight_0: spotlightApps[0] ? hour.spotlightDurations[spotlightApps[0].processId] ?? 0 : 0, spotlight_1: spotlightApps[1] ? hour.spotlightDurations[spotlightApps[1].processId] ?? 0 : 0, spotlight_2: spotlightApps[2] ? hour.spotlightDurations[spotlightApps[2].processId] ?? 0 : 0, otherDurationSeconds: hour.otherDurationSeconds })); }, [data]); const spotlightNames = data?.overview.selectedDay.spotlightApps.map((app) => app.processName) ?? []; const maxWeekDuration = useMemo(() => { if (!data) return 0; return Math.max(...data.overview.week.days.map((day) => day.totalDurationSeconds), 0); }, [data]); if (loading && !data) { return ; } if (!data) return null; return (
{refreshing && (
正在刷新统计
)}
{formatSelectedDateHeadline(data.overview.selectedDay.date)}
{formatDuration(data.totalDurationSeconds)}
{unit === 'week' ? '本周总屏幕时间' : '当天总屏幕时间'} 选中日 {formatDuration(data.overview.selectedDay.totalDurationSeconds)}
周平均
{formatDuration(data.overview.week.averageDurationSeconds)}
活跃应用
{data.apps.length}
采样节奏
{formatDuration(data.estimatedSampleSeconds)}
周概览
点击任意一天可以直接跳到该日期
选中日已高亮
} cursor={{ fill: 'rgba(148, 163, 184, 0.08)' }} /> { const day = data.overview.week.days[index]; if (day) { onSelectDate(day.date, unit === 'week' ? 'day' : undefined); } }}> {weekChartData.map((entry) => ( ))} {[0, 1, 2].map((index) => ( { const day = data.overview.week.days[clickedIndex]; if (day) { onSelectDate(day.date, unit === 'week' ? 'day' : undefined); } }} > {weekChartData.map((entry) => ( ))} ))} { const day = data.overview.week.days[index]; if (day) { onSelectDate(day.date, unit === 'week' ? 'day' : undefined); } }}> {weekChartData.map((entry) => ( ))}
周总计 {formatDuration(data.overview.week.totalDurationSeconds)} 最高 {formatDuration(maxWeekDuration)}
当日分布
24 小时内按主应用堆叠,帮你快速定位高强度时段
{formatDuration(data.overview.selectedDay.totalDurationSeconds)}
} cursor={{ fill: 'rgba(148, 163, 184, 0.08)' }} /> {[0, 1, 2].map((index) => ( ))}
{data.overview.selectedDay.spotlightApps.length > 0 ? data.overview.selectedDay.spotlightApps.map((app, index) => ( )) : (
当前日期没有足够的活跃应用数据来展示聚焦图例。
)}
{formatUpdateTime(data.lastRecordedAt)} 周柱状图可点击跳转,Focus 卡片可直接展开对应应用
); }