PingWatch 网络设备离线监控系统
- FastAPI 后端 + Vue 3 前端 - Docker Compose 一键部署 - Casdoor OAuth 认证集成 - LogHive 集中式日志 - 设备批量 CSV 导入/导出 - WebSocket 实时状态推送 - 企业微信告警通知 - fping 高性能并发 Ping 检测 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
# 构建阶段
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# 运行阶段
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PingWatch - 网络设备监控</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Vue 路由历史模式支持
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API 反向代理
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# WebSocket 反向代理
|
||||
location /ws {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
Generated
+1865
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "pingwatch-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.3.0",
|
||||
"pinia": "^2.1.0",
|
||||
"axios": "^1.7.0",
|
||||
"echarts": "^5.5.0",
|
||||
"vue-echarts": "^7.0.0",
|
||||
"element-plus": "^2.7.0",
|
||||
"@element-plus/icons-vue": "^2.3.0",
|
||||
"dayjs": "^1.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import router from '@/router'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
// 请求拦截器:注入 token
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// 响应拦截器:统一错误处理
|
||||
api.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
(error) => {
|
||||
if (error.response) {
|
||||
const { status, data } = error.response
|
||||
if (status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
router.push('/login')
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
} else if (status === 403) {
|
||||
ElMessage.error('权限不足')
|
||||
} else {
|
||||
ElMessage.error(data?.detail || `请求失败 (${status})`)
|
||||
}
|
||||
} else {
|
||||
ElMessage.error('网络错误')
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// ========== 认证 ==========
|
||||
export const authApi = {
|
||||
login(code) {
|
||||
return api.post('/auth/login', { code })
|
||||
},
|
||||
getMe() {
|
||||
return api.get('/auth/me')
|
||||
},
|
||||
}
|
||||
|
||||
// ========== 设备 ==========
|
||||
export const deviceApi = {
|
||||
list(params) {
|
||||
return api.get('/devices', { params })
|
||||
},
|
||||
get(id) {
|
||||
return api.get(`/devices/${id}`)
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/devices', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return api.put(`/devices/${id}`, data)
|
||||
},
|
||||
delete(id) {
|
||||
return api.delete(`/devices/${id}`)
|
||||
},
|
||||
downloadTemplate() {
|
||||
return api.get('/devices/template/download', { responseType: 'blob' })
|
||||
},
|
||||
importDevices(file) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return api.post('/devices/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// ========== 告警 ==========
|
||||
export const alertApi = {
|
||||
list(params) {
|
||||
return api.get('/alerts', { params })
|
||||
},
|
||||
getLatest(limit = 10) {
|
||||
return api.get('/alerts/latest', { params: { limit } })
|
||||
},
|
||||
}
|
||||
|
||||
// ========== 统计 ==========
|
||||
export const statsApi = {
|
||||
getSummary() {
|
||||
return api.get('/stats/summary')
|
||||
},
|
||||
getDashboard() {
|
||||
return api.get('/stats/dashboard')
|
||||
},
|
||||
getOfflineTrend(days = 7) {
|
||||
return api.get('/stats/offline-trend', { params: { days } })
|
||||
},
|
||||
getOnlineRateTrend(days = 7) {
|
||||
return api.get('/stats/online-rate-trend', { params: { days } })
|
||||
},
|
||||
getPacketLossTop(limit = 10, days = 7) {
|
||||
return api.get('/stats/packet-loss-top', { params: { limit, days } })
|
||||
},
|
||||
}
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// 注册所有 Element Plus 图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/views/Layout.vue'),
|
||||
redirect: '/dashboard',
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/Dashboard.vue'),
|
||||
meta: { title: '仪表盘' },
|
||||
},
|
||||
{
|
||||
path: 'devices',
|
||||
name: 'Devices',
|
||||
component: () => import('@/views/Devices.vue'),
|
||||
meta: { title: '设备列表' },
|
||||
},
|
||||
{
|
||||
path: 'alerts',
|
||||
name: 'Alerts',
|
||||
component: () => import('@/views/Alerts.vue'),
|
||||
meta: { title: '告警记录' },
|
||||
},
|
||||
{
|
||||
path: 'stats',
|
||||
name: 'Stats',
|
||||
component: () => import('@/views/Stats.vue'),
|
||||
meta: { title: '统计分析' },
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'Settings',
|
||||
component: () => import('@/views/Settings.vue'),
|
||||
meta: { title: '系统设置', adminOnly: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
// 路由守卫:检查登录
|
||||
router.beforeEach((to, from, next) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (to.name !== 'Login' && !token) {
|
||||
next({ name: 'Login' })
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,85 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { authApi, statsApi } from '@/api'
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
// 用户状态
|
||||
const user = ref(JSON.parse(localStorage.getItem('user') || 'null'))
|
||||
const token = ref(localStorage.getItem('token') || '')
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||
|
||||
function setUser(userData, tokenStr) {
|
||||
user.value = userData
|
||||
token.value = tokenStr
|
||||
localStorage.setItem('user', JSON.stringify(userData))
|
||||
localStorage.setItem('token', tokenStr)
|
||||
}
|
||||
|
||||
function logout() {
|
||||
user.value = null
|
||||
token.value = ''
|
||||
localStorage.removeItem('user')
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
|
||||
// 仪表盘统计缓存
|
||||
const dashboardData = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchDashboard() {
|
||||
loading.value = true
|
||||
try {
|
||||
dashboardData.value = await statsApi.getDashboard()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket 连接
|
||||
let ws = null
|
||||
|
||||
function connectWebSocket() {
|
||||
if (ws) return
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const wsUrl = `${protocol}//${location.host}/ws`
|
||||
ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[WS] 已连接')
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
if (data.type === 'device_status_change') {
|
||||
// 触发 dashboard 刷新
|
||||
fetchDashboard()
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('[WS] 已断开,3秒后重连')
|
||||
ws = null
|
||||
setTimeout(() => connectWebSocket(), 3000)
|
||||
}
|
||||
|
||||
// 心跳
|
||||
setInterval(() => {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send('ping')
|
||||
}
|
||||
}, 30000)
|
||||
}
|
||||
|
||||
return {
|
||||
user, token, isLoggedIn, isAdmin,
|
||||
setUser, logout,
|
||||
dashboardData, loading, fetchDashboard,
|
||||
connectWebSocket,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div class="alerts-page">
|
||||
<!-- 过滤栏 -->
|
||||
<el-card shadow="hover">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="5">
|
||||
<el-select v-model="filterType" placeholder="告警类型" clearable @change="fetchAlerts" style="width:100%">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="离线" value="offline" />
|
||||
<el-option label="恢复" value="recovered" />
|
||||
<el-option label="系统" value="system" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="5">
|
||||
<el-select v-model="filterResolved" placeholder="状态" clearable @change="fetchAlerts" style="width:100%">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="未恢复" :value="false" />
|
||||
<el-option label="已恢复" :value="true" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="5">
|
||||
<el-date-picker v-model="dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" @change="fetchAlerts" style="width:100%" />
|
||||
</el-col>
|
||||
<el-col :span="5" :offset="4" style="text-align: right">
|
||||
<el-button @click="fetchAlerts"><el-icon><Refresh /></el-icon> 刷新</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- 告警列表 -->
|
||||
<el-card shadow="hover" style="margin-top: 16px">
|
||||
<el-table :data="alerts" stripe v-loading="loading" max-height="calc(100vh - 280px)">
|
||||
<el-table-column type="index" label="#" width="50" />
|
||||
<el-table-column prop="device_name" label="设备" width="130" />
|
||||
<el-table-column prop="device_ip" label="IP" width="130" />
|
||||
<el-table-column prop="device_type" label="类型" width="80">
|
||||
<template #default="{ row }">
|
||||
{{ typeMap[row.device_type] || row.device_type }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="location" label="位置" width="130" show-overflow-tooltip />
|
||||
<el-table-column prop="alert_type" label="告警类型" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.alert_type === 'offline' ? 'danger' : row.alert_type === 'recovered' ? 'success' : 'info'" size="small">
|
||||
{{ row.alert_type === 'offline' ? '离线' : row.alert_type === 'recovered' ? '恢复' : '系统' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="duration_minutes" label="离线时长(分)" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.duration_minutes ?? '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="is_resolved" label="是否恢复" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_resolved ? 'success' : 'danger'" size="small">
|
||||
{{ row.is_resolved ? '已恢复' : '未恢复' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="时间" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@change="fetchAlerts"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { alertApi } from '@/api'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const alerts = ref([])
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const filterType = ref('')
|
||||
const filterResolved = ref('')
|
||||
const dateRange = ref(null)
|
||||
|
||||
const typeMap = { server: '服务器', olt: 'OLT', switch: '交换机', firewall: '防火墙', other: '其他' }
|
||||
|
||||
async function fetchAlerts() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { page: page.value, page_size: pageSize.value }
|
||||
if (filterType.value) params.alert_type = filterType.value
|
||||
if (filterResolved.value !== '') params.is_resolved = filterResolved.value
|
||||
if (dateRange.value) {
|
||||
params.start_time = dayjs(dateRange.value[0]).startOf('day').toISOString()
|
||||
params.end_time = dayjs(dateRange.value[1]).endOf('day').toISOString()
|
||||
}
|
||||
const data = await alertApi.list(params)
|
||||
alerts.value = data.items
|
||||
total.value = data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchAlerts)
|
||||
|
||||
function formatTime(t) {
|
||||
return t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pagination-wrap {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<!-- 状态卡片 -->
|
||||
<el-row :gutter="20" class="stat-cards">
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card total">
|
||||
<div class="stat-value">{{ summary?.total || 0 }}</div>
|
||||
<div class="stat-label">总设备数</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card online">
|
||||
<div class="stat-value">{{ summary?.online || 0 }}</div>
|
||||
<div class="stat-label">在线</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card offline">
|
||||
<div class="stat-value">{{ summary?.offline || 0 }}</div>
|
||||
<div class="stat-label">离线</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card rate">
|
||||
<div class="stat-value">{{ summary?.online_rate || 0 }}%</div>
|
||||
<div class="stat-label">在线率</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 离线趋势 & 丢包率排名 -->
|
||||
<el-row :gutter="20" style="margin-top: 20px">
|
||||
<el-col :span="14">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<span>离线趋势(近7天)</span>
|
||||
</template>
|
||||
<v-chart :option="trendOption" style="height: 320px" autoresize />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="10">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<span>丢包率排名 TOP 10</span>
|
||||
</template>
|
||||
<el-table :data="topLossDevices" stripe size="small" max-height="320">
|
||||
<el-table-column prop="device_name" label="设备" width="100" show-overflow-tooltip />
|
||||
<el-table-column prop="packet_loss_rate" label="丢包率" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.packet_loss_rate > 10 ? 'danger' : row.packet_loss_rate > 3 ? 'warning' : 'success'" size="small">
|
||||
{{ row.packet_loss_rate }}%
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="offline_count" label="离线次数" width="80" />
|
||||
</el-table>
|
||||
<div v-if="!topLossDevices.length" class="empty-hint">暂无数据</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 最近告警 -->
|
||||
<el-card shadow="hover" style="margin-top: 20px">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>最近告警</span>
|
||||
<el-button text type="primary" @click="$router.push('/alerts')">查看全部</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="recentAlerts" stripe max-height="300">
|
||||
<el-table-column prop="device_name" label="设备" width="140" />
|
||||
<el-table-column prop="device_ip" label="IP" width="130" />
|
||||
<el-table-column prop="alert_type" label="类型" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.alert_type === 'offline' ? 'danger' : 'success'" size="small">
|
||||
{{ row.alert_type === 'offline' ? '离线' : '恢复' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="duration_minutes" label="时长(分)" width="80" />
|
||||
<el-table-column prop="location" label="位置" width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="created_at" label="时间" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { statsApi } from '@/api'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const store = useAppStore()
|
||||
|
||||
const summary = ref(null)
|
||||
const recentAlerts = ref([])
|
||||
const topLossDevices = ref([])
|
||||
const offlineTrend = ref([])
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
|
||||
watch(() => store.dashboardData, (data) => {
|
||||
if (data) {
|
||||
summary.value = data.summary
|
||||
recentAlerts.value = data.recent_offline || []
|
||||
topLossDevices.value = data.packet_loss_top || []
|
||||
offlineTrend.value = data.offline_trend || []
|
||||
}
|
||||
})
|
||||
|
||||
async function fetchData() {
|
||||
await store.fetchDashboard()
|
||||
if (store.dashboardData) {
|
||||
summary.value = store.dashboardData.summary
|
||||
recentAlerts.value = store.dashboardData.recent_offline || []
|
||||
topLossDevices.value = store.dashboardData.packet_loss_top || []
|
||||
offlineTrend.value = store.dashboardData.offline_trend || []
|
||||
}
|
||||
}
|
||||
|
||||
const trendOption = computed(() => ({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: offlineTrend.value.map(i => dayjs(i.time).format('MM-DD')),
|
||||
boundaryGap: false,
|
||||
},
|
||||
yAxis: { type: 'value', minInterval: 1 },
|
||||
series: [{
|
||||
name: '离线次数',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: offlineTrend.value.map(i => i.value),
|
||||
areaStyle: {
|
||||
color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [{ offset: 0, color: 'rgba(64,158,255,0.3)' }, { offset: 1, color: 'rgba(64,158,255,0.05)' }] }
|
||||
},
|
||||
lineStyle: { color: '#409eff' },
|
||||
itemStyle: { color: '#409eff' },
|
||||
}],
|
||||
}))
|
||||
|
||||
function formatTime(t) {
|
||||
return t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-cards .stat-card {
|
||||
text-align: center;
|
||||
}
|
||||
.stat-cards .stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.stat-cards .stat-label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.total .stat-value { color: #409eff; }
|
||||
.online .stat-value { color: #67c23a; }
|
||||
.offline .stat-value { color: #f56c6c; }
|
||||
.rate .stat-value { color: #e6a23c; }
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.empty-hint {
|
||||
text-align: center;
|
||||
color: #c0c4cc;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,320 @@
|
||||
<template>
|
||||
<div class="devices-page">
|
||||
<!-- 工具栏 -->
|
||||
<el-card shadow="hover">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6">
|
||||
<el-input v-model="search" placeholder="搜索设备名/IP/位置" clearable @input="fetchDevices" />
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-select v-model="filterType" placeholder="设备类型" clearable @change="fetchDevices" style="width:100%">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="服务器" value="server" />
|
||||
<el-option label="OLT" value="olt" />
|
||||
<el-option label="交换机" value="switch" />
|
||||
<el-option label="防火墙" value="firewall" />
|
||||
<el-option label="其他" value="other" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-select v-model="filterStatus" placeholder="状态" clearable @change="fetchDevices" style="width:100%">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="在线" value="online" />
|
||||
<el-option label="离线" value="offline" />
|
||||
<el-option label="检测中" value="checking" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="6" :offset="4" style="text-align: right">
|
||||
<el-button v-if="store.isAdmin" type="primary" @click="showAddDialog">
|
||||
<el-icon><Plus /></el-icon> 添加设备
|
||||
</el-button>
|
||||
<el-button v-if="store.isAdmin" @click="downloadTemplate">
|
||||
<el-icon><Download /></el-icon> 下载模板
|
||||
</el-button>
|
||||
<el-button v-if="store.isAdmin" @click="showImportDialog">
|
||||
<el-icon><Upload /></el-icon> 批量导入
|
||||
</el-button>
|
||||
<el-button @click="fetchDevices">
|
||||
<el-icon><Refresh /></el-icon> 刷新
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- 设备列表 -->
|
||||
<el-card shadow="hover" style="margin-top: 16px">
|
||||
<el-table :data="devices" stripe v-loading="loading" max-height="calc(100vh - 280px)">
|
||||
<el-table-column prop="name" label="设备名称" width="140" fixed />
|
||||
<el-table-column prop="ip" label="IP 地址" width="140" />
|
||||
<el-table-column prop="device_type" label="类型" width="90">
|
||||
<template #default="{ row }">
|
||||
{{ typeMap[row.device_type] || row.device_type }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="current_status" label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.current_status)" size="small">
|
||||
{{ statusMap[row.current_status] || row.current_status }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="location" label="位置" width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="project_name" label="项目" width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="consecutive_failures" label="连续失败" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: row.consecutive_failures > 0 ? '#f56c6c' : '#67c23a' }">
|
||||
{{ row.consecutive_failures }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="last_ping_time" label="最后 Ping" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ row.last_ping_time ? dayjs(row.last_ping_time).format('MM-DD HH:mm:ss') : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" fixed="right" v-if="store.isAdmin">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" size="small" @click="showEditDialog(row)">编辑</el-button>
|
||||
<el-button text type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 批量导入对话框 -->
|
||||
<el-dialog v-model="importDialogVisible" title="批量导入设备" width="560px" @closed="importResult = null">
|
||||
<div style="margin-bottom: 16px; color: #606266; font-size: 14px">
|
||||
1. 先<a href="#" @click.prevent="downloadTemplate">下载模板</a>,按模板格式填写设备信息<br />
|
||||
2. 选择填写好的 CSV 文件上传
|
||||
</div>
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
drag
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
accept=".csv"
|
||||
:on-change="handleFileChange"
|
||||
:on-remove="handleFileRemove"
|
||||
>
|
||||
<el-icon :size="40"><UploadFilled /></el-icon>
|
||||
<div style="margin-top: 8px">将 CSV 文件拖到此处或点击选择</div>
|
||||
</el-upload>
|
||||
<template #footer>
|
||||
<el-button @click="importDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="importing" :disabled="!importFile" @click="handleImport">
|
||||
开始导入
|
||||
</el-button>
|
||||
</template>
|
||||
<!-- 导入结果 -->
|
||||
<div v-if="importResult" style="margin-top: 16px">
|
||||
<el-alert
|
||||
:title="`成功导入 ${importResult.devices_added} 台设备`"
|
||||
type="success"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<div v-if="importResult.errors?.length" style="margin-top: 8px">
|
||||
<p style="color: #f56c6c; font-size: 13px">以下行导入失败:</p>
|
||||
<ul style="color: #909399; font-size: 13px; padding-left: 20px">
|
||||
<li v-for="(err, i) in importResult.errors" :key="i">{{ err }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 添加/编辑对话框 -->
|
||||
<el-dialog v-model="dialogVisible" :title="isEditing ? '编辑设备' : '添加设备'" width="520px">
|
||||
<el-form :model="form" label-width="100px" :rules="rules" ref="formRef">
|
||||
<el-form-item label="设备名称" prop="name">
|
||||
<el-input v-model="form.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="IP 地址" prop="ip">
|
||||
<el-input v-model="form.ip" />
|
||||
</el-form-item>
|
||||
<el-form-item label="设备类型" prop="device_type">
|
||||
<el-select v-model="form.device_type" style="width:100%">
|
||||
<el-option label="服务器" value="server" />
|
||||
<el-option label="OLT" value="olt" />
|
||||
<el-option label="交换机" value="switch" />
|
||||
<el-option label="防火墙" value="firewall" />
|
||||
<el-option label="其他" value="other" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="位置" prop="location">
|
||||
<el-input v-model="form.location" placeholder="如:县公安局机房" />
|
||||
</el-form-item>
|
||||
<el-form-item label="所属项目" prop="project_name">
|
||||
<el-input v-model="form.project_name" placeholder="如:校园安防4+N项目" />
|
||||
</el-form-item>
|
||||
<el-form-item label="离线阈值" prop="alert_threshold">
|
||||
<el-input-number v-model="form.alert_threshold" :min="1" :max="20" />
|
||||
<span style="margin-left: 8px; color: #909399; font-size: 13px">连续失败次数</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否启用">
|
||||
<el-switch v-model="form.is_enabled" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { deviceApi } from '@/api'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const store = useAppStore()
|
||||
|
||||
const devices = ref([])
|
||||
const loading = ref(false)
|
||||
const search = ref('')
|
||||
const filterType = ref('')
|
||||
const filterStatus = ref('')
|
||||
|
||||
// 批量导入
|
||||
const importDialogVisible = ref(false)
|
||||
const importFile = ref(null)
|
||||
const importing = ref(false)
|
||||
const uploadRef = ref(null)
|
||||
const importResult = ref(null)
|
||||
|
||||
function showImportDialog() {
|
||||
importDialogVisible.value = true
|
||||
}
|
||||
|
||||
function handleFileChange(file) {
|
||||
importFile.value = file.raw
|
||||
importResult.value = null
|
||||
}
|
||||
|
||||
function handleFileRemove() {
|
||||
importFile.value = null
|
||||
importResult.value = null
|
||||
}
|
||||
|
||||
async function downloadTemplate() {
|
||||
try {
|
||||
const blob = await deviceApi.downloadTemplate()
|
||||
const url = window.URL.createObjectURL(new Blob([blob]))
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'device_template.csv'
|
||||
link.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
// error handled by interceptor
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!importFile.value) return
|
||||
importing.value = true
|
||||
try {
|
||||
const result = await deviceApi.importDevices(importFile.value)
|
||||
importResult.value = result
|
||||
if (result.devices_added > 0) {
|
||||
ElMessage.success(`成功导入 ${result.devices_added} 台设备`)
|
||||
fetchDevices()
|
||||
}
|
||||
if (result.errors?.length) {
|
||||
ElMessage.warning(`${result.errors.length} 条数据导入失败`)
|
||||
}
|
||||
uploadRef.value?.clearFiles()
|
||||
importFile.value = null
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const typeMap = { server: '服务器', olt: 'OLT', switch: '交换机', firewall: '防火墙', other: '其他' }
|
||||
const statusMap = { online: '在线', offline: '离线', checking: '检测中', unknown: '未知' }
|
||||
|
||||
function statusTagType(status) {
|
||||
return { online: 'success', offline: 'danger', checking: 'warning', unknown: 'info' }[status] || 'info'
|
||||
}
|
||||
|
||||
async function fetchDevices() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {}
|
||||
if (search.value) params.search = search.value
|
||||
if (filterType.value) params.device_type = filterType.value
|
||||
|
||||
const list = await deviceApi.list(params)
|
||||
if (filterStatus.value) {
|
||||
devices.value = list.filter(d => d.current_status === filterStatus.value)
|
||||
} else {
|
||||
devices.value = list
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchDevices)
|
||||
|
||||
// 添加/编辑对话框
|
||||
const dialogVisible = ref(false)
|
||||
const isEditing = ref(false)
|
||||
const editingId = ref(null)
|
||||
const saving = ref(false)
|
||||
const formRef = ref(null)
|
||||
|
||||
const form = ref({
|
||||
name: '', ip: '', device_type: 'server', location: '', project_name: '',
|
||||
alert_threshold: 5, is_enabled: true,
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入设备名称' }],
|
||||
ip: [{ required: true, message: '请输入 IP 地址' }],
|
||||
device_type: [{ required: true, message: '请选择设备类型' }],
|
||||
}
|
||||
|
||||
function showAddDialog() {
|
||||
isEditing.value = false
|
||||
editingId.value = null
|
||||
form.value = { name: '', ip: '', device_type: 'server', location: '', project_name: '', alert_threshold: 5, is_enabled: true }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function showEditDialog(device) {
|
||||
isEditing.value = true
|
||||
editingId.value = device.id
|
||||
form.value = { ...device }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
await deviceApi.update(editingId.value, form.value)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await deviceApi.create(form.value)
|
||||
ElMessage.success('添加成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchDevices()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(device) {
|
||||
await ElMessageBox.confirm(`确定删除设备「${device.name}」吗?`, '警告', { type: 'warning' })
|
||||
await deviceApi.delete(device.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchDevices()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<el-container class="layout-container">
|
||||
<!-- 侧边栏 -->
|
||||
<el-aside :width="isCollapse ? '64px' : '220px'" class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<span v-if="!isCollapse" class="sidebar-title">PingWatch</span>
|
||||
<el-icon v-else :size="24"><Monitor /></el-icon>
|
||||
</div>
|
||||
|
||||
<el-menu
|
||||
:default-active="route.path"
|
||||
:collapse="isCollapse"
|
||||
:router="true"
|
||||
background-color="#1d1e1f"
|
||||
text-color="#bfcbd9"
|
||||
active-text-color="#409eff"
|
||||
>
|
||||
<el-menu-item index="/dashboard">
|
||||
<el-icon><DataBoard /></el-icon>
|
||||
<span>仪表盘</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/devices">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
<span>设备列表</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/alerts">
|
||||
<el-icon><WarningFilled /></el-icon>
|
||||
<span>告警记录</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/stats">
|
||||
<el-icon><TrendCharts /></el-icon>
|
||||
<span>统计分析</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item v-if="store.isAdmin" index="/settings">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>系统设置</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<el-container>
|
||||
<!-- 顶部栏 -->
|
||||
<el-header class="header">
|
||||
<div class="header-left">
|
||||
<el-icon
|
||||
:size="20"
|
||||
class="collapse-btn"
|
||||
@click="isCollapse = !isCollapse"
|
||||
>
|
||||
<Fold v-if="!isCollapse" />
|
||||
<Expand v-else />
|
||||
</el-icon>
|
||||
<el-breadcrumb separator="/">
|
||||
<el-breadcrumb-item :to="{ path: '/dashboard' }">首页</el-breadcrumb-item>
|
||||
<el-breadcrumb-item v-if="route.meta.title">
|
||||
{{ route.meta.title }}
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-dropdown @command="handleCommand">
|
||||
<span class="user-info">
|
||||
{{ store.user?.display_name || store.user?.username }}
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="profile">
|
||||
<el-icon><User /></el-icon>个人信息
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="logout" divided>
|
||||
<el-icon><SwitchButton /></el-icon>退出登录
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- 主体内容 -->
|
||||
<el-main class="main-content">
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
const isCollapse = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
store.connectWebSocket()
|
||||
})
|
||||
|
||||
function handleCommand(command) {
|
||||
if (command === 'logout') {
|
||||
ElMessageBox.confirm('确定要退出登录吗?', '提示').then(() => {
|
||||
store.logout()
|
||||
router.push('/login')
|
||||
ElMessage.success('已退出')
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-container {
|
||||
height: 100vh;
|
||||
}
|
||||
.sidebar {
|
||||
background-color: #1d1e1f;
|
||||
transition: width 0.3s;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sidebar-header {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
.sidebar-title {
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.header {
|
||||
background: white;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
height: 60px;
|
||||
}
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.collapse-btn {
|
||||
cursor: pointer;
|
||||
color: #606266;
|
||||
}
|
||||
.collapse-btn:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.user-info {
|
||||
cursor: pointer;
|
||||
color: #606266;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.user-info:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
.main-content {
|
||||
background: #f5f7fa;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<h1>PingWatch</h1>
|
||||
<p>网络设备离线监控系统</p>
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
class="login-btn"
|
||||
>
|
||||
<el-icon style="margin-right: 8px"><User /></el-icon>
|
||||
Casdoor 统一登录
|
||||
</el-button>
|
||||
<div class="login-footer">
|
||||
首次登录将自动创建账号
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { authApi } from '@/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
const loading = ref(false)
|
||||
|
||||
// Casdoor 配置
|
||||
const CASDOOR_ENDPOINT = 'https://casdoor.dhdx.fun'
|
||||
const CLIENT_ID = 'e46b9e1eb893027bdf2a'
|
||||
const ORGANIZATION = 'dahua'
|
||||
const APPLICATION = 'PingWatch'
|
||||
const REDIRECT_URI = `${window.location.origin}/login`
|
||||
const SCOPE = 'openid profile email'
|
||||
|
||||
onMounted(() => {
|
||||
// 检查是否从 Casdoor 回调回来(带 code 参数)
|
||||
const code = route.query.code
|
||||
if (code) {
|
||||
handleCallback(code)
|
||||
}
|
||||
})
|
||||
|
||||
async function handleLogin() {
|
||||
// 构造 Casdoor OAuth 授权 URL 并跳转
|
||||
const params = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
response_type: 'code',
|
||||
redirect_uri: REDIRECT_URI,
|
||||
scope: SCOPE,
|
||||
state: generateState(),
|
||||
organization: ORGANIZATION,
|
||||
application: APPLICATION,
|
||||
})
|
||||
const authorizeUrl = `${CASDOOR_ENDPOINT}/login/oauth/authorize?${params.toString()}`
|
||||
window.location.href = authorizeUrl
|
||||
}
|
||||
|
||||
async function handleCallback(code) {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await authApi.login(code)
|
||||
store.setUser(res.user, res.token)
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/dashboard')
|
||||
} catch (e) {
|
||||
// 错误已在 axios 拦截器中处理
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成随机 state,防止 CSRF */
|
||||
function generateState() {
|
||||
const array = new Uint8Array(16)
|
||||
crypto.getRandomValues(array)
|
||||
return Array.from(array, b => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
.login-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
|
||||
min-width: 380px;
|
||||
}
|
||||
.login-header h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
color: #303133;
|
||||
}
|
||||
.login-header p {
|
||||
color: #909399;
|
||||
margin: 8px 0 32px;
|
||||
}
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
}
|
||||
.login-footer {
|
||||
margin-top: 24px;
|
||||
font-size: 13px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<!-- 用户管理 -->
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<span>用户管理</span>
|
||||
</template>
|
||||
<el-table :data="users" stripe v-loading="loading">
|
||||
<el-table-column prop="username" label="用户名" width="140" />
|
||||
<el-table-column prop="display_name" label="显示名称" width="140" />
|
||||
<el-table-column prop="role" label="角色" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.role === 'admin' ? 'danger' : 'info'" size="small">
|
||||
{{ row.role === 'admin' ? '管理员' : '查看者' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="last_login_at" label="最后登录" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ row.last_login_at ? dayjs(row.last_login_at).format('YYYY-MM-DD HH:mm') : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
text
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="toggleRole(row)"
|
||||
:disabled="row.id === currentUserId"
|
||||
>
|
||||
{{ row.role === 'admin' ? '设为查看者' : '设为管理员' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 系统配置 -->
|
||||
<el-card shadow="hover" style="margin-top: 20px">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>数据保留设置</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-form label-width="150px">
|
||||
<el-form-item label="Ping 记录保留">
|
||||
<el-input-number v-model="pingRetention" :min="7" :max="365" /> 天
|
||||
</el-form-item>
|
||||
<el-form-item label="告警记录保留">
|
||||
<el-input-number v-model="alertRetention" :min="30" :max="730" /> 天
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="saveConfig">保存设置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import dayjs from 'dayjs'
|
||||
import api from '@/api'
|
||||
|
||||
const store = useAppStore()
|
||||
const currentUserId = computed(() => store.user?.id)
|
||||
|
||||
const users = ref([])
|
||||
const loading = ref(false)
|
||||
const pingRetention = ref(90)
|
||||
const alertRetention = ref(365)
|
||||
|
||||
onMounted(() => {
|
||||
fetchUsers()
|
||||
})
|
||||
|
||||
async function fetchUsers() {
|
||||
loading.value = true
|
||||
try {
|
||||
users.value = await api.get('/users')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRole(user) {
|
||||
const newRole = user.role === 'admin' ? 'viewer' : 'admin'
|
||||
await ElMessageBox.confirm(
|
||||
`确定将「${user.display_name || user.username}」${newRole === 'admin' ? '设为管理员' : '降为查看者'}?`,
|
||||
'提示'
|
||||
)
|
||||
await api.put(`/users/${user.id}/role`, { role: newRole })
|
||||
ElMessage.success('已更新')
|
||||
fetchUsers()
|
||||
}
|
||||
|
||||
function saveConfig() {
|
||||
ElMessage.success('设置已保存(需要在后端更新配置)')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<div class="stats-page">
|
||||
<!-- 在线率趋势 -->
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>在线率趋势</span>
|
||||
<el-radio-group v-model="rateDays" size="small" @change="fetchRateTrend">
|
||||
<el-radio-button :value="7">7天</el-radio-button>
|
||||
<el-radio-button :value="30">30天</el-radio-button>
|
||||
<el-radio-button :value="90">90天</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</template>
|
||||
<v-chart :option="rateTrendOption" style="height: 360px" autoresize />
|
||||
</el-card>
|
||||
|
||||
<el-row :gutter="20" style="margin-top: 20px">
|
||||
<!-- 离线趋势 -->
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>离线次数趋势</span>
|
||||
<el-radio-group v-model="offlineDays" size="small" @change="fetchOfflineTrend">
|
||||
<el-radio-button :value="7">7天</el-radio-button>
|
||||
<el-radio-button :value="30">30天</el-radio-button>
|
||||
<el-radio-button :value="90">90天</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</template>
|
||||
<v-chart :option="offlineTrendOption" style="height: 300px" autoresize />
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 丢包率排名 -->
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>丢包率排名</span>
|
||||
<el-radio-group v-model="lossDays" size="small" @change="fetchLossTop">
|
||||
<el-radio-button :value="7">7天</el-radio-button>
|
||||
<el-radio-button :value="30">30天</el-radio-button>
|
||||
<el-radio-button :value="90">90天</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="lossTop" stripe size="small" max-height="300">
|
||||
<el-table-column type="index" label="#" width="40" />
|
||||
<el-table-column prop="device_name" label="设备" width="100" show-overflow-tooltip />
|
||||
<el-table-column prop="packet_loss_rate" label="丢包率" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.packet_loss_rate > 10 ? 'danger' : 'warning'" size="small">
|
||||
{{ row.packet_loss_rate }}%
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="offline_count" label="离线次数" width="80" />
|
||||
<el-table-column prop="total_offline_duration" label="总离线(分)" width="90" />
|
||||
<el-table-column prop="avg_response_time" label="平均响应(ms)" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.avg_response_time ?? '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { statsApi } from '@/api'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const rateDays = ref(7)
|
||||
const offlineDays = ref(7)
|
||||
const lossDays = ref(7)
|
||||
const rateTrend = ref([])
|
||||
const offlineTrend = ref([])
|
||||
const lossTop = ref([])
|
||||
|
||||
onMounted(() => {
|
||||
fetchRateTrend()
|
||||
fetchOfflineTrend()
|
||||
fetchLossTop()
|
||||
})
|
||||
|
||||
async function fetchRateTrend() {
|
||||
const data = await statsApi.getOnlineRateTrend(rateDays.value)
|
||||
rateTrend.value = data
|
||||
}
|
||||
|
||||
async function fetchOfflineTrend() {
|
||||
const data = await statsApi.getOfflineTrend(offlineDays.value)
|
||||
offlineTrend.value = data
|
||||
}
|
||||
|
||||
async function fetchLossTop() {
|
||||
const data = await statsApi.getPacketLossTop(10, lossDays.value)
|
||||
lossTop.value = data
|
||||
}
|
||||
|
||||
const rateTrendOption = computed(() => ({
|
||||
tooltip: { trigger: 'axis', valueFormatter: (v) => `${v}%` },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: rateTrend.value.map(i => dayjs(i.time).format('MM-DD')),
|
||||
boundaryGap: false,
|
||||
},
|
||||
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { formatter: '{value}%' } },
|
||||
series: [{
|
||||
name: '在线率',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: rateTrend.value.map(i => i.value),
|
||||
areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [{ offset: 0, color: 'rgba(103,194,58,0.3)' }, { offset: 1, color: 'rgba(103,194,58,0.05)' }] } },
|
||||
lineStyle: { color: '#67c23a' },
|
||||
itemStyle: { color: '#67c23a' },
|
||||
}],
|
||||
}))
|
||||
|
||||
const offlineTrendOption = computed(() => ({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: offlineTrend.value.map(i => dayjs(i.time).format('MM-DD')),
|
||||
boundaryGap: false,
|
||||
},
|
||||
yAxis: { type: 'value', minInterval: 1 },
|
||||
series: [{
|
||||
name: '离线次数',
|
||||
type: 'bar',
|
||||
data: offlineTrend.value.map(i => i.value),
|
||||
itemStyle: { color: '#f56c6c', borderRadius: [4, 4, 0, 0] },
|
||||
}],
|
||||
}))
|
||||
|
||||
// computed for lossTop chart (optional - table suffices)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
preserveSymlinks: true,
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8000',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user