539 lines
20 KiB
TypeScript
539 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import React, { useMemo } from 'react';
|
|
import {
|
|
Bar,
|
|
BarChart,
|
|
CartesianGrid,
|
|
Cell,
|
|
ReferenceLine,
|
|
ResponsiveContainer,
|
|
Tooltip,
|
|
XAxis,
|
|
YAxis
|
|
} from 'recharts';
|
|
import {
|
|
Loader2,
|
|
} 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;
|
|
}
|
|
|
|
const SPOTLIGHT_COLORS = ['#2f7cff', '#17c3d1', '#ff9f43', '#ff6b6b'] as const;
|
|
const SPOTLIGHT_KEYS = ['spotlight_0', 'spotlight_1', 'spotlight_2', 'spotlight_3'] as const;
|
|
type SpotlightKey = typeof SPOTLIGHT_KEYS[number];
|
|
|
|
type WeekChartDatum = Record<SpotlightKey, number> & {
|
|
date: string;
|
|
label: string;
|
|
totalDurationSeconds: number;
|
|
mutedDuration: number;
|
|
selectedRemainder: number;
|
|
isSelected: boolean;
|
|
};
|
|
|
|
type HourChartDatum = Record<SpotlightKey, number> & {
|
|
hour: number;
|
|
label: string;
|
|
totalDurationSeconds: 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 OTHER_COLOR = '#d4d7df';
|
|
const MUTED_BAR_COLOR = '#cfd3dc';
|
|
const WEEK_STACK_KEYS = ['mutedDuration', ...SPOTLIGHT_KEYS, 'selectedRemainder'] as const;
|
|
const HOUR_STACK_KEYS = [...SPOTLIGHT_KEYS, '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-[1.75rem] border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
<div>
|
|
<div className="screen-time-skeleton h-5 w-24 rounded-full" />
|
|
<div className="mt-2 screen-time-skeleton h-10 w-44 rounded-xl" />
|
|
</div>
|
|
<div className="grid w-full grid-cols-3 gap-2 sm:w-64">
|
|
{[0, 1, 2].map((index) => (
|
|
<div key={index} className="screen-time-skeleton h-12 rounded-2xl" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 grid gap-5 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.35fr)]">
|
|
<div className="flex h-36 items-end gap-2">
|
|
{[56, 88, 62, 76, 50, 68, 18].map((height, index) => (
|
|
<div key={index} className="screen-time-skeleton flex-1 rounded-t-2xl" style={{ height }} />
|
|
))}
|
|
</div>
|
|
<div className="flex h-36 items-end gap-1.5">
|
|
{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>
|
|
);
|
|
}
|
|
|
|
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: Array<{ label: string; value: number; color: string }> = SPOTLIGHT_KEYS
|
|
.map((key, index) => ({
|
|
label: spotlightNames[index] ?? '',
|
|
value: datum[key],
|
|
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,
|
|
}: 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) => {
|
|
const spotlightDurations = Object.fromEntries(
|
|
SPOTLIGHT_KEYS.map((key, index) => [
|
|
key,
|
|
day.isSelected ? (data.overview.selectedDay.spotlightApps[index]?.durationSeconds ?? 0) : 0
|
|
])
|
|
) as Record<SpotlightKey, number>;
|
|
|
|
return {
|
|
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,
|
|
...spotlightDurations,
|
|
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) => {
|
|
const spotlightDurations = Object.fromEntries(
|
|
SPOTLIGHT_KEYS.map((key, index) => [
|
|
key,
|
|
spotlightApps[index] ? hour.spotlightDurations[spotlightApps[index].processId] ?? 0 : 0
|
|
])
|
|
) as Record<SpotlightKey, number>;
|
|
|
|
return {
|
|
hour: hour.hour,
|
|
label: hour.label,
|
|
totalDurationSeconds: hour.totalDurationSeconds,
|
|
...spotlightDurations,
|
|
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]);
|
|
const primaryLabel = data ? (unit === 'week' ? '本周' : formatSelectedDateHeadline(data.overview.selectedDay.date)) : '';
|
|
const selectedDayDuration = data?.overview.selectedDay.totalDurationSeconds ?? 0;
|
|
|
|
if (loading && !data) {
|
|
return <OverviewSkeleton />;
|
|
}
|
|
|
|
if (!data) return null;
|
|
|
|
return (
|
|
<div className="relative overflow-hidden rounded-[1.75rem] border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
|
{refreshing && (
|
|
<div className="absolute inset-0 z-20 flex items-start justify-end bg-white/40 p-3 backdrop-blur-[2px] dark:bg-gray-950/25">
|
|
<div className="inline-flex items-center gap-2 rounded-full bg-white/95 px-3 py-1 text-xs font-medium text-gray-700 shadow-lg dark:bg-gray-900/95 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-start lg:justify-between">
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
|
{primaryLabel}
|
|
</div>
|
|
<div className="mt-1 text-4xl font-semibold text-gray-950 dark:text-white sm:text-5xl">
|
|
{formatDuration(data.totalDurationSeconds)}
|
|
</div>
|
|
<div className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
|
{formatUpdateTime(data.lastRecordedAt)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-2 sm:min-w-[22rem]">
|
|
<div className="rounded-2xl bg-gray-50 px-3 py-2 dark:bg-gray-900/70">
|
|
<div className="text-[11px] text-gray-500 dark:text-gray-400">周平均</div>
|
|
<div className="mt-0.5 truncate text-sm font-semibold text-gray-900 dark:text-white">
|
|
{formatDuration(data.overview.week.averageDurationSeconds)}
|
|
</div>
|
|
</div>
|
|
<div className="rounded-2xl bg-gray-50 px-3 py-2 dark:bg-gray-900/70">
|
|
<div className="text-[11px] text-gray-500 dark:text-gray-400">所选日</div>
|
|
<div className="mt-0.5 truncate text-sm font-semibold text-gray-900 dark:text-white">
|
|
{formatDuration(selectedDayDuration)}
|
|
</div>
|
|
</div>
|
|
<div className="rounded-2xl bg-gray-50 px-3 py-2 dark:bg-gray-900/70">
|
|
<div className="text-[11px] text-gray-500 dark:text-gray-400">应用</div>
|
|
<div className="mt-0.5 text-sm font-semibold text-gray-900 dark:text-white">
|
|
{data.apps.length}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 grid gap-5 xl:grid-cols-[minmax(0,0.88fr)_minmax(0,1.35fr)]">
|
|
<section className="min-w-0">
|
|
<div className="flex items-baseline justify-between gap-3">
|
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">周概览</h3>
|
|
<div className="truncate text-xs text-gray-500 dark:text-gray-400">
|
|
平均 {formatDuration(data.overview.week.averageDurationSeconds)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-2 h-36 sm:h-40">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={weekChartData} barCategoryGap="26%" margin={{ top: 8, right: 0, left: 0, bottom: 0 }}>
|
|
<CartesianGrid vertical={false} strokeDasharray="3 5" stroke="#e0e4eb" />
|
|
<XAxis
|
|
axisLine={false}
|
|
tickLine={false}
|
|
dataKey="label"
|
|
height={22}
|
|
tick={{ fill: '#9ca3af', fontSize: 12 }}
|
|
/>
|
|
<YAxis
|
|
axisLine={false}
|
|
tickLine={false}
|
|
orientation="right"
|
|
width={34}
|
|
tick={{ fill: '#b0b7c3', fontSize: 12 }}
|
|
tickFormatter={formatAxisDuration}
|
|
/>
|
|
<ReferenceLine y={data.overview.week.averageDurationSeconds} stroke="#9ca3af" 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>
|
|
{SPOTLIGHT_KEYS.map((key, index) => (
|
|
<Bar
|
|
key={key}
|
|
dataKey={key}
|
|
stackId="week"
|
|
shape={createRoundedTopShape(key, 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-${key}-${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-1 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>
|
|
</section>
|
|
|
|
<section className="min-w-0">
|
|
<div className="flex items-baseline justify-between gap-3">
|
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">当日分布</h3>
|
|
<div className="truncate text-xs text-gray-500 dark:text-gray-400">
|
|
{formatDuration(selectedDayDuration)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-2 h-36 sm:h-40">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={hourChartData} barCategoryGap={2} margin={{ top: 8, right: 0, left: 0, bottom: 0 }}>
|
|
<CartesianGrid vertical={false} strokeDasharray="3 5" stroke="#e0e4eb" />
|
|
<XAxis
|
|
axisLine={false}
|
|
tickLine={false}
|
|
dataKey="label"
|
|
height={22}
|
|
interval={5}
|
|
tick={{ fill: '#9ca3af', fontSize: 12 }}
|
|
/>
|
|
<YAxis
|
|
axisLine={false}
|
|
tickLine={false}
|
|
orientation="right"
|
|
width={38}
|
|
tick={{ fill: '#b0b7c3', fontSize: 12 }}
|
|
tickFormatter={formatAxisDuration}
|
|
/>
|
|
<Tooltip content={<HourTooltip spotlightNames={spotlightNames} />} cursor={{ fill: 'rgba(148, 163, 184, 0.08)' }} />
|
|
{SPOTLIGHT_KEYS.map((key, index) => (
|
|
<Bar
|
|
key={`hour-${key}`}
|
|
dataKey={key}
|
|
stackId="hours"
|
|
fill={SPOTLIGHT_COLORS[index]}
|
|
shape={createRoundedTopShape(key, 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-3 grid grid-cols-2 gap-2">
|
|
{data.overview.selectedDay.spotlightApps.length > 0 ? data.overview.selectedDay.spotlightApps.map((app, index) => (
|
|
<button
|
|
key={app.processId}
|
|
className="flex min-w-0 items-center gap-2 rounded-2xl bg-gray-50 px-3 py-2 text-left transition-colors hover:bg-gray-100 dark:bg-gray-900/70 dark:hover:bg-gray-900"
|
|
>
|
|
<span
|
|
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold text-white"
|
|
style={{ backgroundColor: SPOTLIGHT_COLORS[index] }}
|
|
>
|
|
{index + 1}
|
|
</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-sm font-medium text-gray-900 dark:text-white" title={app.processName}>
|
|
{app.processName}
|
|
</span>
|
|
<span className="block truncate text-xs text-gray-500 dark:text-gray-400">
|
|
{formatDuration(app.durationSeconds)} · {app.percentage}%
|
|
</span>
|
|
</span>
|
|
</button>
|
|
)) : (
|
|
<div className="col-span-2 rounded-2xl bg-gray-50 px-3 py-2 text-sm text-gray-500 dark:bg-gray-900/70 dark:text-gray-400">
|
|
暂无应用数据
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |