feat: 添加屏幕时间
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
>
|
||||
截图时间线
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('screen-time')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center ${activeTab === 'screen-time'
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<BarChart3 className="h-4 w-4 mr-1" />
|
||||
屏幕使用时间
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('starred')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center ${activeTab === 'starred'
|
||||
@@ -101,6 +112,10 @@ export default function HostDetail() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'screen-time' && (
|
||||
<ScreenTimeTab hostname={hostname} />
|
||||
)}
|
||||
|
||||
{activeTab === 'starred' && (
|
||||
<StarredTab
|
||||
hostname={hostname}
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { win32 } from 'path'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { withCors } from '@/lib/middleware'
|
||||
|
||||
const MAX_GAP_MS = 5 * 60 * 1000
|
||||
const DEFAULT_SAMPLE_MS = 30 * 1000
|
||||
const MIN_SAMPLE_MS = 5 * 1000
|
||||
const LABEL_TOKEN_THRESHOLD_RATIO = 0.5
|
||||
const HOUR_MS = 60 * 60 * 1000
|
||||
const DAY_MS = 24 * HOUR_MS
|
||||
const WEEKDAY_LABELS = ['日', '一', '二', '三', '四', '五', '六']
|
||||
|
||||
type Unit = 'day' | 'week'
|
||||
|
||||
interface DateParts {
|
||||
year: number
|
||||
month: number
|
||||
day: number
|
||||
}
|
||||
|
||||
interface TitleUsage {
|
||||
title: string
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
interface MutableAppUsage {
|
||||
processId: string
|
||||
rawProcessName: string
|
||||
durationMs: number
|
||||
titleDurations: Map<string, number>
|
||||
}
|
||||
|
||||
interface HourBucket {
|
||||
totalDurationMs: number
|
||||
appDurations: Map<string, number>
|
||||
}
|
||||
|
||||
const cleanTitle = (title: string) => title.replace(/\s+/g, ' ').trim()
|
||||
|
||||
const trimLabelSeparators = (label: string) => label
|
||||
.replace(/^[\s\-–—|:/\\]+/, '')
|
||||
.replace(/[\s\-–—|:/\\]+$/, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
const stripExtension = (value: string) => value.replace(/\.[^.]+$/, '')
|
||||
|
||||
const toDateKey = ({ year, month, day }: DateParts) => `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
|
||||
const parseDateParts = (value: string | null): DateParts | null => {
|
||||
if (!value) return null
|
||||
|
||||
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/)
|
||||
if (!match) return null
|
||||
|
||||
const year = Number(match[1])
|
||||
const month = Number(match[2])
|
||||
const day = Number(match[3])
|
||||
const candidate = new Date(Date.UTC(year, month - 1, day))
|
||||
|
||||
if (
|
||||
candidate.getUTCFullYear() !== year ||
|
||||
candidate.getUTCMonth() !== month - 1 ||
|
||||
candidate.getUTCDate() !== day
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { year, month, day }
|
||||
}
|
||||
|
||||
const addDays = (parts: DateParts, amount: number): DateParts => {
|
||||
const next = new Date(Date.UTC(parts.year, parts.month - 1, parts.day))
|
||||
next.setUTCDate(next.getUTCDate() + amount)
|
||||
|
||||
return {
|
||||
year: next.getUTCFullYear(),
|
||||
month: next.getUTCMonth() + 1,
|
||||
day: next.getUTCDate()
|
||||
}
|
||||
}
|
||||
|
||||
const getCurrentDateParts = (tzOffsetMinutes: number): DateParts => {
|
||||
const shifted = new Date(Date.now() - tzOffsetMinutes * 60 * 1000)
|
||||
|
||||
return {
|
||||
year: shifted.getUTCFullYear(),
|
||||
month: shifted.getUTCMonth() + 1,
|
||||
day: shifted.getUTCDate()
|
||||
}
|
||||
}
|
||||
|
||||
const getWeekStart = (parts: DateParts): DateParts => {
|
||||
const current = new Date(Date.UTC(parts.year, parts.month - 1, parts.day))
|
||||
const dayOfWeek = current.getUTCDay()
|
||||
const diff = dayOfWeek === 0 ? -6 : 1 - dayOfWeek
|
||||
return addDays(parts, diff)
|
||||
}
|
||||
|
||||
const getLocalDateUtcMs = (parts: DateParts, tzOffsetMinutes: number) => {
|
||||
return Date.UTC(parts.year, parts.month - 1, parts.day) + tzOffsetMinutes * 60 * 1000
|
||||
}
|
||||
|
||||
const normalizeProcess = (processPath: string, title: string) => {
|
||||
const rawProcessName = win32.basename(processPath || '').trim()
|
||||
const normalizedTitle = cleanTitle(title)
|
||||
const fallbackId = normalizedTitle ? `title:${normalizedTitle.toLowerCase()}` : 'unknown'
|
||||
|
||||
return {
|
||||
processId: rawProcessName ? rawProcessName.toLowerCase() : fallbackId,
|
||||
rawProcessName
|
||||
}
|
||||
}
|
||||
|
||||
const tokenizeTitle = (title: string) => title
|
||||
.replace(/[()\[\]{}]/g, ' ')
|
||||
.split(/[\s\-–—|:/\\]+/)
|
||||
.map(token => token.trim())
|
||||
.filter(token => token.length >= 2)
|
||||
|
||||
const buildWeightedTokenLabel = (titles: TitleUsage[], totalDurationMs: number) => {
|
||||
if (titles.length === 0 || totalDurationMs <= 0) return ''
|
||||
|
||||
const weightMap = new Map<string, number>()
|
||||
const originalMap = new Map<string, string>()
|
||||
|
||||
titles.forEach(({ title, durationMs }) => {
|
||||
const seen = new Set<string>()
|
||||
|
||||
tokenizeTitle(title).forEach(token => {
|
||||
const lowerToken = token.toLowerCase()
|
||||
if (seen.has(lowerToken)) return
|
||||
seen.add(lowerToken)
|
||||
|
||||
weightMap.set(lowerToken, (weightMap.get(lowerToken) ?? 0) + durationMs)
|
||||
if (!originalMap.has(lowerToken)) {
|
||||
originalMap.set(lowerToken, token)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const threshold = totalDurationMs * LABEL_TOKEN_THRESHOLD_RATIO
|
||||
const primaryTitleTokens = tokenizeTitle(titles[0].title)
|
||||
|
||||
return primaryTitleTokens
|
||||
.filter(token => (weightMap.get(token.toLowerCase()) ?? 0) >= threshold)
|
||||
.map(token => originalMap.get(token.toLowerCase()) ?? token)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
const getLongestCommonSubstringBetween = (left: string, right: string) => {
|
||||
if (!left || !right) return ''
|
||||
|
||||
const leftLower = left.toLowerCase()
|
||||
const rightLower = right.toLowerCase()
|
||||
const previous = new Array(right.length + 1).fill(0)
|
||||
const current = new Array(right.length + 1).fill(0)
|
||||
let bestLength = 0
|
||||
let bestEnd = 0
|
||||
|
||||
for (let i = 1; i <= left.length; i += 1) {
|
||||
for (let j = 1; j <= right.length; j += 1) {
|
||||
if (leftLower[i - 1] === rightLower[j - 1]) {
|
||||
current[j] = previous[j - 1] + 1
|
||||
if (current[j] > bestLength) {
|
||||
bestLength = current[j]
|
||||
bestEnd = i
|
||||
}
|
||||
} else {
|
||||
current[j] = 0
|
||||
}
|
||||
}
|
||||
|
||||
for (let j = 0; j <= right.length; j += 1) {
|
||||
previous[j] = current[j]
|
||||
current[j] = 0
|
||||
}
|
||||
}
|
||||
|
||||
return left.slice(bestEnd - bestLength, bestEnd)
|
||||
}
|
||||
|
||||
const buildCommonSubstringLabel = (titles: TitleUsage[]) => {
|
||||
if (titles.length === 0) return ''
|
||||
|
||||
let shared = cleanTitle(titles[0].title)
|
||||
for (let index = 1; index < titles.length; index += 1) {
|
||||
shared = getLongestCommonSubstringBetween(shared, cleanTitle(titles[index].title))
|
||||
if (trimLabelSeparators(shared).length < 2) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
return trimLabelSeparators(shared)
|
||||
}
|
||||
|
||||
const resolveProcessDisplayName = (rawProcessName: string, titles: TitleUsage[]) => {
|
||||
const processBaseName = stripExtension(rawProcessName).toLowerCase()
|
||||
const totalDurationMs = titles.reduce((total, item) => total + item.durationMs, 0)
|
||||
const weightedTokenLabel = trimLabelSeparators(buildWeightedTokenLabel(titles, totalDurationMs))
|
||||
const commonSubstringLabel = trimLabelSeparators(buildCommonSubstringLabel(titles.slice(0, 5)))
|
||||
const candidates = [weightedTokenLabel, commonSubstringLabel]
|
||||
.filter(label => label.length >= 2)
|
||||
.sort((left, right) => right.length - left.length)
|
||||
|
||||
const preferredCandidate = candidates.find(label => label.toLowerCase() !== processBaseName)
|
||||
if (preferredCandidate) {
|
||||
return preferredCandidate
|
||||
}
|
||||
|
||||
const fallbackTitle = cleanTitle(titles[0]?.title ?? '')
|
||||
if (fallbackTitle && !/\.exe$/i.test(fallbackTitle)) {
|
||||
return fallbackTitle
|
||||
}
|
||||
|
||||
return stripExtension(rawProcessName) || fallbackTitle || '未知应用'
|
||||
}
|
||||
|
||||
const addDurationToUsageMap = (
|
||||
usageMap: Map<string, MutableAppUsage>,
|
||||
processId: string,
|
||||
rawProcessName: string,
|
||||
title: string,
|
||||
durationMs: number
|
||||
) => {
|
||||
if (durationMs <= 0) return
|
||||
|
||||
const existing = usageMap.get(processId)
|
||||
if (existing) {
|
||||
existing.durationMs += durationMs
|
||||
existing.titleDurations.set(title, (existing.titleDurations.get(title) ?? 0) + durationMs)
|
||||
if (!existing.rawProcessName && rawProcessName) {
|
||||
existing.rawProcessName = rawProcessName
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
usageMap.set(processId, {
|
||||
processId,
|
||||
rawProcessName,
|
||||
durationMs,
|
||||
titleDurations: new Map([[title, durationMs]])
|
||||
})
|
||||
}
|
||||
|
||||
const buildAppList = (usageMap: Map<string, MutableAppUsage>) => {
|
||||
const totalDurationMs = Array.from(usageMap.values()).reduce((sum, item) => sum + item.durationMs, 0)
|
||||
|
||||
return {
|
||||
totalDurationMs,
|
||||
apps: Array.from(usageMap.values())
|
||||
.map(app => {
|
||||
const sortedTitles = Array.from(app.titleDurations.entries())
|
||||
.map(([title, durationMs]) => ({ title, durationMs }))
|
||||
.sort((left, right) => right.durationMs - left.durationMs)
|
||||
|
||||
return {
|
||||
processId: app.processId,
|
||||
processName: resolveProcessDisplayName(app.rawProcessName, sortedTitles),
|
||||
rawProcessName: app.rawProcessName,
|
||||
durationSeconds: Math.round(app.durationMs / 1000),
|
||||
percentage: totalDurationMs > 0 ? Number(((app.durationMs / totalDurationMs) * 100).toFixed(2)) : 0,
|
||||
titleCount: sortedTitles.length,
|
||||
titles: sortedTitles.slice(0, 5).map(title => ({
|
||||
title: title.title,
|
||||
durationSeconds: Math.round(title.durationMs / 1000),
|
||||
percentage: app.durationMs > 0 ? Number(((title.durationMs / app.durationMs) * 100).toFixed(2)) : 0
|
||||
}))
|
||||
}
|
||||
})
|
||||
.sort((left, right) => right.durationSeconds - left.durationSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleScreenTime(req: NextRequest) {
|
||||
try {
|
||||
const pathSegments = req.nextUrl.pathname.split('/')
|
||||
const hostnameIndex = pathSegments.indexOf('hosts') + 1
|
||||
const hostname = pathSegments[hostnameIndex]
|
||||
|
||||
if (!hostname) {
|
||||
return NextResponse.json({ error: '缺少主机名' }, { status: 400 })
|
||||
}
|
||||
|
||||
const searchParams = req.nextUrl.searchParams
|
||||
const unit: Unit = searchParams.get('unit') === 'week' ? 'week' : 'day'
|
||||
const date = searchParams.get('date')
|
||||
const tzOffsetMinutes = Number(searchParams.get('tzOffsetMinutes') ?? '0')
|
||||
|
||||
if (!Number.isFinite(tzOffsetMinutes)) {
|
||||
return NextResponse.json({ error: '时区参数无效' }, { status: 400 })
|
||||
}
|
||||
|
||||
const selectedDate = parseDateParts(date) ?? getCurrentDateParts(tzOffsetMinutes)
|
||||
const selectedDayStartMs = getLocalDateUtcMs(selectedDate, tzOffsetMinutes)
|
||||
const selectedDayEndMs = selectedDayStartMs + DAY_MS
|
||||
const weekStartDate = getWeekStart(selectedDate)
|
||||
const weekStartMs = getLocalDateUtcMs(weekStartDate, tzOffsetMinutes)
|
||||
const weekEndMs = weekStartMs + 7 * DAY_MS
|
||||
|
||||
const aggregateStartDate = unit === 'week' ? weekStartDate : selectedDate
|
||||
const aggregateStartMs = unit === 'week' ? weekStartMs : selectedDayStartMs
|
||||
const aggregateEndMs = unit === 'week' ? weekEndMs : selectedDayEndMs
|
||||
const aggregateEndDate = addDays(aggregateStartDate, unit === 'week' ? 7 : 1)
|
||||
|
||||
const records = await prisma.record.findMany({
|
||||
where: {
|
||||
hostname,
|
||||
timestamp: {
|
||||
gte: new Date(weekStartMs - MAX_GAP_MS),
|
||||
lte: new Date(weekEndMs + MAX_GAP_MS)
|
||||
}
|
||||
},
|
||||
select: {
|
||||
timestamp: true,
|
||||
windows: {
|
||||
select: {
|
||||
title: true,
|
||||
path: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
timestamp: 'asc'
|
||||
}
|
||||
})
|
||||
|
||||
const validDiffs = records
|
||||
.slice(0, -1)
|
||||
.map((record, index) => records[index + 1].timestamp.getTime() - record.timestamp.getTime())
|
||||
.filter(diff => diff > 0 && diff <= MAX_GAP_MS)
|
||||
.sort((left, right) => left - right)
|
||||
|
||||
const medianDiff = validDiffs.length > 0
|
||||
? validDiffs[Math.floor(validDiffs.length / 2)]
|
||||
: DEFAULT_SAMPLE_MS
|
||||
const estimatedSampleMs = Math.min(MAX_GAP_MS, Math.max(MIN_SAMPLE_MS, medianDiff))
|
||||
|
||||
const aggregateUsageMap = new Map<string, MutableAppUsage>()
|
||||
const selectedDayUsageMap = new Map<string, MutableAppUsage>()
|
||||
const weekDayDurations = Array.from({ length: 7 }, () => 0)
|
||||
const hourBuckets = Array.from({ length: 24 }, (): HourBucket => ({
|
||||
totalDurationMs: 0,
|
||||
appDurations: new Map<string, number>()
|
||||
}))
|
||||
|
||||
records.forEach((record, index) => {
|
||||
const activeWindow = record.windows[0]
|
||||
if (!activeWindow) return
|
||||
|
||||
const currentMs = record.timestamp.getTime()
|
||||
const nextMs = records[index + 1]?.timestamp.getTime()
|
||||
const intervalMs = nextMs
|
||||
? Math.min(Math.max(nextMs - currentMs, 0), MAX_GAP_MS)
|
||||
: estimatedSampleMs
|
||||
|
||||
if (intervalMs <= 0) return
|
||||
|
||||
const intervalStartMs = currentMs
|
||||
const intervalEndMs = currentMs + intervalMs
|
||||
const title = cleanTitle(activeWindow.title)
|
||||
|| stripExtension(win32.basename(activeWindow.path || ''))
|
||||
|| '未知窗口'
|
||||
const process = normalizeProcess(activeWindow.path, title)
|
||||
|
||||
const aggregateContributionMs = Math.max(0, Math.min(intervalEndMs, aggregateEndMs) - Math.max(intervalStartMs, aggregateStartMs))
|
||||
addDurationToUsageMap(
|
||||
aggregateUsageMap,
|
||||
process.processId,
|
||||
process.rawProcessName,
|
||||
title,
|
||||
aggregateContributionMs
|
||||
)
|
||||
|
||||
const selectedDayContributionMs = Math.max(0, Math.min(intervalEndMs, selectedDayEndMs) - Math.max(intervalStartMs, selectedDayStartMs))
|
||||
addDurationToUsageMap(
|
||||
selectedDayUsageMap,
|
||||
process.processId,
|
||||
process.rawProcessName,
|
||||
title,
|
||||
selectedDayContributionMs
|
||||
)
|
||||
|
||||
let dayCursorMs = Math.max(intervalStartMs, weekStartMs)
|
||||
const weekIntervalEndMs = Math.min(intervalEndMs, weekEndMs)
|
||||
while (dayCursorMs < weekIntervalEndMs) {
|
||||
const dayIndex = Math.floor((dayCursorMs - weekStartMs) / DAY_MS)
|
||||
if (dayIndex < 0 || dayIndex >= 7) break
|
||||
|
||||
const nextBoundaryMs = Math.min(weekStartMs + (dayIndex + 1) * DAY_MS, weekIntervalEndMs)
|
||||
weekDayDurations[dayIndex] += nextBoundaryMs - dayCursorMs
|
||||
dayCursorMs = nextBoundaryMs
|
||||
}
|
||||
|
||||
let hourCursorMs = Math.max(intervalStartMs, selectedDayStartMs)
|
||||
const dayIntervalEndMs = Math.min(intervalEndMs, selectedDayEndMs)
|
||||
while (hourCursorMs < dayIntervalEndMs) {
|
||||
const hourIndex = Math.floor((hourCursorMs - selectedDayStartMs) / HOUR_MS)
|
||||
if (hourIndex < 0 || hourIndex >= 24) break
|
||||
|
||||
const nextBoundaryMs = Math.min(selectedDayStartMs + (hourIndex + 1) * HOUR_MS, dayIntervalEndMs)
|
||||
const contributionMs = nextBoundaryMs - hourCursorMs
|
||||
hourBuckets[hourIndex].totalDurationMs += contributionMs
|
||||
hourBuckets[hourIndex].appDurations.set(
|
||||
process.processId,
|
||||
(hourBuckets[hourIndex].appDurations.get(process.processId) ?? 0) + contributionMs
|
||||
)
|
||||
hourCursorMs = nextBoundaryMs
|
||||
}
|
||||
})
|
||||
|
||||
const aggregateSummary = buildAppList(aggregateUsageMap)
|
||||
const selectedDaySummary = buildAppList(selectedDayUsageMap)
|
||||
const spotlightApps = selectedDaySummary.apps.slice(0, 3).map(app => ({
|
||||
processId: app.processId,
|
||||
processName: app.processName,
|
||||
durationSeconds: app.durationSeconds,
|
||||
percentage: app.percentage
|
||||
}))
|
||||
const spotlightIds = new Set(spotlightApps.map(app => app.processId))
|
||||
const weekTotalDurationMs = weekDayDurations.reduce((sum, durationMs) => sum + durationMs, 0)
|
||||
const averageDurationSeconds = Math.round((weekTotalDurationMs / 7) / 1000)
|
||||
const latestRecord = records.length > 0 ? records[records.length - 1] : null
|
||||
|
||||
return NextResponse.json({
|
||||
hostname,
|
||||
unit,
|
||||
selectedDate: toDateKey(selectedDate),
|
||||
periodStart: new Date(aggregateStartMs).toISOString(),
|
||||
periodEnd: new Date(aggregateEndMs).toISOString(),
|
||||
periodStartDate: toDateKey(aggregateStartDate),
|
||||
periodEndDate: toDateKey(addDays(aggregateEndDate, -1)),
|
||||
totalDurationSeconds: Math.round(aggregateSummary.totalDurationMs / 1000),
|
||||
estimatedSampleSeconds: Math.round(estimatedSampleMs / 1000),
|
||||
lastRecordedAt: latestRecord ? latestRecord.timestamp.toISOString() : null,
|
||||
apps: aggregateSummary.apps,
|
||||
overview: {
|
||||
week: {
|
||||
totalDurationSeconds: Math.round(weekTotalDurationMs / 1000),
|
||||
averageDurationSeconds,
|
||||
days: weekDayDurations.map((durationMs, index) => {
|
||||
const dayDate = addDays(weekStartDate, index)
|
||||
return {
|
||||
date: toDateKey(dayDate),
|
||||
weekdayLabel: WEEKDAY_LABELS[index === 6 ? 0 : index + 1],
|
||||
totalDurationSeconds: Math.round(durationMs / 1000),
|
||||
isSelected: index === Math.floor((selectedDayStartMs - weekStartMs) / DAY_MS)
|
||||
}
|
||||
})
|
||||
},
|
||||
selectedDay: {
|
||||
date: toDateKey(selectedDate),
|
||||
totalDurationSeconds: Math.round(selectedDaySummary.totalDurationMs / 1000),
|
||||
spotlightApps,
|
||||
hours: hourBuckets.map((bucket, hour) => {
|
||||
const spotlightDurations = Object.fromEntries(
|
||||
spotlightApps.map(app => [app.processId, Math.round((bucket.appDurations.get(app.processId) ?? 0) / 1000)])
|
||||
)
|
||||
const spotlightTotalMs = Array.from(bucket.appDurations.entries())
|
||||
.filter(([processId]) => spotlightIds.has(processId))
|
||||
.reduce((sum, [, durationMs]) => sum + durationMs, 0)
|
||||
|
||||
return {
|
||||
hour,
|
||||
label: `${String(hour).padStart(2, '0')}时`,
|
||||
totalDurationSeconds: Math.round(bucket.totalDurationMs / 1000),
|
||||
spotlightDurations,
|
||||
otherDurationSeconds: Math.round(Math.max(bucket.totalDurationMs - spotlightTotalMs, 0) / 1000)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('获取屏幕使用时间失败:', error)
|
||||
return NextResponse.json({ error: '获取屏幕使用时间失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = withCors(handleScreenTime)
|
||||
@@ -23,6 +23,73 @@ export interface TimeDistributionPoint {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface ScreenTimeTitleUsage {
|
||||
title: string;
|
||||
durationSeconds: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface ScreenTimeAppUsage {
|
||||
processId: string;
|
||||
processName: string;
|
||||
rawProcessName: string;
|
||||
durationSeconds: number;
|
||||
percentage: number;
|
||||
titleCount: number;
|
||||
titles: ScreenTimeTitleUsage[];
|
||||
}
|
||||
|
||||
export interface ScreenTimeSpotlightApp {
|
||||
processId: string;
|
||||
processName: string;
|
||||
durationSeconds: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface ScreenTimeWeekDay {
|
||||
date: string;
|
||||
weekdayLabel: string;
|
||||
totalDurationSeconds: number;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
export interface ScreenTimeHourBucket {
|
||||
hour: number;
|
||||
label: string;
|
||||
totalDurationSeconds: number;
|
||||
spotlightDurations: Record<string, number>;
|
||||
otherDurationSeconds: number;
|
||||
}
|
||||
|
||||
export interface ScreenTimeOverview {
|
||||
week: {
|
||||
totalDurationSeconds: number;
|
||||
averageDurationSeconds: number;
|
||||
days: ScreenTimeWeekDay[];
|
||||
};
|
||||
selectedDay: {
|
||||
date: string;
|
||||
totalDurationSeconds: number;
|
||||
spotlightApps: ScreenTimeSpotlightApp[];
|
||||
hours: ScreenTimeHourBucket[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ScreenTimeResponse {
|
||||
hostname: string;
|
||||
unit: 'day' | 'week';
|
||||
selectedDate: string;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
periodStartDate: string;
|
||||
periodEndDate: string;
|
||||
totalDurationSeconds: number;
|
||||
estimatedSampleSeconds: number;
|
||||
lastRecordedAt: string | null;
|
||||
apps: ScreenTimeAppUsage[];
|
||||
overview: ScreenTimeOverview;
|
||||
}
|
||||
|
||||
export interface Password {
|
||||
value: string;
|
||||
timestamp: string;
|
||||
|
||||
@@ -18,3 +18,53 @@ export const formatDate = (date: string | Date, type: 'full' | 'short' = 'full')
|
||||
}
|
||||
return format(new Date(date), 'yyyy-MM-dd HH:mm:ss');
|
||||
};
|
||||
|
||||
export const formatDuration = (durationSeconds: number) => {
|
||||
if (durationSeconds <= 0) return '0分钟';
|
||||
|
||||
const hours = Math.floor(durationSeconds / 3600);
|
||||
const minutes = Math.floor((durationSeconds % 3600) / 60);
|
||||
const seconds = durationSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
if (minutes > 0) {
|
||||
return `${hours}小时 ${minutes}分钟`;
|
||||
}
|
||||
return `${hours}小时`;
|
||||
}
|
||||
|
||||
if (minutes > 0) {
|
||||
if (seconds > 0 && minutes < 5) {
|
||||
return `${minutes}分钟 ${seconds}秒`;
|
||||
}
|
||||
return `${minutes}分钟`;
|
||||
}
|
||||
|
||||
return `${seconds}秒`;
|
||||
};
|
||||
|
||||
export const getLocalDateInputValue = (date = new Date()) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
export const shiftDateInputValue = (dateString: string, amount: number) => {
|
||||
const [year, month, day] = dateString.split('-').map(Number);
|
||||
const next = new Date(year, month - 1, day);
|
||||
next.setDate(next.getDate() + amount);
|
||||
return getLocalDateInputValue(next);
|
||||
};
|
||||
|
||||
export const formatScreenTimePeriod = (startDate: string, endDate: string, unit: 'day' | 'week') => {
|
||||
if (unit === 'day') {
|
||||
return startDate;
|
||||
}
|
||||
|
||||
if (startDate === endDate) {
|
||||
return startDate;
|
||||
}
|
||||
|
||||
return `${startDate} 至 ${endDate}`;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user