feat: now support make any record starred
This commit is contained in:
parent
6e2976e234
commit
acde78f4a0
46
app/api/records/[recordId]/star/route.ts
Normal file
46
app/api/records/[recordId]/star/route.ts
Normal 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)
|
||||||
@ -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]);
|
||||||
|
|
||||||
// 指针按下处理函数
|
// 指针按下处理函数
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
124
app/hosts/[hostname]/starred/route.ts
Normal file
124
app/hosts/[hostname]/starred/route.ts
Normal 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)
|
||||||
@ -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)) */
|
||||||
@ -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");
|
||||||
@ -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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user