362 lines
15 KiB
TypeScript
362 lines
15 KiB
TypeScript
"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>
|
||
);
|
||
} |