From d1c5b818d9a249a2516374e80873473eec2de191 Mon Sep 17 00:00:00 2001 From: feie9454 Date: Fri, 24 Apr 2026 22:02:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E9=80=9F=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[hostname]/components/ScreenshotsTab.tsx | 77 +++++++++++++++---- app/hosts/[hostname]/screen-time/route.ts | 67 +++++++++------- app/hosts/[hostname]/screenshots/route.ts | 54 ++++++++----- .../migration.sql | 5 ++ prisma/schema.prisma | 5 +- 5 files changed, 148 insertions(+), 60 deletions(-) create mode 100644 prisma/migrations/20260424000000_add_record_relation_indexes/migration.sql diff --git a/app/hosts/[hostname]/components/ScreenshotsTab.tsx b/app/hosts/[hostname]/components/ScreenshotsTab.tsx index de8293d..77687a3 100644 --- a/app/hosts/[hostname]/components/ScreenshotsTab.tsx +++ b/app/hosts/[hostname]/components/ScreenshotsTab.tsx @@ -12,6 +12,30 @@ import { ScreenRecord, TimeDistributionPoint, Segment, Marker } from '../types'; import { formatMemory, formatDate } from '../utils'; import { useStarToggle } from '../hooks/useStarToggle'; +const inFlightJsonRequests = new Map>(); + +const fetchJsonOnce = async (url: string): Promise => { + const existingRequest = inFlightJsonRequests.get(url); + if (existingRequest) { + return existingRequest as Promise; + } + + const request = fetch(url, { cache: 'no-store' }) + .then(async (response) => { + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + + return response.json() as Promise; + }) + .finally(() => { + inFlightJsonRequests.delete(url); + }); + + inFlightJsonRequests.set(url, request); + return request as Promise; +}; + interface ScreenshotsTabProps { hostname: string; selectedDate: string | null; @@ -27,6 +51,9 @@ export default function ScreenshotsTab({ jumpRequest, onLastUpdateChange }: ScreenshotsTabProps) { + const timeDistributionRequestIdRef = useRef(0); + const hourlyRecordsRequestIdRef = useRef(0); + // 状态管理 const [timeDistribution, setTimeDistribution] = useState([]); const [records, setRecords] = useState([]); @@ -58,30 +85,50 @@ export default function ScreenshotsTab({ // 获取时间分布数据 const fetchTimeDistribution = async () => { + const requestId = ++timeDistributionRequestIdRef.current; + try { setLoadingDistribution(true); - const response = await fetch(`/hosts/${hostname}/time-distribution`); - if (!response.ok) throw new Error('获取时间分布数据失败'); - const data = await response.json(); + + const data = await fetchJsonOnce<{ distribution: TimeDistributionPoint[] }>( + `/hosts/${hostname}/time-distribution` + ); + + if (requestId !== timeDistributionRequestIdRef.current) { + return; + } + setTimeDistribution(data.distribution); } catch (error) { - console.error('获取时间分布数据失败:', error); + if (requestId === timeDistributionRequestIdRef.current) { + console.error('获取时间分布数据失败:', error); + } } finally { - setLoadingDistribution(false); + if (requestId === timeDistributionRequestIdRef.current) { + setLoadingDistribution(false); + } } }; // 获取小时记录 const fetchHourlyRecords = async (startTime: number, endTime: number, options?: { targetRecordId?: string, keepSelection?: boolean }) => { + const requestId = ++hourlyRecordsRequestIdRef.current; + const requestUrl = `/hosts/${hostname}/screenshots?startTime=${startTime}&endTime=${endTime}`; + try { setLoadingRecords(true); setShowDetailTimeline(true); - const response = await fetch( - `/hosts/${hostname}/screenshots?startTime=${startTime}&endTime=${endTime}` - ); - if (!response.ok) throw new Error('获取记录数据失败'); - const data = await response.json(); - const newRecords = data.records.reverse(); + + const data = await fetchJsonOnce<{ + lastUpdate: string | null; + records: ScreenRecord[]; + }>(requestUrl); + + if (requestId !== hourlyRecordsRequestIdRef.current) { + return; + } + + const newRecords = data.records; setRecords(newRecords); onLastUpdateChange(data.lastUpdate); setTimeRange({ min: startTime * 1000, max: endTime * 1000 }); @@ -104,9 +151,13 @@ export default function ScreenshotsTab({ } }); } catch (error) { - console.error('获取记录数据失败:', error); + if (requestId === hourlyRecordsRequestIdRef.current) { + console.error('获取记录数据失败:', error); + } } finally { - setLoadingRecords(false); + if (requestId === hourlyRecordsRequestIdRef.current) { + setLoadingRecords(false); + } } }; diff --git a/app/hosts/[hostname]/screen-time/route.ts b/app/hosts/[hostname]/screen-time/route.ts index 752db43..300ff79 100644 --- a/app/hosts/[hostname]/screen-time/route.ts +++ b/app/hosts/[hostname]/screen-time/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { win32 } from 'path' import { prisma } from '@/lib/prisma' import { withCors } from '@/lib/middleware' +import { Prisma } from '@prisma/client' const MAX_GAP_MS = 5 * 60 * 1000 const DEFAULT_SAMPLE_MS = 30 * 1000 @@ -36,6 +37,12 @@ interface HourBucket { appDurations: Map } +interface ScreenTimeRecordRow { + timestamp: Date + title: string | null + path: string | null +} + const cleanTitle = (title: string) => title.replace(/\s+/g, ' ').trim() const trimLabelSeparators = (label: string) => label @@ -304,27 +311,36 @@ async function handleScreenTime(req: NextRequest) { const aggregateEndMs = unit === 'week' ? weekEndMs : selectedDayEndMs const aggregateEndDate = addDays(aggregateStartDate, unit === 'week' ? 7 : 1) - const records = await prisma.record.findMany({ - where: { - hostname, - timestamp: { - gte: new Date(weekStartMs - MAX_GAP_MS), - lte: new Date(weekEndMs + MAX_GAP_MS) - } - }, - select: { - timestamp: true, - windows: { - select: { - title: true, - path: true - } - } - }, - orderBy: { - timestamp: 'asc' - } - }) + const rangeStart = new Date(weekStartMs - MAX_GAP_MS) + const rangeEnd = new Date(weekEndMs + MAX_GAP_MS) + + const records = await prisma.$queryRaw(Prisma.sql` + WITH scoped_records AS ( + SELECT "id", "timestamp" + FROM "records" + WHERE "hostname" = ${hostname} + AND "timestamp" >= ${rangeStart} + AND "timestamp" <= ${rangeEnd} + ), + ranked_windows AS ( + SELECT + w."recordId", + w."title", + w."path", + ROW_NUMBER() OVER (PARTITION BY w."recordId" ORDER BY w."id") AS row_number + FROM "windows" w + INNER JOIN scoped_records r ON r."id" = w."recordId" + ) + SELECT + r."timestamp", + w."title", + w."path" + FROM scoped_records r + LEFT JOIN ranked_windows w + ON w."recordId" = r."id" + AND w.row_number = 1 + ORDER BY r."timestamp" ASC + `) const validDiffs = records .slice(0, -1) @@ -346,8 +362,7 @@ async function handleScreenTime(req: NextRequest) { })) records.forEach((record, index) => { - const activeWindow = record.windows[0] - if (!activeWindow) return + if (record.title === null && record.path === null) return const currentMs = record.timestamp.getTime() const nextMs = records[index + 1]?.timestamp.getTime() @@ -359,10 +374,10 @@ async function handleScreenTime(req: NextRequest) { const intervalStartMs = currentMs const intervalEndMs = currentMs + intervalMs - const title = cleanTitle(activeWindow.title) - || stripExtension(win32.basename(activeWindow.path || '')) + const title = cleanTitle(record.title ?? '') + || stripExtension(win32.basename(record.path ?? '')) || '未知窗口' - const process = normalizeProcess(activeWindow.path, title) + const process = normalizeProcess(record.path ?? '', title) const aggregateContributionMs = Math.max(0, Math.min(intervalEndMs, aggregateEndMs) - Math.max(intervalStartMs, aggregateStartMs)) addDurationToUsageMap( diff --git a/app/hosts/[hostname]/screenshots/route.ts b/app/hosts/[hostname]/screenshots/route.ts index 7d35210..16fac23 100644 --- a/app/hosts/[hostname]/screenshots/route.ts +++ b/app/hosts/[hostname]/screenshots/route.ts @@ -218,26 +218,40 @@ async function handleGetScreenshots(req: NextRequest) { if (endTime) whereClause.timestamp.lte = endTime } - // Get records - const records = await prisma.record.findMany({ - where: whereClause, - include: { - windows: true, - screenshots: true - }, - orderBy: { - timestamp: 'desc' - } - }) - - // Get host info - const host = await prisma.host.findUnique({ - where: { hostname }, - select: { - hostname: true, - lastUpdate: true - } - }) + const [records, host] = await Promise.all([ + prisma.record.findMany({ + where: whereClause, + select: { + id: true, + timestamp: true, + isStarred: true, + windows: { + select: { + title: true, + path: true, + memory: true + } + }, + screenshots: { + select: { + fileId: true, + filename: true, + monitorName: true + } + } + }, + orderBy: { + timestamp: 'asc' + } + }), + prisma.host.findUnique({ + where: { hostname }, + select: { + hostname: true, + lastUpdate: true + } + }) + ]) if (!host) { return NextResponse.json({ error: '未找到主机记录' }, { status: 404 }) diff --git a/prisma/migrations/20260424000000_add_record_relation_indexes/migration.sql b/prisma/migrations/20260424000000_add_record_relation_indexes/migration.sql new file mode 100644 index 0000000..d739cef --- /dev/null +++ b/prisma/migrations/20260424000000_add_record_relation_indexes/migration.sql @@ -0,0 +1,5 @@ +-- CreateIndex +CREATE INDEX "windows_recordId_idx" ON "windows"("recordId"); + +-- CreateIndex +CREATE INDEX "screenshots_recordId_idx" ON "screenshots"("recordId"); \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2f72d66..c58bc2f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -55,6 +55,8 @@ model Window { // Relations record Record @relation(fields: [recordId], references: [id], onDelete: Cascade) + + @@index([recordId]) @@map("windows") } @@ -71,7 +73,8 @@ model Screenshot { // Relations record Record @relation(fields: [recordId], references: [id], onDelete: Cascade) - + + @@index([recordId]) @@index([objectName]) @@map("screenshots") }