349 lines
14 KiB
TypeScript
349 lines
14 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 [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);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="rounded-[1.75rem] border border-gray-200 bg-white p-3 shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
<div className="min-w-0">
|
|
<div className="text-xs font-medium text-gray-500 dark:text-gray-400">
|
|
屏幕使用时间
|
|
</div>
|
|
<h2 className="mt-0.5 truncate text-xl font-semibold text-gray-900 dark:text-white">
|
|
{periodLabel}
|
|
</h2>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
<div className="inline-flex rounded-full bg-gray-100 p-0.5 dark:bg-gray-700/70">
|
|
<button
|
|
onClick={() => setUnit('day')}
|
|
className={`rounded-full px-4 py-1.5 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-4 py-1.5 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="flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 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 h-9 items-center gap-2 rounded-full border border-gray-200 px-3 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="flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 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}
|
|
/>
|
|
|
|
{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-[1.75rem] border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
|
<div className="border-b border-gray-200 px-4 py-3 dark:border-gray-700">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div>
|
|
<h3 className="text-base font-semibold text-gray-900 dark:text-white">最常使用</h3>
|
|
{screenTime && (
|
|
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
|
{screenTime.apps.length} 个应用 · {formatDuration(screenTime.totalDurationSeconds)}
|
|
</div>
|
|
)}
|
|
</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-8 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-8 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, 2)
|
|
: 0;
|
|
|
|
return (
|
|
<div
|
|
key={app.processId}
|
|
className={`px-4 py-3 transition-colors`}
|
|
>
|
|
<button
|
|
onClick={() => toggleExpanded(app.processId)}
|
|
className="w-full text-left"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl bg-gray-100 text-xs font-semibold text-gray-500 dark:bg-gray-700 dark:text-gray-300">
|
|
{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-3.5 w-3.5 shrink-0 text-gray-400" />
|
|
) : (
|
|
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
|
)}
|
|
<div className="truncate text-base font-semibold text-gray-900 dark:text-white" title={app.processName}>
|
|
{app.processName}
|
|
</div>
|
|
<span className="shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-[11px] text-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
|
{app.titleCount} 个标题
|
|
</span>
|
|
</div>
|
|
|
|
<div className="mt-1 flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
|
<span>{app.percentage}%</span>
|
|
<span className="h-1 w-1 rounded-full bg-gray-300 dark:bg-gray-600" />
|
|
<span className="truncate" title={app.processPath || '路径未知'}>
|
|
{app.processPath || '路径未知'}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="mt-2 h-1.5 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-12 mt-3 rounded-2xl bg-gray-50 px-3 py-2.5 dark:bg-gray-900/50">
|
|
<div className="space-y-2.5">
|
|
{app.titles.map((title) => (
|
|
<div key={`${app.processId}-${title.title}`}>
|
|
<div className="flex items-center 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-0.5 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-1.5 h-1.5 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, 2)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{app.titleCount > app.titles.length && (
|
|
<div className="mt-3 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>
|
|
);
|
|
} |