feat: now support make any record starred

This commit is contained in:
feie9454 2025-07-02 21:39:41 +08:00
parent 6e2976e234
commit acde78f4a0
8 changed files with 618 additions and 129 deletions

View File

@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { withCors } from '@/lib/middleware'
// 切换记录的星标状态
async function handleToggleStar(req: NextRequest) {
try {
// 从 URL 路径中提取 recordId
const pathSegments = req.nextUrl.pathname.split('/')
const recordIdIndex = pathSegments.indexOf('records') + 1
const recordId = pathSegments[recordIdIndex]
if (!recordId) {
return NextResponse.json({ error: '缺少记录ID' }, { status: 400 })
}
// 获取当前记录
const record = await prisma.record.findUnique({
where: { id: recordId },
select: { id: true, isStarred: true }
})
if (!record) {
return NextResponse.json({ error: '记录不存在' }, { status: 404 })
}
// 切换星标状态
const updatedRecord = await prisma.record.update({
where: { id: recordId },
data: { isStarred: !record.isStarred },
select: { id: true, isStarred: true }
})
return NextResponse.json({
success: true,
recordId: updatedRecord.id,
isStarred: updatedRecord.isStarred
})
} catch (error) {
console.error('切换星标状态失败:', error)
return NextResponse.json({ error: '操作失败' }, { status: 500 })
}
}
export const PATCH = withCors(handleToggleStar)

View File

@ -108,13 +108,6 @@ export default function TimelineSlider({
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]);
// 指针按下处理函数

View File

@ -2,16 +2,17 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { useParams, useRouter } from 'next/navigation';
import {
ArrowLeft,
RefreshCcw,
ChevronDown,
User,
Globe,
Link,
Clipboard,
ShieldAlert,
Loader2
import {
ArrowLeft,
RefreshCcw,
ChevronDown,
User,
Globe,
Link,
Clipboard,
ShieldAlert,
Loader2,
Star
} from 'lucide-react';
import { format, differenceInMinutes } from 'date-fns';
import TimelineSlider from './components/TimelineSlider';
@ -30,7 +31,9 @@ interface Window {
}
interface ScreenRecord {
id: string;
timestamp: string;
isStarred: boolean;
windows: Window[];
screenshots: Screenshot[];
}
@ -84,7 +87,7 @@ 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[]>([]);
@ -94,7 +97,12 @@ export default function HostDetail() {
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);
@ -102,24 +110,24 @@ export default function HostDetail() {
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 [autoPlaySpeed, setAutoPlaySpeed] = useState(100);
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 formatMemory = (bytes: number | string) => {
bytes = typeof bytes === 'string' ? parseInt(bytes, 10) : bytes;
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
@ -153,6 +161,112 @@ export default function HostDetail() {
}
};
// 获取星标记录
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 {
@ -188,7 +302,7 @@ export default function HostDetail() {
setRecords(data.records.reverse());
setLastUpdate(data.lastUpdate);
setTimeRange({ min: startTime * 1000, max: endTime * 1000 });
requestAnimationFrame(() => {
setSelectedRecord(data.records[0]);
});
@ -202,31 +316,31 @@ export default function HostDetail() {
// 组织凭据数据按用户分组
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({
@ -234,7 +348,7 @@ export default function HostDetail() {
credentials: browserCreds
});
});
result.push({
username,
browsers,
@ -242,7 +356,7 @@ export default function HostDetail() {
lastSyncTime: latestSyncTime
});
});
return result;
}, [credentials]);
@ -291,7 +405,7 @@ export default function HostDetail() {
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;
@ -329,16 +443,10 @@ export default function HostDetail() {
}));
}, [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);
@ -351,7 +459,7 @@ export default function HostDetail() {
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) {
@ -359,7 +467,7 @@ export default function HostDetail() {
closestRecord = record;
}
}
// 使用 setTimeout 避免在渲染过程中更新状态
setTimeout(() => {
setSelectedRecord(closestRecord);
@ -430,12 +538,13 @@ export default function HostDetail() {
};
const startAutoPlayTimer = useCallback(() => {
if (allImagesLoaded && autoPlay) {
if (autoPlay) {
autoPlayTimer.current = setTimeout(() => {
nextFrame();
}, autoPlaySpeed);
}
}, [allImagesLoaded, autoPlay, autoPlaySpeed, currentFrameIndex, records.length]);
}, [autoPlay, autoPlaySpeed, currentFrameIndex, records.length]);
const stopAutoPlayTimer = () => {
if (autoPlayTimer.current) {
@ -452,12 +561,54 @@ export default function HostDetail() {
}
};
// 键盘快捷键处理
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]);
// 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();
@ -486,22 +637,23 @@ export default function HostDetail() {
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) {
if (autoPlay) {
startAutoPlayTimer();
}
}, [selectedRecord, records, autoPlay, allImagesLoaded, startAutoPlayTimer]);
}, [selectedRecord, records, autoPlay, startAutoPlayTimer]);
useEffect(() => {
if (autoPlay && allImagesLoaded) {
if (autoPlay) {
stopAutoPlayTimer();
startAutoPlayTimer();
}
}, [autoPlay, allImagesLoaded, autoPlaySpeed, startAutoPlayTimer]);
}, [autoPlay, autoPlaySpeed, startAutoPlayTimer]);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 overflow-x-hidden">
@ -509,7 +661,7 @@ export default function HostDetail() {
{/* 头部导航 */}
<div className="flex items-center justify-between mb-8">
<div>
<button
<button
onClick={() => router.back()}
className="inline-flex items-center text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
>
@ -530,23 +682,31 @@ export default function HostDetail() {
{/* 选项卡导航 */}
<div className="mb-6 border-b border-gray-200 dark:border-gray-700">
<nav className="-mb-px flex space-x-8">
<button
<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'
}`}
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
<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'
}`}
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>
@ -560,20 +720,20 @@ export default function HostDetail() {
<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
<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}
<RecordDatePicker
value={selectedDate}
dailyCounts={dailyCounts}
onChange={setSelectedDate}
/>
{hourlySegments.length > 0 ? (
<div className="mt-4">
<TimelineSlider
@ -595,7 +755,7 @@ export default function HostDetail() {
<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
<button
onClick={() => {
const selectedSec = Math.floor(hourlySliderValue / 3600000) * 3600;
fetchHourlyRecords(selectedSec, selectedSec + 3600);
@ -605,7 +765,7 @@ export default function HostDetail() {
<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}
@ -624,10 +784,12 @@ export default function HostDetail() {
{/* 图片预览区域及控制按钮 */}
{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
<div
className="relative w-full"
style={{ aspectRatio: imageAspectRatio }}
>
@ -638,7 +800,7 @@ export default function HostDetail() {
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>
@ -648,14 +810,14 @@ export default function HostDetail() {
</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
<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"
@ -664,26 +826,96 @@ export default function HostDetail() {
))}
{/* 控制按钮区域 */}
<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 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>
{/* 窗口信息 */}
@ -714,13 +946,107 @@ export default function HostDetail() {
</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
<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"
>
@ -751,7 +1077,7 @@ export default function HostDetail() {
{/* 用户信息头部 */}
<div className="bg-gray-50 dark:bg-gray-700 px-4 py-3">
<div className="flex items-center justify-between">
<div
<div
className="flex items-center cursor-pointer"
onClick={() => toggleUserExpanded(userGroup.username)}
>
@ -760,18 +1086,10 @@ export default function HostDetail() {
<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 className="ml-2 text-xs text-gray-400 dark:text-gray-500">
{userGroup.lastSyncTime ? `最后同步: ${formatDate(userGroup.lastSyncTime, 'short')}` : '未同步'}
</div>
)}
</div>
</div>
</div>
@ -779,12 +1097,12 @@ export default function HostDetail() {
{expandedUsers.includes(userGroup.username) && (
<div className="divide-y divide-gray-100 dark:divide-gray-600">
{userGroup.browsers.map((browser) => (
<div
<div
key={`${userGroup.username}-${browser.name}`}
className="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700"
>
{/* 浏览器标题 */}
<div
<div
className="flex items-center mb-2 cursor-pointer"
onClick={() => toggleBrowserExpanded(`${userGroup.username}-${browser.name}`)}
>
@ -796,9 +1114,8 @@ export default function HostDetail() {
</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' : ''
}`}
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>
@ -822,9 +1139,8 @@ export default function HostDetail() {
</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' : ''
}`}
className={`h-4 w-4 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${expandedCredentials.includes(cred._id) ? 'rotate-180' : ''
}`}
/>
</div>
@ -857,14 +1173,14 @@ export default function HostDetail() {
<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
onClick={() =>
revealedPasswords.includes(`${cred._id}-${pwdIndex}`)
? null
: revealPassword(`${cred._id}-${pwdIndex}`)
}
>
{revealedPasswords.includes(`${cred._id}-${pwdIndex}`)
? pwd.value
{revealedPasswords.includes(`${cred._id}-${pwdIndex}`)
? pwd.value
: '••••••••'
}
</span>

View File

@ -243,10 +243,19 @@ async function handleGetScreenshots(req: NextRequest) {
return NextResponse.json({ error: '未找到主机记录' }, { status: 404 })
}
// Convert BigInt to string in windows.memory field
const serializedRecords = records.map(record => ({
...record,
windows: record.windows.map(window => ({
...window,
memory: window.memory.toString()
}))
}))
return NextResponse.json({
hostname,
lastUpdate: host.lastUpdate,
records,
records: serializedRecords,
total: records.length
})

View File

@ -0,0 +1,124 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { withCors } from '@/lib/middleware'
// 获取指定主机的星标记录
async function handleGetStarredRecords(req: NextRequest) {
try {
const { searchParams } = req.nextUrl
const pathSegments = req.nextUrl.pathname.split('/')
const hostnameIndex = pathSegments.indexOf('hosts') + 1
const hostname = pathSegments[hostnameIndex]
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '50')
const skip = (page - 1) * limit
if (!hostname) {
return NextResponse.json({ error: '主机名不能为空' }, { status: 400 })
}
// 构建查询条件 - 只获取该主机的星标记录
const whereClause: any = {
isStarred: true,
hostname: hostname
}
// 获取星标记录
const records = await prisma.record.findMany({
where: whereClause,
include: {
windows: true,
screenshots: true,
host: {
select: {
hostname: true,
lastUpdate: true
}
}
},
orderBy: {
timestamp: 'desc'
},
skip,
take: limit
})
// 获取总数
const total = await prisma.record.count({
where: whereClause
})
const serializedRecords = records.map(record => ({
...record,
windows: record.windows.map(window => ({
...window,
memory: window.memory.toString()
}))
}))
return NextResponse.json({
records: serializedRecords,
total,
page,
limit,
totalPages: Math.ceil(total / limit)
})
} catch (error) {
console.error('获取星标记录失败:', error)
return NextResponse.json({ error: '获取星标记录失败' }, { status: 500 })
}
}
// 批量操作星标记录
async function handlePostStarredRecords(req: NextRequest) {
try {
const pathSegments = req.nextUrl.pathname.split('/')
const hostnameIndex = pathSegments.indexOf('hosts') + 1
const hostname = pathSegments[hostnameIndex]
if (!hostname) {
return NextResponse.json({ error: '主机名不能为空' }, { status: 400 })
}
const body = await req.json()
const { action, recordIds } = body
if (!action || !recordIds || !Array.isArray(recordIds)) {
return NextResponse.json({
error: '请求参数不正确,需要 action 和 recordIds 数组'
}, { status: 400 })
}
if (!['star', 'unstar'].includes(action)) {
return NextResponse.json({
error: '无效的操作类型,只支持 star 或 unstar'
}, { status: 400 })
}
const isStarred = action === 'star'
// 批量更新记录的星标状态
const result = await prisma.record.updateMany({
where: {
id: { in: recordIds },
hostname: hostname
},
data: {
isStarred: isStarred
}
})
return NextResponse.json({
success: true,
updatedCount: result.count,
action: action,
recordIds: recordIds
})
} catch (error) {
console.error('批量操作星标记录失败:', error)
return NextResponse.json({ error: '批量操作星标记录失败' }, { status: 500 })
}
}
export const GET = withCors(handleGetStarredRecords)
export const POST = withCors(handlePostStarredRecords)

View File

@ -24,18 +24,12 @@ export async function compressPics(hostname: string, startTime: Date, endTime: D
}
})
// 有两种情况不进行处理:
// 1. 每个记录中有多于一个截图
// 2. 时间段内截图有多种分辨率
const picNames = records.map(record => {
return record.screenshots.map(screenshot => screenshot.objectName)
}).flat();
const picBuffers = (await Promise.all(picNames.map(name => getFileByObjectName(name)))).filter(buffer => buffer !== null && buffer !== undefined);
// 先默认不存在null吧
const vBuffer = await compressImagesToAv1Video(picBuffers)
storeFile(vBuffer, 'test.mp4', 'video/mp4', 'other', hostname)
@ -44,4 +38,4 @@ export async function compressPics(hostname: string, startTime: Date, endTime: D
//DESKTOP-JHHNH9C startTime=1751104800 1751108400
compressPics('DESKTOP-JHHNH9C', new Date(1751104800000), new Date(1751108400000))
/* compressPics('DESKTOP-JHHNH9C', new Date(1751104800000), new Date(1751108400000)) */

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "records" ADD COLUMN "isStarred" BOOLEAN NOT NULL DEFAULT false;
-- CreateIndex
CREATE INDEX "records_isStarred_idx" ON "records"("isStarred");

View File

@ -31,6 +31,7 @@ model Record {
id String @id @default(cuid())
hostname String
timestamp DateTime @default(now())
isStarred Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -41,6 +42,7 @@ model Record {
@@index([hostname, timestamp])
@@index([timestamp])
@@index([isStarred])
@@map("records")
}
@ -49,7 +51,7 @@ model Window {
recordId String
title String
path String
memory Int
memory BigInt
// Relations
record Record @relation(fields: [recordId], references: [id], onDelete: Cascade)