508 lines
17 KiB
TypeScript
508 lines
17 KiB
TypeScript
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
|
|
const MIN_SAMPLE_MS = 5 * 1000
|
|
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<string, number>
|
|
}
|
|
|
|
interface HourBucket {
|
|
totalDurationMs: number
|
|
appDurations: Map<string, number>
|
|
}
|
|
|
|
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 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<string, number>()
|
|
const originalMap = new Map<string, string>()
|
|
|
|
titles.forEach(({ title, durationMs }) => {
|
|
const seen = new Set<string>()
|
|
|
|
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 resolveProcessDisplayName = (rawProcessName: string, titles: TitleUsage[]) => {
|
|
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<string, MutableAppUsage>,
|
|
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<string, MutableAppUsage>) => {
|
|
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, 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<ScreenTimeRecordRow[]>(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<string, MutableAppUsage>()
|
|
const selectedDayUsageMap = new Map<string, MutableAppUsage>()
|
|
const weekDayDurations = Array.from({ length: 7 }, () => 0)
|
|
const hourBuckets = Array.from({ length: 24 }, (): HourBucket => ({
|
|
totalDurationMs: 0,
|
|
appDurations: new Map<string, number>()
|
|
}))
|
|
|
|
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, 3).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)
|