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

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)