diff --git a/app/globals.css b/app/globals.css index e6fdd7f..01d1b3d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -29,3 +29,36 @@ body { * { transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease; } + +@keyframes screen-time-shimmer { + 0% { + transform: translateX(-120%); + } + 100% { + transform: translateX(120%); + } +} + +.screen-time-skeleton { + position: relative; + overflow: hidden; + background: linear-gradient(90deg, rgba(226, 232, 240, 0.7) 0%, rgba(241, 245, 249, 0.95) 50%, rgba(226, 232, 240, 0.7) 100%); +} + +.screen-time-skeleton::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.75) 50%, transparent 100%); + animation: screen-time-shimmer 1.8s ease-in-out infinite; +} + +@media (prefers-color-scheme: dark) { + .screen-time-skeleton { + background: linear-gradient(90deg, rgba(51, 65, 85, 0.7) 0%, rgba(71, 85, 105, 0.95) 50%, rgba(51, 65, 85, 0.7) 100%); + } + + .screen-time-skeleton::after { + background: linear-gradient(90deg, transparent 0%, rgba(148, 163, 184, 0.18) 50%, transparent 100%); + } +} diff --git a/app/hosts/[hostname]/components/ScreenTimeOverviewChart.tsx b/app/hosts/[hostname]/components/ScreenTimeOverviewChart.tsx new file mode 100644 index 0000000..54bd092 --- /dev/null +++ b/app/hosts/[hostname]/components/ScreenTimeOverviewChart.tsx @@ -0,0 +1,536 @@ +"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 卡片可直接展开对应应用 +
+
+ ); +} \ No newline at end of file diff --git a/app/hosts/[hostname]/components/ScreenTimeTab.tsx b/app/hosts/[hostname]/components/ScreenTimeTab.tsx new file mode 100644 index 0000000..2d851fb --- /dev/null +++ b/app/hosts/[hostname]/components/ScreenTimeTab.tsx @@ -0,0 +1,362 @@ +"use client"; + +import React, { useEffect, useMemo, useState } from 'react'; +import { + Calendar, + ChevronDown, + ChevronLeft, + ChevronRight, + Loader2 +} from 'lucide-react'; +import { ScreenTimeResponse } from '../types'; +import { + formatDuration, + formatScreenTimePeriod, + getLocalDateInputValue, + shiftDateInputValue +} from '../utils'; +import ScreenTimeOverviewChart from './ScreenTimeOverviewChart'; + +interface ScreenTimeTabProps { + hostname: string; +} + +export default function ScreenTimeTab({ hostname }: ScreenTimeTabProps) { + const [unit, setUnit] = useState<'day' | 'week'>('day'); + const [selectedDate, setSelectedDate] = useState(getLocalDateInputValue()); + const [screenTime, setScreenTime] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [expandedApps, setExpandedApps] = useState>(new Set()); + const [highlightedProcessId, setHighlightedProcessId] = useState(null); + const [initialLoading, setInitialLoading] = useState(true); + + const abortControllerRef = React.useRef(null); + + useEffect(() => { + const controller = new AbortController(); + abortControllerRef.current?.abort(); + abortControllerRef.current = controller; + + const fetchScreenTime = async () => { + try { + setLoading(true); + setError(null); + + const searchParams = new URLSearchParams({ + unit, + date: selectedDate, + tzOffsetMinutes: String(new Date().getTimezoneOffset()) + }); + + const response = await fetch(`/hosts/${hostname}/screen-time?${searchParams.toString()}`, { + signal: controller.signal + }); + + if (!response.ok) { + throw new Error('获取屏幕使用时间失败'); + } + + const data: ScreenTimeResponse = await response.json(); + setScreenTime(data); + setInitialLoading(false); + setExpandedApps((previous) => { + const next = new Set(); + data.apps.forEach((app) => { + if (previous.has(app.processId)) { + next.add(app.processId); + } + }); + return next; + }); + } catch (fetchError) { + if (controller.signal.aborted) return; + setInitialLoading(false); + setError(fetchError instanceof Error ? fetchError.message : '获取屏幕使用时间失败'); + } finally { + if (!controller.signal.aborted && abortControllerRef.current === controller) { + setLoading(false); + abortControllerRef.current = null; + } + } + }; + + fetchScreenTime(); + + return () => { + controller.abort(); + if (abortControllerRef.current === controller) { + abortControllerRef.current = null; + } + }; + }, [hostname, selectedDate, unit]); + + const maxDurationSeconds = useMemo(() => { + if (!screenTime || screenTime.apps.length === 0) return 0; + return Math.max(...screenTime.apps.map((app) => app.durationSeconds)); + }, [screenTime]); + + const isRefreshing = loading && !!screenTime; + + const periodLabel = screenTime + ? formatScreenTimePeriod(screenTime.periodStartDate, screenTime.periodEndDate, screenTime.unit) + : selectedDate; + + const toggleExpanded = (processId: string) => { + setExpandedApps((previous) => { + const next = new Set(previous); + if (next.has(processId)) { + next.delete(processId); + } else { + next.add(processId); + } + return next; + }); + }; + + const shiftPeriod = (direction: -1 | 1) => { + setSelectedDate((current) => shiftDateInputValue(current, direction * (unit === 'week' ? 7 : 1))); + }; + + const handleOverviewDateSelect = (date: string, nextUnit?: 'day' | 'week') => { + if (nextUnit) { + setUnit(nextUnit); + } + setSelectedDate(date); + }; + + const handleSpotlightAppClick = (processId: string) => { + setHighlightedProcessId(processId); + setExpandedApps((previous) => new Set(previous).add(processId)); + }; + + return ( +
+
+
+
+
+ Screen Time +
+

+ {periodLabel} +

+

+ 切换日期时会自动取消上一条请求,图表与排行保持同步刷新。 +

+
+ +
+
+ + +
+ +
+ + + + + +
+
+
+
+ + + + {error && ( +
+ {error} +
+ )} + +
+
+
+
+

应用使用排行

+

+ 进程默认折叠,展开后可查看时长最长的前 5 个窗口标题。 +

+
+ + {isRefreshing && ( +
+ + 更新中 +
+ )} +
+
+ + {loading && !screenTime && ( +
+ + 正在加载屏幕使用时间数据... +
+ )} + + {!loading && screenTime && screenTime.apps.length === 0 && !error && ( +
+ 当前时间段内没有可统计的活跃窗口记录。 +
+ )} + + {screenTime && screenTime.apps.length > 0 && ( +
+ {screenTime.apps.map((app, index) => { + const isExpanded = expandedApps.has(app.processId); + const processWidth = maxDurationSeconds > 0 + ? Math.max((app.durationSeconds / maxDurationSeconds) * 100, 4) + : 0; + + return ( +
+ + + {isExpanded && ( +
+
+ {app.titles.map((title) => ( +
+
+
+
+ {title.title} +
+
+ 占该进程 {title.percentage}% +
+
+ +
+ {formatDuration(title.durationSeconds)} +
+
+ +
+
+
+
+ ))} +
+ + {app.titleCount > app.titles.length && ( +
+ 其余 {app.titleCount - app.titles.length} 个标题未展示。 +
+ )} +
+ )} +
+ ); + })} +
+ )} + + {isRefreshing && screenTime && ( +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/app/hosts/[hostname]/page.tsx b/app/hosts/[hostname]/page.tsx index 3211856..e76e950 100644 --- a/app/hosts/[hostname]/page.tsx +++ b/app/hosts/[hostname]/page.tsx @@ -2,8 +2,9 @@ import React, { useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; -import { ArrowLeft, Star } from 'lucide-react'; +import { ArrowLeft, BarChart3, Star } from 'lucide-react'; import ScreenshotsTab from './components/ScreenshotsTab'; +import ScreenTimeTab from './components/ScreenTimeTab'; import StarredTab from './components/StarredTab'; import CredentialsTab from './components/CredentialsTab'; import { ScreenRecord } from './types'; @@ -68,6 +69,16 @@ export default function HostDetail() { > 截图时间线 +