Compare commits
No commits in common. "8d8d67ab9e406646acb67beaf71cafc0f0f59e70" and "5fa9d39c2f70221b4209361aa223ebbe148efc16" have entirely different histories.
8d8d67ab9e
...
5fa9d39c2f
@ -1,44 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { config } from '@/lib/config'
|
|
||||||
import { createToken, AUTH_COOKIE_NAME } from '@/lib/auth'
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await req.json()
|
|
||||||
const { username, password } = body
|
|
||||||
|
|
||||||
if (!username || !password) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '用户名和密码不能为空' },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (username === config.auth.username && password === config.auth.password) {
|
|
||||||
const token = await createToken(username)
|
|
||||||
|
|
||||||
const response = NextResponse.json({ success: true })
|
|
||||||
|
|
||||||
response.cookies.set(AUTH_COOKIE_NAME, token, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: 'lax',
|
|
||||||
maxAge: 60 * 60 * 24, // 1 day
|
|
||||||
path: '/'
|
|
||||||
})
|
|
||||||
|
|
||||||
return response
|
|
||||||
} else {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '用户名或密码错误' },
|
|
||||||
{ status: 401 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Login error:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '服务器内部错误' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
export async function POST() {
|
|
||||||
const response = NextResponse.json({ success: true });
|
|
||||||
response.cookies.delete('auth_token');
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
@ -1,300 +0,0 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
|
||||||
import {
|
|
||||||
RefreshCcw,
|
|
||||||
ChevronDown,
|
|
||||||
User,
|
|
||||||
Globe,
|
|
||||||
Link,
|
|
||||||
Clipboard,
|
|
||||||
ShieldAlert,
|
|
||||||
Loader2
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Credential, UserGroup, BrowserGroup } from '../types';
|
|
||||||
import { formatDate } from '../utils';
|
|
||||||
|
|
||||||
interface CredentialsTabProps {
|
|
||||||
hostname: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CredentialsTab({ hostname }: CredentialsTabProps) {
|
|
||||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
|
||||||
const [loadingCredentials, setLoadingCredentials] = useState(false);
|
|
||||||
const [expandedUsers, setExpandedUsers] = useState<string[]>([]);
|
|
||||||
const [expandedBrowsers, setExpandedBrowsers] = useState<string[]>([]);
|
|
||||||
const [expandedCredentials, setExpandedCredentials] = useState<string[]>([]);
|
|
||||||
const [revealedPasswords, setRevealedPasswords] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const fetchCredentials = async () => {
|
|
||||||
try {
|
|
||||||
setLoadingCredentials(true);
|
|
||||||
const response = await fetch(`/hosts/${hostname}/credentials`);
|
|
||||||
if (!response.ok) throw new Error('获取凭据数据失败');
|
|
||||||
const data = await response.json();
|
|
||||||
setCredentials(data);
|
|
||||||
|
|
||||||
if (data.length > 0) {
|
|
||||||
const firstUser = data[0].username;
|
|
||||||
if (!expandedUsers.includes(firstUser)) {
|
|
||||||
setExpandedUsers([firstUser]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取凭据数据失败:', error);
|
|
||||||
} finally {
|
|
||||||
setLoadingCredentials(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchCredentials();
|
|
||||||
}, [hostname]);
|
|
||||||
|
|
||||||
const credentialsByUser = useMemo<UserGroup[]>(() => {
|
|
||||||
const userMap = new Map<string, Credential[]>();
|
|
||||||
|
|
||||||
credentials.forEach(cred => {
|
|
||||||
if (!userMap.has(cred.username)) {
|
|
||||||
userMap.set(cred.username, []);
|
|
||||||
}
|
|
||||||
userMap.get(cred.username)!.push(cred);
|
|
||||||
});
|
|
||||||
|
|
||||||
const result: UserGroup[] = [];
|
|
||||||
|
|
||||||
userMap.forEach((userCreds, username) => {
|
|
||||||
const latestSyncTime = userCreds.reduce((latest, cred) => {
|
|
||||||
const credTime = new Date(cred.lastSyncTime);
|
|
||||||
return credTime > latest ? credTime : latest;
|
|
||||||
}, new Date(0)).toISOString();
|
|
||||||
|
|
||||||
const browserMap = new Map<string, Credential[]>();
|
|
||||||
|
|
||||||
userCreds.forEach(cred => {
|
|
||||||
if (!browserMap.has(cred.browser)) {
|
|
||||||
browserMap.set(cred.browser, []);
|
|
||||||
}
|
|
||||||
browserMap.get(cred.browser)!.push(cred);
|
|
||||||
});
|
|
||||||
|
|
||||||
const browsers: BrowserGroup[] = [];
|
|
||||||
browserMap.forEach((browserCreds, browserName) => {
|
|
||||||
browsers.push({
|
|
||||||
name: browserName,
|
|
||||||
credentials: browserCreds
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
result.push({
|
|
||||||
username,
|
|
||||||
browsers,
|
|
||||||
total: userCreds.length,
|
|
||||||
lastSyncTime: latestSyncTime
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}, [credentials]);
|
|
||||||
|
|
||||||
const toggleUserExpanded = (username: string) => {
|
|
||||||
if (expandedUsers.includes(username)) {
|
|
||||||
setExpandedUsers(expandedUsers.filter(u => u !== username));
|
|
||||||
} else {
|
|
||||||
setExpandedUsers([...expandedUsers, username]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleBrowserExpanded = (browserKey: string) => {
|
|
||||||
if (expandedBrowsers.includes(browserKey)) {
|
|
||||||
setExpandedBrowsers(expandedBrowsers.filter(b => b !== browserKey));
|
|
||||||
} else {
|
|
||||||
setExpandedBrowsers([...expandedBrowsers, browserKey]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleCredentialExpanded = (credId: string) => {
|
|
||||||
if (expandedCredentials.includes(credId)) {
|
|
||||||
setExpandedCredentials(expandedCredentials.filter(c => c !== credId));
|
|
||||||
} else {
|
|
||||||
setExpandedCredentials([...expandedCredentials, credId]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const revealPassword = (passwordKey: string) => {
|
|
||||||
if (!revealedPasswords.includes(passwordKey)) {
|
|
||||||
setRevealedPasswords([...revealedPasswords, passwordKey]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyToClipboard = async (text: string) => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('复制失败:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<h2 className="text-xl font-medium text-gray-900 dark:text-white">凭据信息</h2>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<button
|
|
||||||
onClick={fetchCredentials}
|
|
||||||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded focus:outline-none flex items-center"
|
|
||||||
>
|
|
||||||
<RefreshCcw className={`h-4 w-4 mr-1 ${loadingCredentials ? 'animate-spin' : ''}`} />
|
|
||||||
刷新
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loadingCredentials ? (
|
|
||||||
<div className="flex justify-center py-12">
|
|
||||||
<div className="animate-pulse flex flex-col items-center">
|
|
||||||
<Loader2 className="h-8 w-8 text-blue-600 animate-spin" />
|
|
||||||
<p className="mt-2 text-gray-500 dark:text-gray-400">加载凭据信息...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : credentialsByUser.length === 0 ? (
|
|
||||||
<div className="py-12 flex flex-col items-center justify-center">
|
|
||||||
<ShieldAlert className="h-12 w-12 text-gray-400 mb-3" />
|
|
||||||
<p className="text-gray-500 dark:text-gray-400 mb-1">没有找到任何凭据信息</p>
|
|
||||||
<p className="text-sm text-gray-400 dark:text-gray-500">可能是该主机尚未上报凭据数据</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{credentialsByUser.map((userGroup, index) => (
|
|
||||||
<div key={index} className="border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden">
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-700 px-4 py-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div
|
|
||||||
className="flex items-center cursor-pointer"
|
|
||||||
onClick={() => toggleUserExpanded(userGroup.username)}
|
|
||||||
>
|
|
||||||
<User className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
|
|
||||||
<h3 className="font-medium text-gray-900 dark:text-white">{userGroup.username}</h3>
|
|
||||||
<div className="ml-3 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
({userGroup.browsers.length} 个浏览器, {userGroup.total} 个凭据)
|
|
||||||
</div>
|
|
||||||
<div className="ml-2 text-xs text-gray-400 dark:text-gray-500">
|
|
||||||
{userGroup.lastSyncTime ? `最后同步: ${formatDate(userGroup.lastSyncTime, 'short')}` : '未同步'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{expandedUsers.includes(userGroup.username) && (
|
|
||||||
<div className="divide-y divide-gray-100 dark:divide-gray-600">
|
|
||||||
{userGroup.browsers.map((browser) => (
|
|
||||||
<div
|
|
||||||
key={`${userGroup.username}-${browser.name}`}
|
|
||||||
className="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex items-center mb-2 cursor-pointer"
|
|
||||||
onClick={() => toggleBrowserExpanded(`${userGroup.username}-${browser.name}`)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Globe className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-2" />
|
|
||||||
<span className="font-medium text-gray-800 dark:text-gray-200">{browser.name}</span>
|
|
||||||
<span className="ml-2 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
({browser.credentials.length} 个站点)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ChevronDown
|
|
||||||
className={`h-4 w-4 text-gray-500 dark:text-gray-400 ml-2 transition-transform duration-200 ${expandedBrowsers.includes(`${userGroup.username}-${browser.name}`) ? 'rotate-180' : ''
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{expandedBrowsers.includes(`${userGroup.username}-${browser.name}`) && (
|
|
||||||
<div className="pl-6 space-y-3 mt-2">
|
|
||||||
{browser.credentials.map((cred) => {
|
|
||||||
const credentialId = cred._id || cred.id || `${userGroup.username}-${browser.name}-${cred.url}-${cred.login}`;
|
|
||||||
const isExpanded = expandedCredentials.includes(credentialId);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={credentialId}
|
|
||||||
className="border border-gray-200 dark:border-gray-600 rounded-md overflow-hidden"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="bg-gray-50 dark:bg-gray-700 px-3 py-2 flex items-center justify-between cursor-pointer"
|
|
||||||
onClick={() => toggleCredentialExpanded(credentialId)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Link className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-2" />
|
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-white truncate max-w-md">
|
|
||||||
{cred.url}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ChevronDown
|
|
||||||
className={`h-4 w-4 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="bg-white dark:bg-gray-800 px-3 py-2">
|
|
||||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400 w-16">用户名:</span>
|
|
||||||
<span className="text-sm font-medium ml-2 text-gray-900 dark:text-white">{cred.login}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="flex items-center mb-1">
|
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400 w-16">密码历史:</span>
|
|
||||||
<span className="text-xs bg-gray-100 dark:bg-gray-600 text-gray-600 dark:text-gray-300 rounded px-1">
|
|
||||||
{cred.passwords.length} 条记录
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pl-6 space-y-2 mt-1">
|
|
||||||
{cred.passwords.map((pwd, pwdIndex) => {
|
|
||||||
const pwdKey = `${credentialId}-${pwdIndex}`;
|
|
||||||
const revealed = revealedPasswords.includes(pwdKey);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={pwdIndex}
|
|
||||||
className="flex items-center group relative"
|
|
||||||
>
|
|
||||||
<span className="text-xs text-gray-400 dark:text-gray-500 w-24 flex-shrink-0">
|
|
||||||
{formatDate(pwd.timestamp, 'short')}
|
|
||||||
</span>
|
|
||||||
<div className="flex-1 flex items-center">
|
|
||||||
<span
|
|
||||||
className="text-sm font-mono bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white px-2 py-0.5 rounded flex-1 cursor-pointer"
|
|
||||||
onClick={() => revealed ? null : revealPassword(pwdKey)}
|
|
||||||
>
|
|
||||||
{revealed ? pwd.value : '••••••••'}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => copyToClipboard(pwd.value)}
|
|
||||||
className="ml-2 opacity-0 group-hover:opacity-100 text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
|
||||||
>
|
|
||||||
<Clipboard className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,754 +0,0 @@
|
|||||||
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
|
||||||
import {
|
|
||||||
RefreshCcw,
|
|
||||||
Loader2,
|
|
||||||
Star,
|
|
||||||
Video
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import TimelineSlider from './TimelineSlider';
|
|
||||||
import RecordDatePicker from './RecordDatePicker';
|
|
||||||
import { ScreenRecord, TimeDistributionPoint, Segment, Marker } from '../types';
|
|
||||||
import { formatMemory, formatDate } from '../utils';
|
|
||||||
import { useStarToggle } from '../hooks/useStarToggle';
|
|
||||||
|
|
||||||
interface ScreenshotsTabProps {
|
|
||||||
hostname: string;
|
|
||||||
selectedDate: string | null;
|
|
||||||
onDateChange: (date: string | null) => void;
|
|
||||||
jumpRequest: { timestamp: number; recordId?: string } | null;
|
|
||||||
onLastUpdateChange: (date: string | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ScreenshotsTab({
|
|
||||||
hostname,
|
|
||||||
selectedDate,
|
|
||||||
onDateChange,
|
|
||||||
jumpRequest,
|
|
||||||
onLastUpdateChange
|
|
||||||
}: ScreenshotsTabProps) {
|
|
||||||
// 状态管理
|
|
||||||
const [timeDistribution, setTimeDistribution] = useState<TimeDistributionPoint[]>([]);
|
|
||||||
const [records, setRecords] = useState<ScreenRecord[]>([]);
|
|
||||||
const [selectedRecord, setSelectedRecord] = useState<ScreenRecord | null>(null);
|
|
||||||
const [loadingDistribution, setLoadingDistribution] = useState(false);
|
|
||||||
const [loadingRecords, setLoadingRecords] = useState(false);
|
|
||||||
const [showDetailTimeline, setShowDetailTimeline] = useState(false);
|
|
||||||
// const [lastUpdate, setLastUpdate] = useState<string | null>(null); // Lifted up
|
|
||||||
|
|
||||||
// 生成视频相关状态
|
|
||||||
const [generatingVideo, setGeneratingVideo] = useState(false);
|
|
||||||
|
|
||||||
// 滑块状态
|
|
||||||
const [timeRange, setTimeRange] = useState({ min: 0, max: 0 });
|
|
||||||
const [hourlySliderValue, setHourlySliderValue] = useState(0);
|
|
||||||
const [detailedSliderValue, setDetailedSliderValue] = useState(0);
|
|
||||||
|
|
||||||
// 播放控制
|
|
||||||
const [currentFrameIndex, setCurrentFrameIndex] = useState(-1);
|
|
||||||
const [autoPlay, setAutoPlay] = useState(false);
|
|
||||||
const [autoPlaySpeed, setAutoPlaySpeed] = useState(100);
|
|
||||||
const [imagesLoadedCount, setImagesLoadedCount] = useState(0);
|
|
||||||
const [imageAspectRatio, setImageAspectRatio] = useState(16 / 9);
|
|
||||||
const [loadingImageIds, setLoadingImageIds] = useState<Set<string>>(new Set());
|
|
||||||
const autoPlayTimer = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const wheelDeltaAccumulator = useRef(0);
|
|
||||||
|
|
||||||
const { updatingStars, toggleStar } = useStarToggle();
|
|
||||||
|
|
||||||
// 获取时间分布数据
|
|
||||||
const fetchTimeDistribution = async () => {
|
|
||||||
try {
|
|
||||||
setLoadingDistribution(true);
|
|
||||||
const response = await fetch(`/hosts/${hostname}/time-distribution`);
|
|
||||||
if (!response.ok) throw new Error('获取时间分布数据失败');
|
|
||||||
const data = await response.json();
|
|
||||||
setTimeDistribution(data.distribution);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取时间分布数据失败:', error);
|
|
||||||
} finally {
|
|
||||||
setLoadingDistribution(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取小时记录
|
|
||||||
const fetchHourlyRecords = async (startTime: number, endTime: number, options?: { targetRecordId?: string, keepSelection?: boolean }) => {
|
|
||||||
try {
|
|
||||||
setLoadingRecords(true);
|
|
||||||
setShowDetailTimeline(true);
|
|
||||||
const response = await fetch(
|
|
||||||
`/hosts/${hostname}/screenshots?startTime=${startTime}&endTime=${endTime}`
|
|
||||||
);
|
|
||||||
if (!response.ok) throw new Error('获取记录数据失败');
|
|
||||||
const data = await response.json();
|
|
||||||
const newRecords = data.records.reverse();
|
|
||||||
setRecords(newRecords);
|
|
||||||
onLastUpdateChange(data.lastUpdate);
|
|
||||||
setTimeRange({ min: startTime * 1000, max: endTime * 1000 });
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (options?.targetRecordId) {
|
|
||||||
const found = newRecords.find((r: ScreenRecord) => r.id === options.targetRecordId);
|
|
||||||
if (found) {
|
|
||||||
setSelectedRecord(found);
|
|
||||||
} else {
|
|
||||||
// Fallback if exact ID not found, maybe try timestamp match if we had it, but ID is safer
|
|
||||||
setSelectedRecord(newRecords[0]);
|
|
||||||
}
|
|
||||||
} else if (options?.keepSelection && selectedRecord) {
|
|
||||||
const found = newRecords.find((r: ScreenRecord) => r.id === selectedRecord.id) ||
|
|
||||||
newRecords.find((r: ScreenRecord) => r.timestamp === selectedRecord.timestamp);
|
|
||||||
setSelectedRecord(found || newRecords[0]);
|
|
||||||
} else {
|
|
||||||
setSelectedRecord(newRecords[0]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取记录数据失败:', error);
|
|
||||||
} finally {
|
|
||||||
setLoadingRecords(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 生成视频
|
|
||||||
const generateVideo = async () => {
|
|
||||||
if (!showDetailTimeline || !timeRange.min || !timeRange.max) {
|
|
||||||
alert('请先选择时间范围');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setGeneratingVideo(true);
|
|
||||||
|
|
||||||
const startTime = Math.floor(timeRange.min / 1000);
|
|
||||||
const endTime = Math.floor(timeRange.max / 1000);
|
|
||||||
|
|
||||||
const videoUrl = `/api/generate/video?hostname=${encodeURIComponent(hostname)}&startTime=${startTime}&endTime=${endTime}`;
|
|
||||||
|
|
||||||
// 在新标签页中打开视频
|
|
||||||
window.open(videoUrl, '_blank');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('生成视频失败:', error);
|
|
||||||
alert('生成视频失败,请稍后重试');
|
|
||||||
} finally {
|
|
||||||
setGeneratingVideo(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 日历相关计算
|
|
||||||
const dailyCounts = useMemo(() => {
|
|
||||||
const map: Record<string, number> = {};
|
|
||||||
timeDistribution.forEach(point => {
|
|
||||||
const d = new Date(point.timestamp * 1000);
|
|
||||||
const dateStr = `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`;
|
|
||||||
map[dateStr] = (map[dateStr] || 0) + point.count;
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, [timeDistribution]);
|
|
||||||
|
|
||||||
// 小时分布滑块相关计算
|
|
||||||
const hourlyMinTime = useMemo(() => {
|
|
||||||
if (selectedDate) {
|
|
||||||
const [year, month, day] = selectedDate.split('-').map(Number);
|
|
||||||
const dayStart = new Date(year, month - 1, day);
|
|
||||||
return dayStart.getTime();
|
|
||||||
}
|
|
||||||
if (timeDistribution.length === 0) return Date.now();
|
|
||||||
const minSec = Math.min(...timeDistribution.map(d => d.timestamp));
|
|
||||||
return minSec * 1000;
|
|
||||||
}, [selectedDate, timeDistribution]);
|
|
||||||
|
|
||||||
const hourlyMaxTime = useMemo(() => {
|
|
||||||
if (selectedDate) {
|
|
||||||
const [year, month, day] = selectedDate.split('-').map(Number);
|
|
||||||
const dayStart = new Date(year, month - 1, day);
|
|
||||||
const dayEnd = new Date(dayStart);
|
|
||||||
dayEnd.setDate(dayStart.getDate() + 1);
|
|
||||||
return dayEnd.getTime();
|
|
||||||
}
|
|
||||||
if (timeDistribution.length === 0) return Date.now();
|
|
||||||
const maxSec = Math.max(...timeDistribution.map(d => d.timestamp));
|
|
||||||
return (maxSec + 3599) * 1000;
|
|
||||||
}, [selectedDate, timeDistribution]);
|
|
||||||
|
|
||||||
const hourlySegments = useMemo(() => {
|
|
||||||
const segments: Segment[] = [];
|
|
||||||
if (selectedDate) {
|
|
||||||
const [year, month, day] = selectedDate.split('-').map(Number);
|
|
||||||
const dayStart = new Date(year, month - 1, day);
|
|
||||||
const startSec = Math.floor(dayStart.getTime() / 1000);
|
|
||||||
const dayEnd = new Date(dayStart);
|
|
||||||
dayEnd.setDate(dayEnd.getDate() + 1);
|
|
||||||
const endSec = Math.floor(dayEnd.getTime() / 1000);
|
|
||||||
|
|
||||||
for (let t = startSec; t < endSec; t += 3600) {
|
|
||||||
const segStart = t * 1000;
|
|
||||||
const segEnd = (t + 3600) * 1000;
|
|
||||||
const data = timeDistribution.find(d => d.timestamp >= t && d.timestamp < t + 3600);
|
|
||||||
segments.push({
|
|
||||||
start: segStart,
|
|
||||||
end: segEnd,
|
|
||||||
active: !!data && data.count > 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (timeDistribution.length === 0) return segments;
|
|
||||||
const startSec = Math.min(...timeDistribution.map(d => d.timestamp));
|
|
||||||
const endSec = Math.max(...timeDistribution.map(d => d.timestamp));
|
|
||||||
for (let t = startSec; t <= endSec; t += 3600) {
|
|
||||||
const segStart = t * 1000;
|
|
||||||
const segEnd = (t + 3600) * 1000;
|
|
||||||
const data = timeDistribution.find(d => d.timestamp === t);
|
|
||||||
segments.push({
|
|
||||||
start: segStart,
|
|
||||||
end: segEnd,
|
|
||||||
active: data ? data.count > 0 : false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return segments;
|
|
||||||
}, [selectedDate, timeDistribution]);
|
|
||||||
|
|
||||||
// 详细时间点标记
|
|
||||||
const detailedMarkers = useMemo(() => {
|
|
||||||
return records.map(record => ({
|
|
||||||
time: new Date(record.timestamp).getTime(),
|
|
||||||
label: format(new Date(record.timestamp), 'HH:mm:ss'),
|
|
||||||
active: true
|
|
||||||
}));
|
|
||||||
}, [records]);
|
|
||||||
|
|
||||||
// 事件处理函数
|
|
||||||
const onHourlySliderChange = (newValue: number) => {
|
|
||||||
const selectedSec = Math.floor(newValue / 3600000) * 3600;
|
|
||||||
|
|
||||||
// 使用 setTimeout 避免在渲染过程中更新状态
|
|
||||||
setTimeout(() => {
|
|
||||||
setHourlySliderValue(newValue);
|
|
||||||
fetchHourlyRecords(selectedSec, selectedSec + 3600);
|
|
||||||
}, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDetailedSliderChange = (newValue: number) => {
|
|
||||||
if (records.length === 0) return;
|
|
||||||
const targetTime = newValue;
|
|
||||||
let closestRecord = records[0];
|
|
||||||
let minDiff = Math.abs(new Date(closestRecord.timestamp).getTime() - targetTime);
|
|
||||||
|
|
||||||
for (const record of records) {
|
|
||||||
const diff = Math.abs(new Date(record.timestamp).getTime() - targetTime);
|
|
||||||
if (diff < minDiff) {
|
|
||||||
minDiff = diff;
|
|
||||||
closestRecord = record;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用 setTimeout 避免在渲染过程中更新状态
|
|
||||||
setTimeout(() => {
|
|
||||||
setSelectedRecord(closestRecord);
|
|
||||||
setDetailedSliderValue(newValue);
|
|
||||||
}, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 播放控制
|
|
||||||
const nextFrame = () => {
|
|
||||||
if (currentFrameIndex < records.length - 1) {
|
|
||||||
setCurrentFrameIndex(currentFrameIndex + 1);
|
|
||||||
setSelectedRecord(records[currentFrameIndex + 1]);
|
|
||||||
} else {
|
|
||||||
setAutoPlay(false);
|
|
||||||
}
|
|
||||||
stopAutoPlayTimer();
|
|
||||||
};
|
|
||||||
|
|
||||||
const prevFrame = () => {
|
|
||||||
if (currentFrameIndex > 0) {
|
|
||||||
setCurrentFrameIndex(currentFrameIndex - 1);
|
|
||||||
setSelectedRecord(records[currentFrameIndex - 1]);
|
|
||||||
}
|
|
||||||
stopAutoPlayTimer();
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleAutoPlay = () => {
|
|
||||||
setAutoPlay(!autoPlay);
|
|
||||||
};
|
|
||||||
|
|
||||||
const startAutoPlayTimer = useCallback(() => {
|
|
||||||
|
|
||||||
if (autoPlay) {
|
|
||||||
autoPlayTimer.current = setTimeout(() => {
|
|
||||||
nextFrame();
|
|
||||||
}, autoPlaySpeed);
|
|
||||||
}
|
|
||||||
}, [autoPlay, autoPlaySpeed, currentFrameIndex, records.length]);
|
|
||||||
|
|
||||||
const stopAutoPlayTimer = () => {
|
|
||||||
if (autoPlayTimer.current) {
|
|
||||||
clearTimeout(autoPlayTimer.current);
|
|
||||||
autoPlayTimer.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onImageLoad = (event: React.SyntheticEvent<HTMLImageElement>, fileId: string) => {
|
|
||||||
setImagesLoadedCount(prev => prev + 1);
|
|
||||||
const imgEl = event.target as HTMLImageElement;
|
|
||||||
if (imgEl.naturalHeight !== 0) {
|
|
||||||
setImageAspectRatio(imgEl.naturalWidth / imgEl.naturalHeight);
|
|
||||||
}
|
|
||||||
setLoadingImageIds(prev => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(fileId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onImageError = (fileId: string) => {
|
|
||||||
setLoadingImageIds(prev => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(fileId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleStar = async (recordId: string) => {
|
|
||||||
const newStatus = await toggleStar(recordId);
|
|
||||||
if (newStatus !== null) {
|
|
||||||
setRecords(prev => prev.map(record =>
|
|
||||||
record.id === recordId
|
|
||||||
? { ...record, isStarred: newStatus }
|
|
||||||
: record
|
|
||||||
));
|
|
||||||
if (selectedRecord && selectedRecord.id === recordId) {
|
|
||||||
setSelectedRecord(prev => prev ? { ...prev, isStarred: newStatus } : null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 键盘快捷键处理
|
|
||||||
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) {
|
|
||||||
handleToggleStar(selectedRecord.id);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeyPress);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('keydown', handleKeyPress);
|
|
||||||
};
|
|
||||||
}, [selectedRecord, toggleAutoPlay, prevFrame, nextFrame]);
|
|
||||||
|
|
||||||
// 滚轮事件处理
|
|
||||||
useEffect(() => {
|
|
||||||
const WHEEL_THRESHOLD = 20; // 累计多少像素后才切换(可调整)
|
|
||||||
|
|
||||||
const handleWheel = (event: WheelEvent) => {
|
|
||||||
// 累计水平滚动距离
|
|
||||||
wheelDeltaAccumulator.current += event.deltaX;
|
|
||||||
|
|
||||||
// 检查是否超过阈值
|
|
||||||
if (Math.abs(wheelDeltaAccumulator.current) >= WHEEL_THRESHOLD) {
|
|
||||||
if (wheelDeltaAccumulator.current < 0) {
|
|
||||||
// 向左滚动,上一帧
|
|
||||||
prevFrame();
|
|
||||||
} else {
|
|
||||||
// 向右滚动,下一帧
|
|
||||||
nextFrame();
|
|
||||||
}
|
|
||||||
// 重置累计器
|
|
||||||
wheelDeltaAccumulator.current = 0;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('wheel', handleWheel, { passive: true });
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('wheel', handleWheel);
|
|
||||||
// 清理累计器
|
|
||||||
wheelDeltaAccumulator.current = 0;
|
|
||||||
};
|
|
||||||
}, [prevFrame, nextFrame]);
|
|
||||||
|
|
||||||
// Effects
|
|
||||||
useEffect(() => {
|
|
||||||
fetchTimeDistribution();
|
|
||||||
}, [hostname]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedDate && Object.keys(dailyCounts).length > 0) {
|
|
||||||
const dates = Object.keys(dailyCounts).sort();
|
|
||||||
if (dates.length > 0) {
|
|
||||||
onDateChange(dates[dates.length - 1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [dailyCounts, selectedDate, onDateChange]);
|
|
||||||
|
|
||||||
// Handle Jump Request
|
|
||||||
useEffect(() => {
|
|
||||||
if (jumpRequest) {
|
|
||||||
const { timestamp, recordId } = jumpRequest;
|
|
||||||
const hourStartSec = Math.floor(timestamp / 1000 / 3600) * 3600;
|
|
||||||
const hourStartTime = hourStartSec * 1000;
|
|
||||||
|
|
||||||
setHourlySliderValue(hourStartTime);
|
|
||||||
fetchHourlyRecords(hourStartSec, hourStartSec + 3600, { targetRecordId: recordId });
|
|
||||||
}
|
|
||||||
}, [jumpRequest]);
|
|
||||||
|
|
||||||
// 自动定位到最新有数据的时间段
|
|
||||||
useEffect(() => {
|
|
||||||
if (jumpRequest) return;
|
|
||||||
if (!selectedDate) return;
|
|
||||||
|
|
||||||
// 找到所有有数据的段,并取最后一个(最新的)
|
|
||||||
const activeSegments = hourlySegments.filter(s => s.active);
|
|
||||||
const activeSegment = activeSegments.length > 0 ? activeSegments[activeSegments.length - 1] : undefined;
|
|
||||||
|
|
||||||
if (activeSegment) {
|
|
||||||
// 检查是否需要跳转:
|
|
||||||
// 1. 当前没有记录(初始化)
|
|
||||||
// 2. 当前记录不属于选中的日期(切换日期)
|
|
||||||
let shouldJump = false;
|
|
||||||
if (records.length === 0) {
|
|
||||||
shouldJump = true;
|
|
||||||
} else {
|
|
||||||
const recordDate = new Date(records[0].timestamp);
|
|
||||||
const recordDateStr = `${recordDate.getFullYear()}-${(recordDate.getMonth() + 1).toString().padStart(2, '0')}-${recordDate.getDate().toString().padStart(2, '0')}`;
|
|
||||||
if (recordDateStr !== selectedDate) {
|
|
||||||
shouldJump = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldJump) {
|
|
||||||
const newValue = activeSegment.start + 1800000; // 定位到中间
|
|
||||||
setHourlySliderValue(newValue);
|
|
||||||
const selectedSec = Math.floor(newValue / 3600000) * 3600;
|
|
||||||
fetchHourlyRecords(selectedSec, selectedSec + 3600);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 当天没有数据,清空旧数据以避免误导
|
|
||||||
if (records.length > 0) {
|
|
||||||
setRecords([]);
|
|
||||||
setSelectedRecord(null);
|
|
||||||
setShowDetailTimeline(false);
|
|
||||||
}
|
|
||||||
setHourlySliderValue(hourlyMinTime);
|
|
||||||
}
|
|
||||||
}, [selectedDate, hourlySegments, jumpRequest, records, hourlyMinTime]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setDetailedSliderValue(timeRange.min);
|
|
||||||
}, [timeRange.min]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setImagesLoadedCount(0);
|
|
||||||
|
|
||||||
if (selectedRecord) {
|
|
||||||
const idx = records.findIndex(rec => rec.timestamp === selectedRecord.timestamp);
|
|
||||||
setCurrentFrameIndex(idx);
|
|
||||||
setDetailedSliderValue(new Date(selectedRecord.timestamp).getTime());
|
|
||||||
}
|
|
||||||
if (autoPlay) {
|
|
||||||
startAutoPlayTimer();
|
|
||||||
}
|
|
||||||
}, [selectedRecord, records, autoPlay, startAutoPlayTimer]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedRecord) {
|
|
||||||
setLoadingImageIds(new Set(selectedRecord.screenshots.map(s => s.fileId)));
|
|
||||||
} else {
|
|
||||||
setLoadingImageIds(new Set());
|
|
||||||
}
|
|
||||||
}, [selectedRecord]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoPlay) {
|
|
||||||
stopAutoPlayTimer();
|
|
||||||
startAutoPlayTimer();
|
|
||||||
}
|
|
||||||
}, [autoPlay, autoPlaySpeed, startAutoPlayTimer]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="lg:flex lg:gap-6 items-start">
|
|
||||||
<div className="lg:w-1/3 lg:flex-shrink-0 space-y-8">
|
|
||||||
{/* 小时分布滑块(时间分布) */}
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h2 className="text-lg font-medium text-gray-900 dark:text-white">时间分布</h2>
|
|
||||||
<button
|
|
||||||
onClick={fetchTimeDistribution}
|
|
||||||
className="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800"
|
|
||||||
>
|
|
||||||
<RefreshCcw className={`h-5 w-5 text-gray-600 dark:text-gray-400 ${loadingDistribution ? 'animate-spin' : ''}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<RecordDatePicker
|
|
||||||
value={selectedDate}
|
|
||||||
dailyCounts={dailyCounts}
|
|
||||||
onChange={onDateChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{hourlySegments.length > 0 ? (
|
|
||||||
<div className="mt-4">
|
|
||||||
<TimelineSlider
|
|
||||||
minTime={hourlyMinTime}
|
|
||||||
maxTime={hourlyMaxTime}
|
|
||||||
value={hourlySliderValue}
|
|
||||||
mode="segments"
|
|
||||||
segments={hourlySegments}
|
|
||||||
onChange={onHourlySliderChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-gray-500 dark:text-gray-400 mt-4">加载时间分布中...</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 详细时间点滑块 */}
|
|
||||||
{showDetailTimeline && (
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h2 className="text-lg font-medium text-gray-900 dark:text-white">时间点详情</h2>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={generateVideo}
|
|
||||||
disabled={generatingVideo}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 text-white text-sm rounded focus:outline-none transition-colors"
|
|
||||||
title="生成视频"
|
|
||||||
>
|
|
||||||
{generatingVideo ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Video className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{generatingVideo ? '生成中...' : '生成视频'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const selectedSec = Math.floor(hourlySliderValue / 3600000) * 3600;
|
|
||||||
fetchHourlyRecords(selectedSec, selectedSec + 3600, { keepSelection: true });
|
|
||||||
}}
|
|
||||||
className="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800"
|
|
||||||
title="刷新"
|
|
||||||
>
|
|
||||||
<RefreshCcw className={`h-5 w-5 text-gray-600 dark:text-gray-400 ${loadingRecords ? 'animate-spin' : ''}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{records.length > 0 ? (
|
|
||||||
<TimelineSlider
|
|
||||||
minTime={timeRange.min}
|
|
||||||
maxTime={timeRange.max}
|
|
||||||
value={detailedSliderValue}
|
|
||||||
mode="ticks"
|
|
||||||
markers={detailedMarkers}
|
|
||||||
onChange={onDetailedSliderChange}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="text-gray-500 dark:text-gray-400">加载记录中...</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 图片预览区域及控制按钮 */}
|
|
||||||
<div className="lg:flex-1 min-w-0 mt-8 lg:mt-0">
|
|
||||||
{selectedRecord && (
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
|
|
||||||
{
|
|
||||||
!(navigator as any).connection?.saveData &&
|
|
||||||
records.map(record => record.screenshots).flat()
|
|
||||||
.filter((_, index) => Math.abs(index - currentFrameIndex) <= 20)
|
|
||||||
.map(screenshot => <link rel="preload" key={screenshot.fileId} href={`/screenshots/${screenshot.fileId}`} as="image" />)
|
|
||||||
}
|
|
||||||
{/* 图片预览区域 */}
|
|
||||||
{selectedRecord.screenshots.map((screenshot, sIndex) => (
|
|
||||||
<div key={sIndex} className="relative mb-6">
|
|
||||||
<div
|
|
||||||
className="relative w-full"
|
|
||||||
style={{ aspectRatio: imageAspectRatio }}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={`/screenshots/${screenshot.fileId}`}
|
|
||||||
alt={screenshot.monitorName}
|
|
||||||
className="absolute top-0 left-0 w-full h-full object-contain shadow-sm hover:shadow-md transition-shadow"
|
|
||||||
onLoad={(e) => onImageLoad(e, screenshot.fileId)}
|
|
||||||
onError={() => onImageError(screenshot.fileId)}
|
|
||||||
/>
|
|
||||||
{loadingImageIds.has(screenshot.fileId) && (
|
|
||||||
<div className="absolute top-2 right-2 bg-black/40 rounded-full p-2">
|
|
||||||
<Loader2 className="h-5 w-5 text-white animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 图片说明 */}
|
|
||||||
<div className="absolute bottom-4 left-4 bg-black/40 dark:bg-gray-900/40 text-white px-2 py-1 rounded">
|
|
||||||
<div className="text-sm">{screenshot.monitorName}</div>
|
|
||||||
<div className="text-xs">
|
|
||||||
{new Date(selectedRecord.timestamp).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 左侧点击区域 - 上一帧 */}
|
|
||||||
<div
|
|
||||||
onPointerDown={(e) => { e.preventDefault(); prevFrame(); }}
|
|
||||||
onTouchStart={(e) => e.preventDefault()}
|
|
||||||
className="absolute bottom-0 left-0 h-full w-1/3 text-white px-2 py-1 cursor-pointer"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 右侧点击区域 - 下一帧 */}
|
|
||||||
<div
|
|
||||||
onPointerDown={(e) => { e.preventDefault(); nextFrame(); }}
|
|
||||||
onTouchStart={(e) => e.preventDefault()}
|
|
||||||
className="absolute bottom-0 right-0 h-full w-1/3 text-white px-2 py-1 cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* 控制按钮区域 */}
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-xl p-4 mb-6 backdrop-blur-sm border border-gray-200/50 dark:border-gray-600/50">
|
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
|
||||||
|
|
||||||
{/* 速度控制 */}
|
|
||||||
<div className="flex items-center gap-3 bg-white dark:bg-gray-800 rounded-lg px-4 py-2.5 shadow-sm border border-gray-200 dark:border-gray-600">
|
|
||||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 min-w-max">播放速度</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={autoPlaySpeed}
|
|
||||||
onChange={(e) => setAutoPlaySpeed(Number(e.target.value))}
|
|
||||||
className="w-20 text-sm px-3 py-1.5 border border-gray-300 dark:border-gray-500 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white transition-colors"
|
|
||||||
min="0"
|
|
||||||
max="2000"
|
|
||||||
step="50"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400 min-w-max">毫秒</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 播放控制按钮 */}
|
|
||||||
<button
|
|
||||||
onClick={toggleAutoPlay}
|
|
||||||
className={`flex items-center gap-2 px-6 py-2.5 rounded-lg font-medium text-sm transition-all duration-200 shadow-sm ${autoPlay
|
|
||||||
? 'bg-red-500 hover:bg-red-600 text-white shadow-red-500/25'
|
|
||||||
: 'bg-blue-500 hover:bg-blue-600 text-white shadow-blue-500/25'
|
|
||||||
} hover:shadow-lg hover:scale-105 focus:outline-none focus:ring-2 focus:ring-offset-2 ${autoPlay ? 'focus:ring-red-500' : 'focus:ring-blue-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{!autoPlay ? (
|
|
||||||
<>
|
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
播放
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
暂停
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* 星标按钮 */}
|
|
||||||
<button
|
|
||||||
onClick={() => handleToggleStar(selectedRecord.id)}
|
|
||||||
disabled={updatingStars.has(selectedRecord.id)}
|
|
||||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg font-medium text-sm transition-all duration-200 shadow-sm border ${selectedRecord.isStarred
|
|
||||||
? 'bg-yellow-50 hover:bg-yellow-100 dark:bg-yellow-900/20 dark:hover:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800 shadow-yellow-500/20'
|
|
||||||
: 'bg-white hover:bg-gray-50 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-600 hover:text-yellow-600 dark:hover:text-yellow-400'
|
|
||||||
} ${updatingStars.has(selectedRecord.id)
|
|
||||||
? 'opacity-50 cursor-not-allowed'
|
|
||||||
: 'hover:scale-105 hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yellow-500'
|
|
||||||
}`}
|
|
||||||
title={selectedRecord.isStarred ? '取消星标' : '添加星标'}
|
|
||||||
>
|
|
||||||
{updatingStars.has(selectedRecord.id) ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
处理中
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Star className={`h-4 w-4 ${selectedRecord.isStarred ? 'fill-current' : ''}`} />
|
|
||||||
{selectedRecord.isStarred ? '已收藏' : '收藏'}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 快捷键提示 */}
|
|
||||||
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-600">
|
|
||||||
<div className="flex flex-wrap justify-center gap-4 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<kbd className="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">空格</kbd>
|
|
||||||
播放/暂停
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<kbd className="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">←/→</kbd>
|
|
||||||
上一帧/下一帧
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<kbd className="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">S</kbd>
|
|
||||||
切换星标
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 窗口信息 */}
|
|
||||||
<div className="w-full">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-3">活动窗口</h3>
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-700 rounded-md p-4">
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
||||||
{selectedRecord.windows.map((window, index) => (
|
|
||||||
<div key={index} className="p-4 bg-white dark:bg-gray-800 rounded-md shadow-sm">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="font-medium text-gray-900 dark:text-white">
|
|
||||||
{window.title}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400 break-all">
|
|
||||||
{window.path}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm flex items-center text-gray-600 dark:text-gray-400">
|
|
||||||
内存占用: {formatMemory(window.memory)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,130 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { Star, RefreshCcw, Loader2 } from 'lucide-react';
|
|
||||||
import { ScreenRecord } from '../types';
|
|
||||||
import { formatDate } from '../utils';
|
|
||||||
import { useStarToggle } from '../hooks/useStarToggle';
|
|
||||||
|
|
||||||
interface StarredTabProps {
|
|
||||||
hostname: string;
|
|
||||||
onViewRecord: (record: ScreenRecord) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function StarredTab({ hostname, onViewRecord }: StarredTabProps) {
|
|
||||||
const [starredRecords, setStarredRecords] = useState<ScreenRecord[]>([]);
|
|
||||||
const [loadingStarred, setLoadingStarred] = useState(false);
|
|
||||||
const { updatingStars, toggleStar } = useStarToggle();
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchStarredRecords();
|
|
||||||
}, [hostname]);
|
|
||||||
|
|
||||||
const handleToggleStar = async (recordId: string) => {
|
|
||||||
const newStatus = await toggleStar(recordId);
|
|
||||||
if (newStatus === false) {
|
|
||||||
// Removed from starred
|
|
||||||
setStarredRecords(prev => prev.filter(r => r.id !== recordId));
|
|
||||||
} else if (newStatus === true) {
|
|
||||||
// Shouldn't happen in this view usually, but if it does...
|
|
||||||
fetchStarredRecords();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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={() => handleToggleStar(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={() => onViewRecord(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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
export function useStarToggle() {
|
|
||||||
const [updatingStars, setUpdatingStars] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
const toggleStar = async (recordId: string) => {
|
|
||||||
if (updatingStars.has(recordId)) return null;
|
|
||||||
|
|
||||||
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();
|
|
||||||
return data.isStarred as boolean;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('切换星标状态失败:', error);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
setUpdatingStars(prev => {
|
|
||||||
const newSet = new Set(prev);
|
|
||||||
newSet.delete(recordId);
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return { updatingStars, toggleStar };
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,65 +0,0 @@
|
|||||||
export interface Screenshot {
|
|
||||||
fileId: string;
|
|
||||||
filename: string;
|
|
||||||
monitorName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Window {
|
|
||||||
title: string;
|
|
||||||
path: string;
|
|
||||||
memory: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScreenRecord {
|
|
||||||
id: string;
|
|
||||||
timestamp: string;
|
|
||||||
isStarred: boolean;
|
|
||||||
windows: Window[];
|
|
||||||
screenshots: Screenshot[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TimeDistributionPoint {
|
|
||||||
count: number;
|
|
||||||
timestamp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Password {
|
|
||||||
value: string;
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Credential {
|
|
||||||
_id?: string;
|
|
||||||
id?: string;
|
|
||||||
hostname: string;
|
|
||||||
username: string;
|
|
||||||
browser: string;
|
|
||||||
url: string;
|
|
||||||
login: string;
|
|
||||||
passwords: Password[];
|
|
||||||
lastSyncTime: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BrowserGroup {
|
|
||||||
name: string;
|
|
||||||
credentials: Credential[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserGroup {
|
|
||||||
username: string;
|
|
||||||
browsers: BrowserGroup[];
|
|
||||||
total: number;
|
|
||||||
lastSyncTime?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Segment {
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
active: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Marker {
|
|
||||||
time: number;
|
|
||||||
label?: string;
|
|
||||||
active?: boolean;
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
import { format } from 'date-fns';
|
|
||||||
|
|
||||||
export const formatMemory = (bytes: number | string) => {
|
|
||||||
bytes = typeof bytes === 'string' ? parseInt(bytes, 10) : bytes;
|
|
||||||
const units = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
let size = bytes;
|
|
||||||
let unitIndex = 0;
|
|
||||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
||||||
size /= 1024;
|
|
||||||
unitIndex++;
|
|
||||||
}
|
|
||||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatDate = (date: string | Date, type: 'full' | 'short' = 'full') => {
|
|
||||||
if (type === 'short') {
|
|
||||||
return format(new Date(date), 'MM-dd HH:mm');
|
|
||||||
}
|
|
||||||
return format(new Date(date), 'yyyy-MM-dd HH:mm:ss');
|
|
||||||
};
|
|
||||||
@ -24,7 +24,7 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
rel: "apple-touch-icon",
|
rel: "apple-touch-icon",
|
||||||
url: "/apple-touch-icon.png",
|
url: "/favicon.png",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,147 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { Lock, User, ArrowRight, AlertCircle, Loader2 } from 'lucide-react';
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const [username, setUsername] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/auth/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ username, password }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
router.push('/');
|
|
||||||
router.refresh();
|
|
||||||
} else {
|
|
||||||
setError(data.error || '登录失败');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError('网络错误,请稍后重试');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 px-4 sm:px-6 lg:px-8 transition-colors duration-200">
|
|
||||||
<div className="max-w-md w-full space-y-8">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="mx-auto h-16 w-16 bg-blue-600 rounded-2xl flex items-center justify-center shadow-lg transform rotate-3 hover:rotate-0 transition-transform duration-300">
|
|
||||||
<Lock className="h-8 w-8 text-white" />
|
|
||||||
</div>
|
|
||||||
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white tracking-tight">
|
|
||||||
系统登录
|
|
||||||
</h2>
|
|
||||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
请输入您的管理员凭证以继续
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 py-8 px-4 shadow-xl rounded-2xl sm:px-10 border border-gray-100 dark:border-gray-700">
|
|
||||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
|
||||||
{error && (
|
|
||||||
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4 border border-red-200 dark:border-red-800 animate-in fade-in slide-in-from-top-2">
|
|
||||||
<div className="flex">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<AlertCircle className="h-5 w-5 text-red-400" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-3">
|
|
||||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
|
||||||
{error}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
用户名
|
|
||||||
</label>
|
|
||||||
<div className="mt-1 relative rounded-md shadow-sm">
|
|
||||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<User className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
id="username"
|
|
||||||
name="username"
|
|
||||||
type="text"
|
|
||||||
autoComplete="username"
|
|
||||||
required
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg leading-5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm transition-colors"
|
|
||||||
placeholder="请输入用户名"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
密码
|
|
||||||
</label>
|
|
||||||
<div className="mt-1 relative rounded-md shadow-sm">
|
|
||||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<Lock className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="current-password"
|
|
||||||
required
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg leading-5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm transition-colors"
|
|
||||||
placeholder="请输入密码"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full flex justify-center items-center py-2.5 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 transform active:scale-[0.98]"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="animate-spin -ml-1 mr-2 h-4 w-4" />
|
|
||||||
登录中...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
登录
|
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-center text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
© {new Date().getFullYear()} WinUpdate Neo. All rights reserved.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
234
app/page.tsx
234
app/page.tsx
@ -2,16 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import {
|
import { XCircle } from 'lucide-react';
|
||||||
Monitor,
|
|
||||||
Clock,
|
|
||||||
LogOut,
|
|
||||||
Upload,
|
|
||||||
Calendar,
|
|
||||||
Search,
|
|
||||||
Server,
|
|
||||||
AlertCircle
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { format, differenceInMinutes } from 'date-fns';
|
import { format, differenceInMinutes } from 'date-fns';
|
||||||
|
|
||||||
interface Host {
|
interface Host {
|
||||||
@ -24,7 +15,6 @@ export default function Home() {
|
|||||||
const [hosts, setHosts] = useState<Host[]>([]);
|
const [hosts, setHosts] = useState<Host[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
|
|
||||||
const fetchHosts = async () => {
|
const fetchHosts = async () => {
|
||||||
try {
|
try {
|
||||||
@ -40,16 +30,6 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = async () => {
|
|
||||||
try {
|
|
||||||
await fetch('/api/auth/logout', { method: 'POST' });
|
|
||||||
// 刷新页面以触发重新认证(Basic Auth 或重定向)
|
|
||||||
window.location.reload();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Logout failed', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (date: string) => {
|
const formatDate = (date: string) => {
|
||||||
return format(new Date(date), 'yyyy-MM-dd HH:mm:ss');
|
return format(new Date(date), 'yyyy-MM-dd HH:mm:ss');
|
||||||
};
|
};
|
||||||
@ -67,174 +47,86 @@ export default function Home() {
|
|||||||
fetchHosts();
|
fetchHosts();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filteredHosts = hosts.filter(host =>
|
|
||||||
host.hostname.toLowerCase().includes(searchTerm.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
const activeHostsCount = hosts.filter(h => isRecent(h.lastUpdate)).length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors duration-200">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
{/* Navigation Bar */}
|
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||||
<nav className="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700 sticky top-0 z-10">
|
<div className="px-4 py-6 sm:px-0">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="flex justify-between items-center mb-8">
|
||||||
<div className="flex justify-between h-16">
|
<h1 className="text-3xl font-semibold text-gray-900 dark:text-white">屏幕截图监控系统</h1>
|
||||||
<div className="flex items-center">
|
<div className="space-x-4">
|
||||||
<Monitor className="h-8 w-8 text-blue-600 dark:text-blue-400" />
|
|
||||||
<span className="ml-3 text-xl font-bold text-gray-900 dark:text-white">
|
|
||||||
屏幕监控系统
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/tasks')}
|
onClick={() => router.push('/tasks')}
|
||||||
className="inline-flex items-center justify-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||||
title="定时任务管理"
|
|
||||||
>
|
>
|
||||||
<Calendar className="h-4 w-4 sm:mr-2" />
|
定时任务管理
|
||||||
<span className="hidden sm:inline">任务管理</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/upload')}
|
onClick={() => router.push('/upload')}
|
||||||
className="inline-flex items-center justify-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 shadow-sm"
|
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
|
||||||
title="上传新版本客户端"
|
|
||||||
>
|
>
|
||||||
<Upload className="h-4 w-4 sm:mr-2" />
|
上传新版本客户端
|
||||||
<span className="hidden sm:inline">上传版本</span>
|
|
||||||
</button>
|
|
||||||
<div className="h-6 w-px bg-gray-300 dark:bg-gray-600 mx-2"></div>
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="inline-flex items-center justify-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/40 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
|
||||||
title="退出登录"
|
|
||||||
>
|
|
||||||
<LogOut className="h-4 w-4 sm:mr-2" />
|
|
||||||
<span className="hidden sm:inline">退出</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main className="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
|
{/* 主机列表卡片网格 */}
|
||||||
{/* Stats & Search */}
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
{hosts.map((host) => (
|
||||||
<div className="flex items-center space-x-4">
|
<div
|
||||||
<div className="bg-white dark:bg-gray-800 px-4 py-3 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 flex items-center">
|
key={host.hostname}
|
||||||
<div className="p-2 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 mr-3">
|
className="bg-white dark:bg-gray-800 shadow-sm rounded-lg overflow-hidden hover:shadow-md dark:hover:shadow-lg transition-shadow cursor-pointer"
|
||||||
<Server className="h-5 w-5" />
|
onClick={() => navigateToHost(host.hostname)}
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 font-medium uppercase">总主机数</p>
|
|
||||||
<p className="text-xl font-bold text-gray-900 dark:text-white">{hosts.length}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white dark:bg-gray-800 px-4 py-3 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 flex items-center">
|
|
||||||
<div className="p-2 rounded-full bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 mr-3">
|
|
||||||
<div className="h-2.5 w-2.5 rounded-full bg-green-500 ring-4 ring-green-100 dark:ring-green-900/30"></div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 font-medium uppercase">在线主机</p>
|
|
||||||
<p className="text-xl font-bold text-gray-900 dark:text-white">{activeHostsCount}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative max-w-md w-full sm:w-64">
|
|
||||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<Search className="h-5 w-5 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md leading-5 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm transition-colors"
|
|
||||||
placeholder="搜索主机名..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex flex-col justify-center items-center h-64">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 dark:border-blue-400"></div>
|
|
||||||
<p className="mt-4 text-gray-500 dark:text-gray-400">正在加载主机列表...</p>
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6 flex items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<AlertCircle className="mx-auto h-12 w-12 text-red-400 dark:text-red-500 mb-4" />
|
|
||||||
<h3 className="text-lg font-medium text-red-800 dark:text-red-200">加载失败</h3>
|
|
||||||
<p className="mt-2 text-red-700 dark:text-red-300">{error}</p>
|
|
||||||
<button
|
|
||||||
onClick={fetchHosts}
|
|
||||||
className="mt-4 px-4 py-2 bg-red-100 dark:bg-red-800 text-red-700 dark:text-red-100 rounded-md hover:bg-red-200 dark:hover:bg-red-700 transition-colors"
|
|
||||||
>
|
>
|
||||||
重试
|
<div className="px-4 py-5 sm:p-6">
|
||||||
</button>
|
<div className="flex items-center justify-between">
|
||||||
</div>
|
<div>
|
||||||
</div>
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
|
||||||
) : filteredHosts.length === 0 ? (
|
{decodeURI(host.hostname)}
|
||||||
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
</h3>
|
||||||
<Server className="mx-auto h-12 w-12 text-gray-400" />
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-white">没有找到主机</h3>
|
最后更新: {formatDate(host.lastUpdate)}
|
||||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
</p>
|
||||||
{searchTerm ? '尝试调整搜索关键词' : '暂无主机连接记录'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
|
||||||
{filteredHosts.map((host) => {
|
|
||||||
const online = isRecent(host.lastUpdate);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={host.hostname}
|
|
||||||
onClick={() => navigateToHost(host.hostname)}
|
|
||||||
className="group bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden hover:shadow-md hover:border-blue-300 dark:hover:border-blue-700 transition-all duration-200 cursor-pointer relative"
|
|
||||||
>
|
|
||||||
<div className={`absolute top-0 left-0 w-1 h-full ${online ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'}`}></div>
|
|
||||||
<div className="p-5 pl-6">
|
|
||||||
<div className="flex justify-between items-start mb-4">
|
|
||||||
<div className="bg-gray-100 dark:bg-gray-700 p-2 rounded-lg group-hover:bg-blue-50 dark:group-hover:bg-blue-900/20 transition-colors">
|
|
||||||
<Monitor className="h-6 w-6 text-gray-600 dark:text-gray-300 group-hover:text-blue-600 dark:group-hover:text-blue-400" />
|
|
||||||
</div>
|
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
|
||||||
online
|
|
||||||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
|
|
||||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
|
||||||
}`}>
|
|
||||||
{online ? '在线' : '离线'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white truncate mb-1" title={decodeURI(host.hostname)}>
|
<div
|
||||||
{decodeURI(host.hostname)}
|
className={`h-3 w-3 rounded-full ${
|
||||||
</h3>
|
isRecent(host.lastUpdate) ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'
|
||||||
|
}`}
|
||||||
<div className="flex items-center text-sm text-gray-500 dark:text-gray-400 mt-3">
|
></div>
|
||||||
<Clock className="h-4 w-4 mr-1.5 flex-shrink-0" />
|
|
||||||
<span className="truncate">
|
|
||||||
{formatDate(host.lastUpdate)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-700/30 px-5 py-3 border-t border-gray-100 dark:border-gray-700 flex justify-between items-center">
|
|
||||||
<span className="text-xs font-medium text-blue-600 dark:text-blue-400 group-hover:underline">
|
|
||||||
查看详情
|
|
||||||
</span>
|
|
||||||
<div className="h-6 w-6 rounded-full bg-white dark:bg-gray-800 flex items-center justify-center shadow-sm text-gray-400 group-hover:text-blue-500 transition-colors">
|
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</main>
|
{/* 加载状态 */}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex justify-center items-center mt-8">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 dark:border-white"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 错误提示 */}
|
||||||
|
{error && (
|
||||||
|
<div className="mt-8 bg-red-50 dark:bg-red-900/50 p-4 rounded-md">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<XCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||||
|
加载失败
|
||||||
|
</h3>
|
||||||
|
<div className="mt-2 text-sm text-red-700 dark:text-red-300">
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
83
lib/auth.ts
83
lib/auth.ts
@ -1,83 +0,0 @@
|
|||||||
import { config } from './config'
|
|
||||||
|
|
||||||
export const AUTH_COOKIE_NAME = 'auth_token'
|
|
||||||
const SECRET_KEY = config.auth.secret
|
|
||||||
|
|
||||||
// Helper to generate a random nonce
|
|
||||||
function generateNonce() {
|
|
||||||
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to create a signed token (simplified JWT-like structure)
|
|
||||||
export async function createToken(username: string) {
|
|
||||||
const header = { alg: 'HS256', typ: 'JWT' }
|
|
||||||
const payload = {
|
|
||||||
sub: username,
|
|
||||||
iat: Date.now(),
|
|
||||||
exp: Date.now() + 24 * 60 * 60 * 1000, // 24 hours
|
|
||||||
nonce: generateNonce() // Ensure token is different every time
|
|
||||||
}
|
|
||||||
|
|
||||||
const encodedHeader = btoa(JSON.stringify(header))
|
|
||||||
const encodedPayload = btoa(JSON.stringify(payload))
|
|
||||||
const data = `${encodedHeader}.${encodedPayload}`
|
|
||||||
|
|
||||||
const key = await crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
new TextEncoder().encode(SECRET_KEY),
|
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
|
||||||
false,
|
|
||||||
['sign']
|
|
||||||
)
|
|
||||||
|
|
||||||
const signature = await crypto.subtle.sign(
|
|
||||||
'HMAC',
|
|
||||||
key,
|
|
||||||
new TextEncoder().encode(data)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Convert signature to base64
|
|
||||||
const signatureArray = Array.from(new Uint8Array(signature))
|
|
||||||
const encodedSignature = btoa(String.fromCharCode.apply(null, signatureArray))
|
|
||||||
|
|
||||||
return `${data}.${encodedSignature}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to verify token
|
|
||||||
export async function verifyToken(token: string) {
|
|
||||||
try {
|
|
||||||
const parts = token.split('.')
|
|
||||||
if (parts.length !== 3) return false
|
|
||||||
|
|
||||||
const [encodedHeader, encodedPayload, encodedSignature] = parts
|
|
||||||
const data = `${encodedHeader}.${encodedPayload}`
|
|
||||||
|
|
||||||
const key = await crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
new TextEncoder().encode(SECRET_KEY),
|
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
|
||||||
false,
|
|
||||||
['verify']
|
|
||||||
)
|
|
||||||
|
|
||||||
const signature = new Uint8Array(
|
|
||||||
atob(encodedSignature).split('').map(c => c.charCodeAt(0))
|
|
||||||
)
|
|
||||||
|
|
||||||
const isValid = await crypto.subtle.verify(
|
|
||||||
'HMAC',
|
|
||||||
key,
|
|
||||||
signature,
|
|
||||||
new TextEncoder().encode(data)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!isValid) return false
|
|
||||||
|
|
||||||
const payload = JSON.parse(atob(encodedPayload))
|
|
||||||
if (payload.exp < Date.now()) return false
|
|
||||||
|
|
||||||
return true
|
|
||||||
} catch (e) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,8 +2,7 @@ export const config = {
|
|||||||
port: process.env.PORT || 3000,
|
port: process.env.PORT || 3000,
|
||||||
auth: {
|
auth: {
|
||||||
username: process.env.AUTH_USERNAME || 'admin',
|
username: process.env.AUTH_USERNAME || 'admin',
|
||||||
password: process.env.AUTH_PASSWORD || 'password',
|
password: process.env.AUTH_PASSWORD || 'password'
|
||||||
secret: process.env.AUTH_SECRET || 'winupdate-neo-secret-key'
|
|
||||||
},
|
},
|
||||||
// For file upload settings
|
// For file upload settings
|
||||||
upload: {
|
upload: {
|
||||||
|
|||||||
@ -1,13 +1,12 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { verifyToken, AUTH_COOKIE_NAME } from './lib/auth'
|
import { config } from './lib/config'
|
||||||
|
|
||||||
export async function middleware(req: NextRequest) {
|
export function middleware(req: NextRequest) {
|
||||||
const url = new URL(req.url)
|
const url = new URL(req.url)
|
||||||
|
|
||||||
// Skip auth for certain paths
|
// Skip auth for certain paths (same logic as original Express app)
|
||||||
if (
|
if (
|
||||||
url.pathname.startsWith('/api/auth/login') || // Allow login API
|
url.pathname.startsWith('/api/') ||
|
||||||
url.pathname.startsWith('/api/') || // Other APIs might need their own auth or be public
|
|
||||||
url.pathname.startsWith('/screenshots/') ||
|
url.pathname.startsWith('/screenshots/') ||
|
||||||
url.pathname.startsWith('/downloads/') ||
|
url.pathname.startsWith('/downloads/') ||
|
||||||
url.pathname.startsWith('/manifest.json') ||
|
url.pathname.startsWith('/manifest.json') ||
|
||||||
@ -15,45 +14,51 @@ export async function middleware(req: NextRequest) {
|
|||||||
url.pathname.includes('favicon.png') ||
|
url.pathname.includes('favicon.png') ||
|
||||||
url.pathname.includes('install') ||
|
url.pathname.includes('install') ||
|
||||||
url.pathname.includes('WinupdateCore') ||
|
url.pathname.includes('WinupdateCore') ||
|
||||||
// req.method === 'POST' || // Remove this blanket allowance for POST
|
req.method === 'POST' ||
|
||||||
url.pathname.startsWith('/_next/') ||
|
url.pathname.startsWith('/_next/') || // Next.js static files
|
||||||
url.pathname.startsWith('/api-test')
|
url.pathname.startsWith('/api-test') // API test page (for development)
|
||||||
) {
|
) {
|
||||||
return NextResponse.next()
|
return NextResponse.next()
|
||||||
}
|
}
|
||||||
|
|
||||||
const authCookie = req.cookies.get(AUTH_COOKIE_NAME)
|
// For all other paths, require authentication
|
||||||
const isLoginPage = url.pathname === '/login'
|
const authHeader = req.headers.get('authorization')
|
||||||
let isValidToken = false
|
|
||||||
|
|
||||||
if (authCookie) {
|
if (!authHeader) {
|
||||||
isValidToken = await verifyToken(authCookie.value)
|
return new NextResponse('Authentication required', {
|
||||||
|
status: 401,
|
||||||
|
headers: {
|
||||||
|
'WWW-Authenticate': 'Basic realm="Restricted Access"'
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Login Page
|
try {
|
||||||
if (isLoginPage) {
|
const auth = Buffer.from(authHeader.split(' ')[1], 'base64').toString()
|
||||||
if (isValidToken) {
|
const [username, password] = auth.split(':')
|
||||||
// If already logged in, redirect to home
|
|
||||||
return NextResponse.redirect(new URL('/', req.url))
|
if (username === config.auth.username && password === config.auth.password) {
|
||||||
|
return NextResponse.next()
|
||||||
|
} else {
|
||||||
|
return new NextResponse('Authentication failed', {
|
||||||
|
status: 401,
|
||||||
|
headers: {
|
||||||
|
'WWW-Authenticate': 'Basic realm="Restricted Access"'
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
// Allow access to login page
|
} catch {
|
||||||
return NextResponse.next()
|
return new NextResponse('Invalid authentication', {
|
||||||
|
status: 401,
|
||||||
|
headers: {
|
||||||
|
'WWW-Authenticate': 'Basic realm="Restricted Access"'
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Protected Routes
|
|
||||||
if (!isValidToken) {
|
|
||||||
// Redirect to login page if not authenticated
|
|
||||||
const loginUrl = new URL('/login', req.url)
|
|
||||||
// Optional: Add return URL support
|
|
||||||
// loginUrl.searchParams.set('from', url.pathname)
|
|
||||||
return NextResponse.redirect(loginUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.next()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 配置中间件匹配的路径
|
// 配置中间件匹配的路径
|
||||||
export const config = {
|
export const config_middleware = {
|
||||||
matcher: [
|
matcher: [
|
||||||
/*
|
/*
|
||||||
* Match all request paths except for the ones starting with:
|
* Match all request paths except for the ones starting with:
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 123 KiB |
Loading…
x
Reference in New Issue
Block a user