import { NextRequest, NextResponse } from 'next/server' import { win32 } from 'path' import { prisma } from '@/lib/prisma' import { withCors } from '@/lib/middleware' import { PROCESS_DISPLAY_NAME_PRESETS } from '@/lib/config' import { Prisma } from '@prisma/client' const MAX_GAP_MS = 5 * 60 * 1000 const DEFAULT_SAMPLE_MS = 30 * 1000 const MIN_SAMPLE_MS = 5 * 1000 const SPOTLIGHT_APP_LIMIT = 4 const LABEL_TOKEN_THRESHOLD_RATIO = 0.5 const HOUR_MS = 60 * 60 * 1000 const DAY_MS = 24 * HOUR_MS const WEEKDAY_LABELS = ['日', '一', '二', '三', '四', '五', '六'] type Unit = 'day' | 'week' interface DateParts { year: number month: number day: number } interface TitleUsage { title: string durationMs: number } interface MutableAppUsage { processId: string rawProcessName: string rawProcessPath: string durationMs: number titleDurations: Map } interface HourBucket { totalDurationMs: number 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 .replace(/^[\s\-–—|:/\\]+/, '') .replace(/[\s\-–—|:/\\]+$/, '') .replace(/\s+/g, ' ') .trim() 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 parseDateParts = (value: string | null): DateParts | null => { if (!value) return null const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/) if (!match) return null const year = Number(match[1]) const month = Number(match[2]) const day = Number(match[3]) const candidate = new Date(Date.UTC(year, month - 1, day)) if ( candidate.getUTCFullYear() !== year || candidate.getUTCMonth() !== month - 1 || candidate.getUTCDate() !== day ) { return null } return { year, month, day } } const addDays = (parts: DateParts, amount: number): DateParts => { const next = new Date(Date.UTC(parts.year, parts.month - 1, parts.day)) next.setUTCDate(next.getUTCDate() + amount) return { year: next.getUTCFullYear(), month: next.getUTCMonth() + 1, day: next.getUTCDate() } } const getCurrentDateParts = (tzOffsetMinutes: number): DateParts => { const shifted = new Date(Date.now() - tzOffsetMinutes * 60 * 1000) return { year: shifted.getUTCFullYear(), month: shifted.getUTCMonth() + 1, day: shifted.getUTCDate() } } const getWeekStart = (parts: DateParts): DateParts => { const current = new Date(Date.UTC(parts.year, parts.month - 1, parts.day)) const dayOfWeek = current.getUTCDay() const diff = dayOfWeek === 0 ? -6 : 1 - dayOfWeek return addDays(parts, diff) } const getLocalDateUtcMs = (parts: DateParts, tzOffsetMinutes: number) => { return Date.UTC(parts.year, parts.month - 1, parts.day) + tzOffsetMinutes * 60 * 1000 } const normalizeProcess = (processPath: string, title: string) => { const rawProcessName = win32.basename(processPath || '').trim() const rawProcessPath = processPath.trim() const normalizedTitle = cleanTitle(title) const fallbackId = normalizedTitle ? `title:${normalizedTitle.toLowerCase()}` : 'unknown' return { processId: rawProcessName ? rawProcessName.toLowerCase() : fallbackId, rawProcessName, rawProcessPath } } const tokenizeTitle = (title: string) => title .replace(/[()\[\]{}]/g, ' ') .split(/[\s\-–—|:/\\]+/) .map(token => token.trim()) .filter(token => token.length >= 2) const buildWeightedTokenLabel = (titles: TitleUsage[], totalDurationMs: number) => { if (titles.length === 0 || totalDurationMs <= 0) return '' const weightMap = new Map() const originalMap = new Map() titles.forEach(({ title, durationMs }) => { const seen = new Set() tokenizeTitle(title).forEach(token => { const lowerToken = token.toLowerCase() if (seen.has(lowerToken)) return seen.add(lowerToken) weightMap.set(lowerToken, (weightMap.get(lowerToken) ?? 0) + durationMs) if (!originalMap.has(lowerToken)) { originalMap.set(lowerToken, token) } }) }) const threshold = totalDurationMs * LABEL_TOKEN_THRESHOLD_RATIO const primaryTitleTokens = tokenizeTitle(titles[0].title) return primaryTitleTokens .filter(token => (weightMap.get(token.toLowerCase()) ?? 0) >= threshold) .map(token => originalMap.get(token.toLowerCase()) ?? token) .join(' ') } const getLongestCommonSubstringBetween = (left: string, right: string) => { if (!left || !right) return '' const leftLower = left.toLowerCase() const rightLower = right.toLowerCase() const previous = new Array(right.length + 1).fill(0) const current = new Array(right.length + 1).fill(0) let bestLength = 0 let bestEnd = 0 for (let i = 1; i <= left.length; i += 1) { for (let j = 1; j <= right.length; j += 1) { if (leftLower[i - 1] === rightLower[j - 1]) { current[j] = previous[j - 1] + 1 if (current[j] > bestLength) { bestLength = current[j] bestEnd = i } } else { current[j] = 0 } } for (let j = 0; j <= right.length; j += 1) { previous[j] = current[j] current[j] = 0 } } return left.slice(bestEnd - bestLength, bestEnd) } const buildCommonSubstringLabel = (titles: TitleUsage[]) => { if (titles.length === 0) return '' let shared = cleanTitle(titles[0].title) for (let index = 1; index < titles.length; index += 1) { shared = getLongestCommonSubstringBetween(shared, cleanTitle(titles[index].title)) if (trimLabelSeparators(shared).length < 2) { return '' } } return trimLabelSeparators(shared) } 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 totalDurationMs = titles.reduce((total, item) => total + item.durationMs, 0) const weightedTokenLabel = trimLabelSeparators(buildWeightedTokenLabel(titles, totalDurationMs)) const commonSubstringLabel = trimLabelSeparators(buildCommonSubstringLabel(titles.slice(0, 5))) const candidates = [weightedTokenLabel, commonSubstringLabel] .filter(label => label.length >= 2) .sort((left, right) => right.length - left.length) const preferredCandidate = candidates.find(label => label.toLowerCase() !== processBaseName) if (preferredCandidate) { return preferredCandidate } const fallbackTitle = cleanTitle(titles[0]?.title ?? '') if (fallbackTitle && !/\.exe$/i.test(fallbackTitle)) { return fallbackTitle } return stripExtension(rawProcessName) || fallbackTitle || '未知应用' } const addDurationToUsageMap = ( usageMap: Map, processId: string, rawProcessName: string, rawProcessPath: string, title: string, durationMs: number ) => { if (durationMs <= 0) return const existing = usageMap.get(processId) if (existing) { existing.durationMs += durationMs existing.titleDurations.set(title, (existing.titleDurations.get(title) ?? 0) + durationMs) if (!existing.rawProcessName && rawProcessName) { existing.rawProcessName = rawProcessName } if (!existing.rawProcessPath && rawProcessPath) { existing.rawProcessPath = rawProcessPath } return } usageMap.set(processId, { processId, rawProcessName, rawProcessPath, durationMs, titleDurations: new Map([[title, durationMs]]) }) } const buildAppList = (usageMap: Map) => { const totalDurationMs = Array.from(usageMap.values()).reduce((sum, item) => sum + item.durationMs, 0) return { totalDurationMs, apps: Array.from(usageMap.values()) .map(app => { const sortedTitles = Array.from(app.titleDurations.entries()) .map(([title, durationMs]) => ({ title, durationMs })) .sort((left, right) => right.durationMs - left.durationMs) return { processId: app.processId, processName: resolveProcessDisplayName(app.rawProcessName, app.rawProcessPath, sortedTitles), rawProcessName: app.rawProcessName, processPath: app.rawProcessPath, durationSeconds: Math.round(app.durationMs / 1000), percentage: totalDurationMs > 0 ? Number(((app.durationMs / totalDurationMs) * 100).toFixed(2)) : 0, titleCount: sortedTitles.length, titles: sortedTitles.slice(0, 5).map(title => ({ title: title.title, durationSeconds: Math.round(title.durationMs / 1000), percentage: app.durationMs > 0 ? Number(((title.durationMs / app.durationMs) * 100).toFixed(2)) : 0 })) } }) .sort((left, right) => right.durationSeconds - left.durationSeconds) } } async function handleScreenTime(req: NextRequest) { try { const pathSegments = req.nextUrl.pathname.split('/') const hostnameIndex = pathSegments.indexOf('hosts') + 1 const hostname = pathSegments[hostnameIndex] if (!hostname) { return NextResponse.json({ error: '缺少主机名' }, { status: 400 }) } const searchParams = req.nextUrl.searchParams const unit: Unit = searchParams.get('unit') === 'week' ? 'week' : 'day' const date = searchParams.get('date') const tzOffsetMinutes = Number(searchParams.get('tzOffsetMinutes') ?? '0') if (!Number.isFinite(tzOffsetMinutes)) { return NextResponse.json({ error: '时区参数无效' }, { status: 400 }) } const selectedDate = parseDateParts(date) ?? getCurrentDateParts(tzOffsetMinutes) const selectedDayStartMs = getLocalDateUtcMs(selectedDate, tzOffsetMinutes) const selectedDayEndMs = selectedDayStartMs + DAY_MS const weekStartDate = getWeekStart(selectedDate) const weekStartMs = getLocalDateUtcMs(weekStartDate, tzOffsetMinutes) const weekEndMs = weekStartMs + 7 * DAY_MS const aggregateStartDate = unit === 'week' ? weekStartDate : selectedDate const aggregateStartMs = unit === 'week' ? weekStartMs : selectedDayStartMs const aggregateEndMs = unit === 'week' ? weekEndMs : selectedDayEndMs const aggregateEndDate = addDays(aggregateStartDate, unit === 'week' ? 7 : 1) 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) .map((record, index) => records[index + 1].timestamp.getTime() - record.timestamp.getTime()) .filter(diff => diff > 0 && diff <= MAX_GAP_MS) .sort((left, right) => left - right) const medianDiff = validDiffs.length > 0 ? validDiffs[Math.floor(validDiffs.length / 2)] : DEFAULT_SAMPLE_MS const estimatedSampleMs = Math.min(MAX_GAP_MS, Math.max(MIN_SAMPLE_MS, medianDiff)) const aggregateUsageMap = new Map() const selectedDayUsageMap = new Map() const weekDayDurations = Array.from({ length: 7 }, () => 0) const hourBuckets = Array.from({ length: 24 }, (): HourBucket => ({ totalDurationMs: 0, appDurations: new Map() })) records.forEach((record, index) => { if (record.title === null && record.path === null) return const currentMs = record.timestamp.getTime() const nextMs = records[index + 1]?.timestamp.getTime() const intervalMs = nextMs ? Math.min(Math.max(nextMs - currentMs, 0), MAX_GAP_MS) : estimatedSampleMs if (intervalMs <= 0) return const intervalStartMs = currentMs const intervalEndMs = currentMs + intervalMs const title = cleanTitle(record.title ?? '') || stripExtension(win32.basename(record.path ?? '')) || '未知窗口' const process = normalizeProcess(record.path ?? '', title) const aggregateContributionMs = Math.max(0, Math.min(intervalEndMs, aggregateEndMs) - Math.max(intervalStartMs, aggregateStartMs)) addDurationToUsageMap( aggregateUsageMap, process.processId, process.rawProcessName, process.rawProcessPath, title, aggregateContributionMs ) const selectedDayContributionMs = Math.max(0, Math.min(intervalEndMs, selectedDayEndMs) - Math.max(intervalStartMs, selectedDayStartMs)) addDurationToUsageMap( selectedDayUsageMap, process.processId, process.rawProcessName, process.rawProcessPath, title, selectedDayContributionMs ) let dayCursorMs = Math.max(intervalStartMs, weekStartMs) const weekIntervalEndMs = Math.min(intervalEndMs, weekEndMs) while (dayCursorMs < weekIntervalEndMs) { const dayIndex = Math.floor((dayCursorMs - weekStartMs) / DAY_MS) if (dayIndex < 0 || dayIndex >= 7) break const nextBoundaryMs = Math.min(weekStartMs + (dayIndex + 1) * DAY_MS, weekIntervalEndMs) weekDayDurations[dayIndex] += nextBoundaryMs - dayCursorMs dayCursorMs = nextBoundaryMs } let hourCursorMs = Math.max(intervalStartMs, selectedDayStartMs) const dayIntervalEndMs = Math.min(intervalEndMs, selectedDayEndMs) while (hourCursorMs < dayIntervalEndMs) { const hourIndex = Math.floor((hourCursorMs - selectedDayStartMs) / HOUR_MS) if (hourIndex < 0 || hourIndex >= 24) break const nextBoundaryMs = Math.min(selectedDayStartMs + (hourIndex + 1) * HOUR_MS, dayIntervalEndMs) const contributionMs = nextBoundaryMs - hourCursorMs hourBuckets[hourIndex].totalDurationMs += contributionMs hourBuckets[hourIndex].appDurations.set( process.processId, (hourBuckets[hourIndex].appDurations.get(process.processId) ?? 0) + contributionMs ) hourCursorMs = nextBoundaryMs } }) const aggregateSummary = buildAppList(aggregateUsageMap) const selectedDaySummary = buildAppList(selectedDayUsageMap) const spotlightApps = selectedDaySummary.apps.slice(0, SPOTLIGHT_APP_LIMIT).map(app => ({ processId: app.processId, processName: app.processName, durationSeconds: app.durationSeconds, percentage: app.percentage })) const spotlightIds = new Set(spotlightApps.map(app => app.processId)) const weekTotalDurationMs = weekDayDurations.reduce((sum, durationMs) => sum + durationMs, 0) const averageDurationSeconds = Math.round((weekTotalDurationMs / 7) / 1000) const latestRecord = records.length > 0 ? records[records.length - 1] : null return NextResponse.json({ hostname, unit, selectedDate: toDateKey(selectedDate), periodStart: new Date(aggregateStartMs).toISOString(), periodEnd: new Date(aggregateEndMs).toISOString(), periodStartDate: toDateKey(aggregateStartDate), periodEndDate: toDateKey(addDays(aggregateEndDate, -1)), totalDurationSeconds: Math.round(aggregateSummary.totalDurationMs / 1000), estimatedSampleSeconds: Math.round(estimatedSampleMs / 1000), lastRecordedAt: latestRecord ? latestRecord.timestamp.toISOString() : null, apps: aggregateSummary.apps, overview: { week: { totalDurationSeconds: Math.round(weekTotalDurationMs / 1000), averageDurationSeconds, days: weekDayDurations.map((durationMs, index) => { const dayDate = addDays(weekStartDate, index) return { date: toDateKey(dayDate), weekdayLabel: WEEKDAY_LABELS[index === 6 ? 0 : index + 1], totalDurationSeconds: Math.round(durationMs / 1000), isSelected: index === Math.floor((selectedDayStartMs - weekStartMs) / DAY_MS) } }) }, selectedDay: { date: toDateKey(selectedDate), totalDurationSeconds: Math.round(selectedDaySummary.totalDurationMs / 1000), spotlightApps, hours: hourBuckets.map((bucket, hour) => { const spotlightDurations = Object.fromEntries( spotlightApps.map(app => [app.processId, Math.round((bucket.appDurations.get(app.processId) ?? 0) / 1000)]) ) const spotlightTotalMs = Array.from(bucket.appDurations.entries()) .filter(([processId]) => spotlightIds.has(processId)) .reduce((sum, [, durationMs]) => sum + durationMs, 0) return { hour, label: `${String(hour).padStart(2, '0')}时`, totalDurationSeconds: Math.round(bucket.totalDurationMs / 1000), spotlightDurations, otherDurationSeconds: Math.round(Math.max(bucket.totalDurationMs - spotlightTotalMs, 0) / 1000) } }) } } }) } catch (error) { console.error('获取屏幕使用时间失败:', error) return NextResponse.json({ error: '获取屏幕使用时间失败' }, { status: 500 }) } } export const GET = withCors(handleScreenTime)