diff --git a/app/api/records/[recordId]/star/route.ts b/app/api/records/[recordId]/star/route.ts new file mode 100644 index 0000000..c5737bf --- /dev/null +++ b/app/api/records/[recordId]/star/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { withCors } from '@/lib/middleware' + +// 切换记录的星标状态 +async function handleToggleStar(req: NextRequest) { + try { + // 从 URL 路径中提取 recordId + const pathSegments = req.nextUrl.pathname.split('/') + const recordIdIndex = pathSegments.indexOf('records') + 1 + const recordId = pathSegments[recordIdIndex] + + if (!recordId) { + return NextResponse.json({ error: '缺少记录ID' }, { status: 400 }) + } + + // 获取当前记录 + const record = await prisma.record.findUnique({ + where: { id: recordId }, + select: { id: true, isStarred: true } + }) + + if (!record) { + return NextResponse.json({ error: '记录不存在' }, { status: 404 }) + } + + // 切换星标状态 + const updatedRecord = await prisma.record.update({ + where: { id: recordId }, + data: { isStarred: !record.isStarred }, + select: { id: true, isStarred: true } + }) + + return NextResponse.json({ + success: true, + recordId: updatedRecord.id, + isStarred: updatedRecord.isStarred + }) + + } catch (error) { + console.error('切换星标状态失败:', error) + return NextResponse.json({ error: '操作失败' }, { status: 500 }) + } +} + +export const PATCH = withCors(handleToggleStar) diff --git a/app/hosts/[hostname]/components/TimelineSlider.tsx b/app/hosts/[hostname]/components/TimelineSlider.tsx index a365de9..a3fb980 100644 --- a/app/hosts/[hostname]/components/TimelineSlider.tsx +++ b/app/hosts/[hostname]/components/TimelineSlider.tsx @@ -108,13 +108,6 @@ export default function TimelineSlider({ window.removeEventListener('pointermove', pointerMoveHandler); window.removeEventListener('pointerup', pointerUpHandler); - // 最终确保调用onChange通知最新的值 - setInternalValue(currentValue => { - const snapped = getSnapValue(currentValue); - lastNotifiedValue.current = snapped; - onChange(snapped); - return snapped; - }); }, [getSnapValue, onChange, pointerMoveHandler]); // 指针按下处理函数 diff --git a/app/hosts/[hostname]/page.tsx b/app/hosts/[hostname]/page.tsx index 99ec50b..35cb950 100644 --- a/app/hosts/[hostname]/page.tsx +++ b/app/hosts/[hostname]/page.tsx @@ -2,16 +2,17 @@ import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { useParams, useRouter } from 'next/navigation'; -import { - ArrowLeft, - RefreshCcw, - ChevronDown, - User, - Globe, - Link, - Clipboard, - ShieldAlert, - Loader2 +import { + ArrowLeft, + RefreshCcw, + ChevronDown, + User, + Globe, + Link, + Clipboard, + ShieldAlert, + Loader2, + Star } from 'lucide-react'; import { format, differenceInMinutes } from 'date-fns'; import TimelineSlider from './components/TimelineSlider'; @@ -30,7 +31,9 @@ interface Window { } interface ScreenRecord { + id: string; timestamp: string; + isStarred: boolean; windows: Window[]; screenshots: Screenshot[]; } @@ -84,7 +87,7 @@ export default function HostDetail() { const params = useParams(); const router = useRouter(); const hostname = params.hostname as string; - + // 状态管理 const [activeTab, setActiveTab] = useState('screenshots'); const [timeDistribution, setTimeDistribution] = useState([]); @@ -94,7 +97,12 @@ export default function HostDetail() { const [loadingRecords, setLoadingRecords] = useState(false); const [showDetailTimeline, setShowDetailTimeline] = useState(false); const [lastUpdate, setLastUpdate] = useState(null); - + + // 星标相关状态 + const [starredRecords, setStarredRecords] = useState([]); + const [loadingStarred, setLoadingStarred] = useState(false); + const [updatingStars, setUpdatingStars] = useState>(new Set()); + // 凭据相关状态 const [credentials, setCredentials] = useState([]); const [loadingCredentials, setLoadingCredentials] = useState(false); @@ -102,24 +110,24 @@ export default function HostDetail() { const [expandedBrowsers, setExpandedBrowsers] = useState([]); const [expandedCredentials, setExpandedCredentials] = useState([]); const [revealedPasswords, setRevealedPasswords] = useState([]); - + // 日历和滑块状态 const [selectedDate, setSelectedDate] = useState(null); const [timeRange, setTimeRange] = useState({ min: 0, max: 0 }); const [hourlySliderValue, setHourlySliderValue] = useState(0); const [detailedSliderValue, setDetailedSliderValue] = useState(0); - + // 播放控制 const [currentFrameIndex, setCurrentFrameIndex] = useState(-1); const [autoPlay, setAutoPlay] = useState(false); - const [autoPlaySpeed, setAutoPlaySpeed] = useState(200); + const [autoPlaySpeed, setAutoPlaySpeed] = useState(100); const [imagesLoadedCount, setImagesLoadedCount] = useState(0); const [imageAspectRatio, setImageAspectRatio] = useState(16 / 9); const autoPlayTimer = useRef(null); // 格式化内存大小 - const formatMemory = (bytes: number) => { - if (typeof bytes !== 'number') return '0 B'; + const formatMemory = (bytes: number | string) => { + bytes = typeof bytes === 'string' ? parseInt(bytes, 10) : bytes; const units = ['B', 'KB', 'MB', 'GB']; let size = bytes; let unitIndex = 0; @@ -153,6 +161,112 @@ export default function HostDetail() { } }; + // 获取星标记录 + const fetchStarredRecords = async () => { + try { + setLoadingStarred(true); + const response = await fetch(`/hosts/${hostname}/starred`); + if (!response.ok) throw new Error('获取星标记录失败'); + const data = await response.json(); + setStarredRecords(data.records); + } catch (error) { + console.error('获取星标记录失败:', error); + } finally { + setLoadingStarred(false); + } + }; + + // 切换星标状态 + const toggleRecordStar = async (recordId: string) => { + if (updatingStars.has(recordId)) return; + + try { + setUpdatingStars(prev => new Set(prev).add(recordId)); + const response = await fetch(`/api/records/${recordId}/star`, { + method: 'PATCH' + }); + + if (!response.ok) throw new Error('切换星标状态失败'); + const data = await response.json(); + + // 更新当前记录列表中的星标状态 + setRecords(prev => prev.map(record => + record.id === recordId + ? { ...record, isStarred: data.isStarred } + : record + )); + + // 更新选中记录的星标状态 + if (selectedRecord && selectedRecord.id === recordId) { + setSelectedRecord(prev => prev ? { ...prev, isStarred: data.isStarred } : null); + } + + // 如果在星标页面,更新星标记录列表 + if (activeTab === 'starred') { + if (data.isStarred) { + // 如果变为星标,重新获取星标列表 + fetchStarredRecords(); + } else { + // 如果取消星标,从列表中移除 + setStarredRecords(prev => prev.filter(record => record.id !== recordId)); + } + } + + } catch (error) { + console.error('切换星标状态失败:', error); + } finally { + setUpdatingStars(prev => { + const newSet = new Set(prev); + newSet.delete(recordId); + return newSet; + }); + } + }; + + // 批量操作星标记录 + const batchToggleStars = async (recordIds: string[], action: 'star' | 'unstar') => { + try { + const response = await fetch(`/hosts/${hostname}/starred`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + action, + recordIds + }) + }); + + if (!response.ok) throw new Error('批量操作星标失败'); + const data = await response.json(); + + const isStarred = action === 'star'; + + // 更新当前记录列表中的星标状态 + setRecords(prev => prev.map(record => + recordIds.includes(record.id) + ? { ...record, isStarred } + : record + )); + + // 更新选中记录的星标状态(如果存在) + if (selectedRecord && recordIds.includes(selectedRecord.id)) { + setSelectedRecord(prev => prev ? { ...prev, isStarred } : null); + } + + // 如果在星标页面,重新获取星标记录列表 + if (activeTab === 'starred') { + fetchStarredRecords(); + } + + return data; + + } catch (error) { + console.error('批量操作星标失败:', error); + throw error; + } + }; + // 获取凭据数据 const fetchCredentials = async () => { try { @@ -188,7 +302,7 @@ export default function HostDetail() { setRecords(data.records.reverse()); setLastUpdate(data.lastUpdate); setTimeRange({ min: startTime * 1000, max: endTime * 1000 }); - + requestAnimationFrame(() => { setSelectedRecord(data.records[0]); }); @@ -202,31 +316,31 @@ export default function HostDetail() { // 组织凭据数据按用户分组 const credentialsByUser = useMemo(() => { const userMap = new Map(); - + credentials.forEach(cred => { if (!userMap.has(cred.username)) { userMap.set(cred.username, []); } userMap.get(cred.username)!.push(cred); }); - + const result: UserGroup[] = []; - + userMap.forEach((userCreds, username) => { const latestSyncTime = userCreds.reduce((latest, cred) => { const credTime = new Date(cred.lastSyncTime); return credTime > latest ? credTime : latest; }, new Date(0)).toISOString(); - + const browserMap = new Map(); - + userCreds.forEach(cred => { if (!browserMap.has(cred.browser)) { browserMap.set(cred.browser, []); } browserMap.get(cred.browser)!.push(cred); }); - + const browsers: BrowserGroup[] = []; browserMap.forEach((browserCreds, browserName) => { browsers.push({ @@ -234,7 +348,7 @@ export default function HostDetail() { credentials: browserCreds }); }); - + result.push({ username, browsers, @@ -242,7 +356,7 @@ export default function HostDetail() { lastSyncTime: latestSyncTime }); }); - + return result; }, [credentials]); @@ -291,7 +405,7 @@ export default function HostDetail() { const dayEnd = new Date(dayStart); dayEnd.setDate(dayEnd.getDate() + 1); const endSec = Math.floor(dayEnd.getTime() / 1000); - + for (let t = startSec; t < endSec; t += 3600) { const segStart = t * 1000; const segEnd = (t + 3600) * 1000; @@ -329,16 +443,10 @@ export default function HostDetail() { })); }, [records]); - // 计算属性 - const allImagesLoaded = useMemo(() => { - if (!selectedRecord) return false; - return imagesLoadedCount >= selectedRecord.screenshots.length; - }, [selectedRecord, imagesLoadedCount]); - // 事件处理函数 const onHourlySliderChange = (newValue: number) => { const selectedSec = Math.floor(newValue / 3600000) * 3600; - + // 使用 setTimeout 避免在渲染过程中更新状态 setTimeout(() => { setHourlySliderValue(newValue); @@ -351,7 +459,7 @@ export default function HostDetail() { const targetTime = newValue; let closestRecord = records[0]; let minDiff = Math.abs(new Date(closestRecord.timestamp).getTime() - targetTime); - + for (const record of records) { const diff = Math.abs(new Date(record.timestamp).getTime() - targetTime); if (diff < minDiff) { @@ -359,7 +467,7 @@ export default function HostDetail() { closestRecord = record; } } - + // 使用 setTimeout 避免在渲染过程中更新状态 setTimeout(() => { setSelectedRecord(closestRecord); @@ -430,12 +538,13 @@ export default function HostDetail() { }; const startAutoPlayTimer = useCallback(() => { - if (allImagesLoaded && autoPlay) { + + if (autoPlay) { autoPlayTimer.current = setTimeout(() => { nextFrame(); }, autoPlaySpeed); } - }, [allImagesLoaded, autoPlay, autoPlaySpeed, currentFrameIndex, records.length]); + }, [autoPlay, autoPlaySpeed, currentFrameIndex, records.length]); const stopAutoPlayTimer = () => { if (autoPlayTimer.current) { @@ -452,12 +561,54 @@ export default function HostDetail() { } }; + // 键盘快捷键处理 + useEffect(() => { + const handleKeyPress = (event: KeyboardEvent) => { + // 防止在输入框中触发快捷键 + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { + return; + } + + switch (event.code) { + case 'Space': + event.preventDefault(); + toggleAutoPlay(); + break; + case 'ArrowLeft': + event.preventDefault(); + prevFrame(); + break; + case 'ArrowRight': + event.preventDefault(); + nextFrame(); + break; + case 'KeyS': + event.preventDefault(); + if (selectedRecord) { + toggleRecordStar(selectedRecord.id); + } + break; + } + }; + + document.addEventListener('keydown', handleKeyPress); + return () => { + document.removeEventListener('keydown', handleKeyPress); + }; + }, [selectedRecord, toggleAutoPlay, prevFrame, nextFrame, toggleRecordStar]); + // Effects useEffect(() => { fetchTimeDistribution(); fetchCredentials(); }, [hostname]); + useEffect(() => { + if (activeTab === 'starred') { + fetchStarredRecords(); + } + }, [activeTab, hostname]); + useEffect(() => { if (!selectedDate && Object.keys(dailyCounts).length > 0) { const dates = Object.keys(dailyCounts).sort(); @@ -486,22 +637,23 @@ export default function HostDetail() { useEffect(() => { setImagesLoadedCount(0); + if (selectedRecord) { const idx = records.findIndex(rec => rec.timestamp === selectedRecord.timestamp); setCurrentFrameIndex(idx); setDetailedSliderValue(new Date(selectedRecord.timestamp).getTime()); } - if (autoPlay && allImagesLoaded) { + if (autoPlay) { startAutoPlayTimer(); } - }, [selectedRecord, records, autoPlay, allImagesLoaded, startAutoPlayTimer]); + }, [selectedRecord, records, autoPlay, startAutoPlayTimer]); useEffect(() => { - if (autoPlay && allImagesLoaded) { + if (autoPlay) { stopAutoPlayTimer(); startAutoPlayTimer(); } - }, [autoPlay, allImagesLoaded, autoPlaySpeed, startAutoPlayTimer]); + }, [autoPlay, autoPlaySpeed, startAutoPlayTimer]); return (
@@ -509,7 +661,7 @@ export default function HostDetail() { {/* 头部导航 */}
- - + @@ -560,20 +720,20 @@ export default function HostDetail() {

时间分布

-
- - - + {hourlySegments.length > 0 ? (

时间点详情

-
- + {records.length > 0 ? ( + + {/* 图片预览区域 */} {selectedRecord.screenshots.map((screenshot, sIndex) => (
-
@@ -638,7 +800,7 @@ export default function HostDetail() { onLoad={(e) => onImageLoad(e, screenshot.fileId)} />
- + {/* 图片说明 */}
{screenshot.monitorName}
@@ -648,14 +810,14 @@ export default function HostDetail() {
{/* 左侧点击区域 - 上一帧 */} -
{ e.preventDefault(); prevFrame(); }} onTouchStart={(e) => e.preventDefault()} className="absolute bottom-0 left-0 h-full w-1/3 text-white px-2 py-1 cursor-pointer" /> - + {/* 右侧点击区域 - 下一帧 */} -
{ e.preventDefault(); nextFrame(); }} onTouchStart={(e) => e.preventDefault()} className="absolute bottom-0 right-0 h-full w-1/3 text-white px-2 py-1 cursor-pointer" @@ -664,26 +826,96 @@ export default function HostDetail() { ))} {/* 控制按钮区域 */} -
- - +
+
+ + {/* 速度控制 */} +
+ 播放速度 +
+ setAutoPlaySpeed(Number(e.target.value))} + className="w-20 text-sm px-3 py-1.5 border border-gray-300 dark:border-gray-500 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white transition-colors" + min="0" + max="2000" + step="50" + /> + 毫秒 +
+
+ + {/* 播放控制按钮 */} + + + {/* 星标按钮 */} + +
+ + {/* 快捷键提示 */} +
+
+ + 空格 + 播放/暂停 + + + ←/→ + 上一帧/下一帧 + + + S + 切换星标 + +
+
{/* 窗口信息 */} @@ -714,13 +946,107 @@ export default function HostDetail() {
)} + {/* 星标记录选项卡 */} + {activeTab === 'starred' && ( +
+
+

+ + 星标记录 +

+ +
+ + {/* 加载状态 */} + {loadingStarred ? ( +
+
+ +

加载星标记录...

+
+
+ ) : starredRecords.length === 0 ? ( +
+ +

还没有星标记录

+

在截图时间线中点击星标按钮来收藏重要记录

+
+ ) : ( +
+ {starredRecords.map((record) => ( +
+ {/* 缩略图 */} + {record.screenshots.length > 0 && ( +
+ {record.screenshots[0].monitorName} +
+ )} + + {/* 记录信息 */} +
+
+
+ {formatDate(record.timestamp, 'short')} +
+ +
+ +
+ {record.screenshots.length} 个截图 • {record.windows.length} 个窗口 +
+ + {/* 窗口预览 */} + {record.windows.length > 0 && ( +
+ 主要窗口: {record.windows[0].title} +
+ )} + + {/* 查看按钮 */} + +
+
+ ))} +
+ )} +
+ )} + {/* 凭据信息选项卡 */} {activeTab === 'credentials' && (

凭据信息

-
@@ -857,14 +1173,14 @@ export default function HostDetail() {
- revealedPasswords.includes(`${cred._id}-${pwdIndex}`) - ? null + onClick={() => + revealedPasswords.includes(`${cred._id}-${pwdIndex}`) + ? null : revealPassword(`${cred._id}-${pwdIndex}`) } > - {revealedPasswords.includes(`${cred._id}-${pwdIndex}`) - ? pwd.value + {revealedPasswords.includes(`${cred._id}-${pwdIndex}`) + ? pwd.value : '••••••••' } diff --git a/app/hosts/[hostname]/screenshots/route.ts b/app/hosts/[hostname]/screenshots/route.ts index 48e0c8c..14dba89 100644 --- a/app/hosts/[hostname]/screenshots/route.ts +++ b/app/hosts/[hostname]/screenshots/route.ts @@ -243,10 +243,19 @@ async function handleGetScreenshots(req: NextRequest) { return NextResponse.json({ error: '未找到主机记录' }, { status: 404 }) } + // Convert BigInt to string in windows.memory field + const serializedRecords = records.map(record => ({ + ...record, + windows: record.windows.map(window => ({ + ...window, + memory: window.memory.toString() + })) + })) + return NextResponse.json({ hostname, lastUpdate: host.lastUpdate, - records, + records: serializedRecords, total: records.length }) diff --git a/app/hosts/[hostname]/starred/route.ts b/app/hosts/[hostname]/starred/route.ts new file mode 100644 index 0000000..390e7a6 --- /dev/null +++ b/app/hosts/[hostname]/starred/route.ts @@ -0,0 +1,124 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { withCors } from '@/lib/middleware' + +// 获取指定主机的星标记录 +async function handleGetStarredRecords(req: NextRequest) { + try { + const { searchParams } = req.nextUrl + const pathSegments = req.nextUrl.pathname.split('/') + const hostnameIndex = pathSegments.indexOf('hosts') + 1 + const hostname = pathSegments[hostnameIndex] + const page = parseInt(searchParams.get('page') || '1') + const limit = parseInt(searchParams.get('limit') || '50') + const skip = (page - 1) * limit + + if (!hostname) { + return NextResponse.json({ error: '主机名不能为空' }, { status: 400 }) + } + + // 构建查询条件 - 只获取该主机的星标记录 + const whereClause: any = { + isStarred: true, + hostname: hostname + } + + // 获取星标记录 + const records = await prisma.record.findMany({ + where: whereClause, + include: { + windows: true, + screenshots: true, + host: { + select: { + hostname: true, + lastUpdate: true + } + } + }, + orderBy: { + timestamp: 'desc' + }, + skip, + take: limit + }) + + // 获取总数 + const total = await prisma.record.count({ + where: whereClause + }) + const serializedRecords = records.map(record => ({ + ...record, + windows: record.windows.map(window => ({ + ...window, + memory: window.memory.toString() + })) + })) + return NextResponse.json({ + records: serializedRecords, + total, + page, + limit, + totalPages: Math.ceil(total / limit) + }) + + } catch (error) { + console.error('获取星标记录失败:', error) + return NextResponse.json({ error: '获取星标记录失败' }, { status: 500 }) + } +} + +// 批量操作星标记录 +async function handlePostStarredRecords(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 body = await req.json() + const { action, recordIds } = body + + if (!action || !recordIds || !Array.isArray(recordIds)) { + return NextResponse.json({ + error: '请求参数不正确,需要 action 和 recordIds 数组' + }, { status: 400 }) + } + + if (!['star', 'unstar'].includes(action)) { + return NextResponse.json({ + error: '无效的操作类型,只支持 star 或 unstar' + }, { status: 400 }) + } + + const isStarred = action === 'star' + + // 批量更新记录的星标状态 + const result = await prisma.record.updateMany({ + where: { + id: { in: recordIds }, + hostname: hostname + }, + data: { + isStarred: isStarred + } + }) + + return NextResponse.json({ + success: true, + updatedCount: result.count, + action: action, + recordIds: recordIds + }) + + } catch (error) { + console.error('批量操作星标记录失败:', error) + return NextResponse.json({ error: '批量操作星标记录失败' }, { status: 500 }) + } +} + +export const GET = withCors(handleGetStarredRecords) +export const POST = withCors(handlePostStarredRecords) diff --git a/lib/schedule/compressPics.ts b/lib/schedule/compressPics.ts index fcd4498..f25488c 100644 --- a/lib/schedule/compressPics.ts +++ b/lib/schedule/compressPics.ts @@ -24,18 +24,12 @@ export async function compressPics(hostname: string, startTime: Date, endTime: D } }) - // 有两种情况不进行处理: - // 1. 每个记录中有多于一个截图 - // 2. 时间段内截图有多种分辨率 - const picNames = records.map(record => { return record.screenshots.map(screenshot => screenshot.objectName) }).flat(); const picBuffers = (await Promise.all(picNames.map(name => getFileByObjectName(name)))).filter(buffer => buffer !== null && buffer !== undefined); - // 先默认不存在null吧 - const vBuffer = await compressImagesToAv1Video(picBuffers) storeFile(vBuffer, 'test.mp4', 'video/mp4', 'other', hostname) @@ -44,4 +38,4 @@ export async function compressPics(hostname: string, startTime: Date, endTime: D //DESKTOP-JHHNH9C startTime=1751104800 1751108400 -compressPics('DESKTOP-JHHNH9C', new Date(1751104800000), new Date(1751108400000)) \ No newline at end of file +/* compressPics('DESKTOP-JHHNH9C', new Date(1751104800000), new Date(1751108400000)) */ \ No newline at end of file diff --git a/prisma/migrations/20250702014112_add_record_starred/migration.sql b/prisma/migrations/20250702014112_add_record_starred/migration.sql new file mode 100644 index 0000000..ac951bb --- /dev/null +++ b/prisma/migrations/20250702014112_add_record_starred/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "records" ADD COLUMN "isStarred" BOOLEAN NOT NULL DEFAULT false; + +-- CreateIndex +CREATE INDEX "records_isStarred_idx" ON "records"("isStarred"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e1dd118..59b5a02 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -31,6 +31,7 @@ model Record { id String @id @default(cuid()) hostname String timestamp DateTime @default(now()) + isStarred Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -41,6 +42,7 @@ model Record { @@index([hostname, timestamp]) @@index([timestamp]) + @@index([isStarred]) @@map("records") } @@ -49,7 +51,7 @@ model Window { recordId String title String path String - memory Int + memory BigInt // Relations record Record @relation(fields: [recordId], references: [id], onDelete: Cascade)