Compare commits

...
7 Commits
Author SHA1 Message Date
feie9454 7571316685 feat: 添加 ip 追踪 2026-06-09 18:43:54 +08:00
feie9454 ca654754ae fix: 修复前端快速滑动时多个网络请求 2026-06-09 17:32:35 +08:00
feie9454 681e3c7493 fix: 修复 fileid索引 2026-05-17 18:42:19 +08:00
feie9454andCopilot fea99d37fe feat: 标签调整宽度和图标,top 3 -> top 4
Co-authored-by: Copilot <copilot@github.com>
2026-04-25 19:00:52 +08:00
feie9454andCopilot 520cfd96c7 feat: 别名/屏蔽移到 .env
Co-authored-by: Copilot <copilot@github.com>
2026-04-25 18:17:58 +08:00
feie9454andCopilot f7f0e2fb73 feat: DisplayName 别名
Co-authored-by: Copilot <copilot@github.com>
2026-04-25 13:18:52 +08:00
feie9454andCopilot d82872ad18 feat: 添加遮罩筛选
Co-authored-by: Copilot <copilot@github.com>
2026-04-25 13:02:05 +08:00
21 changed files with 991 additions and 344 deletions
+8
View File
@@ -26,3 +26,11 @@ QQ_BOT_TARGET_ID=1234567890
NEXT_PUBLIC_VAPID_PUBLIC_KEY= NEXT_PUBLIC_VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY= VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:admin@example.com VAPID_SUBJECT=mailto:admin@example.com
# Window filters
# Format: keyword1,keyword2
IGNORED_WINDOW_KEYWORDS=NVIDIA Overlay,Twinkle Tray Panel
# Screen Time display name presets
# Format: match=displayName;match=displayName
PROCESS_DISPLAY_NAME_PRESETS=microsoft vs code\code.exe=Visual Studio Code;msedge.exe=Microsoft Edge;windowsTerminal.exe=终端;windowsterminal.exe=终端;blender.exe=Blender;chrome.exe=Google Chrome;steamwebhelper.exe=Steam;steam.exe=Steam;bandizip.exe=Bandizip;explorer.exe=文件资源管理器;devenv.exe=Visual Studio;idea64.exe=IntelliJ IDEA;pycharm64.exe=PyCharm;cursor.exe=Cursor;obsidian.exe=Obsidian;notion.exe=Notion;wechat.exe=微信;qq.exe=QQ;teams.exe=Microsoft Teams;telegram.exe=Telegram
@@ -13,11 +13,7 @@ import {
YAxis YAxis
} from 'recharts'; } from 'recharts';
import { import {
CalendarDays,
Clock3,
Loader2, Loader2,
Sparkles,
WandSparkles
} from 'lucide-react'; } from 'lucide-react';
import { ScreenTimeResponse } from '../types'; import { ScreenTimeResponse } from '../types';
import { formatDuration, getLocalDateInputValue } from '../utils'; import { formatDuration, getLocalDateInputValue } from '../utils';
@@ -28,30 +24,27 @@ interface ScreenTimeOverviewChartProps {
loading: boolean; loading: boolean;
refreshing: boolean; refreshing: boolean;
onSelectDate: (date: string, nextUnit?: 'day' | 'week') => void; onSelectDate: (date: string, nextUnit?: 'day' | 'week') => void;
onSpotlightAppClick: (processId: string) => void;
} }
interface WeekChartDatum { const SPOTLIGHT_COLORS = ['#2f7cff', '#17c3d1', '#ff9f43', '#ff6b6b'] as const;
const SPOTLIGHT_KEYS = ['spotlight_0', 'spotlight_1', 'spotlight_2', 'spotlight_3'] as const;
type SpotlightKey = typeof SPOTLIGHT_KEYS[number];
type WeekChartDatum = Record<SpotlightKey, number> & {
date: string; date: string;
label: string; label: string;
totalDurationSeconds: number; totalDurationSeconds: number;
mutedDuration: number; mutedDuration: number;
selectedRemainder: number; selectedRemainder: number;
spotlight_0: number;
spotlight_1: number;
spotlight_2: number;
isSelected: boolean; isSelected: boolean;
} };
interface HourChartDatum { type HourChartDatum = Record<SpotlightKey, number> & {
hour: number; hour: number;
label: string; label: string;
totalDurationSeconds: number; totalDurationSeconds: number;
spotlight_0: number;
spotlight_1: number;
spotlight_2: number;
otherDurationSeconds: number; otherDurationSeconds: number;
} };
interface TooltipPayloadRow<T> { interface TooltipPayloadRow<T> {
payload: T; payload: T;
@@ -66,11 +59,10 @@ interface RoundedBarShapeProps {
payload?: Record<string, unknown>; payload?: Record<string, unknown>;
} }
const SPOTLIGHT_COLORS = ['#2f7cff', '#17c3d1', '#ff9f43'];
const OTHER_COLOR = '#d4d7df'; const OTHER_COLOR = '#d4d7df';
const MUTED_BAR_COLOR = '#cfd3dc'; const MUTED_BAR_COLOR = '#cfd3dc';
const WEEK_STACK_KEYS = ['mutedDuration', 'spotlight_0', 'spotlight_1', 'spotlight_2', 'selectedRemainder'] as const; const WEEK_STACK_KEYS = ['mutedDuration', ...SPOTLIGHT_KEYS, 'selectedRemainder'] as const;
const HOUR_STACK_KEYS = ['spotlight_0', 'spotlight_1', 'spotlight_2', 'otherDurationSeconds'] as const; const HOUR_STACK_KEYS = [...SPOTLIGHT_KEYS, 'otherDurationSeconds'] as const;
const getNumericValue = (value: unknown) => { const getNumericValue = (value: unknown) => {
return typeof value === 'number' && Number.isFinite(value) ? value : 0; return typeof value === 'number' && Number.isFinite(value) ? value : 0;
@@ -157,19 +149,25 @@ const formatUpdateTime = (value: string | null) => {
const OverviewSkeleton = () => { const OverviewSkeleton = () => {
return ( 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="rounded-[1.75rem] border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div className="screen-time-skeleton h-6 w-36 rounded-full" /> <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="mt-4 screen-time-skeleton h-14 w-56 rounded-2xl" /> <div>
<div className="mt-8 rounded-[1.75rem] bg-gray-50 p-5 dark:bg-gray-900/60"> <div className="screen-time-skeleton h-5 w-24 rounded-full" />
<div className="flex h-40 items-end gap-3"> <div className="mt-2 screen-time-skeleton h-10 w-44 rounded-xl" />
{[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="grid w-full grid-cols-3 gap-2 sm:w-64">
{[0, 1, 2].map((index) => (
<div key={index} className="screen-time-skeleton h-12 rounded-2xl" />
))} ))}
</div> </div>
<div className="mt-8 flex h-32 items-end gap-2"> </div>
<div className="mt-4 grid gap-5 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.35fr)]">
<div className="flex h-36 items-end gap-2">
{[56, 88, 62, 76, 50, 68, 18].map((height, index) => (
<div key={index} className="screen-time-skeleton flex-1 rounded-t-2xl" style={{ height }} />
))}
</div>
<div className="flex h-36 items-end gap-1.5">
{Array.from({ length: 18 }).map((_, index) => ( {Array.from({ length: 18 }).map((_, index) => (
<div <div
key={index} key={index}
@@ -200,9 +198,6 @@ function WeekTooltip({
<div className="mt-1 text-sm font-semibold text-gray-900 dark:text-white"> <div className="mt-1 text-sm font-semibold text-gray-900 dark:text-white">
{formatDuration(datum.totalDurationSeconds)} {formatDuration(datum.totalDurationSeconds)}
</div> </div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{datum.isSelected ? '当前选中日,点击其他日期可跳转' : '点击查看这一天'}
</div>
</div> </div>
); );
} }
@@ -219,10 +214,10 @@ function HourTooltip({
if (!active || !payload || payload.length === 0) return null; if (!active || !payload || payload.length === 0) return null;
const datum = payload[0].payload; const datum = payload[0].payload;
const rows = [0, 1, 2] const rows: Array<{ label: string; value: number; color: string }> = SPOTLIGHT_KEYS
.map((index) => ({ .map((key, index) => ({
label: spotlightNames[index], label: spotlightNames[index] ?? '',
value: datum[`spotlight_${index}` as keyof HourChartDatum] as number, value: datum[key],
color: SPOTLIGHT_COLORS[index] color: SPOTLIGHT_COLORS[index]
})) }))
.filter((row) => row.label && row.value > 0); .filter((row) => row.label && row.value > 0);
@@ -262,7 +257,6 @@ export default function ScreenTimeOverviewChart({
loading, loading,
refreshing, refreshing,
onSelectDate, onSelectDate,
onSpotlightAppClick
}: ScreenTimeOverviewChartProps) { }: ScreenTimeOverviewChartProps) {
const weekChartData = useMemo(() => { const weekChartData = useMemo(() => {
if (!data) return [] as WeekChartDatum[]; if (!data) return [] as WeekChartDatum[];
@@ -270,32 +264,46 @@ export default function ScreenTimeOverviewChart({
const spotlightTotal = data.overview.selectedDay.spotlightApps const spotlightTotal = data.overview.selectedDay.spotlightApps
.reduce((sum, app) => sum + app.durationSeconds, 0); .reduce((sum, app) => sum + app.durationSeconds, 0);
return data.overview.week.days.map((day) => ({ return data.overview.week.days.map((day) => {
const spotlightDurations = Object.fromEntries(
SPOTLIGHT_KEYS.map((key, index) => [
key,
day.isSelected ? (data.overview.selectedDay.spotlightApps[index]?.durationSeconds ?? 0) : 0
])
) as Record<SpotlightKey, number>;
return {
date: day.date, date: day.date,
label: day.weekdayLabel, label: day.weekdayLabel,
totalDurationSeconds: day.totalDurationSeconds, totalDurationSeconds: day.totalDurationSeconds,
mutedDuration: day.isSelected ? 0 : day.totalDurationSeconds, mutedDuration: day.isSelected ? 0 : day.totalDurationSeconds,
selectedRemainder: day.isSelected ? Math.max(day.totalDurationSeconds - spotlightTotal, 0) : 0, selectedRemainder: day.isSelected ? Math.max(day.totalDurationSeconds - spotlightTotal, 0) : 0,
spotlight_0: day.isSelected ? (data.overview.selectedDay.spotlightApps[0]?.durationSeconds ?? 0) : 0, ...spotlightDurations,
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 isSelected: day.isSelected
})); };
});
}, [data]); }, [data]);
const hourChartData = useMemo(() => { const hourChartData = useMemo(() => {
if (!data) return [] as HourChartDatum[]; if (!data) return [] as HourChartDatum[];
const spotlightApps = data.overview.selectedDay.spotlightApps; const spotlightApps = data.overview.selectedDay.spotlightApps;
return data.overview.selectedDay.hours.map((hour) => ({ return data.overview.selectedDay.hours.map((hour) => {
const spotlightDurations = Object.fromEntries(
SPOTLIGHT_KEYS.map((key, index) => [
key,
spotlightApps[index] ? hour.spotlightDurations[spotlightApps[index].processId] ?? 0 : 0
])
) as Record<SpotlightKey, number>;
return {
hour: hour.hour, hour: hour.hour,
label: hour.label, label: hour.label,
totalDurationSeconds: hour.totalDurationSeconds, totalDurationSeconds: hour.totalDurationSeconds,
spotlight_0: spotlightApps[0] ? hour.spotlightDurations[spotlightApps[0].processId] ?? 0 : 0, ...spotlightDurations,
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 otherDurationSeconds: hour.otherDurationSeconds
})); };
});
}, [data]); }, [data]);
const spotlightNames = data?.overview.selectedDay.spotlightApps.map((app) => app.processName) ?? []; const spotlightNames = data?.overview.selectedDay.spotlightApps.map((app) => app.processName) ?? [];
@@ -303,6 +311,8 @@ export default function ScreenTimeOverviewChart({
if (!data) return 0; if (!data) return 0;
return Math.max(...data.overview.week.days.map((day) => day.totalDurationSeconds), 0); return Math.max(...data.overview.week.days.map((day) => day.totalDurationSeconds), 0);
}, [data]); }, [data]);
const primaryLabel = data ? (unit === 'week' ? '本周' : formatSelectedDateHeadline(data.overview.selectedDay.date)) : '';
const selectedDayDuration = data?.overview.selectedDay.totalDurationSeconds ?? 0;
if (loading && !data) { if (loading && !data) {
return <OverviewSkeleton />; return <OverviewSkeleton />;
@@ -311,81 +321,80 @@ export default function ScreenTimeOverviewChart({
if (!data) return null; if (!data) return null;
return ( 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%)]"> <div className="relative overflow-hidden rounded-[1.75rem] border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800">
{refreshing && ( {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="absolute inset-0 z-20 flex items-start justify-end bg-white/40 p-3 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"> <div className="inline-flex items-center gap-2 rounded-full bg-white/95 px-3 py-1 text-xs font-medium text-gray-700 shadow-lg dark:bg-gray-900/95 dark:text-gray-200">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className="h-3.5 w-3.5 animate-spin" />
正在刷新统计 更新中
</div> </div>
</div> </div>
)} )}
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div className="min-w-0">
<div className="flex items-center gap-2 text-sm font-medium text-slate-500 dark:text-slate-300"> <div className="text-sm font-medium text-gray-500 dark:text-gray-400">
<CalendarDays className="h-4 w-4" /> {primaryLabel}
{formatSelectedDateHeadline(data.overview.selectedDay.date)}
</div> </div>
<div className="mt-2 text-4xl font-semibold tracking-tight text-gray-950 dark:text-white sm:text-5xl"> <div className="mt-1 text-4xl font-semibold text-gray-950 dark:text-white sm:text-5xl">
{formatDuration(data.totalDurationSeconds)} {formatDuration(data.totalDurationSeconds)}
</div> </div>
<div className="mt-2 text-sm text-gray-500 dark:text-gray-400"> <div className="mt-1 text-xs text-gray-400 dark:text-gray-500">
{unit === 'week' ? '本周总屏幕时间' : '当天总屏幕时间'} {formatUpdateTime(data.lastRecordedAt)}
<span className="mx-2 text-gray-300 dark:text-gray-600">•</span>
选中日 {formatDuration(data.overview.selectedDay.totalDurationSeconds)}
</div> </div>
</div> </div>
<div className="grid gap-3 sm:grid-cols-3 lg:min-w-[22rem]"> <div className="grid grid-cols-3 gap-2 sm: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="rounded-2xl bg-gray-50 px-3 py-2 dark:bg-gray-900/70">
<div className="text-xs text-gray-500 dark:text-gray-400">周平均</div> <div className="text-[11px] text-gray-500 dark:text-gray-400">周平均</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white"> <div className="mt-0.5 truncate text-sm font-semibold text-gray-900 dark:text-white">
{formatDuration(data.overview.week.averageDurationSeconds)} {formatDuration(data.overview.week.averageDurationSeconds)}
</div> </div>
</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="rounded-2xl bg-gray-50 px-3 py-2 dark:bg-gray-900/70">
<div className="text-xs text-gray-500 dark:text-gray-400">活跃应用</div> <div className="text-[11px] text-gray-500 dark:text-gray-400">所选日</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white"> <div className="mt-0.5 truncate text-sm font-semibold text-gray-900 dark:text-white">
{formatDuration(selectedDayDuration)}
</div>
</div>
<div className="rounded-2xl bg-gray-50 px-3 py-2 dark:bg-gray-900/70">
<div className="text-[11px] text-gray-500 dark:text-gray-400">应用</div>
<div className="mt-0.5 text-sm font-semibold text-gray-900 dark:text-white">
{data.apps.length} {data.apps.length}
</div> </div>
</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> </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="mt-4 grid gap-5 xl:grid-cols-[minmax(0,0.88fr)_minmax(0,1.35fr)]">
<div className="flex items-center justify-between gap-4"> <section className="min-w-0">
<div> <div className="flex items-baseline justify-between gap-3">
<div className="text-sm font-medium text-gray-700 dark:text-gray-200">周概览</div> <h3 className="text-sm font-semibold text-gray-900 dark:text-white">周概览</h3>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400"> <div className="truncate text-xs text-gray-500 dark:text-gray-400">
点击任意一天可以直接跳到该日期 平均 {formatDuration(data.overview.week.averageDurationSeconds)}
</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> </div>
<div className="mt-4 h-48"> <div className="mt-2 h-36 sm:h-40">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={weekChartData} barCategoryGap="20%"> <BarChart data={weekChartData} barCategoryGap="26%" margin={{ top: 8, right: 0, left: 0, bottom: 0 }}>
<CartesianGrid vertical={false} strokeDasharray="4 4" stroke="#d9dde5" /> <CartesianGrid vertical={false} strokeDasharray="3 5" stroke="#e0e4eb" />
<XAxis axisLine={false} tickLine={false} dataKey="label" tick={{ fill: '#94a3b8', fontSize: 12 }} /> <XAxis
axisLine={false}
tickLine={false}
dataKey="label"
height={22}
tick={{ fill: '#9ca3af', fontSize: 12 }}
/>
<YAxis <YAxis
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
orientation="right" orientation="right"
width={34}
tick={{ fill: '#b0b7c3', fontSize: 12 }} tick={{ fill: '#b0b7c3', fontSize: 12 }}
tickFormatter={formatAxisDuration} tickFormatter={formatAxisDuration}
/> />
<ReferenceLine y={data.overview.week.averageDurationSeconds} stroke="#94a3b8" strokeDasharray="4 6" /> <ReferenceLine y={data.overview.week.averageDurationSeconds} stroke="#9ca3af" strokeDasharray="4 6" />
<Tooltip content={<WeekTooltip />} cursor={{ fill: 'rgba(148, 163, 184, 0.08)' }} /> <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) => { <Bar dataKey="mutedDuration" stackId="week" shape={createRoundedTopShape('mutedDuration', WEEK_STACK_KEYS, 12)} onClick={(_, index) => {
@@ -402,12 +411,12 @@ export default function ScreenTimeOverviewChart({
/> />
))} ))}
</Bar> </Bar>
{[0, 1, 2].map((index) => ( {SPOTLIGHT_KEYS.map((key, index) => (
<Bar <Bar
key={`spotlight-${index}`} key={key}
dataKey={`spotlight_${index}`} dataKey={key}
stackId="week" stackId="week"
shape={createRoundedTopShape(`spotlight_${index}`, WEEK_STACK_KEYS, 12)} shape={createRoundedTopShape(key, WEEK_STACK_KEYS, 12)}
onClick={(_, clickedIndex) => { onClick={(_, clickedIndex) => {
const day = data.overview.week.days[clickedIndex]; const day = data.overview.week.days[clickedIndex];
if (day) { if (day) {
@@ -417,7 +426,7 @@ export default function ScreenTimeOverviewChart({
> >
{weekChartData.map((entry) => ( {weekChartData.map((entry) => (
<Cell <Cell
key={`spotlight-cell-${index}-${entry.date}`} key={`spotlight-cell-${key}-${entry.date}`}
cursor="pointer" cursor="pointer"
fill={entry.isSelected ? SPOTLIGHT_COLORS[index] : 'transparent'} fill={entry.isSelected ? SPOTLIGHT_COLORS[index] : 'transparent'}
/> />
@@ -442,50 +451,48 @@ export default function ScreenTimeOverviewChart({
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
<div className="mt-3 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400"> <div className="mt-1 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<span>周总计 {formatDuration(data.overview.week.totalDurationSeconds)}</span> <span>总计 {formatDuration(data.overview.week.totalDurationSeconds)}</span>
<span>最高 {formatDuration(maxWeekDuration)}</span> <span>最高 {formatDuration(maxWeekDuration)}</span>
</div> </div>
</section>
<div className="mt-7 flex items-center justify-between gap-4"> <section className="min-w-0">
<div> <div className="flex items-baseline justify-between gap-3">
<div className="text-sm font-medium text-gray-700 dark:text-gray-200">当日分布</div> <h3 className="text-sm font-semibold text-gray-900 dark:text-white">当日分布</h3>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400"> <div className="truncate text-xs text-gray-500 dark:text-gray-400">
24 小时内按主应用堆叠,帮你快速定位高强度时段 {formatDuration(selectedDayDuration)}
</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> </div>
<div className="mt-4 h-52"> <div className="mt-2 h-36 sm:h-40">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={hourChartData} barCategoryGap={2}> <BarChart data={hourChartData} barCategoryGap={2} margin={{ top: 8, right: 0, left: 0, bottom: 0 }}>
<CartesianGrid vertical={false} strokeDasharray="4 4" stroke="#d9dde5" /> <CartesianGrid vertical={false} strokeDasharray="3 5" stroke="#e0e4eb" />
<XAxis <XAxis
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
dataKey="label" dataKey="label"
interval={2} height={22}
tick={{ fill: '#94a3b8', fontSize: 12 }} interval={5}
tick={{ fill: '#9ca3af', fontSize: 12 }}
/> />
<YAxis <YAxis
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
orientation="right" orientation="right"
width={38}
tick={{ fill: '#b0b7c3', fontSize: 12 }} tick={{ fill: '#b0b7c3', fontSize: 12 }}
tickFormatter={formatAxisDuration} tickFormatter={formatAxisDuration}
/> />
<Tooltip content={<HourTooltip spotlightNames={spotlightNames} />} cursor={{ fill: 'rgba(148, 163, 184, 0.08)' }} /> <Tooltip content={<HourTooltip spotlightNames={spotlightNames} />} cursor={{ fill: 'rgba(148, 163, 184, 0.08)' }} />
{[0, 1, 2].map((index) => ( {SPOTLIGHT_KEYS.map((key, index) => (
<Bar <Bar
key={`hour-spotlight-${index}`} key={`hour-${key}`}
dataKey={`spotlight_${index}`} dataKey={key}
stackId="hours" stackId="hours"
fill={SPOTLIGHT_COLORS[index]} fill={SPOTLIGHT_COLORS[index]}
shape={createRoundedTopShape(`spotlight_${index}`, HOUR_STACK_KEYS, 10)} shape={createRoundedTopShape(key, HOUR_STACK_KEYS, 10)}
/> />
))} ))}
<Bar <Bar
@@ -498,38 +505,34 @@ export default function ScreenTimeOverviewChart({
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
<div className="mt-5 flex flex-wrap gap-3"> <div className="mt-3 grid grid-cols-2 gap-2">
{data.overview.selectedDay.spotlightApps.length > 0 ? data.overview.selectedDay.spotlightApps.map((app, index) => ( {data.overview.selectedDay.spotlightApps.length > 0 ? data.overview.selectedDay.spotlightApps.map((app, index) => (
<button <button
key={app.processId} key={app.processId}
onClick={() => onSpotlightAppClick(app.processId)} className="flex min-w-0 items-center gap-2 rounded-2xl bg-gray-50 px-3 py-2 text-left transition-colors hover:bg-gray-100 dark:bg-gray-900/70 dark:hover:bg-gray-900"
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
<span className="min-w-0 flex-1"> className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold text-white"
<span className="flex items-center gap-2 text-xs uppercase tracking-[0.18em] text-gray-400 dark:text-gray-500"> style={{ backgroundColor: SPOTLIGHT_COLORS[index] }}
<WandSparkles className="h-3.5 w-3.5" /> >
Focus {index + 1} {index + 1}
</span> </span>
<span className="mt-1 block truncate text-sm font-semibold text-gray-900 dark:text-white" title={app.processName}> <span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-gray-900 dark:text-white" title={app.processName}>
{app.processName} {app.processName}
</span> </span>
<span className="mt-1 block text-xs text-gray-500 dark:text-gray-400"> <span className="block truncate text-xs text-gray-500 dark:text-gray-400">
{formatDuration(app.durationSeconds)} · {app.percentage}% {formatDuration(app.durationSeconds)} · {app.percentage}%
</span> </span>
</span> </span>
</button> </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 className="col-span-2 rounded-2xl bg-gray-50 px-3 py-2 text-sm text-gray-500 dark:bg-gray-900/70 dark:text-gray-400">
当前日期没有足够的活跃应用数据来展示聚焦图例。 暂无应用数据
</div> </div>
)} )}
</div> </div>
</div> </section>
<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>
</div> </div>
); );
@@ -28,7 +28,6 @@ export default function ScreenTimeTab({ hostname }: ScreenTimeTabProps) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [expandedApps, setExpandedApps] = useState<Set<string>>(new Set()); const [expandedApps, setExpandedApps] = useState<Set<string>>(new Set());
const [highlightedProcessId, setHighlightedProcessId] = useState<string | null>(null);
const [initialLoading, setInitialLoading] = useState(true); const [initialLoading, setInitialLoading] = useState(true);
const abortControllerRef = React.useRef<AbortController | null>(null); const abortControllerRef = React.useRef<AbortController | null>(null);
@@ -125,32 +124,24 @@ export default function ScreenTimeTab({ hostname }: ScreenTimeTabProps) {
setSelectedDate(date); setSelectedDate(date);
}; };
const handleSpotlightAppClick = (processId: string) => {
setHighlightedProcessId(processId);
setExpandedApps((previous) => new Set(previous).add(processId));
};
return ( return (
<div className="space-y-6"> <div className="space-y-4">
<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="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-4 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div> <div className="min-w-0">
<div className="text-sm font-medium text-blue-600 dark:text-blue-400"> <div className="text-xs font-medium text-gray-500 dark:text-gray-400">
Screen Time 屏幕使用时间
</div> </div>
<h2 className="mt-2 text-2xl font-semibold text-gray-900 dark:text-white"> <h2 className="mt-0.5 truncate text-xl font-semibold text-gray-900 dark:text-white">
{periodLabel} {periodLabel}
</h2> </h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
切换日期时会自动取消上一条请求,图表与排行保持同步刷新。
</p>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="inline-flex rounded-full bg-gray-100 p-1 dark:bg-gray-700/70"> <div className="inline-flex rounded-full bg-gray-100 p-0.5 dark:bg-gray-700/70">
<button <button
onClick={() => setUnit('day')} onClick={() => setUnit('day')}
className={`rounded-full px-5 py-2 text-sm font-medium ${unit === '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' ? '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' : 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white'
}`} }`}
@@ -159,7 +150,7 @@ export default function ScreenTimeTab({ hostname }: ScreenTimeTabProps) {
</button> </button>
<button <button
onClick={() => setUnit('week')} onClick={() => setUnit('week')}
className={`rounded-full px-5 py-2 text-sm font-medium ${unit === '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' ? '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' : 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white'
}`} }`}
@@ -171,13 +162,13 @@ export default function ScreenTimeTab({ hostname }: ScreenTimeTabProps) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => shiftPeriod(-1)} 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" 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' ? '上一周' : '上一天'} title={unit === 'week' ? '上一周' : '上一天'}
> >
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</button> </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"> <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" /> <Calendar className="h-4 w-4 text-gray-400" />
<input <input
type="date" type="date"
@@ -189,7 +180,7 @@ export default function ScreenTimeTab({ hostname }: ScreenTimeTabProps) {
<button <button
onClick={() => shiftPeriod(1)} 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" 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' ? '下一周' : '下一天'} title={unit === 'week' ? '下一周' : '下一天'}
> >
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
@@ -205,7 +196,6 @@ export default function ScreenTimeTab({ hostname }: ScreenTimeTabProps) {
loading={loading || initialLoading} loading={loading || initialLoading}
refreshing={isRefreshing} refreshing={isRefreshing}
onSelectDate={handleOverviewDateSelect} onSelectDate={handleOverviewDateSelect}
onSpotlightAppClick={handleSpotlightAppClick}
/> />
{error && ( {error && (
@@ -214,14 +204,16 @@ export default function ScreenTimeTab({ hostname }: ScreenTimeTabProps) {
</div> </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="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-5 py-4 dark:border-gray-700"> <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 className="flex items-center justify-between gap-4">
<div> <div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">应用使用排行</h3> <h3 className="text-base font-semibold text-gray-900 dark:text-white">最常使用</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400"> {screenTime && (
进程默认折叠,展开后可查看时长最长的前 5 个窗口标题。 <div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
</p> {screenTime.apps.length} 个应用 · {formatDuration(screenTime.totalDurationSeconds)}
</div>
)}
</div> </div>
{isRefreshing && ( {isRefreshing && (
@@ -234,14 +226,14 @@ export default function ScreenTimeTab({ hostname }: ScreenTimeTabProps) {
</div> </div>
{loading && !screenTime && ( {loading && !screenTime && (
<div className="flex items-center justify-center gap-3 px-6 py-12 text-sm text-gray-500 dark:text-gray-400"> <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" /> <Loader2 className="h-5 w-5 animate-spin" />
正在加载屏幕使用时间数据... 正在加载屏幕使用时间数据...
</div> </div>
)} )}
{!loading && screenTime && screenTime.apps.length === 0 && !error && ( {!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 className="px-6 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
当前时间段内没有可统计的活跃窗口记录。 当前时间段内没有可统计的活跃窗口记录。
</div> </div>
)} )}
@@ -251,52 +243,47 @@ export default function ScreenTimeTab({ hostname }: ScreenTimeTabProps) {
{screenTime.apps.map((app, index) => { {screenTime.apps.map((app, index) => {
const isExpanded = expandedApps.has(app.processId); const isExpanded = expandedApps.has(app.processId);
const processWidth = maxDurationSeconds > 0 const processWidth = maxDurationSeconds > 0
? Math.max((app.durationSeconds / maxDurationSeconds) * 100, 4) ? Math.max((app.durationSeconds / maxDurationSeconds) * 100, 2)
: 0; : 0;
return ( return (
<div <div
key={app.processId} key={app.processId}
className={`px-5 py-4 transition-colors ${highlightedProcessId === app.processId className={`px-4 py-3 transition-colors`}
? 'bg-blue-50/70 dark:bg-blue-500/8'
: ''
}`}
> >
<button <button
onClick={() => toggleExpanded(app.processId)} onClick={() => toggleExpanded(app.processId)}
className="w-full text-left" className="w-full text-left"
> >
<div className="flex items-start gap-4"> <div className="flex items-center gap-3">
<div className="mt-1 text-sm font-semibold text-gray-400 dark:text-gray-500"> <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')} {String(index + 1).padStart(2, '0')}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isExpanded ? ( {isExpanded ? (
<ChevronDown className="h-4 w-4 text-gray-400" /> <ChevronDown className="h-3.5 w-3.5 shrink-0 text-gray-400" />
) : ( ) : (
<ChevronRight className="h-4 w-4 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}> <div className="truncate text-base font-semibold text-gray-900 dark:text-white" title={app.processName}>
{app.processName} {app.processName}
</div> </div>
{highlightedProcessId === app.processId && ( <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">
<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} 个标题 {app.titleCount} 个标题
</span> </span>
</div> </div>
<div className="mt-2 flex items-center gap-3 text-sm text-gray-500 dark:text-gray-400"> <div className="mt-1 flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<span>{formatDuration(app.durationSeconds)}</span>
<span>{app.percentage}%</span> <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>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700"> <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
<div <div
className="h-full rounded-full bg-blue-600 dark:bg-blue-500" className="h-full rounded-full bg-blue-600 dark:bg-blue-500"
style={{ width: `${processWidth}%` }} style={{ width: `${processWidth}%` }}
@@ -311,17 +298,17 @@ export default function ScreenTimeTab({ hostname }: ScreenTimeTabProps) {
</button> </button>
{isExpanded && ( {isExpanded && (
<div className="ml-10 mt-4 rounded-2xl bg-gray-50 p-4 dark:bg-gray-900/50"> <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-3"> <div className="space-y-2.5">
{app.titles.map((title) => ( {app.titles.map((title) => (
<div key={`${app.processId}-${title.title}`}> <div key={`${app.processId}-${title.title}`}>
<div className="flex items-start justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-gray-800 dark:text-gray-100" title={title.title}> <div className="truncate text-sm font-medium text-gray-800 dark:text-gray-100" title={title.title}>
{title.title} {title.title}
</div> </div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400"> <div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
占该进程 {title.percentage}% {title.percentage}%
</div> </div>
</div> </div>
@@ -330,10 +317,10 @@ export default function ScreenTimeTab({ hostname }: ScreenTimeTabProps) {
</div> </div>
</div> </div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white dark:bg-gray-800"> <div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-white dark:bg-gray-800">
<div <div
className="h-full rounded-full bg-emerald-500 dark:bg-emerald-400" className="h-full rounded-full bg-emerald-500 dark:bg-emerald-400"
style={{ width: `${Math.max(title.percentage, 4)}%` }} style={{ width: `${Math.max(title.percentage, 2)}%` }}
/> />
</div> </div>
</div> </div>
@@ -341,8 +328,8 @@ export default function ScreenTimeTab({ hostname }: ScreenTimeTabProps) {
</div> </div>
{app.titleCount > app.titles.length && ( {app.titleCount > app.titles.length && (
<div className="mt-4 text-xs text-gray-500 dark:text-gray-400"> <div className="mt-3 text-xs text-gray-500 dark:text-gray-400">
其余 {app.titleCount - app.titles.length} 个标题未展示。 还有 {app.titleCount - app.titles.length} 个标题
</div> </div>
)} )}
</div> </div>
@@ -14,20 +14,22 @@ import { useStarToggle } from '../hooks/useStarToggle';
const inFlightJsonRequests = new Map<string, Promise<unknown>>(); const inFlightJsonRequests = new Map<string, Promise<unknown>>();
const fetchJson = async <T,>(url: string, signal?: AbortSignal): Promise<T> => {
const response = await fetch(url, { cache: 'no-store', signal });
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return response.json() as Promise<T>;
};
const fetchJsonOnce = async <T,>(url: string): Promise<T> => { const fetchJsonOnce = async <T,>(url: string): Promise<T> => {
const existingRequest = inFlightJsonRequests.get(url); const existingRequest = inFlightJsonRequests.get(url);
if (existingRequest) { if (existingRequest) {
return existingRequest as Promise<T>; return existingRequest as Promise<T>;
} }
const request = fetch(url, { cache: 'no-store' }) const request = fetchJson<T>(url)
.then(async (response) => {
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return response.json() as Promise<T>;
})
.finally(() => { .finally(() => {
inFlightJsonRequests.delete(url); inFlightJsonRequests.delete(url);
}); });
@@ -36,6 +38,12 @@ const fetchJsonOnce = async <T,>(url: string): Promise<T> => {
return request as Promise<T>; return request as Promise<T>;
}; };
const isAbortError = (error: unknown) => (
error instanceof DOMException && error.name === 'AbortError'
);
const HOURLY_RECORDS_REQUEST_DEBOUNCE_MS = 120;
interface ScreenshotsTabProps { interface ScreenshotsTabProps {
hostname: string; hostname: string;
selectedDate: string | null; selectedDate: string | null;
@@ -53,6 +61,8 @@ export default function ScreenshotsTab({
}: ScreenshotsTabProps) { }: ScreenshotsTabProps) {
const timeDistributionRequestIdRef = useRef(0); const timeDistributionRequestIdRef = useRef(0);
const hourlyRecordsRequestIdRef = useRef(0); const hourlyRecordsRequestIdRef = useRef(0);
const hourlyRecordsAbortControllerRef = useRef<AbortController | null>(null);
const hourlyRecordsDebounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 状态管理 // 状态管理
const [timeDistribution, setTimeDistribution] = useState<TimeDistributionPoint[]>([]); const [timeDistribution, setTimeDistribution] = useState<TimeDistributionPoint[]>([]);
@@ -83,6 +93,25 @@ export default function ScreenshotsTab({
const { updatingStars, toggleStar } = useStarToggle(); const { updatingStars, toggleStar } = useStarToggle();
const clearPendingHourlyRecordsRequest = () => {
if (hourlyRecordsDebounceTimerRef.current) {
clearTimeout(hourlyRecordsDebounceTimerRef.current);
hourlyRecordsDebounceTimerRef.current = null;
}
};
const scheduleHourlyRecordsFetch = (
startTime: number,
endTime: number,
options?: { targetRecordId?: string, keepSelection?: boolean }
) => {
clearPendingHourlyRecordsRequest();
hourlyRecordsDebounceTimerRef.current = setTimeout(() => {
hourlyRecordsDebounceTimerRef.current = null;
fetchHourlyRecords(startTime, endTime, options);
}, HOURLY_RECORDS_REQUEST_DEBOUNCE_MS);
};
// 获取时间分布数据 // 获取时间分布数据
const fetchTimeDistribution = async () => { const fetchTimeDistribution = async () => {
const requestId = ++timeDistributionRequestIdRef.current; const requestId = ++timeDistributionRequestIdRef.current;
@@ -112,19 +141,23 @@ export default function ScreenshotsTab({
// 获取小时记录 // 获取小时记录
const fetchHourlyRecords = async (startTime: number, endTime: number, options?: { targetRecordId?: string, keepSelection?: boolean }) => { const fetchHourlyRecords = async (startTime: number, endTime: number, options?: { targetRecordId?: string, keepSelection?: boolean }) => {
clearPendingHourlyRecordsRequest();
const requestId = ++hourlyRecordsRequestIdRef.current; const requestId = ++hourlyRecordsRequestIdRef.current;
const requestUrl = `/hosts/${hostname}/screenshots?startTime=${startTime}&endTime=${endTime}`; const requestUrl = `/hosts/${hostname}/screenshots?startTime=${startTime}&endTime=${endTime}`;
hourlyRecordsAbortControllerRef.current?.abort();
const abortController = new AbortController();
hourlyRecordsAbortControllerRef.current = abortController;
try { try {
setLoadingRecords(true); setLoadingRecords(true);
setShowDetailTimeline(true); setShowDetailTimeline(true);
const data = await fetchJsonOnce<{ const data = await fetchJson<{
lastUpdate: string | null; lastUpdate: string | null;
records: ScreenRecord[]; records: ScreenRecord[];
}>(requestUrl); }>(requestUrl, abortController.signal);
if (requestId !== hourlyRecordsRequestIdRef.current) { if (requestId !== hourlyRecordsRequestIdRef.current || abortController.signal.aborted) {
return; return;
} }
@@ -134,6 +167,10 @@ export default function ScreenshotsTab({
setTimeRange({ min: startTime * 1000, max: endTime * 1000 }); setTimeRange({ min: startTime * 1000, max: endTime * 1000 });
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (requestId !== hourlyRecordsRequestIdRef.current || abortController.signal.aborted) {
return;
}
if (options?.targetRecordId) { if (options?.targetRecordId) {
const found = newRecords.find((r: ScreenRecord) => r.id === options.targetRecordId); const found = newRecords.find((r: ScreenRecord) => r.id === options.targetRecordId);
if (found) { if (found) {
@@ -151,11 +188,19 @@ export default function ScreenshotsTab({
} }
}); });
} catch (error) { } catch (error) {
if (isAbortError(error)) {
return;
}
if (requestId === hourlyRecordsRequestIdRef.current) { if (requestId === hourlyRecordsRequestIdRef.current) {
console.error('获取记录数据失败:', error); console.error('获取记录数据失败:', error);
} }
} finally { } finally {
if (requestId === hourlyRecordsRequestIdRef.current) { if (hourlyRecordsAbortControllerRef.current === abortController) {
hourlyRecordsAbortControllerRef.current = null;
}
if (requestId === hourlyRecordsRequestIdRef.current && !abortController.signal.aborted) {
setLoadingRecords(false); setLoadingRecords(false);
} }
} }
@@ -273,12 +318,8 @@ export default function ScreenshotsTab({
// 事件处理函数 // 事件处理函数
const onHourlySliderChange = (newValue: number) => { const onHourlySliderChange = (newValue: number) => {
const selectedSec = Math.floor(newValue / 3600000) * 3600; const selectedSec = Math.floor(newValue / 3600000) * 3600;
// 使用 setTimeout 避免在渲染过程中更新状态
setTimeout(() => {
setHourlySliderValue(newValue); setHourlySliderValue(newValue);
fetchHourlyRecords(selectedSec, selectedSec + 3600); scheduleHourlyRecordsFetch(selectedSec, selectedSec + 3600);
}, 0);
}; };
const onDetailedSliderChange = (newValue: number) => { const onDetailedSliderChange = (newValue: number) => {
@@ -443,6 +484,19 @@ export default function ScreenshotsTab({
}, [prevFrame, nextFrame]); }, [prevFrame, nextFrame]);
// Effects // Effects
useEffect(() => {
return () => {
if (hourlyRecordsDebounceTimerRef.current) {
clearTimeout(hourlyRecordsDebounceTimerRef.current);
hourlyRecordsDebounceTimerRef.current = null;
}
hourlyRecordsRequestIdRef.current += 1;
hourlyRecordsAbortControllerRef.current?.abort();
hourlyRecordsAbortControllerRef.current = null;
};
}, []);
useEffect(() => { useEffect(() => {
fetchTimeDistribution(); fetchTimeDistribution();
}, [hostname]); }, [hostname]);
@@ -633,7 +687,7 @@ export default function ScreenshotsTab({
{ {
!(navigator as any).connection?.saveData && !(navigator as any).connection?.saveData &&
records.map(record => record.screenshots).flat() records.map(record => record.screenshots).flat()
.filter((_, index) => Math.abs(index - currentFrameIndex) <= 20) .filter((_, index) => Math.abs(index - currentFrameIndex) <= 5)
.map(screenshot => <link rel="preload" key={screenshot.fileId} href={`/screenshots/${screenshot.fileId}`} as="image" />) .map(screenshot => <link rel="preload" key={screenshot.fileId} href={`/screenshots/${screenshot.fileId}`} as="image" />)
} }
{/* 图片预览区域 */} {/* 图片预览区域 */}
@@ -797,6 +851,26 @@ export default function ScreenshotsTab({
</div> </div>
</div> </div>
</div> </div>
<div className="w-full mt-6">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-3">来源 IP</h3>
<div className="bg-gray-50 dark:bg-gray-700 rounded-md p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="p-4 bg-white dark:bg-gray-800 rounded-md shadow-sm">
<div className="text-sm text-gray-500 dark:text-gray-400 mb-1">IP 地址</div>
<div className="font-medium text-gray-900 dark:text-white break-all">
{selectedRecord.ip || '未记录'}
</div>
</div>
<div className="p-4 bg-white dark:bg-gray-800 rounded-md shadow-sm">
<div className="text-sm text-gray-500 dark:text-gray-400 mb-1">归属地</div>
<div className="font-medium text-gray-900 dark:text-white break-all">
{selectedRecord.ipLocation || (selectedRecord.ip ? '未知归属地' : '未记录')}
</div>
</div>
</div>
</div>
</div>
</div> </div>
)} )}
</div> </div>
+10 -9
View File
@@ -2,7 +2,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { ArrowLeft, BarChart3, Star } from 'lucide-react'; import { ArrowLeft, BarChart3, Star, KeySquare, ChartNoAxesGantt } from 'lucide-react';
import ScreenshotsTab from './components/ScreenshotsTab'; import ScreenshotsTab from './components/ScreenshotsTab';
import ScreenTimeTab from './components/ScreenTimeTab'; import ScreenTimeTab from './components/ScreenTimeTab';
import StarredTab from './components/StarredTab'; import StarredTab from './components/StarredTab';
@@ -59,15 +59,15 @@ export default function HostDetail() {
{/* 选项卡导航 */} {/* 选项卡导航 */}
<div className="mb-6 border-b border-gray-200 dark:border-gray-700"> <div className="mb-6 border-b border-gray-200 dark:border-gray-700">
<nav className="-mb-px flex space-x-8"> <nav className="-mb-px flex space-x-6">
<button <button
onClick={() => setActiveTab('screenshots')} onClick={() => setActiveTab('screenshots')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${activeTab === 'screenshots' className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center ${activeTab === 'screenshots'
? 'border-blue-600 text-blue-600' ? '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' : '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'
}`} }`}
> ><ChartNoAxesGantt className="h-4 w-4 mr-1"/>
截图时间线 时间线
</button> </button>
<button <button
onClick={() => setActiveTab('screen-time')} onClick={() => setActiveTab('screen-time')}
@@ -77,7 +77,7 @@ export default function HostDetail() {
}`} }`}
> >
<BarChart3 className="h-4 w-4 mr-1" /> <BarChart3 className="h-4 w-4 mr-1" />
屏幕使用时间 屏幕使用
</button> </button>
<button <button
onClick={() => setActiveTab('starred')} onClick={() => setActiveTab('starred')}
@@ -87,16 +87,17 @@ export default function HostDetail() {
}`} }`}
> >
<Star className="h-4 w-4 mr-1" /> <Star className="h-4 w-4 mr-1" />
星标记录 星标
</button> </button>
<button <button
onClick={() => setActiveTab('credentials')} onClick={() => setActiveTab('credentials')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${activeTab === 'credentials' className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center ${activeTab === 'credentials'
? 'border-blue-600 text-blue-600' ? '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' : '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'
}`} }`}
> >
凭据信息 <KeySquare className="h-4 w-4 mr-1" />
凭据
</button> </button>
</nav> </nav>
</div> </div>
+36 -4
View File
@@ -2,11 +2,13 @@ import { NextRequest, NextResponse } from 'next/server'
import { win32 } from 'path' import { win32 } from 'path'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { withCors } from '@/lib/middleware' import { withCors } from '@/lib/middleware'
import { PROCESS_DISPLAY_NAME_PRESETS } from '@/lib/config'
import { Prisma } from '@prisma/client' import { Prisma } from '@prisma/client'
const MAX_GAP_MS = 5 * 60 * 1000 const MAX_GAP_MS = 5 * 60 * 1000
const DEFAULT_SAMPLE_MS = 30 * 1000 const DEFAULT_SAMPLE_MS = 30 * 1000
const MIN_SAMPLE_MS = 5 * 1000 const MIN_SAMPLE_MS = 5 * 1000
const SPOTLIGHT_APP_LIMIT = 4
const LABEL_TOKEN_THRESHOLD_RATIO = 0.5 const LABEL_TOKEN_THRESHOLD_RATIO = 0.5
const HOUR_MS = 60 * 60 * 1000 const HOUR_MS = 60 * 60 * 1000
const DAY_MS = 24 * HOUR_MS const DAY_MS = 24 * HOUR_MS
@@ -28,6 +30,7 @@ interface TitleUsage {
interface MutableAppUsage { interface MutableAppUsage {
processId: string processId: string
rawProcessName: string rawProcessName: string
rawProcessPath: string
durationMs: number durationMs: number
titleDurations: Map<string, number> titleDurations: Map<string, number>
} }
@@ -53,6 +56,8 @@ const trimLabelSeparators = (label: string) => label
const stripExtension = (value: string) => value.replace(/\.[^.]+$/, '') const stripExtension = (value: string) => value.replace(/\.[^.]+$/, '')
const normalizeProcessPathForMatch = (value: string) => value.replace(/\//g, '\\').toLowerCase()
const toDateKey = ({ year, month, day }: DateParts) => `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` const toDateKey = ({ year, month, day }: DateParts) => `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
const parseDateParts = (value: string | null): DateParts | null => { const parseDateParts = (value: string | null): DateParts | null => {
@@ -111,12 +116,14 @@ const getLocalDateUtcMs = (parts: DateParts, tzOffsetMinutes: number) => {
const normalizeProcess = (processPath: string, title: string) => { const normalizeProcess = (processPath: string, title: string) => {
const rawProcessName = win32.basename(processPath || '').trim() const rawProcessName = win32.basename(processPath || '').trim()
const rawProcessPath = processPath.trim()
const normalizedTitle = cleanTitle(title) const normalizedTitle = cleanTitle(title)
const fallbackId = normalizedTitle ? `title:${normalizedTitle.toLowerCase()}` : 'unknown' const fallbackId = normalizedTitle ? `title:${normalizedTitle.toLowerCase()}` : 'unknown'
return { return {
processId: rawProcessName ? rawProcessName.toLowerCase() : fallbackId, processId: rawProcessName ? rawProcessName.toLowerCase() : fallbackId,
rawProcessName rawProcessName,
rawProcessPath
} }
} }
@@ -202,7 +209,24 @@ const buildCommonSubstringLabel = (titles: TitleUsage[]) => {
return trimLabelSeparators(shared) return trimLabelSeparators(shared)
} }
const resolveProcessDisplayName = (rawProcessName: string, titles: TitleUsage[]) => { const resolvePresetProcessDisplayName = (rawProcessName: string, rawProcessPath: string) => {
const normalizedProcessName = rawProcessName.toLowerCase()
const normalizedProcessPath = normalizeProcessPathForMatch(rawProcessPath)
const matchedPreset = PROCESS_DISPLAY_NAME_PRESETS.find(({ match }) => {
const normalizedMatch = match.toLowerCase()
return normalizedProcessName === normalizedMatch || normalizedProcessPath.includes(normalizedMatch)
})
return matchedPreset?.displayName ?? ''
}
const resolveProcessDisplayName = (rawProcessName: string, rawProcessPath: string, titles: TitleUsage[]) => {
const presetDisplayName = resolvePresetProcessDisplayName(rawProcessName, rawProcessPath)
if (presetDisplayName) {
return presetDisplayName
}
const processBaseName = stripExtension(rawProcessName).toLowerCase() const processBaseName = stripExtension(rawProcessName).toLowerCase()
const totalDurationMs = titles.reduce((total, item) => total + item.durationMs, 0) const totalDurationMs = titles.reduce((total, item) => total + item.durationMs, 0)
const weightedTokenLabel = trimLabelSeparators(buildWeightedTokenLabel(titles, totalDurationMs)) const weightedTokenLabel = trimLabelSeparators(buildWeightedTokenLabel(titles, totalDurationMs))
@@ -228,6 +252,7 @@ const addDurationToUsageMap = (
usageMap: Map<string, MutableAppUsage>, usageMap: Map<string, MutableAppUsage>,
processId: string, processId: string,
rawProcessName: string, rawProcessName: string,
rawProcessPath: string,
title: string, title: string,
durationMs: number durationMs: number
) => { ) => {
@@ -240,12 +265,16 @@ const addDurationToUsageMap = (
if (!existing.rawProcessName && rawProcessName) { if (!existing.rawProcessName && rawProcessName) {
existing.rawProcessName = rawProcessName existing.rawProcessName = rawProcessName
} }
if (!existing.rawProcessPath && rawProcessPath) {
existing.rawProcessPath = rawProcessPath
}
return return
} }
usageMap.set(processId, { usageMap.set(processId, {
processId, processId,
rawProcessName, rawProcessName,
rawProcessPath,
durationMs, durationMs,
titleDurations: new Map([[title, durationMs]]) titleDurations: new Map([[title, durationMs]])
}) })
@@ -264,8 +293,9 @@ const buildAppList = (usageMap: Map<string, MutableAppUsage>) => {
return { return {
processId: app.processId, processId: app.processId,
processName: resolveProcessDisplayName(app.rawProcessName, sortedTitles), processName: resolveProcessDisplayName(app.rawProcessName, app.rawProcessPath, sortedTitles),
rawProcessName: app.rawProcessName, rawProcessName: app.rawProcessName,
processPath: app.rawProcessPath,
durationSeconds: Math.round(app.durationMs / 1000), durationSeconds: Math.round(app.durationMs / 1000),
percentage: totalDurationMs > 0 ? Number(((app.durationMs / totalDurationMs) * 100).toFixed(2)) : 0, percentage: totalDurationMs > 0 ? Number(((app.durationMs / totalDurationMs) * 100).toFixed(2)) : 0,
titleCount: sortedTitles.length, titleCount: sortedTitles.length,
@@ -384,6 +414,7 @@ async function handleScreenTime(req: NextRequest) {
aggregateUsageMap, aggregateUsageMap,
process.processId, process.processId,
process.rawProcessName, process.rawProcessName,
process.rawProcessPath,
title, title,
aggregateContributionMs aggregateContributionMs
) )
@@ -393,6 +424,7 @@ async function handleScreenTime(req: NextRequest) {
selectedDayUsageMap, selectedDayUsageMap,
process.processId, process.processId,
process.rawProcessName, process.rawProcessName,
process.rawProcessPath,
title, title,
selectedDayContributionMs selectedDayContributionMs
) )
@@ -427,7 +459,7 @@ async function handleScreenTime(req: NextRequest) {
const aggregateSummary = buildAppList(aggregateUsageMap) const aggregateSummary = buildAppList(aggregateUsageMap)
const selectedDaySummary = buildAppList(selectedDayUsageMap) const selectedDaySummary = buildAppList(selectedDayUsageMap)
const spotlightApps = selectedDaySummary.apps.slice(0, 3).map(app => ({ const spotlightApps = selectedDaySummary.apps.slice(0, SPOTLIGHT_APP_LIMIT).map(app => ({
processId: app.processId, processId: app.processId,
processName: app.processName, processName: app.processName,
durationSeconds: app.durationSeconds, durationSeconds: app.durationSeconds,
+10 -1
View File
@@ -3,6 +3,9 @@ import { prisma } from '@/lib/prisma'
import { storeFile } from '@/lib/fileStorage' import { storeFile } from '@/lib/fileStorage'
import { push } from '@/lib/push' import { push } from '@/lib/push'
import { withCors } from '@/lib/middleware' import { withCors } from '@/lib/middleware'
import { filterIgnoredWindows } from '@/lib/windowFilters'
import { getClientIp } from '@/lib/clientIp'
import { getIpGeodataLabel } from '@/lib/geodata'
import ffmpeg from 'fluent-ffmpeg' import ffmpeg from 'fluent-ffmpeg'
import { writeFileSync, unlinkSync, mkdtempSync, readFileSync, rmSync } from 'fs' import { writeFileSync, unlinkSync, mkdtempSync, readFileSync, rmSync } from 'fs'
import { tmpdir } from 'os' import { tmpdir } from 'os'
@@ -26,9 +29,12 @@ async function handleScreenshotUpload(req: NextRequest) {
return NextResponse.json({ error: '缺少主机名' }, { status: 400 }) return NextResponse.json({ error: '缺少主机名' }, { status: 400 })
} }
const clientIp = getClientIp(req)
const formData = await req.formData() const formData = await req.formData()
const files: File[] = [] const files: File[] = []
const windowsInfo: WindowInfo[] = JSON.parse(formData.get('windows_info') as string || '[]') const rawWindowsInfo: WindowInfo[] = JSON.parse(formData.get('windows_info') as string || '[]')
const windowsInfo = filterIgnoredWindows(rawWindowsInfo)
// Extract files from formData // Extract files from formData
for (const [key, value] of formData.entries()) { for (const [key, value] of formData.entries()) {
@@ -125,6 +131,7 @@ async function handleScreenshotUpload(req: NextRequest) {
const newRecord = await prisma.record.create({ const newRecord = await prisma.record.create({
data: { data: {
hostname, hostname,
ip: clientIp,
timestamp: new Date(), timestamp: new Date(),
windows: { windows: {
create: windowsInfo create: windowsInfo
@@ -224,6 +231,7 @@ async function handleGetScreenshots(req: NextRequest) {
select: { select: {
id: true, id: true,
timestamp: true, timestamp: true,
ip: true,
isStarred: true, isStarred: true,
windows: { windows: {
select: { select: {
@@ -260,6 +268,7 @@ async function handleGetScreenshots(req: NextRequest) {
// Convert BigInt to string in windows.memory field // Convert BigInt to string in windows.memory field
const serializedRecords = records.map(record => ({ const serializedRecords = records.map(record => ({
...record, ...record,
ipLocation: getIpGeodataLabel(record.ip),
windows: record.windows.map(window => ({ windows: record.windows.map(window => ({
...window, ...window,
memory: window.memory.toString() memory: window.memory.toString()
+2
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { withCors } from '@/lib/middleware' import { withCors } from '@/lib/middleware'
import { getIpGeodataLabel } from '@/lib/geodata'
// 获取指定主机的星标记录 // 获取指定主机的星标记录
async function handleGetStarredRecords(req: NextRequest) { async function handleGetStarredRecords(req: NextRequest) {
@@ -49,6 +50,7 @@ async function handleGetStarredRecords(req: NextRequest) {
}) })
const serializedRecords = records.map(record => ({ const serializedRecords = records.map(record => ({
...record, ...record,
ipLocation: getIpGeodataLabel(record.ip),
windows: record.windows.map(window => ({ windows: record.windows.map(window => ({
...window, ...window,
memory: window.memory.toString() memory: window.memory.toString()
+3
View File
@@ -13,6 +13,8 @@ export interface Window {
export interface ScreenRecord { export interface ScreenRecord {
id: string; id: string;
timestamp: string; timestamp: string;
ip: string | null;
ipLocation: string | null;
isStarred: boolean; isStarred: boolean;
windows: Window[]; windows: Window[];
screenshots: Screenshot[]; screenshots: Screenshot[];
@@ -33,6 +35,7 @@ export interface ScreenTimeAppUsage {
processId: string; processId: string;
processName: string; processName: string;
rawProcessName: string; rawProcessName: string;
processPath: string;
durationSeconds: number; durationSeconds: number;
percentage: number; percentage: number;
titleCount: number; titleCount: number;
+93 -13
View File
@@ -1,8 +1,86 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getFileByObjectName } from '@/lib/fileStorage' import { Readable } from 'stream'
import { getFileStreamByObjectName } from '@/lib/fileStorage'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { withCors } from '@/lib/middleware' import { withCors } from '@/lib/middleware'
export const runtime = 'nodejs'
interface ScreenshotFileMetadata {
objectName: string
filename: string
contentType: string
fileSize: number
}
const MAX_SCREENSHOT_METADATA_CACHE_SIZE = 5000
const screenshotMetadataCache = new Map<string, ScreenshotFileMetadata>()
function cacheScreenshotMetadata(fileId: string, metadata: ScreenshotFileMetadata) {
if (screenshotMetadataCache.size >= MAX_SCREENSHOT_METADATA_CACHE_SIZE) {
const oldestFileId = screenshotMetadataCache.keys().next().value
if (oldestFileId) {
screenshotMetadataCache.delete(oldestFileId)
}
}
screenshotMetadataCache.set(fileId, metadata)
}
async function findScreenshotMetadata(fileId: string): Promise<ScreenshotFileMetadata | null> {
const cached = screenshotMetadataCache.get(fileId)
if (cached) {
return cached
}
const screenshot = await prisma.screenshot.findFirst({
where: { fileId },
select: {
objectName: true,
filename: true,
contentType: true,
fileSize: true
}
})
if (screenshot) {
cacheScreenshotMetadata(fileId, screenshot)
}
return screenshot
}
function createScreenshotHeaders(fileId: string, screenshot: ScreenshotFileMetadata) {
const headers = new Headers({
'Content-Type': resolveScreenshotContentType(screenshot),
'Cache-Control': 'public, max-age=31536000, immutable',
'ETag': `"${fileId}"`,
'X-Content-Type-Options': 'nosniff'
})
if (screenshot.fileSize > 0) {
headers.set('Content-Length', String(screenshot.fileSize))
}
return headers
}
function resolveScreenshotContentType(screenshot: ScreenshotFileMetadata) {
const storedContentType = screenshot.contentType?.trim()
const filePath = `${screenshot.filename} ${screenshot.objectName}`.toLowerCase()
if (storedContentType && storedContentType !== 'image/webp') {
return storedContentType
}
if (filePath.includes('.avif')) return 'image/avif'
if (filePath.includes('.webp')) return 'image/webp'
if (filePath.includes('.png')) return 'image/png'
if (filePath.includes('.jpg') || filePath.includes('.jpeg')) return 'image/jpeg'
return storedContentType || 'image/webp'
}
async function handleScreenshotFile(req: NextRequest) { async function handleScreenshotFile(req: NextRequest) {
try { try {
const pathSegments = req.nextUrl.pathname.split('/') const pathSegments = req.nextUrl.pathname.split('/')
@@ -13,27 +91,29 @@ async function handleScreenshotFile(req: NextRequest) {
return NextResponse.json({ error: '缺少文件ID' }, { status: 400 }) return NextResponse.json({ error: '缺少文件ID' }, { status: 400 })
} }
// 从数据库查找 objectName const screenshot = await findScreenshotMetadata(fileId)
const screenshot = await prisma.screenshot.findFirst({
where: { fileId },
select: { objectName: true }
})
if (!screenshot) { if (!screenshot) {
return NextResponse.json({ error: '截图不存在' }, { status: 404 }) return NextResponse.json({ error: '截图不存在' }, { status: 404 })
} }
const file = await getFileByObjectName(screenshot.objectName) const headers = createScreenshotHeaders(fileId, screenshot)
if (req.headers.get('if-none-match') === headers.get('ETag')) {
headers.delete('Content-Length')
return new NextResponse(null, { status: 304, headers })
}
const file = await getFileStreamByObjectName(screenshot.objectName, {
contentType: resolveScreenshotContentType(screenshot),
filename: screenshot.filename,
size: screenshot.fileSize
})
if (!file) { if (!file) {
return NextResponse.json({ error: '文件不存在' }, { status: 404 }) return NextResponse.json({ error: '文件不存在' }, { status: 404 })
} }
return new NextResponse(file.buffer, { return new NextResponse(Readable.toWeb(file.stream) as unknown as BodyInit, { headers })
headers: {
'Content-Type': file.contentType || 'image/webp',
'Cache-Control': 'public, max-age=31536000',
}
})
} catch (error) { } catch (error) {
console.error('获取截图失败:', error) console.error('获取截图失败:', error)
+3
View File
@@ -15,6 +15,7 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dotenv-cli": "^8.0.0", "dotenv-cli": "^8.0.0",
"fluent-ffmpeg": "^2.1.3", "fluent-ffmpeg": "^2.1.3",
"ip2region": "^2.3.0",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"minio": "^8.0.5", "minio": "^8.0.5",
"multer": "^2.0.1", "multer": "^2.0.1",
@@ -381,6 +382,8 @@
"internmap": ["internmap@2.0.3", "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], "internmap": ["internmap@2.0.3", "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"ip2region": ["ip2region@2.3.0", "https://registry.npmmirror.com/ip2region/-/ip2region-2.3.0.tgz", { "peerDependencies": { "@types/node": "*" } }, "sha512-zV5Xsadzrx9Ej6heoyhbXMsfGWWQ3C6bAIYStrHhw9kzLpGpVNlnAyRBxxPgxA1GNqr1Ti7oUxcWsMWNN3jZBg=="],
"ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="], "ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="],
"is-arguments": ["is-arguments@1.2.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="], "is-arguments": ["is-arguments@1.2.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="],
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bun
import { prisma } from '../lib/prisma'
import { IGNORED_WINDOW_KEYWORDS } from '../lib/windowFilters'
async function cleanupIgnoredWindows() {
console.log(`开始清理数据库中的忽略窗口记录: ${IGNORED_WINDOW_KEYWORDS.join(', ')}`)
const whereClause = {
OR: [
...IGNORED_WINDOW_KEYWORDS.flatMap(keyword => ([
{
title: {
contains: keyword,
mode: 'insensitive' as const
}
},
{
path: {
contains: keyword,
mode: 'insensitive' as const
}
}
]))
]
}
const [matchedCount, affectedHosts] = await Promise.all([
prisma.window.count({ where: whereClause }),
prisma.window.findMany({
where: whereClause,
select: {
record: {
select: {
hostname: true
}
}
},
distinct: ['recordId']
})
])
if (matchedCount === 0) {
console.log('没有找到需要清理的忽略窗口记录。')
return
}
const hostnames = Array.from(new Set(affectedHosts.map(item => item.record.hostname))).sort()
console.log(`匹配到 ${matchedCount} 条窗口记录,涉及 ${hostnames.length} 台主机。`)
console.log(`主机列表: ${hostnames.join(', ')}`)
const deleteResult = await prisma.window.deleteMany({
where: whereClause
})
console.log(`已删除 ${deleteResult.count} 条忽略窗口记录。`)
}
cleanupIgnoredWindows()
.catch((error) => {
console.error('清理忽略窗口失败:', error)
process.exitCode = 1
})
.finally(async () => {
await prisma.$disconnect()
})
+148
View File
@@ -0,0 +1,148 @@
import { isIP } from 'net'
import type { NextRequest } from 'next/server'
const DIRECT_IP_HEADERS = [
'cf-connecting-ip',
'true-client-ip',
'fly-client-ip',
'fastly-client-ip'
]
const FALLBACK_IP_HEADERS = [
'x-real-ip',
'x-client-ip'
]
const LIST_IP_HEADERS = [
'x-forwarded-for',
'x-original-forwarded-for'
]
export function getClientIp(req: NextRequest) {
return getClientIpFromHeaders(req.headers)
}
export function getClientIpFromHeaders(headers: Headers) {
const candidates: string[] = []
for (const header of DIRECT_IP_HEADERS) {
const value = headers.get(header)
if (value) {
candidates.push(value)
}
}
const forwarded = headers.get('forwarded')
if (forwarded) {
candidates.push(...parseForwardedHeader(forwarded))
}
for (const header of LIST_IP_HEADERS) {
const value = headers.get(header)
if (value) {
candidates.push(...value.split(','))
}
}
for (const header of FALLBACK_IP_HEADERS) {
const value = headers.get(header)
if (value) {
candidates.push(value)
}
}
for (const candidate of candidates) {
const ip = normalizeIpCandidate(candidate)
if (ip) {
return ip
}
}
return null
}
export function normalizeIpCandidate(value?: string | null) {
if (!value) {
return null
}
let candidate = value.trim()
if (!candidate || candidate.toLowerCase() === 'unknown') {
return null
}
if (candidate.startsWith('"') && candidate.endsWith('"')) {
candidate = candidate.slice(1, -1)
}
if (candidate.startsWith('[')) {
const closingBracketIndex = candidate.indexOf(']')
if (closingBracketIndex > 0) {
candidate = candidate.slice(1, closingBracketIndex)
}
} else {
const colonCount = (candidate.match(/:/g) || []).length
if (colonCount === 1 && candidate.includes('.')) {
candidate = candidate.slice(0, candidate.lastIndexOf(':'))
}
}
const zoneIndex = candidate.indexOf('%')
if (zoneIndex > -1) {
candidate = candidate.slice(0, zoneIndex)
}
candidate = candidate.trim()
if (candidate.toLowerCase().startsWith('::ffff:')) {
const ipv4 = candidate.slice(7)
if (isIP(ipv4) === 4) {
return ipv4
}
}
return isIP(candidate) ? candidate : null
}
export function isPrivateIp(ip: string) {
const normalizedIp = normalizeIpCandidate(ip)
if (!normalizedIp) {
return false
}
if (isIP(normalizedIp) === 4) {
const [first, second] = normalizedIp.split('.').map(Number)
return first === 10 ||
first === 127 ||
first === 0 ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && second === 168) ||
(first === 169 && second === 254) ||
(first === 100 && second >= 64 && second <= 127)
}
const lowerIp = normalizedIp.toLowerCase()
return lowerIp === '::1' ||
lowerIp === '::' ||
lowerIp.startsWith('fc') ||
lowerIp.startsWith('fd') ||
lowerIp.startsWith('fe8') ||
lowerIp.startsWith('fe9') ||
lowerIp.startsWith('fea') ||
lowerIp.startsWith('feb')
}
function parseForwardedHeader(value: string) {
const candidates: string[] = []
for (const entry of value.split(',')) {
for (const segment of entry.split(';')) {
const [key, rawValue] = segment.split('=')
if (key?.trim().toLowerCase() === 'for' && rawValue) {
candidates.push(rawValue.trim())
}
}
}
return candidates
}
+93
View File
@@ -1,3 +1,90 @@
export interface ProcessDisplayNamePreset {
match: string
displayName: string
}
const DEFAULT_IGNORED_WINDOW_KEYWORDS = [
'NVIDIA Overlay',
'Twinkle Tray Panel'
]
const DEFAULT_PROCESS_DISPLAY_NAME_PRESETS: ProcessDisplayNamePreset[] = [
{ match: 'microsoft vs code\\code.exe', displayName: 'Visual Studio Code' },
{ match: 'msedge.exe', displayName: 'Microsoft Edge' },
{ match: 'windowsTerminal.exe', displayName: '终端' },
{ match: 'windowsterminal.exe', displayName: '终端' },
{ match: 'blender.exe', displayName: 'Blender' },
{ match: 'chrome.exe', displayName: 'Google Chrome' },
{ match: 'steamwebhelper.exe', displayName: 'Steam' },
{ match: 'steam.exe', displayName: 'Steam' },
{ match: 'bandizip.exe', displayName: 'Bandizip' },
{ match: 'explorer.exe', displayName: '文件资源管理器' },
{ match: 'devenv.exe', displayName: 'Visual Studio' },
{ match: 'idea64.exe', displayName: 'IntelliJ IDEA' },
{ match: 'pycharm64.exe', displayName: 'PyCharm' },
{ match: 'cursor.exe', displayName: 'Cursor' },
{ match: 'obsidian.exe', displayName: 'Obsidian' },
{ match: 'notion.exe', displayName: 'Notion' },
{ match: 'wechat.exe', displayName: '微信' },
{ match: 'qq.exe', displayName: 'QQ' },
{ match: 'teams.exe', displayName: 'Microsoft Teams' },
{ match: 'telegram.exe', displayName: 'Telegram' }
]
const parseCommaSeparatedEnv = (value: string | undefined, fallback: string[]) => {
if (!value?.trim()) {
return fallback
}
const parsed = value
.split(',')
.map(item => item.trim())
.filter(Boolean)
return parsed.length > 0 ? parsed : fallback
}
const parseProcessDisplayNamePresetsEnv = (
value: string | undefined,
fallback: ProcessDisplayNamePreset[]
) => {
if (!value?.trim()) {
return fallback
}
const parsed = value
.split(';')
.map(item => item.trim())
.filter(Boolean)
.map((item) => {
const separatorIndex = item.indexOf('=')
if (separatorIndex <= 0 || separatorIndex === item.length - 1) {
return null
}
const match = item.slice(0, separatorIndex).trim()
const displayName = item.slice(separatorIndex + 1).trim()
if (!match || !displayName) {
return null
}
return { match, displayName }
})
.filter((item): item is ProcessDisplayNamePreset => item !== null)
return parsed.length > 0 ? parsed : fallback
}
export const IGNORED_WINDOW_KEYWORDS = parseCommaSeparatedEnv(
process.env.IGNORED_WINDOW_KEYWORDS,
DEFAULT_IGNORED_WINDOW_KEYWORDS
)
export const PROCESS_DISPLAY_NAME_PRESETS = parseProcessDisplayNamePresetsEnv(
process.env.PROCESS_DISPLAY_NAME_PRESETS,
DEFAULT_PROCESS_DISPLAY_NAME_PRESETS
)
export const config = { export const config = {
port: process.env.PORT || 3000, port: process.env.PORT || 3000,
auth: { auth: {
@@ -5,6 +92,12 @@ export const config = {
password: process.env.AUTH_PASSWORD || 'password', password: process.env.AUTH_PASSWORD || 'password',
secret: process.env.AUTH_SECRET || 'winupdate-neo-secret-key' secret: process.env.AUTH_SECRET || 'winupdate-neo-secret-key'
}, },
windows: {
ignoredKeywords: IGNORED_WINDOW_KEYWORDS
},
screenTime: {
processDisplayNamePresets: PROCESS_DISPLAY_NAME_PRESETS
},
// For file upload settings // For file upload settings
upload: { upload: {
maxFileSize: 10 * 1024 * 1024, // 10MB maxFileSize: 10 * 1024 * 1024, // 10MB
+35
View File
@@ -1,5 +1,6 @@
import * as Minio from 'minio' import * as Minio from 'minio'
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
import { Readable } from 'stream'
import { minioClient, BUCKET_NAME, initializeMinIO } from './minioClient' import { minioClient, BUCKET_NAME, initializeMinIO } from './minioClient'
export interface StoredFile { export interface StoredFile {
@@ -20,6 +21,19 @@ export interface FileMetadata {
objectName: string objectName: string
} }
export interface StoredFileStream {
stream: Readable
contentType: string
filename: string
size?: number
}
export interface StoredFileStreamMetadata {
contentType?: string | null
filename?: string | null
size?: number | null
}
// 生成对象名称,使用分层结构优化性能 // 生成对象名称,使用分层结构优化性能
function generateObjectName(type: 'screenshot' | 'version' | 'other', hostname?: string): string { function generateObjectName(type: 'screenshot' | 'version' | 'other', hostname?: string): string {
const now = new Date() const now = new Date()
@@ -168,6 +182,27 @@ export async function getFileByObjectName(objectName: string): Promise<{ buffer:
} }
} }
export async function getFileStreamByObjectName(
objectName: string,
metadata: StoredFileStreamMetadata = {}
): Promise<StoredFileStream | null> {
try {
await ensureMinIOReady()
const stream = await minioClient.getObject(BUCKET_NAME, objectName)
return {
stream,
contentType: metadata.contentType || 'application/octet-stream',
filename: metadata.filename || objectName.split('/').pop() || 'unknown',
size: metadata.size ?? undefined
}
} catch (error) {
console.error('❌ 获取文件流失败:', error)
return null
}
}
export async function deleteFile(objectName: string): Promise<boolean> { export async function deleteFile(objectName: string): Promise<boolean> {
try { try {
await ensureMinIOReady() await ensureMinIOReady()
+75
View File
@@ -0,0 +1,75 @@
import IP2Region from 'ip2region'
import { existsSync } from 'fs'
import { join } from 'path'
import { isPrivateIp, normalizeIpCandidate } from './clientIp'
interface IpGeodata {
country: string
province: string
city: string
isp: string
}
const ipLocationLabelCache = new Map<string, string>()
let ip2Region: IP2Region | null | undefined
export function getIpGeodataLabel(ip?: string | null) {
const normalizedIp = normalizeIpCandidate(ip)
if (!normalizedIp) {
return null
}
const cachedLabel = ipLocationLabelCache.get(normalizedIp)
if (cachedLabel) {
return cachedLabel
}
const label = resolveIpGeodataLabel(normalizedIp)
ipLocationLabelCache.set(normalizedIp, label)
return label
}
function resolveIpGeodataLabel(ip: string) {
if (isPrivateIp(ip)) {
return '内网地址'
}
try {
const geodata = getIp2Region()?.search(ip) as IpGeodata | null
const parts = uniqueNonEmptyValues([
geodata?.country,
geodata?.province,
geodata?.city,
geodata?.isp
])
return parts.length > 0 ? parts.join(' ') : '未知归属地'
} catch {
return '未知归属地'
}
}
function getIp2Region() {
if (ip2Region !== undefined) {
return ip2Region
}
const dataDir = join(process.cwd(), 'node_modules', 'ip2region', 'data')
const ipv4db = join(dataDir, 'ip2region.db')
const ipv6db = join(dataDir, 'ipv6wry.db')
if (!existsSync(ipv4db) || !existsSync(ipv6db)) {
console.warn(`[geodata] ip2region database files not found in ${dataDir}`)
ip2Region = null
return ip2Region
}
ip2Region = new IP2Region({ ipv4db, ipv6db })
return ip2Region
}
function uniqueNonEmptyValues(values: Array<string | undefined>) {
return Array.from(new Set(values
.map(value => value?.trim())
.filter((value): value is string => !!value && value !== '0')))
}
+19
View File
@@ -0,0 +1,19 @@
import { IGNORED_WINDOW_KEYWORDS } from './config'
export { IGNORED_WINDOW_KEYWORDS }
const normalizedIgnoredWindowKeywords = IGNORED_WINDOW_KEYWORDS.map(keyword => keyword.toLowerCase())
interface WindowLike {
title?: string | null
path?: string | null
}
export const isIgnoredWindow = ({ title, path }: WindowLike) => {
const normalizedValue = `${title ?? ''}\n${path ?? ''}`.toLowerCase()
return normalizedIgnoredWindowKeywords.some(keyword => normalizedValue.includes(keyword))
}
export const filterIgnoredWindows = <T extends WindowLike>(windows: T[]) => {
return windows.filter(window => !isIgnoredWindow(window))
}
+4 -1
View File
@@ -10,7 +10,9 @@
"db:generate": "prisma generate", "db:generate": "prisma generate",
"db:migrate": "prisma migrate dev", "db:migrate": "prisma migrate dev",
"db:reset": "prisma migrate reset", "db:reset": "prisma migrate reset",
"db:studio": "prisma studio" "db:studio": "prisma studio",
"db:cleanup:ignored-windows": "bun envtests/cleanup-nvidia-overlay.ts",
"db:cleanup:nvidia-overlay": "bun envtests/cleanup-nvidia-overlay.ts"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^6.10.1", "@prisma/client": "^6.10.1",
@@ -24,6 +26,7 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dotenv-cli": "^8.0.0", "dotenv-cli": "^8.0.0",
"fluent-ffmpeg": "^2.1.3", "fluent-ffmpeg": "^2.1.3",
"ip2region": "^2.3.0",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"minio": "^8.0.5", "minio": "^8.0.5",
"multer": "^2.0.1", "multer": "^2.0.1",
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "screenshots_fileId_idx" ON "screenshots"("fileId");
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "records" ADD COLUMN "ip" TEXT;
+2
View File
@@ -30,6 +30,7 @@ model Host {
model Record { model Record {
id String @id @default(cuid()) id String @id @default(cuid())
hostname String hostname String
ip String?
timestamp DateTime @default(now()) timestamp DateTime @default(now())
isStarred Boolean @default(false) isStarred Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -75,6 +76,7 @@ model Screenshot {
record Record @relation(fields: [recordId], references: [id], onDelete: Cascade) record Record @relation(fields: [recordId], references: [id], onDelete: Cascade)
@@index([recordId]) @@index([recordId])
@@index([fileId])
@@index([objectName]) @@index([objectName])
@@map("screenshots") @@map("screenshots")
} }