feie9454 d82872ad18 feat: 添加遮罩筛选
Co-authored-by: Copilot <copilot@github.com>
2026-04-25 13:02:05 +08:00

363 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
</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-2 truncate text-xs text-gray-500 dark:text-gray-400" title={app.processPath || '路径未知'}>
: {app.processPath || '路径未知'}
</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>
);
}