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('pointerup', pointerUpHandler);
|
||||
|
||||
// 最终确保调用onChange通知最新的值
|
||||
setInternalValue(currentValue => {
|
||||
const snapped = getSnapValue(currentValue);
|
||||
lastNotifiedValue.current = snapped;
|
||||
onChange(snapped);
|
||||
return snapped;
|
||||
});
|
||||
}, [getSnapValue, onChange, pointerMoveHandler]);
|
||||
|
||||
// 指针按下处理函数
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
})
|
||||
|
||||
|
||||
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 => {
|
||||
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)) */
|
||||
@ -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())
|
||||
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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user