complete frontend - refact from vue
This commit is contained in:
parent
1e65c2d872
commit
1d41ad6ef1
158
app/decode/page.tsx
Normal file
158
app/decode/page.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
|
||||
interface DecodedLine {
|
||||
content: string
|
||||
isError: boolean
|
||||
}
|
||||
|
||||
export default function DecodePage() {
|
||||
const [decodedLines, setDecodedLines] = useState<DecodedLine[]>([])
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const resultRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 转换 Unix 时间戳为本地时间字符串
|
||||
const formatTimestamp = (line: string): string => {
|
||||
const match = line.match(/^\[(\d+)\](.*)/)
|
||||
if (match) {
|
||||
const timestamp = parseInt(match[1])
|
||||
const date = new Date(timestamp * 1000)
|
||||
const formattedTime = date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
return `[${formattedTime}]${match[2]}`
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
const decodeBase64 = (base64String: string): string => {
|
||||
try {
|
||||
const binaryString = atob(base64String.trim())
|
||||
const bytes = new Uint8Array(binaryString.length)
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i)
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
return decoder.decode(bytes)
|
||||
} catch (e) {
|
||||
throw new Error(`解码失败: ${(e as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (resultRef.current) {
|
||||
resultRef.current.scrollTop = resultRef.current.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
// 当解码行数更新时滚动到底部
|
||||
useEffect(() => {
|
||||
if (decodedLines.length > 0) {
|
||||
setTimeout(scrollToBottom, 100)
|
||||
}
|
||||
}, [decodedLines])
|
||||
|
||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
|
||||
if (!file) {
|
||||
setErrorMessage('请选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await file.text()
|
||||
const lines = text.split('\n')
|
||||
|
||||
const processedLines: DecodedLine[] = lines
|
||||
.filter(line => line.trim())
|
||||
.map((line, index) => {
|
||||
try {
|
||||
const decodedLine = decodeBase64(line)
|
||||
const formattedLine = formatTimestamp(decodedLine)
|
||||
return {
|
||||
content: formattedLine,
|
||||
isError: false
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`第 ${index + 1} 行解码失败:`, e)
|
||||
return {
|
||||
content: `第 ${index + 1} 行解码失败: ${line}`,
|
||||
isError: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
setDecodedLines(processedLines)
|
||||
setErrorMessage('')
|
||||
} catch (e) {
|
||||
setErrorMessage('文件读取失败,请重试')
|
||||
console.error('文件读取错误:', e)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">Base64 日志解码器</h1>
|
||||
|
||||
{/* 文件上传区域 */}
|
||||
<div className="mb-8">
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
|
||||
<label className="block mb-4 text-sm font-medium text-gray-700">
|
||||
选择日志文件:
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".txt,.log"
|
||||
onChange={handleFileChange}
|
||||
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4
|
||||
file:rounded-md file:border-0 file:text-sm file:font-semibold
|
||||
file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100
|
||||
cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{errorMessage && (
|
||||
<div className="mb-6 p-4 bg-red-50 text-red-600 rounded-md">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 解码结果 */}
|
||||
{decodedLines.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-lg font-semibold text-gray-700 mb-4">解码结果:</h2>
|
||||
<div
|
||||
ref={resultRef}
|
||||
className="bg-white shadow rounded-lg overflow-auto max-h-[600px]"
|
||||
>
|
||||
<div className="divide-y divide-gray-100">
|
||||
{decodedLines.map((line, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`py-1.5 px-4 hover:bg-gray-50 leading-tight ${
|
||||
line.isError ? 'bg-red-50' : ''
|
||||
}`}
|
||||
>
|
||||
<pre className="whitespace-pre-wrap font-mono text-sm text-gray-800">
|
||||
{line.content}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
147
app/hosts/[hostname]/components/RecordDatePicker.tsx
Normal file
147
app/hosts/[hostname]/components/RecordDatePicker.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
|
||||
interface RecordDatePickerProps {
|
||||
value: string | null;
|
||||
dailyCounts: Record<string, number>;
|
||||
onChange: (date: string) => void;
|
||||
}
|
||||
|
||||
export default function RecordDatePicker({ value, dailyCounts, onChange }: RecordDatePickerProps) {
|
||||
const today = new Date();
|
||||
const initialDate = value ? new Date(value) : today;
|
||||
|
||||
const [currentYear, setCurrentYear] = useState(initialDate.getFullYear());
|
||||
const [currentMonth, setCurrentMonth] = useState(initialDate.getMonth());
|
||||
|
||||
const weekDays = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
|
||||
const calendarDays = useMemo(() => {
|
||||
const year = currentYear;
|
||||
const month = currentMonth;
|
||||
const firstDayOfMonth = new Date(year, month, 1);
|
||||
const lastDayOfMonth = new Date(year, month + 1, 0);
|
||||
const daysInMonth = lastDayOfMonth.getDate();
|
||||
const startDayIndex = firstDayOfMonth.getDay();
|
||||
const totalCells = Math.ceil((startDayIndex + daysInMonth) / 7) * 7;
|
||||
|
||||
const days = [];
|
||||
const startDate = new Date(year, month, 1 - startDayIndex);
|
||||
|
||||
for (let i = 0; i < totalCells; i++) {
|
||||
const date = new Date(startDate);
|
||||
date.setDate(startDate.getDate() + i);
|
||||
const dateString = `${date.getFullYear()}-${(date.getMonth() + 1)
|
||||
.toString()
|
||||
.padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`;
|
||||
|
||||
days.push({
|
||||
date,
|
||||
day: date.getDate(),
|
||||
isCurrentMonth: date.getMonth() === month,
|
||||
dateString,
|
||||
count: dailyCounts[dateString] || 0
|
||||
});
|
||||
}
|
||||
return days;
|
||||
}, [currentYear, currentMonth, dailyCounts]);
|
||||
|
||||
const maxCount = useMemo(() => {
|
||||
let max = 0;
|
||||
calendarDays.forEach((day) => {
|
||||
if (day.count > max) {
|
||||
max = day.count;
|
||||
}
|
||||
});
|
||||
return max;
|
||||
}, [calendarDays]);
|
||||
|
||||
const selectDay = (day: { date: Date; dateString: string; isCurrentMonth: boolean }) => {
|
||||
if (!day.isCurrentMonth) {
|
||||
setCurrentYear(day.date.getFullYear());
|
||||
setCurrentMonth(day.date.getMonth());
|
||||
}
|
||||
onChange(day.dateString);
|
||||
};
|
||||
|
||||
const getDayStyle = (day: { dateString: string; count: number }) => {
|
||||
const styles: React.CSSProperties = {};
|
||||
|
||||
if (value === day.dateString) {
|
||||
styles.outline = '2px solid #2563EB';
|
||||
}
|
||||
|
||||
if (day.count > 0) {
|
||||
const ratio = maxCount > 0 ? day.count / maxCount : 0;
|
||||
const opacity = 0.3 + ratio * 0.7;
|
||||
styles.backgroundColor = `rgba(16, 185, 129, ${opacity})`;
|
||||
styles.color = opacity > 0.6 ? 'white' : 'black';
|
||||
} else {
|
||||
styles.backgroundColor = 'transparent';
|
||||
styles.color = 'inherit';
|
||||
}
|
||||
|
||||
return styles;
|
||||
};
|
||||
|
||||
const prevMonth = () => {
|
||||
if (currentMonth === 0) {
|
||||
setCurrentYear(currentYear - 1);
|
||||
setCurrentMonth(11);
|
||||
} else {
|
||||
setCurrentMonth(currentMonth - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const nextMonth = () => {
|
||||
if (currentMonth === 11) {
|
||||
setCurrentYear(currentYear + 1);
|
||||
setCurrentMonth(0);
|
||||
} else {
|
||||
setCurrentMonth(currentMonth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-white rounded shadow">
|
||||
{/* 月份切换头部 */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<button onClick={prevMonth} className="p-2 rounded hover:bg-gray-200">
|
||||
<
|
||||
</button>
|
||||
<div className="font-semibold text-lg">
|
||||
{currentYear} - {(currentMonth + 1).toString().padStart(2, '0')}
|
||||
</div>
|
||||
<button onClick={nextMonth} className="p-2 rounded hover:bg-gray-200">
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 星期标题 */}
|
||||
<div className="grid grid-cols-7 gap-1 text-center text-sm font-medium text-gray-600 mb-1">
|
||||
{weekDays.map((day) => (
|
||||
<div key={day}>{day}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 日历网格 */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{calendarDays.map((day, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`p-2 my-1 rounded cursor-pointer text-sm flex items-center justify-center ${
|
||||
!day.isCurrentMonth ? 'opacity-50' : ''
|
||||
}`}
|
||||
style={getDayStyle(day)}
|
||||
onClick={() => selectDay(day)}
|
||||
>
|
||||
<div className="relative">
|
||||
<span>{day.day}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
219
app/hosts/[hostname]/components/TimelineSlider.tsx
Normal file
219
app/hosts/[hostname]/components/TimelineSlider.tsx
Normal file
@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface Segment {
|
||||
start: number;
|
||||
end: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface Marker {
|
||||
time: number;
|
||||
label?: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
interface TimelineSliderProps {
|
||||
minTime: number;
|
||||
maxTime: number;
|
||||
value: number;
|
||||
mode?: 'default' | 'segments' | 'ticks';
|
||||
segments?: Segment[];
|
||||
markers?: Marker[];
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
export default function TimelineSlider({
|
||||
minTime,
|
||||
maxTime,
|
||||
value,
|
||||
mode = 'default',
|
||||
segments = [],
|
||||
markers = [],
|
||||
onChange
|
||||
}: TimelineSliderProps) {
|
||||
const sliderRef = useRef<HTMLDivElement>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [internalValue, setInternalValue] = useState(value);
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
|
||||
// 同步外部value到内部状态(只在非拖拽状态下)
|
||||
useEffect(() => {
|
||||
if (!dragging) {
|
||||
setInternalValue(value);
|
||||
}
|
||||
}, [value, dragging]);
|
||||
|
||||
const formattedValue = format(new Date(internalValue), 'HH:mm:ss');
|
||||
const percentage = ((internalValue - minTime) / (maxTime - minTime)) * 100;
|
||||
|
||||
const getSnapValue = useCallback((val: number): number => {
|
||||
if (mode === 'ticks' && markers.length > 0) {
|
||||
let closest = markers[0].time;
|
||||
let minDiff = Math.abs(val - markers[0].time);
|
||||
for (const marker of markers) {
|
||||
const diff = Math.abs(val - marker.time);
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
closest = marker.time;
|
||||
}
|
||||
}
|
||||
return closest;
|
||||
} else if (mode === 'segments' && segments.length > 0) {
|
||||
let closestMidpoint: number | null = null;
|
||||
let minDiff = Infinity;
|
||||
for (const segment of segments) {
|
||||
if (segment.active) {
|
||||
const midpoint = (segment.start + segment.end) / 2;
|
||||
const diff = Math.abs(val - midpoint);
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
closestMidpoint = midpoint;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (closestMidpoint !== null) {
|
||||
return closestMidpoint;
|
||||
}
|
||||
}
|
||||
return val;
|
||||
}, [mode, markers, segments]);
|
||||
|
||||
// 使用ref来跟踪上次通知的值,避免频繁调用onChange
|
||||
const lastNotifiedValue = useRef(value);
|
||||
|
||||
// 指针移动处理函数
|
||||
const pointerMoveHandler = useCallback((event: PointerEvent) => {
|
||||
if (!sliderRef.current) return;
|
||||
const rect = sliderRef.current.getBoundingClientRect();
|
||||
let x = event.clientX - rect.left;
|
||||
x = Math.max(0, Math.min(x, rect.width));
|
||||
const newValue = minTime + (x / rect.width) * (maxTime - minTime);
|
||||
setInternalValue(newValue);
|
||||
|
||||
// 节流调用onChange,避免过于频繁的更新
|
||||
const snapped = getSnapValue(newValue);
|
||||
if (Math.abs(snapped - lastNotifiedValue.current) > 100) { // 100ms的节流
|
||||
lastNotifiedValue.current = snapped;
|
||||
onChange(snapped);
|
||||
}
|
||||
}, [minTime, maxTime, getSnapValue, onChange]);
|
||||
|
||||
// 指针释放处理函数
|
||||
const pointerUpHandler = useCallback(() => {
|
||||
setDragging(false);
|
||||
setShowTooltip(false);
|
||||
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]);
|
||||
|
||||
// 指针按下处理函数
|
||||
const onPointerDown = (event: React.PointerEvent) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
if (!sliderRef.current) return;
|
||||
const rect = sliderRef.current.getBoundingClientRect();
|
||||
let x = event.clientX - rect.left;
|
||||
x = Math.max(0, Math.min(x, rect.width));
|
||||
const newValue = minTime + (x / rect.width) * (maxTime - minTime);
|
||||
|
||||
setDragging(true);
|
||||
setShowTooltip(true);
|
||||
setInternalValue(newValue);
|
||||
|
||||
// 立即通知父组件值的变化
|
||||
const snapped = getSnapValue(newValue);
|
||||
lastNotifiedValue.current = snapped;
|
||||
onChange(snapped);
|
||||
|
||||
window.addEventListener('pointermove', pointerMoveHandler);
|
||||
window.addEventListener('pointerup', pointerUpHandler);
|
||||
};
|
||||
|
||||
const getSegmentStyle = (segment: Segment) => {
|
||||
const startPercent = ((segment.start - minTime) / (maxTime - minTime)) * 100;
|
||||
const endPercent = ((segment.end - minTime) / (maxTime - minTime)) * 100;
|
||||
const widthPercent = endPercent - startPercent;
|
||||
return {
|
||||
position: 'absolute' as const,
|
||||
left: `${startPercent}%`,
|
||||
width: `${widthPercent}%`,
|
||||
height: '100%',
|
||||
backgroundColor: segment.active ? '#4ADE80' : '#E5E7EB',
|
||||
borderLeft: '1px solid #E5E7EB',
|
||||
borderRight: '1px solid #E5E7EB',
|
||||
};
|
||||
};
|
||||
|
||||
const getMarkerStyle = (marker: Marker) => {
|
||||
const pos = ((marker.time - minTime) / (maxTime - minTime)) * 100;
|
||||
return {
|
||||
position: 'absolute' as const,
|
||||
left: `${pos}%`,
|
||||
top: '50%',
|
||||
width: '2px',
|
||||
height: '100%',
|
||||
backgroundColor: marker.active ? '#4ADE80' : '#9CA3AF',
|
||||
transform: 'translate(-50%, -50%)'
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sliderRef}
|
||||
className="relative h-10 select-none"
|
||||
style={{ touchAction: 'none' }}
|
||||
onPointerDown={onPointerDown}
|
||||
>
|
||||
{/* 底部轨道 */}
|
||||
<div
|
||||
className="absolute top-1/2 left-0 right-0 h-1.5 bg-gray-200 transform -translate-y-1/2 rounded-sm"
|
||||
>
|
||||
{/* Segments 模式 */}
|
||||
{mode === 'segments' && segments.map((segment, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={getSegmentStyle(segment)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Ticks 模式 */}
|
||||
{mode === 'ticks' && markers.map((marker, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={getMarkerStyle(marker)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 可拖动的滑块 */}
|
||||
<div
|
||||
className="absolute top-1/2 w-5 h-5 bg-green-400 border-2 border-white rounded-full shadow-lg cursor-pointer z-10"
|
||||
style={{
|
||||
left: `${percentage}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
|
||||
>
|
||||
{/* Tooltip */}
|
||||
{showTooltip && (
|
||||
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 bg-black bg-opacity-70 text-white text-xs rounded whitespace-nowrap">
|
||||
{formattedValue}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
902
app/hosts/[hostname]/page.tsx
Normal file
902
app/hosts/[hostname]/page.tsx
Normal file
@ -0,0 +1,902 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import {
|
||||
ArrowLeft,
|
||||
RefreshCcw,
|
||||
ChevronDown,
|
||||
User,
|
||||
Globe,
|
||||
Link,
|
||||
Clipboard,
|
||||
ShieldAlert,
|
||||
Loader2
|
||||
} from 'lucide-react';
|
||||
import { format, differenceInMinutes } from 'date-fns';
|
||||
import TimelineSlider from './components/TimelineSlider';
|
||||
import RecordDatePicker from './components/RecordDatePicker';
|
||||
|
||||
interface Screenshot {
|
||||
fileId: string;
|
||||
filename: string;
|
||||
monitorName: string;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
title: string;
|
||||
path: string;
|
||||
memory: number;
|
||||
}
|
||||
|
||||
interface ScreenRecord {
|
||||
timestamp: string;
|
||||
windows: Window[];
|
||||
screenshots: Screenshot[];
|
||||
}
|
||||
|
||||
interface TimeDistributionPoint {
|
||||
count: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface Password {
|
||||
value: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface Credential {
|
||||
_id: string;
|
||||
hostname: string;
|
||||
username: string;
|
||||
browser: string;
|
||||
url: string;
|
||||
login: string;
|
||||
passwords: Password[];
|
||||
lastSyncTime: Date;
|
||||
}
|
||||
|
||||
interface BrowserGroup {
|
||||
name: string;
|
||||
credentials: Credential[];
|
||||
}
|
||||
|
||||
interface UserGroup {
|
||||
username: string;
|
||||
browsers: BrowserGroup[];
|
||||
total: number;
|
||||
lastSyncTime?: string;
|
||||
}
|
||||
|
||||
interface Segment {
|
||||
start: number;
|
||||
end: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface Marker {
|
||||
time: number;
|
||||
label?: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
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[]>([]);
|
||||
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);
|
||||
|
||||
// 凭据相关状态
|
||||
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 [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 [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 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]}`;
|
||||
};
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (date: string, 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');
|
||||
};
|
||||
|
||||
// 获取时间分布数据
|
||||
const fetchTimeDistribution = async () => {
|
||||
try {
|
||||
setLoadingDistribution(true);
|
||||
const response = await fetch(`/api/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 fetchCredentials = async () => {
|
||||
try {
|
||||
setLoadingCredentials(true);
|
||||
const response = await fetch(`/api/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);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取小时记录
|
||||
const fetchHourlyRecords = async (startTime: number, endTime: number) => {
|
||||
try {
|
||||
setLoadingRecords(true);
|
||||
setShowDetailTimeline(true);
|
||||
const response = await fetch(
|
||||
`/api/hosts/${hostname}/screenshots?startTime=${startTime}&endTime=${endTime}`
|
||||
);
|
||||
if (!response.ok) throw new Error('获取记录数据失败');
|
||||
const data = await response.json();
|
||||
setRecords(data.records.reverse());
|
||||
setLastUpdate(data.lastUpdate);
|
||||
setTimeRange({ min: startTime * 1000, max: endTime * 1000 });
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
setSelectedRecord(data.records[0]);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取记录数据失败:', error);
|
||||
} finally {
|
||||
setLoadingRecords(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 组织凭据数据按用户分组
|
||||
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 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 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);
|
||||
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 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);
|
||||
}
|
||||
};
|
||||
|
||||
// 播放控制
|
||||
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 (allImagesLoaded && autoPlay) {
|
||||
autoPlayTimer.current = setTimeout(() => {
|
||||
nextFrame();
|
||||
}, autoPlaySpeed);
|
||||
}
|
||||
}, [allImagesLoaded, 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);
|
||||
}
|
||||
};
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
fetchTimeDistribution();
|
||||
fetchCredentials();
|
||||
}, [hostname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedDate && Object.keys(dailyCounts).length > 0) {
|
||||
const dates = Object.keys(dailyCounts).sort();
|
||||
if (dates.length > 0) {
|
||||
setSelectedDate(dates[0]);
|
||||
}
|
||||
}
|
||||
}, [dailyCounts, selectedDate]);
|
||||
|
||||
useEffect(() => {
|
||||
setHourlySliderValue(hourlyMinTime);
|
||||
}, [hourlyMinTime]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDate) {
|
||||
const activeSegment = hourlySegments.find(s => s.active);
|
||||
const newValue = activeSegment ? activeSegment.start + 1800000 : hourlyMinTime;
|
||||
setHourlySliderValue(newValue);
|
||||
onHourlySliderChange(newValue);
|
||||
}
|
||||
}, [selectedDate, hourlySegments, 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 && allImagesLoaded) {
|
||||
startAutoPlayTimer();
|
||||
}
|
||||
}, [selectedRecord, records, autoPlay, allImagesLoaded, startAutoPlayTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoPlay && allImagesLoaded) {
|
||||
stopAutoPlayTimer();
|
||||
startAutoPlayTimer();
|
||||
}
|
||||
}, [autoPlay, allImagesLoaded, autoPlaySpeed, startAutoPlayTimer]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 overflow-x-hidden">
|
||||
<div className="max-w-8xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
{/* 头部导航 */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="inline-flex items-center text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 mr-2" />
|
||||
返回
|
||||
</button>
|
||||
<h1 className="text-3xl font-semibold text-gray-900 mt-2">
|
||||
{hostname}
|
||||
</h1>
|
||||
</div>
|
||||
{lastUpdate && (
|
||||
<div className="text-sm text-gray-500">
|
||||
最后更新: {formatDate(lastUpdate)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 选项卡导航 */}
|
||||
<div className="mb-6 border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<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 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
截图时间线
|
||||
</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 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
凭据信息
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* 截图时间线选项卡 */}
|
||||
{activeTab === 'screenshots' && (
|
||||
<div>
|
||||
{/* 小时分布滑块(时间分布) */}
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-medium text-gray-900">时间分布</h2>
|
||||
<button
|
||||
onClick={fetchTimeDistribution}
|
||||
className="p-2 rounded hover:bg-gray-100"
|
||||
>
|
||||
<RefreshCcw className={`h-5 w-5 text-gray-600 ${loadingDistribution ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<RecordDatePicker
|
||||
value={selectedDate}
|
||||
dailyCounts={dailyCounts}
|
||||
onChange={setSelectedDate}
|
||||
/>
|
||||
|
||||
{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 mt-4">加载时间分布中...</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 详细时间点滑块 */}
|
||||
{showDetailTimeline && (
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-medium text-gray-900">时间点详情</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
const selectedSec = Math.floor(hourlySliderValue / 3600000) * 3600;
|
||||
fetchHourlyRecords(selectedSec, selectedSec + 3600);
|
||||
}}
|
||||
className="p-2 rounded hover:bg-gray-100"
|
||||
>
|
||||
<RefreshCcw className={`h-5 w-5 text-gray-600 ${loadingRecords ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{records.length > 0 ? (
|
||||
<TimelineSlider
|
||||
minTime={timeRange.min}
|
||||
maxTime={timeRange.max}
|
||||
value={detailedSliderValue}
|
||||
mode="ticks"
|
||||
markers={detailedMarkers}
|
||||
onChange={onDetailedSliderChange}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-gray-500">加载记录中...</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片预览区域及控制按钮 */}
|
||||
{selectedRecord && (
|
||||
<div className="bg-white rounded-lg shadow-sm p-6">
|
||||
{/* 图片预览区域 */}
|
||||
{selectedRecord.screenshots.map((screenshot, sIndex) => (
|
||||
<div key={sIndex} className="relative mb-6">
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{ aspectRatio: imageAspectRatio }}
|
||||
>
|
||||
<img
|
||||
src={`/api/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)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 图片说明 */}
|
||||
<div className="absolute bottom-4 left-4 bg-black bg-opacity-60 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="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">速度</span>
|
||||
<input
|
||||
type="number"
|
||||
value={autoPlaySpeed}
|
||||
onChange={(e) => setAutoPlaySpeed(Number(e.target.value))}
|
||||
className="w-16 text-sm px-1 py-1 border rounded focus:outline-none"
|
||||
min="100"
|
||||
max="2000"
|
||||
step="100"
|
||||
/>
|
||||
<span className="text-sm text-gray-600">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>
|
||||
|
||||
{/* 窗口信息 */}
|
||||
<div className="w-full">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-3">活动窗口</h3>
|
||||
<div className="bg-gray-50 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 rounded-md shadow-sm">
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium text-gray-900">
|
||||
{window.title}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 break-all">
|
||||
{window.path}
|
||||
</div>
|
||||
<div className="text-sm flex items-center text-gray-600">
|
||||
内存占用: {formatMemory(window.memory)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 凭据信息选项卡 */}
|
||||
{activeTab === 'credentials' && (
|
||||
<div className="bg-white rounded-lg shadow-sm p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-medium text-gray-900">凭据信息</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">加载凭据信息...</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 mb-1">没有找到任何凭据信息</p>
|
||||
<p className="text-sm text-gray-400">可能是该主机尚未上报凭据数据</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{credentialsByUser.map((userGroup, index) => (
|
||||
<div key={index} className="border border-gray-200 rounded-md overflow-hidden">
|
||||
{/* 用户信息头部 */}
|
||||
<div className="bg-gray-50 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 mr-2" />
|
||||
<h3 className="font-medium text-gray-900">{userGroup.username}</h3>
|
||||
<div className="ml-3 text-sm text-gray-500">
|
||||
({userGroup.browsers.length} 个浏览器, {userGroup.total} 个凭据)
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`h-5 w-5 text-gray-500 ml-2 transition-transform duration-200 ${
|
||||
expandedUsers.includes(userGroup.username) ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{userGroup.lastSyncTime && (
|
||||
<div className="text-xs text-gray-500">
|
||||
最后同步: {formatDate(userGroup.lastSyncTime, 'short')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 用户凭据内容 */}
|
||||
{expandedUsers.includes(userGroup.username) && (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{userGroup.browsers.map((browser) => (
|
||||
<div
|
||||
key={`${userGroup.username}-${browser.name}`}
|
||||
className="px-4 py-3 hover:bg-gray-50"
|
||||
>
|
||||
{/* 浏览器标题 */}
|
||||
<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 mr-2" />
|
||||
<span className="font-medium text-gray-800">{browser.name}</span>
|
||||
<span className="ml-2 text-sm text-gray-500">
|
||||
({browser.credentials.length} 个站点)
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 text-gray-500 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) => (
|
||||
<div
|
||||
key={`${userGroup.username}-${browser.name}-${cred._id}`}
|
||||
className="border border-gray-200 rounded-md overflow-hidden"
|
||||
>
|
||||
{/* 凭据网站头部 */}
|
||||
<div
|
||||
className="bg-gray-50 px-3 py-2 flex items-center justify-between cursor-pointer"
|
||||
onClick={() => toggleCredentialExpanded(cred._id)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Link className="h-4 w-4 text-gray-500 mr-2" />
|
||||
<div className="text-sm font-medium text-gray-900 truncate max-w-md">
|
||||
{cred.url}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 text-gray-500 transition-transform duration-200 ${
|
||||
expandedCredentials.includes(cred._id) ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 凭据详情 */}
|
||||
{expandedCredentials.includes(cred._id) && (
|
||||
<div className="bg-white 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 w-16">用户名:</span>
|
||||
<span className="text-sm font-medium ml-2">{cred.login}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center mb-1">
|
||||
<span className="text-sm text-gray-500 w-16">密码历史:</span>
|
||||
<span className="text-xs bg-gray-100 text-gray-600 rounded px-1">
|
||||
{cred.passwords.length} 条记录
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="pl-6 space-y-2 mt-1">
|
||||
{cred.passwords.map((pwd, pwdIndex) => (
|
||||
<div
|
||||
key={pwdIndex}
|
||||
className="flex items-center group relative"
|
||||
>
|
||||
<span className="text-xs text-gray-400 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 px-2 py-0.5 rounded flex-1 cursor-pointer"
|
||||
onClick={() =>
|
||||
revealedPasswords.includes(`${cred._id}-${pwdIndex}`)
|
||||
? null
|
||||
: revealPassword(`${cred._id}-${pwdIndex}`)
|
||||
}
|
||||
>
|
||||
{revealedPasswords.includes(`${cred._id}-${pwdIndex}`)
|
||||
? pwd.value
|
||||
: '••••••••'
|
||||
}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => copyToClipboard(pwd.value)}
|
||||
className="ml-2 opacity-0 group-hover:opacity-100 text-gray-400 hover:text-gray-700"
|
||||
>
|
||||
<Clipboard className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
203
app/page.tsx
203
app/page.tsx
@ -1,103 +1,116 @@
|
||||
import Image from "next/image";
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { XCircle } from 'lucide-react';
|
||||
import { format, differenceInMinutes } from 'date-fns';
|
||||
|
||||
interface Host {
|
||||
hostname: string;
|
||||
lastUpdate: string;
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
||||
<li className="mb-2 tracking-[-.01em]">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
|
||||
app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li className="tracking-[-.01em]">
|
||||
Save and see your changes instantly.
|
||||
</li>
|
||||
</ol>
|
||||
const router = useRouter();
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
const fetchHosts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/hosts');
|
||||
if (!response.ok) throw new Error('获取主机列表失败');
|
||||
const data = await response.json();
|
||||
setHosts(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '未知错误');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
return format(new Date(date), 'yyyy-MM-dd HH:mm:ss');
|
||||
};
|
||||
|
||||
const isRecent = (date: string) => {
|
||||
const diffMinutes = differenceInMinutes(new Date(), new Date(date));
|
||||
return diffMinutes <= 60; // 1小时内
|
||||
};
|
||||
|
||||
const navigateToHost = (hostname: string) => {
|
||||
router.push(`/hosts/${hostname}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchHosts();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div className="px-4 py-6 sm:px-0">
|
||||
<h1 className="text-3xl font-semibold text-gray-900 mb-8">屏幕截图监控系统</h1>
|
||||
|
||||
{/* 主机列表卡片网格 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{hosts.map((host) => (
|
||||
<div
|
||||
key={host.hostname}
|
||||
className="bg-white shadow-sm rounded-lg overflow-hidden hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => navigateToHost(host.hostname)}
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
{host.hostname}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
最后更新: {formatDate(host.lastUpdate)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={`h-3 w-3 rounded-full ${
|
||||
isRecent(host.lastUpdate) ? 'bg-green-500' : 'bg-gray-300'
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 加载状态 */}
|
||||
{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"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="mt-8 bg-red-50 p-4 rounded-md">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<XCircle className="h-5 w-5 text-red-400" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">
|
||||
加载失败
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-red-700">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
229
app/upload/page.tsx
Normal file
229
app/upload/page.tsx
Normal file
@ -0,0 +1,229 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
interface VersionInfo {
|
||||
version: string
|
||||
download_url: string
|
||||
checksum: string
|
||||
}
|
||||
|
||||
export default function UploadPage() {
|
||||
const [currentVersion, setCurrentVersion] = useState<VersionInfo | null>(null)
|
||||
const [newVersion, setNewVersion] = useState('')
|
||||
const [versionFile, setVersionFile] = useState<File | null>(null)
|
||||
const [versionFileHash, setVersionFileHash] = useState('')
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [uploadError, setUploadError] = useState('')
|
||||
const [uploadSuccess, setUploadSuccess] = useState(false)
|
||||
|
||||
const canUploadVersion = newVersion && versionFile && !isUploading
|
||||
|
||||
// 计算文件 SHA-256
|
||||
const calculateSha256 = async (file: File): Promise<string> => {
|
||||
const buffer = await file.arrayBuffer()
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
// 获取当前版本信息
|
||||
const fetchCurrentVersion = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/api/version')
|
||||
if (response.ok) {
|
||||
const data: VersionInfo = await response.json()
|
||||
setCurrentVersion(data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取版本信息失败:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 处理版本文件选择
|
||||
const handleVersionFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const input = event.target
|
||||
if (input.files && input.files[0]) {
|
||||
const file = input.files[0]
|
||||
setVersionFile(file)
|
||||
setVersionFileHash(await calculateSha256(file))
|
||||
}
|
||||
}
|
||||
|
||||
// 清除表单
|
||||
const resetForm = () => {
|
||||
setNewVersion('')
|
||||
setVersionFile(null)
|
||||
setVersionFileHash('')
|
||||
setUploadError('')
|
||||
}
|
||||
|
||||
// 上传新版本
|
||||
const uploadVersion = async () => {
|
||||
if (!versionFile || !newVersion) return
|
||||
|
||||
setIsUploading(true)
|
||||
setUploadError('')
|
||||
setUploadSuccess(false)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', versionFile)
|
||||
formData.append('version', newVersion)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/upload/version', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Upload failed')
|
||||
}
|
||||
|
||||
await fetchCurrentVersion()
|
||||
setUploadSuccess(true)
|
||||
resetForm()
|
||||
setTimeout(() => {
|
||||
setUploadSuccess(false)
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
console.error('上传失败:', err)
|
||||
setUploadError('上传失败,请重试')
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchCurrentVersion()
|
||||
}, [fetchCurrentVersion])
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-6">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-8">软件版本管理</h1>
|
||||
|
||||
{/* 当前版本信息卡片 */}
|
||||
<div className="bg-white rounded-lg shadow-md mb-8 overflow-hidden">
|
||||
<div className="bg-gray-50 px-6 py-4 border-b">
|
||||
<h2 className="text-xl font-semibold text-gray-700">当前版本信息</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
{currentVersion ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-12 gap-4 items-center">
|
||||
<span className="text-gray-600 col-span-2">版本号:</span>
|
||||
<span className="font-medium text-gray-800 col-span-10">{currentVersion.version}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-4 items-center">
|
||||
<span className="text-gray-600 col-span-2">下载地址:</span>
|
||||
<a
|
||||
href={currentVersion.download_url}
|
||||
className="text-blue-600 hover:text-blue-800 break-all col-span-10"
|
||||
>
|
||||
{currentVersion.download_url}
|
||||
</a>
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-4 items-center">
|
||||
<span className="text-gray-600 col-span-2">校验和:</span>
|
||||
<span className="font-mono text-sm text-gray-700 break-all col-span-10">
|
||||
{currentVersion.checksum}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-500 italic">暂无版本信息</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 上传新版本表单 */}
|
||||
<div className="bg-white rounded-lg shadow-md">
|
||||
<div className="bg-gray-50 px-6 py-4 border-b">
|
||||
<h2 className="text-xl font-semibold text-gray-700">上传新版本</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 版本号输入 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
版本号
|
||||
</label>
|
||||
<input
|
||||
value={newVersion}
|
||||
onChange={(e) => setNewVersion(e.target.value)}
|
||||
type="text"
|
||||
className="w-full px-4 py-2 border rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition"
|
||||
placeholder="例如: 1.0.0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 文件上传 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
选择文件
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg px-6 py-8">
|
||||
<div className="text-center">
|
||||
<input
|
||||
type="file"
|
||||
onChange={handleVersionFileChange}
|
||||
className="hidden"
|
||||
id="file-upload"
|
||||
/>
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className="cursor-pointer inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
选择文件
|
||||
</label>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
{versionFile?.name || '未选择文件'}
|
||||
</p>
|
||||
</div>
|
||||
{versionFileHash && (
|
||||
<div className="mt-4 text-center">
|
||||
<p className="text-xs text-gray-500">SHA-256 校验和:</p>
|
||||
<p className="font-mono text-xs text-gray-600 break-all">
|
||||
{versionFileHash}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{uploadError && (
|
||||
<div className="text-red-600 text-sm">
|
||||
{uploadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 成功提示 */}
|
||||
{uploadSuccess && (
|
||||
<div className="bg-green-50 text-green-800 px-4 py-2 rounded-md text-sm">
|
||||
上传成功!
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={uploadVersion}
|
||||
disabled={!canUploadVersion}
|
||||
className="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isUploading && (
|
||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
)}
|
||||
{isUploading ? '上传中...' : '上传新版本'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
6
bun.lock
6
bun.lock
@ -11,6 +11,8 @@
|
||||
"@types/multer": "^1.4.13",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"cors": "^2.8.5",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.525.0",
|
||||
"minio": "^8.0.5",
|
||||
"multer": "^2.0.1",
|
||||
"next": "15.3.4",
|
||||
@ -235,6 +237,8 @@
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
|
||||
|
||||
"decode-uri-component": ["decode-uri-component@0.2.2", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="],
|
||||
|
||||
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
|
||||
@ -319,6 +323,8 @@
|
||||
|
||||
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||
|
||||
"lucide-react": ["lucide-react@0.525.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
@ -20,6 +20,8 @@
|
||||
"@types/multer": "^1.4.13",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"cors": "^2.8.5",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.525.0",
|
||||
"minio": "^8.0.5",
|
||||
"multer": "^2.0.1",
|
||||
"next": "15.3.4",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user