feat: 添加屏幕时间

This commit is contained in:
2026-04-24 21:14:02 +08:00
parent 35a948c515
commit fd20a4411b
10 changed files with 1670 additions and 13 deletions
@@ -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<T> {
payload: T;
}
interface RoundedBarShapeProps {
fill?: string;
x?: number;
y?: number;
width?: number;
height?: number;
payload?: Record<string, unknown>;
}
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 <rect x={x} y={y} width={width} height={height} fill={fill} />;
}
return <path d={getRoundedTopBarPath(x, y, width, height, radius)} fill={fill} />;
};
};
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 (
<div className="rounded-[2rem] border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div className="screen-time-skeleton h-6 w-36 rounded-full" />
<div className="mt-4 screen-time-skeleton h-14 w-56 rounded-2xl" />
<div className="mt-8 rounded-[1.75rem] bg-gray-50 p-5 dark:bg-gray-900/60">
<div className="flex h-40 items-end gap-3">
{[56, 88, 62, 76, 50, 68, 18].map((height, index) => (
<div key={index} className="flex flex-1 flex-col items-center gap-3">
<div className="screen-time-skeleton w-full rounded-t-2xl" style={{ height }} />
<div className="screen-time-skeleton h-4 w-4 rounded-full" />
</div>
))}
</div>
<div className="mt-8 flex h-32 items-end gap-2">
{Array.from({ length: 18 }).map((_, index) => (
<div
key={index}
className="screen-time-skeleton flex-1 rounded-t-xl"
style={{ height: `${20 + (index % 6) * 12}px` }}
/>
))}
</div>
</div>
</div>
);
};
function WeekTooltip({
active,
payload
}: {
active?: boolean;
payload?: Array<TooltipPayloadRow<WeekChartDatum>>;
}) {
if (!active || !payload || payload.length === 0) return null;
const datum = payload[0].payload;
return (
<div className="rounded-2xl border border-gray-200 bg-white/95 px-4 py-3 shadow-xl backdrop-blur dark:border-gray-700 dark:bg-gray-900/95">
<div className="text-xs font-medium text-gray-500 dark:text-gray-400">{datum.date}</div>
<div className="mt-1 text-sm font-semibold text-gray-900 dark:text-white">
{formatDuration(datum.totalDurationSeconds)}
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{datum.isSelected ? '当前选中日,点击其他日期可跳转' : '点击查看这一天'}
</div>
</div>
);
}
function HourTooltip({
active,
payload,
spotlightNames
}: {
active?: boolean;
payload?: Array<TooltipPayloadRow<HourChartDatum>>;
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 (
<div className="min-w-44 rounded-2xl border border-gray-200 bg-white/95 px-4 py-3 shadow-xl backdrop-blur dark:border-gray-700 dark:bg-gray-900/95">
<div className="text-xs font-medium text-gray-500 dark:text-gray-400">{datum.label}</div>
<div className="mt-1 text-sm font-semibold text-gray-900 dark:text-white">
总计 {formatDuration(datum.totalDurationSeconds)}
</div>
<div className="mt-3 space-y-2">
{rows.map((row) => (
<div key={row.label} className="flex items-center justify-between gap-4 text-xs">
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-300">
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: row.color }} />
<span className="truncate">{row.label}</span>
</div>
<span className="font-medium text-gray-900 dark:text-white">{formatDuration(row.value)}</span>
</div>
))}
</div>
</div>
);
}
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 <OverviewSkeleton />;
}
if (!data) return null;
return (
<div className="relative overflow-hidden rounded-[2rem] border border-gray-200 bg-[radial-gradient(circle_at_top_left,rgba(59,130,246,0.10),transparent_28%),linear-gradient(180deg,#ffffff_0%,#f8fafc_100%)] p-6 shadow-sm dark:border-gray-700 dark:bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_28%),linear-gradient(180deg,#111827_0%,#0f172a_100%)]">
{refreshing && (
<div className="absolute inset-0 z-20 flex items-start justify-end bg-white/35 p-4 backdrop-blur-[2px] dark:bg-gray-950/25">
<div className="inline-flex items-center gap-2 rounded-full bg-white/90 px-3 py-1.5 text-xs font-medium text-gray-700 shadow-lg dark:bg-gray-900/90 dark:text-gray-200">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
正在刷新统计
</div>
</div>
)}
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<div className="flex items-center gap-2 text-sm font-medium text-slate-500 dark:text-slate-300">
<CalendarDays className="h-4 w-4" />
{formatSelectedDateHeadline(data.overview.selectedDay.date)}
</div>
<div className="mt-2 text-4xl font-semibold tracking-tight text-gray-950 dark:text-white sm:text-5xl">
{formatDuration(data.totalDurationSeconds)}
</div>
<div className="mt-2 text-sm text-gray-500 dark:text-gray-400">
{unit === 'week' ? '本周总屏幕时间' : '当天总屏幕时间'}
<span className="mx-2 text-gray-300 dark:text-gray-600">•</span>
选中日 {formatDuration(data.overview.selectedDay.totalDurationSeconds)}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3 lg:min-w-[22rem]">
<div className="rounded-2xl bg-white/80 px-4 py-3 shadow-sm ring-1 ring-black/5 backdrop-blur dark:bg-gray-900/70 dark:ring-white/10">
<div className="text-xs text-gray-500 dark:text-gray-400">周平均</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
{formatDuration(data.overview.week.averageDurationSeconds)}
</div>
</div>
<div className="rounded-2xl bg-white/80 px-4 py-3 shadow-sm ring-1 ring-black/5 backdrop-blur dark:bg-gray-900/70 dark:ring-white/10">
<div className="text-xs text-gray-500 dark:text-gray-400">活跃应用</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
{data.apps.length}
</div>
</div>
<div className="rounded-2xl bg-white/80 px-4 py-3 shadow-sm ring-1 ring-black/5 backdrop-blur dark:bg-gray-900/70 dark:ring-white/10">
<div className="text-xs text-gray-500 dark:text-gray-400">采样节奏</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
{formatDuration(data.estimatedSampleSeconds)}
</div>
</div>
</div>
</div>
<div className="mt-6 rounded-[1.75rem] border border-white/70 bg-white/70 p-5 shadow-inner shadow-white/40 backdrop-blur dark:border-gray-700/70 dark:bg-gray-900/60 dark:shadow-black/20">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium text-gray-700 dark:text-gray-200">周概览</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
点击任意一天可以直接跳到该日期
</div>
</div>
<div className="inline-flex items-center gap-2 rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700 dark:bg-blue-500/10 dark:text-blue-300">
<Sparkles className="h-3.5 w-3.5" />
选中日已高亮
</div>
</div>
<div className="mt-4 h-48">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={weekChartData} barCategoryGap="20%">
<CartesianGrid vertical={false} strokeDasharray="4 4" stroke="#d9dde5" />
<XAxis axisLine={false} tickLine={false} dataKey="label" tick={{ fill: '#94a3b8', fontSize: 12 }} />
<YAxis
axisLine={false}
tickLine={false}
orientation="right"
tick={{ fill: '#b0b7c3', fontSize: 12 }}
tickFormatter={formatAxisDuration}
/>
<ReferenceLine y={data.overview.week.averageDurationSeconds} stroke="#94a3b8" strokeDasharray="4 6" />
<Tooltip content={<WeekTooltip />} cursor={{ fill: 'rgba(148, 163, 184, 0.08)' }} />
<Bar dataKey="mutedDuration" stackId="week" shape={createRoundedTopShape('mutedDuration', WEEK_STACK_KEYS, 12)} onClick={(_, index) => {
const day = data.overview.week.days[index];
if (day) {
onSelectDate(day.date, unit === 'week' ? 'day' : undefined);
}
}}>
{weekChartData.map((entry) => (
<Cell
key={`muted-${entry.date}`}
cursor="pointer"
fill={entry.isSelected ? 'transparent' : MUTED_BAR_COLOR}
/>
))}
</Bar>
{[0, 1, 2].map((index) => (
<Bar
key={`spotlight-${index}`}
dataKey={`spotlight_${index}`}
stackId="week"
shape={createRoundedTopShape(`spotlight_${index}`, WEEK_STACK_KEYS, 12)}
onClick={(_, clickedIndex) => {
const day = data.overview.week.days[clickedIndex];
if (day) {
onSelectDate(day.date, unit === 'week' ? 'day' : undefined);
}
}}
>
{weekChartData.map((entry) => (
<Cell
key={`spotlight-cell-${index}-${entry.date}`}
cursor="pointer"
fill={entry.isSelected ? SPOTLIGHT_COLORS[index] : 'transparent'}
/>
))}
</Bar>
))}
<Bar dataKey="selectedRemainder" stackId="week" shape={createRoundedTopShape('selectedRemainder', WEEK_STACK_KEYS, 12)} onClick={(_, index) => {
const day = data.overview.week.days[index];
if (day) {
onSelectDate(day.date, unit === 'week' ? 'day' : undefined);
}
}}>
{weekChartData.map((entry) => (
<Cell
key={`remainder-${entry.date}`}
cursor="pointer"
fill={entry.isSelected ? OTHER_COLOR : 'transparent'}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
<div className="mt-3 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<span>周总计 {formatDuration(data.overview.week.totalDurationSeconds)}</span>
<span>最高 {formatDuration(maxWeekDuration)}</span>
</div>
<div className="mt-7 flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium text-gray-700 dark:text-gray-200">当日分布</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
24 小时内按主应用堆叠,帮你快速定位高强度时段
</div>
</div>
<div className="inline-flex items-center gap-2 rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-600 dark:bg-gray-800 dark:text-gray-300">
<Clock3 className="h-3.5 w-3.5" />
{formatDuration(data.overview.selectedDay.totalDurationSeconds)}
</div>
</div>
<div className="mt-4 h-52">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={hourChartData} barCategoryGap={2}>
<CartesianGrid vertical={false} strokeDasharray="4 4" stroke="#d9dde5" />
<XAxis
axisLine={false}
tickLine={false}
dataKey="label"
interval={2}
tick={{ fill: '#94a3b8', fontSize: 12 }}
/>
<YAxis
axisLine={false}
tickLine={false}
orientation="right"
tick={{ fill: '#b0b7c3', fontSize: 12 }}
tickFormatter={formatAxisDuration}
/>
<Tooltip content={<HourTooltip spotlightNames={spotlightNames} />} cursor={{ fill: 'rgba(148, 163, 184, 0.08)' }} />
{[0, 1, 2].map((index) => (
<Bar
key={`hour-spotlight-${index}`}
dataKey={`spotlight_${index}`}
stackId="hours"
fill={SPOTLIGHT_COLORS[index]}
shape={createRoundedTopShape(`spotlight_${index}`, HOUR_STACK_KEYS, 10)}
/>
))}
<Bar
dataKey="otherDurationSeconds"
stackId="hours"
fill={OTHER_COLOR}
shape={createRoundedTopShape('otherDurationSeconds', HOUR_STACK_KEYS, 10)}
/>
</BarChart>
</ResponsiveContainer>
</div>
<div className="mt-5 flex flex-wrap gap-3">
{data.overview.selectedDay.spotlightApps.length > 0 ? data.overview.selectedDay.spotlightApps.map((app, index) => (
<button
key={app.processId}
onClick={() => onSpotlightAppClick(app.processId)}
className="flex min-w-36 flex-1 items-start gap-3 rounded-2xl bg-white/80 px-4 py-3 text-left shadow-sm ring-1 ring-black/5 transition-transform hover:-translate-y-0.5 dark:bg-gray-900/70 dark:ring-white/10"
>
<span className="mt-1 h-3 w-3 rounded-full" style={{ backgroundColor: SPOTLIGHT_COLORS[index] }} />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2 text-xs uppercase tracking-[0.18em] text-gray-400 dark:text-gray-500">
<WandSparkles className="h-3.5 w-3.5" />
Focus {index + 1}
</span>
<span className="mt-1 block truncate text-sm font-semibold text-gray-900 dark:text-white" title={app.processName}>
{app.processName}
</span>
<span className="mt-1 block text-xs text-gray-500 dark:text-gray-400">
{formatDuration(app.durationSeconds)} · {app.percentage}%
</span>
</span>
</button>
)) : (
<div className="rounded-2xl bg-gray-50 px-4 py-3 text-sm text-gray-500 dark:bg-gray-900/60 dark:text-gray-400">
当前日期没有足够的活跃应用数据来展示聚焦图例。
</div>
)}
</div>
</div>
<div className="mt-4 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<span>{formatUpdateTime(data.lastRecordedAt)}</span>
<span>周柱状图可点击跳转,Focus 卡片可直接展开对应应用</span>
</div>
</div>
);
}
@@ -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<ScreenTimeResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [expandedApps, setExpandedApps] = useState<Set<string>>(new Set());
const [highlightedProcessId, setHighlightedProcessId] = useState<string | null>(null);
const [initialLoading, setInitialLoading] = useState(true);
const abortControllerRef = React.useRef<AbortController | null>(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<string>();
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 (
<div className="space-y-6">
<div className="rounded-[2rem] border border-gray-200 bg-[linear-gradient(180deg,#ffffff_0%,#f8fafc_100%)] p-5 shadow-sm dark:border-gray-700 dark:bg-[linear-gradient(180deg,#111827_0%,#0f172a_100%)]">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<div className="text-sm font-medium text-blue-600 dark:text-blue-400">
Screen Time
</div>
<h2 className="mt-2 text-2xl font-semibold text-gray-900 dark:text-white">
{periodLabel}
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
切换日期时会自动取消上一条请求,图表与排行保持同步刷新。
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="inline-flex rounded-full bg-gray-100 p-1 dark:bg-gray-700/70">
<button
onClick={() => setUnit('day')}
className={`rounded-full px-5 py-2 text-sm font-medium ${unit === 'day'
? 'bg-white text-blue-600 shadow-sm dark:bg-gray-900 dark:text-blue-400'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white'
}`}
>
按天
</button>
<button
onClick={() => setUnit('week')}
className={`rounded-full px-5 py-2 text-sm font-medium ${unit === 'week'
? 'bg-white text-blue-600 shadow-sm dark:bg-gray-900 dark:text-blue-400'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white'
}`}
>
按周
</button>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => shiftPeriod(-1)}
className="rounded-lg border border-gray-200 p-2 text-gray-600 hover:border-blue-500 hover:text-blue-600 dark:border-gray-600 dark:text-gray-300 dark:hover:border-blue-400 dark:hover:text-blue-400"
title={unit === 'week' ? '上一周' : '上一天'}
>
<ChevronLeft className="h-4 w-4" />
</button>
<label className="flex items-center gap-2 rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-700 dark:border-gray-600 dark:text-gray-200">
<Calendar className="h-4 w-4 text-gray-400" />
<input
type="date"
value={selectedDate}
onChange={(event) => setSelectedDate(event.target.value)}
className="bg-transparent outline-none"
/>
</label>
<button
onClick={() => shiftPeriod(1)}
className="rounded-lg border border-gray-200 p-2 text-gray-600 hover:border-blue-500 hover:text-blue-600 dark:border-gray-600 dark:text-gray-300 dark:hover:border-blue-400 dark:hover:text-blue-400"
title={unit === 'week' ? '下一周' : '下一天'}
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
</div>
</div>
<ScreenTimeOverviewChart
data={screenTime}
unit={unit}
loading={loading || initialLoading}
refreshing={isRefreshing}
onSelectDate={handleOverviewDateSelect}
onSpotlightAppClick={handleSpotlightAppClick}
/>
{error && (
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-300">
{error}
</div>
)}
<div className="relative overflow-hidden rounded-[2rem] border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div className="border-b border-gray-200 px-5 py-4 dark:border-gray-700">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">应用使用排行</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
进程默认折叠,展开后可查看时长最长的前 5 个窗口标题。
</p>
</div>
{isRefreshing && (
<div className="inline-flex items-center gap-2 rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700 dark:bg-blue-500/10 dark:text-blue-300">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
更新中
</div>
)}
</div>
</div>
{loading && !screenTime && (
<div className="flex items-center justify-center gap-3 px-6 py-12 text-sm text-gray-500 dark:text-gray-400">
<Loader2 className="h-5 w-5 animate-spin" />
正在加载屏幕使用时间数据...
</div>
)}
{!loading && screenTime && screenTime.apps.length === 0 && !error && (
<div className="px-6 py-12 text-center text-sm text-gray-500 dark:text-gray-400">
当前时间段内没有可统计的活跃窗口记录。
</div>
)}
{screenTime && screenTime.apps.length > 0 && (
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{screenTime.apps.map((app, index) => {
const isExpanded = expandedApps.has(app.processId);
const processWidth = maxDurationSeconds > 0
? Math.max((app.durationSeconds / maxDurationSeconds) * 100, 4)
: 0;
return (
<div
key={app.processId}
className={`px-5 py-4 transition-colors ${highlightedProcessId === app.processId
? 'bg-blue-50/70 dark:bg-blue-500/8'
: ''
}`}
>
<button
onClick={() => toggleExpanded(app.processId)}
className="w-full text-left"
>
<div className="flex items-start gap-4">
<div className="mt-1 text-sm font-semibold text-gray-400 dark:text-gray-500">
{String(index + 1).padStart(2, '0')}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-gray-400" />
) : (
<ChevronRight className="h-4 w-4 text-gray-400" />
)}
<div className="truncate text-base font-semibold text-gray-900 dark:text-white" title={app.processName}>
{app.processName}
</div>
{highlightedProcessId === app.processId && (
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-medium text-blue-700 dark:bg-blue-500/15 dark:text-blue-300">
Focus
</span>
)}
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-300">
{app.titleCount} 个标题
</span>
</div>
<div className="mt-2 flex items-center gap-3 text-sm text-gray-500 dark:text-gray-400">
<span>{formatDuration(app.durationSeconds)}</span>
<span>{app.percentage}%</span>
</div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
<div
className="h-full rounded-full bg-blue-600 dark:bg-blue-500"
style={{ width: `${processWidth}%` }}
/>
</div>
</div>
<div className="shrink-0 text-sm font-medium text-gray-900 dark:text-white">
{formatDuration(app.durationSeconds)}
</div>
</div>
</button>
{isExpanded && (
<div className="ml-10 mt-4 rounded-2xl bg-gray-50 p-4 dark:bg-gray-900/50">
<div className="space-y-3">
{app.titles.map((title) => (
<div key={`${app.processId}-${title.title}`}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-gray-800 dark:text-gray-100" title={title.title}>
{title.title}
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
占该进程 {title.percentage}%
</div>
</div>
<div className="shrink-0 text-sm text-gray-700 dark:text-gray-200">
{formatDuration(title.durationSeconds)}
</div>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white dark:bg-gray-800">
<div
className="h-full rounded-full bg-emerald-500 dark:bg-emerald-400"
style={{ width: `${Math.max(title.percentage, 4)}%` }}
/>
</div>
</div>
))}
</div>
{app.titleCount > app.titles.length && (
<div className="mt-4 text-xs text-gray-500 dark:text-gray-400">
其余 {app.titleCount - app.titles.length} 个标题未展示。
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
{isRefreshing && screenTime && (
<div className="pointer-events-none absolute inset-0 bg-white/20 backdrop-blur-[1px] dark:bg-gray-950/10" />
)}
</div>
</div>
);
}