feat: 添加屏幕时间
This commit is contained in:
@@ -0,0 +1,481 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { win32 } from 'path'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { withCors } from '@/lib/middleware'
|
||||
|
||||
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
|
||||
durationMs: number
|
||||
titleDurations: Map<string, number>
|
||||
}
|
||||
|
||||
interface HourBucket {
|
||||
totalDurationMs: number
|
||||
appDurations: Map<string, number>
|
||||
}
|
||||
|
||||
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 normalizedTitle = cleanTitle(title)
|
||||
const fallbackId = normalizedTitle ? `title:${normalizedTitle.toLowerCase()}` : 'unknown'
|
||||
|
||||
return {
|
||||
processId: rawProcessName ? rawProcessName.toLowerCase() : fallbackId,
|
||||
rawProcessName
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
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
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
usageMap.set(processId, {
|
||||
processId,
|
||||
rawProcessName,
|
||||
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,
|
||||
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 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 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) => {
|
||||
const activeWindow = record.windows[0]
|
||||
if (!activeWindow) 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(activeWindow.title)
|
||||
|| stripExtension(win32.basename(activeWindow.path || ''))
|
||||
|| '未知窗口'
|
||||
const process = normalizeProcess(activeWindow.path, title)
|
||||
|
||||
const aggregateContributionMs = Math.max(0, Math.min(intervalEndMs, aggregateEndMs) - Math.max(intervalStartMs, aggregateStartMs))
|
||||
addDurationToUsageMap(
|
||||
aggregateUsageMap,
|
||||
process.processId,
|
||||
process.rawProcessName,
|
||||
title,
|
||||
aggregateContributionMs
|
||||
)
|
||||
|
||||
const selectedDayContributionMs = Math.max(0, Math.min(intervalEndMs, selectedDayEndMs) - Math.max(intervalStartMs, selectedDayStartMs))
|
||||
addDurationToUsageMap(
|
||||
selectedDayUsageMap,
|
||||
process.processId,
|
||||
process.rawProcessName,
|
||||
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)
|
||||
Reference in New Issue
Block a user