feie9454 fea99d37fe feat: 标签调整宽度和图标,top 3 -> top 4
Co-authored-by: Copilot <copilot@github.com>
2026-04-25 19:00:52 +08:00

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>
);
}