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('pointermove', pointerMoveHandler);
window.removeEventListener('pointerup', pointerUpHandler); window.removeEventListener('pointerup', pointerUpHandler);
// 最终确保调用onChange通知最新的值
setInternalValue(currentValue => {
const snapped = getSnapValue(currentValue);
lastNotifiedValue.current = snapped;
onChange(snapped);
return snapped;
});
}, [getSnapValue, onChange, pointerMoveHandler]); }, [getSnapValue, onChange, pointerMoveHandler]);
// 指针按下处理函数 // 指针按下处理函数

View File

@ -11,7 +11,8 @@ import {
Link, Link,
Clipboard, Clipboard,
ShieldAlert, ShieldAlert,
Loader2 Loader2,
Star
} from 'lucide-react'; } from 'lucide-react';
import { format, differenceInMinutes } from 'date-fns'; import { format, differenceInMinutes } from 'date-fns';
import TimelineSlider from './components/TimelineSlider'; import TimelineSlider from './components/TimelineSlider';
@ -30,7 +31,9 @@ interface Window {
} }
interface ScreenRecord { interface ScreenRecord {
id: string;
timestamp: string; timestamp: string;
isStarred: boolean;
windows: Window[]; windows: Window[];
screenshots: Screenshot[]; screenshots: Screenshot[];
} }
@ -95,6 +98,11 @@ export default function HostDetail() {
const [showDetailTimeline, setShowDetailTimeline] = useState(false); const [showDetailTimeline, setShowDetailTimeline] = useState(false);
const [lastUpdate, setLastUpdate] = useState<string | null>(null); 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 [credentials, setCredentials] = useState<Credential[]>([]);
const [loadingCredentials, setLoadingCredentials] = useState(false); const [loadingCredentials, setLoadingCredentials] = useState(false);
@ -112,14 +120,14 @@ export default function HostDetail() {
// 播放控制 // 播放控制
const [currentFrameIndex, setCurrentFrameIndex] = useState(-1); const [currentFrameIndex, setCurrentFrameIndex] = useState(-1);
const [autoPlay, setAutoPlay] = useState(false); const [autoPlay, setAutoPlay] = useState(false);
const [autoPlaySpeed, setAutoPlaySpeed] = useState(200); const [autoPlaySpeed, setAutoPlaySpeed] = useState(100);
const [imagesLoadedCount, setImagesLoadedCount] = useState(0); const [imagesLoadedCount, setImagesLoadedCount] = useState(0);
const [imageAspectRatio, setImageAspectRatio] = useState(16 / 9); const [imageAspectRatio, setImageAspectRatio] = useState(16 / 9);
const autoPlayTimer = useRef<NodeJS.Timeout | null>(null); const autoPlayTimer = useRef<NodeJS.Timeout | null>(null);
// 格式化内存大小 // 格式化内存大小
const formatMemory = (bytes: number) => { const formatMemory = (bytes: number | string) => {
if (typeof bytes !== 'number') return '0 B'; bytes = typeof bytes === 'string' ? parseInt(bytes, 10) : bytes;
const units = ['B', 'KB', 'MB', 'GB']; const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes; let size = bytes;
let unitIndex = 0; 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 () => { const fetchCredentials = async () => {
try { try {
@ -329,12 +443,6 @@ export default function HostDetail() {
})); }));
}, [records]); }, [records]);
// 计算属性
const allImagesLoaded = useMemo(() => {
if (!selectedRecord) return false;
return imagesLoadedCount >= selectedRecord.screenshots.length;
}, [selectedRecord, imagesLoadedCount]);
// 事件处理函数 // 事件处理函数
const onHourlySliderChange = (newValue: number) => { const onHourlySliderChange = (newValue: number) => {
const selectedSec = Math.floor(newValue / 3600000) * 3600; const selectedSec = Math.floor(newValue / 3600000) * 3600;
@ -430,12 +538,13 @@ export default function HostDetail() {
}; };
const startAutoPlayTimer = useCallback(() => { const startAutoPlayTimer = useCallback(() => {
if (allImagesLoaded && autoPlay) {
if (autoPlay) {
autoPlayTimer.current = setTimeout(() => { autoPlayTimer.current = setTimeout(() => {
nextFrame(); nextFrame();
}, autoPlaySpeed); }, autoPlaySpeed);
} }
}, [allImagesLoaded, autoPlay, autoPlaySpeed, currentFrameIndex, records.length]); }, [autoPlay, autoPlaySpeed, currentFrameIndex, records.length]);
const stopAutoPlayTimer = () => { const stopAutoPlayTimer = () => {
if (autoPlayTimer.current) { 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 // Effects
useEffect(() => { useEffect(() => {
fetchTimeDistribution(); fetchTimeDistribution();
fetchCredentials(); fetchCredentials();
}, [hostname]); }, [hostname]);
useEffect(() => {
if (activeTab === 'starred') {
fetchStarredRecords();
}
}, [activeTab, hostname]);
useEffect(() => { useEffect(() => {
if (!selectedDate && Object.keys(dailyCounts).length > 0) { if (!selectedDate && Object.keys(dailyCounts).length > 0) {
const dates = Object.keys(dailyCounts).sort(); const dates = Object.keys(dailyCounts).sort();
@ -486,22 +637,23 @@ export default function HostDetail() {
useEffect(() => { useEffect(() => {
setImagesLoadedCount(0); setImagesLoadedCount(0);
if (selectedRecord) { if (selectedRecord) {
const idx = records.findIndex(rec => rec.timestamp === selectedRecord.timestamp); const idx = records.findIndex(rec => rec.timestamp === selectedRecord.timestamp);
setCurrentFrameIndex(idx); setCurrentFrameIndex(idx);
setDetailedSliderValue(new Date(selectedRecord.timestamp).getTime()); setDetailedSliderValue(new Date(selectedRecord.timestamp).getTime());
} }
if (autoPlay && allImagesLoaded) { if (autoPlay) {
startAutoPlayTimer(); startAutoPlayTimer();
} }
}, [selectedRecord, records, autoPlay, allImagesLoaded, startAutoPlayTimer]); }, [selectedRecord, records, autoPlay, startAutoPlayTimer]);
useEffect(() => { useEffect(() => {
if (autoPlay && allImagesLoaded) { if (autoPlay) {
stopAutoPlayTimer(); stopAutoPlayTimer();
startAutoPlayTimer(); startAutoPlayTimer();
} }
}, [autoPlay, allImagesLoaded, autoPlaySpeed, startAutoPlayTimer]); }, [autoPlay, autoPlaySpeed, startAutoPlayTimer]);
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 overflow-x-hidden"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900 overflow-x-hidden">
@ -532,21 +684,29 @@ export default function HostDetail() {
<nav className="-mb-px flex space-x-8"> <nav className="-mb-px flex space-x-8">
<button <button
onClick={() => setActiveTab('screenshots')} onClick={() => setActiveTab('screenshots')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${ className={`py-2 px-1 border-b-2 font-medium text-sm ${activeTab === 'screenshots'
activeTab === 'screenshots' ? 'border-blue-600 text-blue-600'
? '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'
: '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 <button
onClick={() => setActiveTab('credentials')} onClick={() => setActiveTab('credentials')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${ className={`py-2 px-1 border-b-2 font-medium text-sm ${activeTab === 'credentials'
activeTab === 'credentials' ? 'border-blue-600 text-blue-600'
? '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'
: '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>
@ -624,6 +784,8 @@ export default function HostDetail() {
{/* 图片预览区域及控制按钮 */} {/* 图片预览区域及控制按钮 */}
{selectedRecord && ( {selectedRecord && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
{/* 图片预览区域 */} {/* 图片预览区域 */}
{selectedRecord.screenshots.map((screenshot, sIndex) => ( {selectedRecord.screenshots.map((screenshot, sIndex) => (
<div key={sIndex} className="relative mb-6"> <div key={sIndex} className="relative mb-6">
@ -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"> <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">
<label className="flex items-center space-x-1"> <div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<span className="text-sm text-gray-600 dark:text-gray-400"></span>
<input {/* 速度控制 */}
type="number" <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">
value={autoPlaySpeed} <span className="text-sm font-medium text-gray-700 dark:text-gray-300 min-w-max"></span>
onChange={(e) => setAutoPlaySpeed(Number(e.target.value))} <div className="flex items-center gap-2">
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" <input
min="100" type="number"
max="2000" value={autoPlaySpeed}
step="100" 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"
<span className="text-sm text-gray-600 dark:text-gray-400">ms</span> min="0"
</label> max="2000"
<button step="50"
onClick={toggleAutoPlay} />
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded focus:outline-none" <span className="text-sm text-gray-500 dark:text-gray-400 min-w-max"></span>
> </div>
{!autoPlay ? '播放' : '暂停'} </div>
</button>
{/* 播放控制按钮 */}
<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>
{/* 窗口信息 */} {/* 窗口信息 */}
@ -714,6 +946,100 @@ export default function HostDetail() {
</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' && ( {activeTab === 'credentials' && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
@ -760,18 +1086,10 @@ export default function HostDetail() {
<div className="ml-3 text-sm text-gray-500 dark:text-gray-400"> <div className="ml-3 text-sm text-gray-500 dark:text-gray-400">
({userGroup.browsers.length} , {userGroup.total} ) ({userGroup.browsers.length} , {userGroup.total} )
</div> </div>
<ChevronDown <div className="ml-2 text-xs text-gray-400 dark:text-gray-500">
className={`h-5 w-5 text-gray-500 dark:text-gray-400 ml-2 transition-transform duration-200 ${ {userGroup.lastSyncTime ? `最后同步: ${formatDate(userGroup.lastSyncTime, 'short')}` : '未同步'}
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>
</div> </div>
</div> </div>
@ -796,9 +1114,8 @@ export default function HostDetail() {
</span> </span>
</div> </div>
<ChevronDown <ChevronDown
className={`h-4 w-4 text-gray-500 dark:text-gray-400 ml-2 transition-transform duration-200 ${ 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' : ''
expandedBrowsers.includes(`${userGroup.username}-${browser.name}`) ? 'rotate-180' : '' }`}
}`}
/> />
</div> </div>
@ -822,9 +1139,8 @@ export default function HostDetail() {
</div> </div>
</div> </div>
<ChevronDown <ChevronDown
className={`h-4 w-4 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${ className={`h-4 w-4 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${expandedCredentials.includes(cred._id) ? 'rotate-180' : ''
expandedCredentials.includes(cred._id) ? 'rotate-180' : '' }`}
}`}
/> />
</div> </div>

View File

@ -243,10 +243,19 @@ async function handleGetScreenshots(req: NextRequest) {
return NextResponse.json({ error: '未找到主机记录' }, { status: 404 }) 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({ return NextResponse.json({
hostname, hostname,
lastUpdate: host.lastUpdate, lastUpdate: host.lastUpdate,
records, records: serializedRecords,
total: records.length 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 => { const picNames = records.map(record => {
return record.screenshots.map(screenshot => screenshot.objectName) return record.screenshots.map(screenshot => screenshot.objectName)
}).flat(); }).flat();
const picBuffers = (await Promise.all(picNames.map(name => getFileByObjectName(name)))).filter(buffer => buffer !== null && buffer !== undefined); const picBuffers = (await Promise.all(picNames.map(name => getFileByObjectName(name)))).filter(buffer => buffer !== null && buffer !== undefined);
// 先默认不存在null吧
const vBuffer = await compressImagesToAv1Video(picBuffers) const vBuffer = await compressImagesToAv1Video(picBuffers)
storeFile(vBuffer, 'test.mp4', 'video/mp4', 'other', hostname) 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 //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()) id String @id @default(cuid())
hostname String hostname String
timestamp DateTime @default(now()) timestamp DateTime @default(now())
isStarred Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@ -41,6 +42,7 @@ model Record {
@@index([hostname, timestamp]) @@index([hostname, timestamp])
@@index([timestamp]) @@index([timestamp])
@@index([isStarred])
@@map("records") @@map("records")
} }
@ -49,7 +51,7 @@ model Window {
recordId String recordId String
title String title String
path String path String
memory Int memory BigInt
// Relations // Relations
record Record @relation(fields: [recordId], references: [id], onDelete: Cascade) record Record @relation(fields: [recordId], references: [id], onDelete: Cascade)