first commit
This commit is contained in:
commit
064ef84469
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal 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
3
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
33
README.md
Normal file
33
README.md
Normal 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
|
||||
```
|
||||
13
index.html
Normal file
13
index.html
Normal 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
35
package.json
Normal 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
2835
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
71
src/App.vue
Normal file
71
src/App.vue
Normal 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
1
src/assets/logo.svg
Normal 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
3
src/assets/main.css
Normal file
@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
159
src/components/RecordDatePicker.vue
Normal file
159
src/components/RecordDatePicker.vue
Normal 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"><</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">></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()); // 0~11
|
||||
|
||||
// 星期标题(中文)
|
||||
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>
|
||||
236
src/components/TimelineSlider.vue
Normal file
236
src/components/TimelineSlider.vue
Normal 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
8
src/main.ts
Normal 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
27
src/router/index.ts
Normal 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
131
src/views/Base64Decoder.vue
Normal 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
818
src/views/HostDetail.vue
Normal 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
106
src/views/HostList.vue
Normal 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>
|
||||
206
src/views/VersionUploader.vue
Normal file
206
src/views/VersionUploader.vue
Normal 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
12
tailwind.config.js
Normal 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
12
tsconfig.app.json
Normal 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
11
tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
18
tsconfig.node.json
Normal file
18
tsconfig.node.json
Normal 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
12
vite.config.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user