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[dates.length - 1]); } } }, [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 activeSegments = hourlySegments.filter(s => s.active); const activeSegment = activeSegments.length > 0 ? activeSegments[activeSegments.length - 1] : undefined; 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)}
))}
)}
); }