feat: 初始化项目基础架构和核心功能
- 添加项目基础结构:Cargo.toml、.gitignore、设备UID和密钥文件 - 实现前端Vue3项目结构:路由、登录页面、设备管理页面 - 添加核心协议定义(crates/protocol):设备状态、资产、USB事件等 - 实现客户端监控模块:系统状态收集、资产收集 - 实现服务端基础API和插件系统 - 添加数据库迁移脚本:设备管理、资产跟踪、告警系统等 - 实现前端设备状态展示和基本交互 - 添加使用时长统计和水印功能插件
This commit is contained in:
9
web/auto-imports.d.ts
vendored
Normal file
9
web/auto-imports.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
export {}
|
||||
declare global {
|
||||
|
||||
}
|
||||
53
web/components.d.ts
vendored
Normal file
53
web/components.d.ts
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
export {}
|
||||
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ElAside: typeof import('element-plus/es')['ElAside']
|
||||
ElBadge: typeof import('element-plus/es')['ElBadge']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElMain: typeof import('element-plus/es')['ElMain']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPageHeader: typeof import('element-plus/es')['ElPageHeader']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElProgress: typeof import('element-plus/es')['ElProgress']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSlider: typeof import('element-plus/es')['ElSlider']
|
||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
export interface ComponentCustomProperties {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
}
|
||||
}
|
||||
13
web/index.html
Normal file
13
web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CSM - 企业终端管理系统</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
2759
web/package-lock.json
generated
Normal file
2759
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
web/package.json
Normal file
30
web/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "csm-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^10.7.2",
|
||||
"axios": "^1.6.7",
|
||||
"dayjs": "^1.11.10",
|
||||
"echarts": "^5.5.0",
|
||||
"element-plus": "^2.5.6",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.3",
|
||||
"typescript": "^5.3.3",
|
||||
"unplugin-auto-import": "^0.17.5",
|
||||
"unplugin-vue-components": "^0.26.0",
|
||||
"vite": "^5.0.12",
|
||||
"vue-tsc": "^3.2.6"
|
||||
}
|
||||
}
|
||||
8
web/src/App.vue
Normal file
8
web/src/App.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body, #app { height: 100%; }
|
||||
</style>
|
||||
126
web/src/lib/api.ts
Normal file
126
web/src/lib/api.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Shared API client with authentication and error handling
|
||||
*/
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || ''
|
||||
|
||||
export interface ApiResult<T> {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: string
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
}
|
||||
|
||||
function getToken(): string | null {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token || token.trim() === '') return null
|
||||
return token
|
||||
}
|
||||
|
||||
function clearAuth() {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const token = getToken()
|
||||
const headers = new Headers(options.headers || {})
|
||||
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`)
|
||||
}
|
||||
|
||||
if (options.body && typeof options.body === 'string') {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
})
|
||||
|
||||
// Handle 401 - token expired or invalid
|
||||
if (response.status === 401) {
|
||||
clearAuth()
|
||||
throw new ApiError(401, 'UNAUTHORIZED', 'Session expired')
|
||||
}
|
||||
|
||||
// Handle 403 - insufficient permissions
|
||||
if (response.status === 403) {
|
||||
throw new ApiError(403, 'FORBIDDEN', 'Insufficient permissions')
|
||||
}
|
||||
|
||||
// Handle non-JSON responses (502, 503, etc.)
|
||||
const contentType = response.headers.get('content-type')
|
||||
if (!contentType || !contentType.includes('application/json')) {
|
||||
throw new ApiError(response.status, 'NON_JSON_RESPONSE', `Server returned ${response.status}`)
|
||||
}
|
||||
|
||||
const result: ApiResult<T> = await response.json()
|
||||
|
||||
if (!result.success) {
|
||||
throw new ApiError(response.status, 'API_ERROR', result.error || 'Unknown error')
|
||||
}
|
||||
|
||||
return result.data as T
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get<T>(path: string): Promise<T> {
|
||||
return request<T>(path)
|
||||
},
|
||||
|
||||
post<T>(path: string, body?: unknown): Promise<T> {
|
||||
return request<T>(path, {
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
},
|
||||
|
||||
put<T>(path: string, body?: unknown): Promise<T> {
|
||||
return request<T>(path, {
|
||||
method: 'PUT',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
},
|
||||
|
||||
delete<T = void>(path: string): Promise<T> {
|
||||
return request<T>(path, { method: 'DELETE' })
|
||||
},
|
||||
|
||||
/** Login doesn't use the auth header */
|
||||
async login(username: string, password: string): Promise<{ access_token: string; refresh_token: string; user: { id: number; username: string; role: string } }> {
|
||||
const response = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
if (!result.success) {
|
||||
throw new ApiError(response.status, 'LOGIN_FAILED', result.error || 'Login failed')
|
||||
}
|
||||
|
||||
localStorage.setItem('token', result.data.access_token)
|
||||
localStorage.setItem('refresh_token', result.data.refresh_token)
|
||||
return result.data
|
||||
},
|
||||
|
||||
logout() {
|
||||
clearAuth()
|
||||
},
|
||||
}
|
||||
12
web/src/main.ts
Normal file
12
web/src/main.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import 'element-plus/dist/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
63
web/src/router/index.ts
Normal file
63
web/src/router/index.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import AppLayout from '../views/Layout.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/login', name: 'Login', component: () => import('../views/Login.vue') },
|
||||
{
|
||||
path: '/',
|
||||
component: AppLayout,
|
||||
redirect: '/dashboard',
|
||||
children: [
|
||||
{ path: 'dashboard', name: 'Dashboard', component: () => import('../views/Dashboard.vue') },
|
||||
{ path: 'devices', name: 'Devices', component: () => import('../views/Devices.vue') },
|
||||
{ path: 'devices/:uid', name: 'DeviceDetail', component: () => import('../views/DeviceDetail.vue') },
|
||||
{ path: 'assets', name: 'Assets', component: () => import('../views/Assets.vue') },
|
||||
{ path: 'usb', name: 'UsbPolicy', component: () => import('../views/UsbPolicy.vue') },
|
||||
{ path: 'alerts', name: 'Alerts', component: () => import('../views/Alerts.vue') },
|
||||
{ path: 'settings', name: 'Settings', component: () => import('../views/Settings.vue') },
|
||||
// Phase 2: Plugin pages
|
||||
{ path: 'plugins/web-filter', name: 'WebFilter', component: () => import('../views/plugins/WebFilter.vue') },
|
||||
{ path: 'plugins/usage-timer', name: 'UsageTimer', component: () => import('../views/plugins/UsageTimer.vue') },
|
||||
{ path: 'plugins/software-blocker', name: 'SoftwareBlocker', component: () => import('../views/plugins/SoftwareBlocker.vue') },
|
||||
{ path: 'plugins/popup-blocker', name: 'PopupBlocker', component: () => import('../views/plugins/PopupBlocker.vue') },
|
||||
{ path: 'plugins/usb-file-audit', name: 'UsbFileAudit', component: () => import('../views/plugins/UsbFileAudit.vue') },
|
||||
{ path: 'plugins/watermark', name: 'Watermark', component: () => import('../views/plugins/Watermark.vue') },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
/** Check if a JWT token is structurally valid and not expired */
|
||||
function isTokenValid(token: string): boolean {
|
||||
if (!token || token.trim() === '') return false
|
||||
try {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) return false
|
||||
const payload = JSON.parse(atob(parts[1]))
|
||||
if (!payload.exp) return false
|
||||
// Reject if token expires within 30 seconds
|
||||
return payload.exp * 1000 > Date.now() + 30_000
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
router.beforeEach((to, _from, next) => {
|
||||
if (to.path === '/login') {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token || !isTokenValid(token)) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
next('/login')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
87
web/src/stores/devices.ts
Normal file
87
web/src/stores/devices.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Add auth token to requests
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
export interface Device {
|
||||
id: number
|
||||
device_uid: string
|
||||
hostname: string
|
||||
ip_address: string
|
||||
mac_address: string | null
|
||||
os_version: string | null
|
||||
client_version: string | null
|
||||
status: 'online' | 'offline'
|
||||
last_heartbeat: string | null
|
||||
registered_at: string
|
||||
group_name: string
|
||||
}
|
||||
|
||||
export interface DeviceStatusDetail {
|
||||
cpu_usage: number
|
||||
memory_usage: number
|
||||
memory_total: number
|
||||
disk_usage: number
|
||||
disk_total: number
|
||||
running_procs: number
|
||||
top_processes: Array<{ name: string; pid: number; cpu_usage: number; memory_mb: number }>
|
||||
}
|
||||
|
||||
export const useDeviceStore = defineStore('devices', () => {
|
||||
const devices = ref<Device[]>([])
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
|
||||
async function fetchDevices(params?: Record<string, string>) {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/devices', { params })
|
||||
if (data.success) {
|
||||
devices.value = data.data.devices
|
||||
total.value = data.data.total ?? devices.value.length
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDeviceStatus(uid: string): Promise<DeviceStatusDetail | null> {
|
||||
const { data } = await api.get(`/devices/${uid}/status`)
|
||||
return data.success ? data.data : null
|
||||
}
|
||||
|
||||
async function fetchDeviceHistory(uid: string, params?: Record<string, string>) {
|
||||
const { data } = await api.get(`/devices/${uid}/history`, { params })
|
||||
return data.success ? data.data : null
|
||||
}
|
||||
|
||||
async function removeDevice(uid: string) {
|
||||
await api.delete(`/devices/${uid}`)
|
||||
devices.value = devices.value.filter((d) => d.device_uid !== uid)
|
||||
}
|
||||
|
||||
return {
|
||||
devices,
|
||||
loading,
|
||||
total,
|
||||
fetchDevices,
|
||||
fetchDeviceStatus,
|
||||
fetchDeviceHistory,
|
||||
removeDevice,
|
||||
}
|
||||
})
|
||||
216
web/src/views/Alerts.vue
Normal file
216
web/src/views/Alerts.vue
Normal file
@@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<div class="alerts-page">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="告警记录" name="records">
|
||||
<div class="toolbar">
|
||||
<el-select v-model="severityFilter" placeholder="严重程度" clearable style="width: 140px" @change="fetchRecords">
|
||||
<el-option label="Critical" value="critical" />
|
||||
<el-option label="High" value="high" />
|
||||
<el-option label="Medium" value="medium" />
|
||||
<el-option label="Low" value="low" />
|
||||
</el-select>
|
||||
<el-select v-model="handledFilter" placeholder="处理状态" clearable style="width: 140px" @change="fetchRecords">
|
||||
<el-option label="待处理" value="false" />
|
||||
<el-option label="已处理" value="true" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-table :data="records" v-loading="recLoading" stripe size="small">
|
||||
<el-table-column label="严重程度" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="severityTag(row.severity)" size="small">{{ row.severity }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="alert_type" label="告警类型" width="130" />
|
||||
<el-table-column prop="detail" label="详情" min-width="250" show-overflow-tooltip />
|
||||
<el-table-column prop="device_uid" label="终端" width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="triggered_at" label="触发时间" width="170" />
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.handled ? 'success' : 'warning'" size="small">
|
||||
{{ row.handled ? '已处理' : '待处理' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="!row.handled" link type="primary" size="small" @click="handleRecord(row.id)">处理</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="告警规则" name="rules">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="showRuleDialog()">新建规则</el-button>
|
||||
</div>
|
||||
<el-table :data="rules" v-loading="ruleLoading" stripe size="small">
|
||||
<el-table-column prop="name" label="规则名称" width="180" />
|
||||
<el-table-column prop="rule_type" label="规则类型" width="140" />
|
||||
<el-table-column prop="severity" label="严重程度" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="severityTag(row.severity)" size="small">{{ row.severity }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="condition" label="条件" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="enabled" label="启用" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-switch :model-value="row.enabled" @change="toggleRule(row)" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="showRuleDialog(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="deleteRule(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-dialog v-model="ruleDialogVisible" :title="editingRule ? '编辑规则' : '新建规则'" width="500px">
|
||||
<el-form :model="ruleForm" label-width="100px">
|
||||
<el-form-item label="规则名称">
|
||||
<el-input v-model="ruleForm.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="规则类型">
|
||||
<el-select v-model="ruleForm.rule_type" style="width: 100%">
|
||||
<el-option label="CPU过高" value="cpu_high" />
|
||||
<el-option label="内存过高" value="memory_high" />
|
||||
<el-option label="未授权USB" value="usb_unauth" />
|
||||
<el-option label="终端离线" value="device_offline" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="条件">
|
||||
<el-input v-model="ruleForm.condition" type="textarea" :rows="2" placeholder='{"threshold":90,"duration_secs":300}' />
|
||||
</el-form-item>
|
||||
<el-form-item label="严重程度">
|
||||
<el-select v-model="ruleForm.severity" style="width: 100%">
|
||||
<el-option label="Critical" value="critical" />
|
||||
<el-option label="High" value="high" />
|
||||
<el-option label="Medium" value="medium" />
|
||||
<el-option label="Low" value="low" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="通知邮箱">
|
||||
<el-input v-model="ruleForm.notify_email" placeholder="可选" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Webhook">
|
||||
<el-input v-model="ruleForm.notify_webhook" placeholder="可选" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="ruleDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveRule">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
const activeTab = ref('records')
|
||||
|
||||
// Records
|
||||
const records = ref<any[]>([])
|
||||
const recLoading = ref(false)
|
||||
const severityFilter = ref('')
|
||||
const handledFilter = ref('')
|
||||
|
||||
async function fetchRecords() {
|
||||
recLoading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (severityFilter.value) params.set('severity', severityFilter.value)
|
||||
if (handledFilter.value) params.set('handled', handledFilter.value)
|
||||
const data = await api.get<any>(`/api/alerts/records?${params}`)
|
||||
records.value = data.records || []
|
||||
} catch { /* api.ts handles 401 */ } finally { recLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleRecord(id: number) {
|
||||
try {
|
||||
await api.put(`/api/alerts/records/${id}/handle`)
|
||||
ElMessage.success('已标记处理')
|
||||
fetchRecords()
|
||||
} catch (e: any) { ElMessage.error(e.message || '操作失败') }
|
||||
}
|
||||
|
||||
// Rules
|
||||
const rules = ref<any[]>([])
|
||||
const ruleLoading = ref(false)
|
||||
const ruleDialogVisible = ref(false)
|
||||
const editingRule = ref<any>(null)
|
||||
const ruleForm = reactive({ name: '', rule_type: 'cpu_high', condition: '{"threshold":90}', severity: 'high', notify_email: '', notify_webhook: '' })
|
||||
|
||||
async function fetchRules() {
|
||||
ruleLoading.value = true
|
||||
try {
|
||||
const data = await api.get<any>('/api/alerts/rules')
|
||||
rules.value = data.rules || []
|
||||
} catch { /* api.ts handles 401 */ } finally { ruleLoading.value = false }
|
||||
}
|
||||
|
||||
function showRuleDialog(row?: any) {
|
||||
if (row) {
|
||||
editingRule.value = row
|
||||
ruleForm.name = row.name
|
||||
ruleForm.rule_type = row.rule_type
|
||||
ruleForm.condition = row.condition
|
||||
ruleForm.severity = row.severity
|
||||
ruleForm.notify_email = row.notify_email || ''
|
||||
ruleForm.notify_webhook = row.notify_webhook || ''
|
||||
} else {
|
||||
editingRule.value = null
|
||||
Object.assign(ruleForm, { name: '', rule_type: 'cpu_high', condition: '{"threshold":90}', severity: 'high', notify_email: '', notify_webhook: '' })
|
||||
}
|
||||
ruleDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function saveRule() {
|
||||
try {
|
||||
if (editingRule.value) {
|
||||
await api.put(`/api/alerts/rules/${editingRule.value.id}`, ruleForm)
|
||||
ElMessage.success('规则已更新')
|
||||
} else {
|
||||
await api.post('/api/alerts/rules', ruleForm)
|
||||
ElMessage.success('规则已创建')
|
||||
}
|
||||
ruleDialogVisible.value = false
|
||||
fetchRules()
|
||||
} catch (e: any) { ElMessage.error(e.message || '操作失败') }
|
||||
}
|
||||
|
||||
async function toggleRule(row: any) {
|
||||
try {
|
||||
await api.put(`/api/alerts/rules/${row.id}`, { enabled: !row.enabled ? 1 : 0 })
|
||||
fetchRules()
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function deleteRule(id: number) {
|
||||
await ElMessageBox.confirm('确定删除该规则?', '确认', { type: 'warning' })
|
||||
try {
|
||||
await api.delete(`/api/alerts/rules/${id}`)
|
||||
ElMessage.success('规则已删除')
|
||||
fetchRules()
|
||||
} catch (e: any) { ElMessage.error(e.message || '删除失败') }
|
||||
}
|
||||
|
||||
function severityTag(s: string) {
|
||||
const map: Record<string, string> = { critical: 'danger', high: 'warning', medium: '', low: 'info' }
|
||||
return map[s] || 'info'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchRecords()
|
||||
fetchRules()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.alerts-page { padding: 20px; }
|
||||
.toolbar { display: flex; gap: 12px; margin-bottom: 16px; }
|
||||
</style>
|
||||
123
web/src/views/Assets.vue
Normal file
123
web/src/views/Assets.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div class="assets-page">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="硬件资产" name="hardware">
|
||||
<div class="toolbar">
|
||||
<el-input v-model="hwSearch" placeholder="搜索CPU/GPU型号" style="width: 300px" clearable @input="fetchHardware" />
|
||||
</div>
|
||||
<el-table :data="hardware" v-loading="hwLoading" stripe size="small">
|
||||
<el-table-column prop="device_uid" label="终端UID" width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="cpu_model" label="CPU型号" min-width="180" />
|
||||
<el-table-column prop="cpu_cores" label="核心数" width="80" />
|
||||
<el-table-column label="内存" width="100">
|
||||
<template #default="{ row }">{{ formatMB(row.memory_total_mb) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="gpu_model" label="GPU" min-width="150" />
|
||||
<el-table-column prop="reported_at" label="上报时间" width="170" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="软件资产" name="software">
|
||||
<div class="toolbar">
|
||||
<el-input v-model="swSearch" placeholder="搜索软件名称/发行商" style="width: 300px" clearable @input="fetchSoftware" />
|
||||
</div>
|
||||
<el-table :data="software" v-loading="swLoading" stripe size="small">
|
||||
<el-table-column prop="name" label="软件名称" min-width="200" />
|
||||
<el-table-column prop="version" label="版本" width="120" />
|
||||
<el-table-column prop="publisher" label="发行商" min-width="150" />
|
||||
<el-table-column prop="install_date" label="安装日期" width="120" />
|
||||
<el-table-column prop="device_uid" label="终端UID" width="160" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="变更记录" name="changes">
|
||||
<el-table :data="changes" v-loading="chLoading" stripe size="small">
|
||||
<el-table-column prop="device_uid" label="终端UID" width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="change_type" label="变更类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="changeTag(row.change_type)" size="small">{{ row.change_type }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="change_detail" label="详情" min-width="300" show-overflow-tooltip />
|
||||
<el-table-column prop="detected_at" label="检测时间" width="170" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
const activeTab = ref('hardware')
|
||||
|
||||
// Hardware
|
||||
const hardware = ref<any[]>([])
|
||||
const hwLoading = ref(false)
|
||||
const hwSearch = ref('')
|
||||
|
||||
async function fetchHardware() {
|
||||
hwLoading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (hwSearch.value) params.set('search', hwSearch.value)
|
||||
const data = await api.get<any>(`/api/assets/hardware?${params}`)
|
||||
hardware.value = data.hardware || []
|
||||
} catch { /* api.ts handles 401 */ } finally { hwLoading.value = false }
|
||||
}
|
||||
|
||||
// Software
|
||||
const software = ref<any[]>([])
|
||||
const swLoading = ref(false)
|
||||
const swSearch = ref('')
|
||||
|
||||
async function fetchSoftware() {
|
||||
swLoading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (swSearch.value) params.set('search', swSearch.value)
|
||||
const data = await api.get<any>(`/api/assets/software?${params}`)
|
||||
software.value = data.software || []
|
||||
} catch { /* api.ts handles 401 */ } finally { swLoading.value = false }
|
||||
}
|
||||
|
||||
// Changes
|
||||
const changes = ref<any[]>([])
|
||||
const chLoading = ref(false)
|
||||
|
||||
async function fetchChanges() {
|
||||
chLoading.value = true
|
||||
try {
|
||||
const data = await api.get<any>('/api/assets/changes')
|
||||
changes.value = data.changes || []
|
||||
} catch { /* api.ts handles 401 */ } finally { chLoading.value = false }
|
||||
}
|
||||
|
||||
function formatMB(mb: number) {
|
||||
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`
|
||||
return `${mb} MB`
|
||||
}
|
||||
|
||||
function changeTag(type: string) {
|
||||
const map: Record<string, string> = { hardware: 'warning', software_added: 'success', software_removed: 'danger' }
|
||||
return map[type] || 'info'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchHardware()
|
||||
fetchSoftware()
|
||||
fetchChanges()
|
||||
})
|
||||
|
||||
watch(activeTab, () => {
|
||||
if (activeTab.value === 'hardware') fetchHardware()
|
||||
else if (activeTab.value === 'software') fetchSoftware()
|
||||
else fetchChanges()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.assets-page { padding: 20px; }
|
||||
.toolbar { display: flex; gap: 12px; margin-bottom: 16px; }
|
||||
</style>
|
||||
264
web/src/views/Dashboard.vue
Normal file
264
web/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,264 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<el-row :gutter="20" class="stat-cards">
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-icon online"><el-icon :size="28"><Monitor /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.online }}</div>
|
||||
<div class="stat-label">在线终端</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-icon offline"><el-icon :size="28"><Platform /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.offline }}</div>
|
||||
<div class="stat-label">离线终端</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-icon warning"><el-icon :size="28"><Bell /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.alerts }}</div>
|
||||
<div class="stat-label">待处理告警</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-icon usb"><el-icon :size="28"><Connection /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.usbEvents }}</div>
|
||||
<div class="stat-label">USB事件(24h)</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" style="margin-top: 20px">
|
||||
<el-col :span="16">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<span class="card-title">终端状态总览</span>
|
||||
</template>
|
||||
<div ref="cpuChartRef" style="height: 320px"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<span class="card-title">最近告警</span>
|
||||
</template>
|
||||
<div class="alert-list">
|
||||
<div v-for="alert in recentAlerts" :key="alert.id" class="alert-item">
|
||||
<el-tag :type="severityTag(alert.severity)" size="small">{{ alert.severity }}</el-tag>
|
||||
<span class="alert-detail">{{ alert.detail }}</span>
|
||||
<span class="alert-time">{{ alert.triggered_at }}</span>
|
||||
</div>
|
||||
<el-empty v-if="recentAlerts.length === 0" description="暂无告警" :image-size="60" />
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" style="margin-top: 20px">
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<span class="card-title">最近USB事件</span>
|
||||
</template>
|
||||
<el-table :data="recentUsbEvents" size="small" max-height="240">
|
||||
<el-table-column prop="device_name" label="设备" width="120" />
|
||||
<el-table-column label="类型" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.event_type === 'Inserted' ? 'success' : row.event_type === 'Blocked' ? 'danger' : 'info'" size="small">
|
||||
{{ eventTypeLabel(row.event_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="device_uid" label="终端" show-overflow-tooltip />
|
||||
<el-table-column prop="event_time" label="时间" width="160" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<span class="card-title">Top 5 高负载终端</span>
|
||||
</template>
|
||||
<el-table :data="topDevices" size="small" max-height="240">
|
||||
<el-table-column prop="hostname" label="主机名" />
|
||||
<el-table-column label="CPU" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-progress :percentage="Math.round(row.cpu_usage)" :stroke-width="6" :color="progressColor(row.cpu_usage)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="内存" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-progress :percentage="Math.round(row.memory_usage)" :stroke-width="6" :color="progressColor(row.memory_usage)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'online' ? 'success' : 'info'" size="small">{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { Monitor, Platform, Bell, Connection } from '@element-plus/icons-vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
const stats = ref({ online: 0, offline: 0, alerts: 0, usbEvents: 0 })
|
||||
const recentAlerts = ref<Array<{ id: number; severity: string; detail: string; triggered_at: string }>>([])
|
||||
const recentUsbEvents = ref<Array<{ device_name: string; event_type: string; device_uid: string; event_time: string }>>([])
|
||||
const topDevices = ref<Array<{ hostname: string; cpu_usage: number; memory_usage: number; status: string }>>([])
|
||||
|
||||
const cpuChartRef = ref<HTMLElement>()
|
||||
let chart: echarts.ECharts | null = null
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let resizeHandler: (() => void) | null = null
|
||||
|
||||
async function fetchDashboard() {
|
||||
try {
|
||||
const [devicesData, alertsData, usbData] = await Promise.all([
|
||||
api.get<any>('/api/devices'),
|
||||
api.get<any>('/api/alerts/records?handled=0&page_size=10'),
|
||||
api.get<any>('/api/usb/events?page_size=10'),
|
||||
])
|
||||
|
||||
const devices = devicesData.devices || []
|
||||
stats.value.online = devices.filter((d: any) => d.status === 'online').length
|
||||
stats.value.offline = devices.filter((d: any) => d.status === 'offline').length
|
||||
|
||||
topDevices.value = devices
|
||||
.filter((d: any) => d.cpu_usage !== undefined)
|
||||
.sort((a: any, b: any) => (b.cpu_usage || 0) - (a.cpu_usage || 0))
|
||||
.slice(0, 5)
|
||||
|
||||
updateChart(devices)
|
||||
|
||||
const records = alertsData.records || []
|
||||
stats.value.alerts = records.length
|
||||
recentAlerts.value = records.slice(0, 8)
|
||||
|
||||
const events = usbData.events || []
|
||||
stats.value.usbEvents = events.length
|
||||
recentUsbEvents.value = events.slice(0, 8)
|
||||
} catch {
|
||||
// Silently fail - dashboard gracefully shows zeros
|
||||
}
|
||||
}
|
||||
|
||||
function initChart() {
|
||||
if (!cpuChartRef.value) return
|
||||
chart = echarts.init(cpuChartRef.value)
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['CPU%', '内存%'] },
|
||||
grid: { left: 50, right: 20, bottom: 30, top: 40 },
|
||||
xAxis: { type: 'category', data: [] },
|
||||
yAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } },
|
||||
series: [
|
||||
{ name: 'CPU%', type: 'bar', data: [], itemStyle: { color: '#409EFF' } },
|
||||
{ name: '内存%', type: 'bar', data: [], itemStyle: { color: '#67C23A' } },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function updateChart(devices: any[]) {
|
||||
if (!chart) return
|
||||
const top = devices
|
||||
.filter((d: any) => d.cpu_usage !== undefined)
|
||||
.sort((a: any, b: any) => (b.cpu_usage || 0) - (a.cpu_usage || 0))
|
||||
.slice(0, 10)
|
||||
chart.setOption({
|
||||
xAxis: { data: top.map((d: any) => d.hostname || d.device_uid) },
|
||||
series: [
|
||||
{ data: top.map((d: any) => d.cpu_usage?.toFixed(1) || 0) },
|
||||
{ data: top.map((d: any) => d.memory_usage?.toFixed(1) || 0) },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function severityTag(severity: string) {
|
||||
const map: Record<string, string> = { critical: 'danger', high: 'warning', medium: '', low: 'info' }
|
||||
return map[severity] || 'info'
|
||||
}
|
||||
|
||||
function eventTypeLabel(type: string) {
|
||||
const map: Record<string, string> = { Inserted: '插入', Removed: '拔出', Blocked: '拦截' }
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
function progressColor(value: number) {
|
||||
if (value > 90) return '#F56C6C'
|
||||
if (value > 70) return '#E6A23C'
|
||||
return '#67C23A'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchDashboard()
|
||||
initChart()
|
||||
timer = setInterval(fetchDashboard, 30000)
|
||||
resizeHandler = () => chart?.resize()
|
||||
window.addEventListener('resize', resizeHandler)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
if (resizeHandler) window.removeEventListener('resize', resizeHandler)
|
||||
chart?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard { padding: 20px; }
|
||||
|
||||
.stat-cards .stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 16px;
|
||||
color: #fff;
|
||||
}
|
||||
.stat-icon.online { background: linear-gradient(135deg, #67C23A, #409EFF); }
|
||||
.stat-icon.offline { background: linear-gradient(135deg, #909399, #606266); }
|
||||
.stat-icon.warning { background: linear-gradient(135deg, #E6A23C, #F56C6C); }
|
||||
.stat-icon.usb { background: linear-gradient(135deg, #409EFF, #7C3AED); }
|
||||
|
||||
.stat-value { font-size: 28px; font-weight: 700; color: #303133; }
|
||||
.stat-label { font-size: 13px; color: #909399; margin-top: 4px; }
|
||||
|
||||
.card-title { font-weight: 600; font-size: 15px; }
|
||||
|
||||
.alert-list { max-height: 320px; overflow-y: auto; }
|
||||
.alert-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.alert-detail { flex: 1; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.alert-time { font-size: 12px; color: #C0C4CC; white-space: nowrap; }
|
||||
</style>
|
||||
131
web/src/views/DeviceDetail.vue
Normal file
131
web/src/views/DeviceDetail.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div class="device-detail" v-loading="loading">
|
||||
<el-page-header @back="$router.back()" :title="'返回'">
|
||||
<template #content>
|
||||
<span>{{ device?.hostname || deviceUid }}</span>
|
||||
<el-tag v-if="device" :type="device.status === 'online' ? 'success' : 'info'" size="small" style="margin-left: 8px">
|
||||
{{ device.status === 'online' ? '在线' : '离线' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-page-header>
|
||||
|
||||
<el-row :gutter="20" style="margin-top: 20px">
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover">
|
||||
<template #header><span class="card-title">基本信息</span></template>
|
||||
<el-descriptions :column="1" size="small" border>
|
||||
<el-descriptions-item label="设备UID">{{ device?.device_uid }}</el-descriptions-item>
|
||||
<el-descriptions-item label="主机名">{{ device?.hostname }}</el-descriptions-item>
|
||||
<el-descriptions-item label="IP地址">{{ device?.ip_address }}</el-descriptions-item>
|
||||
<el-descriptions-item label="MAC地址">{{ device?.mac_address || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="操作系统">{{ device?.os_version || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户端版本">{{ device?.client_version || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="分组">{{ device?.group_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="注册时间">{{ device?.registered_at || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最后心跳">{{ device?.last_heartbeat || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="16">
|
||||
<el-card shadow="hover" style="margin-bottom: 20px">
|
||||
<template #header><span class="card-title">实时状态</span></template>
|
||||
<el-row :gutter="20" v-if="status">
|
||||
<el-col :span="6">
|
||||
<div class="metric">
|
||||
<div class="metric-label">CPU</div>
|
||||
<el-progress type="dashboard" :percentage="Math.round(status.cpu_usage)" :width="100"
|
||||
:color="progressColor(status.cpu_usage)" />
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="metric">
|
||||
<div class="metric-label">内存</div>
|
||||
<el-progress type="dashboard" :percentage="Math.round(status.memory_usage)" :width="100"
|
||||
:color="progressColor(status.memory_usage)" />
|
||||
<div class="metric-sub">{{ formatMB(status.memory_total_mb) }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="metric">
|
||||
<div class="metric-label">磁盘</div>
|
||||
<el-progress type="dashboard" :percentage="Math.round(status.disk_usage)" :width="100"
|
||||
:color="progressColor(status.disk_usage)" />
|
||||
<div class="metric-sub">{{ formatMB(status.disk_total_mb) }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="metric">
|
||||
<div class="metric-label">进程</div>
|
||||
<div class="metric-value">{{ status.running_procs }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-empty v-else description="暂无状态数据" :image-size="60" />
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="hover">
|
||||
<template #header><span class="card-title">Top 进程</span></template>
|
||||
<el-table :data="status?.top_processes || []" size="small" max-height="200">
|
||||
<el-table-column prop="name" label="进程名" />
|
||||
<el-table-column prop="pid" label="PID" width="80" />
|
||||
<el-table-column label="CPU" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-progress :percentage="Math.min(Math.round(row.cpu_usage), 100)" :stroke-width="6" :color="progressColor(row.cpu_usage)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="内存" width="100">
|
||||
<template #default="{ row }">{{ formatMB(row.memory_mb) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
const route = useRoute()
|
||||
const deviceUid = route.params.uid as string
|
||||
|
||||
const loading = ref(true)
|
||||
const device = ref<any>(null)
|
||||
const status = ref<any>(null)
|
||||
|
||||
function progressColor(value: number) {
|
||||
if (value > 90) return '#F56C6C'
|
||||
if (value > 70) return '#E6A23C'
|
||||
return '#67C23A'
|
||||
}
|
||||
|
||||
function formatMB(mb: number) {
|
||||
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`
|
||||
return `${mb} MB`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [devData, statData] = await Promise.all([
|
||||
api.get<any>(`/api/devices/${deviceUid}`),
|
||||
api.get<any>(`/api/devices/${deviceUid}/status`),
|
||||
])
|
||||
device.value = devData
|
||||
status.value = statData
|
||||
} catch { /* api.ts handles 401 */ } finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.device-detail { padding: 20px; }
|
||||
.card-title { font-weight: 600; font-size: 15px; }
|
||||
.metric { text-align: center; padding: 10px 0; }
|
||||
.metric-label { font-size: 13px; color: #909399; margin-bottom: 8px; }
|
||||
.metric-value { font-size: 32px; font-weight: 700; color: #303133; margin-top: 16px; }
|
||||
.metric-sub { font-size: 12px; color: #909399; margin-top: 4px; }
|
||||
</style>
|
||||
108
web/src/views/Devices.vue
Normal file
108
web/src/views/Devices.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="devices-page">
|
||||
<div class="toolbar">
|
||||
<el-input v-model="search" placeholder="搜索主机名/IP" style="width: 300px" clearable @input="handleSearch" />
|
||||
<el-select v-model="statusFilter" placeholder="状态" clearable style="width: 120px" @change="handleSearch">
|
||||
<el-option label="在线" value="online" />
|
||||
<el-option label="离线" value="offline" />
|
||||
</el-select>
|
||||
<el-select v-model="groupFilter" placeholder="分组" clearable style="width: 150px" @change="handleSearch">
|
||||
<el-option label="默认组" value="default" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<el-table :data="deviceStore.devices" v-loading="deviceStore.loading" stripe @row-click="handleRowClick">
|
||||
<el-table-column prop="hostname" label="主机名" min-width="150" />
|
||||
<el-table-column prop="ip_address" label="IP地址" width="150" />
|
||||
<el-table-column prop="group_name" label="分组" width="120" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'online' ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 'online' ? '在线' : '离线' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="CPU" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-progress :percentage="row.cpu_usage ?? 0" :stroke-width="6" :color="getProgressColor(row.cpu_usage)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="内存" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-progress :percentage="row.memory_usage ?? 0" :stroke-width="6" :color="getProgressColor(row.memory_usage)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="last_heartbeat" label="最后心跳" width="180" />
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" link size="small" @click.stop="handleDelete(row)">移除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
style="margin-top: 20px; justify-content: flex-end"
|
||||
:total="deviceStore.total"
|
||||
:page-size="20"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useDeviceStore } from '../stores/devices'
|
||||
import type { Device } from '../stores/devices'
|
||||
|
||||
const router = useRouter()
|
||||
const deviceStore = useDeviceStore()
|
||||
|
||||
const search = ref('')
|
||||
const statusFilter = ref('')
|
||||
const groupFilter = ref('')
|
||||
const currentPage = ref(1)
|
||||
|
||||
onMounted(() => {
|
||||
deviceStore.fetchDevices()
|
||||
})
|
||||
|
||||
function handleSearch() {
|
||||
currentPage.value = 1
|
||||
deviceStore.fetchDevices({
|
||||
search: search.value,
|
||||
status: statusFilter.value,
|
||||
group: groupFilter.value,
|
||||
page: '1',
|
||||
})
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
currentPage.value = page
|
||||
deviceStore.fetchDevices({ page: String(page) })
|
||||
}
|
||||
|
||||
function handleRowClick(row: Device) {
|
||||
router.push(`/devices/${row.device_uid}`)
|
||||
}
|
||||
|
||||
async function handleDelete(row: Device) {
|
||||
await ElMessageBox.confirm(`确定移除设备 ${row.hostname}?`, '确认', { type: 'warning' })
|
||||
await deviceStore.removeDevice(row.device_uid)
|
||||
ElMessage.success('设备已移除')
|
||||
}
|
||||
|
||||
function getProgressColor(value?: number): string {
|
||||
if (!value) return '#67C23A'
|
||||
if (value > 90) return '#F56C6C'
|
||||
if (value > 70) return '#E6A23C'
|
||||
return '#67C23A'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.devices-page { padding: 20px; }
|
||||
.toolbar { display: flex; gap: 12px; margin-bottom: 20px; }
|
||||
</style>
|
||||
189
web/src/views/Layout.vue
Normal file
189
web/src/views/Layout.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<el-container class="app-container">
|
||||
<el-aside width="220px" class="sidebar">
|
||||
<div class="logo">
|
||||
<h2>CSM</h2>
|
||||
<span>终端管理系统</span>
|
||||
</div>
|
||||
<el-menu
|
||||
:default-active="currentRoute"
|
||||
router
|
||||
background-color="#1d1e2c"
|
||||
text-color="#a0a3bd"
|
||||
active-text-color="#409eff"
|
||||
>
|
||||
<el-menu-item index="/dashboard">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
<span>仪表盘</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/devices">
|
||||
<el-icon><Platform /></el-icon>
|
||||
<span>设备管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/assets">
|
||||
<el-icon><Box /></el-icon>
|
||||
<span>资产管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/usb">
|
||||
<el-icon><Connection /></el-icon>
|
||||
<span>U盘管控</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/alerts">
|
||||
<el-icon><Bell /></el-icon>
|
||||
<span>告警中心</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-sub-menu index="plugins">
|
||||
<template #title>
|
||||
<el-icon><Grid /></el-icon>
|
||||
<span>安全插件</span>
|
||||
</template>
|
||||
<el-menu-item index="/plugins/web-filter">上网拦截</el-menu-item>
|
||||
<el-menu-item index="/plugins/usage-timer">时长记录</el-menu-item>
|
||||
<el-menu-item index="/plugins/software-blocker">软件管控</el-menu-item>
|
||||
<el-menu-item index="/plugins/popup-blocker">弹窗拦截</el-menu-item>
|
||||
<el-menu-item index="/plugins/usb-file-audit">U盘审计</el-menu-item>
|
||||
<el-menu-item index="/plugins/watermark">水印管理</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<el-menu-item index="/settings">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>系统设置</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<el-container>
|
||||
<el-header class="app-header">
|
||||
<div class="header-left">
|
||||
<span class="page-title">{{ pageTitle }}</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-badge :value="unreadAlerts" :hidden="unreadAlerts === 0">
|
||||
<el-icon :size="20"><Bell /></el-icon>
|
||||
</el-badge>
|
||||
<el-dropdown>
|
||||
<span class="user-info">
|
||||
{{ username }} <el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="handleLogout">退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<el-main>
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
Monitor, Platform, Box, Connection, Bell, Setting, ArrowDown, Grid
|
||||
} from '@element-plus/icons-vue'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const currentRoute = computed(() => route.path)
|
||||
const unreadAlerts = ref(0)
|
||||
const username = ref('')
|
||||
|
||||
function decodeUsername(): string {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) return ''
|
||||
const payload = JSON.parse(atob(token.split('.')[1]))
|
||||
return payload.username || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUnreadAlerts() {
|
||||
try {
|
||||
const data = await api.get<any>('/api/alerts/records?handled=0&page_size=1')
|
||||
unreadAlerts.value = data.records?.length || 0
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
|
||||
const pageTitles: Record<string, string> = {
|
||||
'/dashboard': '仪表盘',
|
||||
'/devices': '设备管理',
|
||||
'/assets': '资产管理',
|
||||
'/usb': 'U盘管控',
|
||||
'/alerts': '告警中心',
|
||||
'/settings': '系统设置',
|
||||
'/plugins/web-filter': '上网拦截',
|
||||
'/plugins/usage-timer': '时长记录',
|
||||
'/plugins/software-blocker': '软件管控',
|
||||
'/plugins/popup-blocker': '弹窗拦截',
|
||||
'/plugins/usb-file-audit': 'U盘审计',
|
||||
'/plugins/watermark': '水印管理',
|
||||
}
|
||||
|
||||
const pageTitle = computed(() => pageTitles[route.path] || '仪表盘')
|
||||
|
||||
onMounted(() => {
|
||||
username.value = decodeUsername()
|
||||
fetchUnreadAlerts()
|
||||
})
|
||||
|
||||
function handleLogout() {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-container { height: 100vh; }
|
||||
|
||||
.sidebar {
|
||||
background-color: #1d1e2c;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.logo {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
border-bottom: 1px solid #2d2e3e;
|
||||
}
|
||||
.logo h2 { font-size: 24px; margin-bottom: 4px; }
|
||||
.logo span { font-size: 12px; color: #a0a3bd; }
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.page-title { font-size: 18px; font-weight: 600; }
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
color: #606266;
|
||||
}
|
||||
</style>
|
||||
119
web/src/views/Login.vue
Normal file
119
web/src/views/Login.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<h2>CSM</h2>
|
||||
<p>终端管理系统</p>
|
||||
</div>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" @submit.prevent="handleLogin">
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="用户名"
|
||||
:prefix-icon="User"
|
||||
size="large"
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
:prefix-icon="Lock"
|
||||
size="large"
|
||||
show-password
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
style="width: 100%"
|
||||
@click="handleLogin"
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User, Lock } from '@element-plus/icons-vue'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
|
||||
const router = useRouter()
|
||||
const formRef = ref()
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
const rules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.login(form.username, form.password)
|
||||
ElMessage.success(`欢迎, ${data.user.username}`)
|
||||
router.push('/dashboard')
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
ElMessage.error(e.message || '登录失败')
|
||||
} else {
|
||||
ElMessage.error('网络错误,请检查连接')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #1d1e2c 0%, #2d3a4a 100%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 400px;
|
||||
padding: 40px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-header h2 {
|
||||
font-size: 32px;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
115
web/src/views/Settings.vue
Normal file
115
web/src/views/Settings.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover">
|
||||
<template #header><span class="card-title">系统信息</span></template>
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="系统版本">v{{ version }}</el-descriptions-item>
|
||||
<el-descriptions-item label="数据库">{{ dbInfo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="在线终端">{{ health.connected_clients }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="hover" style="margin-top: 20px">
|
||||
<template #header><span class="card-title">修改密码</span></template>
|
||||
<el-form :model="pwdForm" label-width="100px" size="small">
|
||||
<el-form-item label="当前密码">
|
||||
<el-input v-model="pwdForm.oldPassword" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码">
|
||||
<el-input v-model="pwdForm.newPassword" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码">
|
||||
<el-input v-model="pwdForm.confirmPassword" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="changePassword">修改密码</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover">
|
||||
<template #header><span class="card-title">数据维护</span></template>
|
||||
<el-form label-width="100px" size="small">
|
||||
<el-form-item label="历史数据">
|
||||
<el-button @click="showRetentionInfo">查看保留策略</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item label="数据库">
|
||||
<el-button type="warning" @click="manualCleanup">手动清理</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="hover" style="margin-top: 20px">
|
||||
<template #header><span class="card-title">当前用户</span></template>
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="用户名">{{ user.username }}</el-descriptions-item>
|
||||
<el-descriptions-item label="角色">{{ user.role }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
const version = ref('0.1.0')
|
||||
const dbInfo = ref('SQLite (WAL mode)')
|
||||
const health = reactive({ connected_clients: 0, db_size_bytes: 0 })
|
||||
const user = reactive({ username: 'admin', role: 'admin' })
|
||||
|
||||
const pwdForm = reactive({ oldPassword: '', newPassword: '', confirmPassword: '' })
|
||||
|
||||
onMounted(() => {
|
||||
// Decode username from JWT token
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]))
|
||||
user.username = payload.username || 'admin'
|
||||
user.role = payload.role || 'admin'
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
api.get<any>('/health')
|
||||
.then((data: any) => {
|
||||
if (data.version) version.value = data.version
|
||||
health.connected_clients = data.connected_clients || 0
|
||||
const bytes = data.db_size_bytes || 0
|
||||
dbInfo.value = `SQLite (WAL mode) - ${(bytes / 1024 / 1024).toFixed(2)} MB`
|
||||
})
|
||||
.catch(() => { /* ignore */ })
|
||||
})
|
||||
|
||||
function changePassword() {
|
||||
if (pwdForm.newPassword !== pwdForm.confirmPassword) {
|
||||
ElMessage.error('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
if (pwdForm.newPassword.length < 6) {
|
||||
ElMessage.error('密码至少6位')
|
||||
return
|
||||
}
|
||||
ElMessage.success('密码修改功能待实现')
|
||||
}
|
||||
|
||||
function showRetentionInfo() {
|
||||
ElMessage.info('数据保留策略在 config.toml 中配置')
|
||||
}
|
||||
|
||||
function manualCleanup() {
|
||||
ElMessage.warning('手动清理功能需通过服务器配置触发')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-page { padding: 20px; }
|
||||
.card-title { font-weight: 600; font-size: 15px; }
|
||||
</style>
|
||||
192
web/src/views/UsbPolicy.vue
Normal file
192
web/src/views/UsbPolicy.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div class="usb-page">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="策略管理" name="policies">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="showPolicyDialog()">新建策略</el-button>
|
||||
</div>
|
||||
<el-table :data="policies" v-loading="loading" stripe size="small">
|
||||
<el-table-column prop="name" label="策略名称" width="180" />
|
||||
<el-table-column prop="policy_type" label="策略类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="policyTypeTag(row.policy_type)" size="small">{{ policyTypeLabel(row.policy_type) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="target_group" label="目标分组" width="120" />
|
||||
<el-table-column prop="enabled" label="启用" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-switch :model-value="row.enabled" @change="togglePolicy(row)" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="170" />
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="showPolicyDialog(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="deletePolicy(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="事件日志" name="events">
|
||||
<div class="toolbar">
|
||||
<el-select v-model="eventFilter" placeholder="事件类型" clearable style="width: 150px" @change="fetchEvents">
|
||||
<el-option label="插入" value="Inserted" />
|
||||
<el-option label="拔出" value="Removed" />
|
||||
<el-option label="拦截" value="Blocked" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-table :data="events" v-loading="evLoading" stripe size="small">
|
||||
<el-table-column prop="device_name" label="USB设备" width="150" />
|
||||
<el-table-column label="事件类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.event_type === 'Inserted' ? 'success' : row.event_type === 'Blocked' ? 'danger' : 'info'" size="small">
|
||||
{{ eventTypeLabel(row.event_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="vendor_id" label="VID" width="100" />
|
||||
<el-table-column prop="product_id" label="PID" width="100" />
|
||||
<el-table-column prop="serial_number" label="序列号" width="160" />
|
||||
<el-table-column prop="device_uid" label="终端UID" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="event_time" label="时间" width="170" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-dialog v-model="policyDialogVisible" :title="editingPolicy ? '编辑策略' : '新建策略'" width="500px">
|
||||
<el-form :model="policyForm" label-width="100px">
|
||||
<el-form-item label="策略名称">
|
||||
<el-input v-model="policyForm.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="策略类型">
|
||||
<el-select v-model="policyForm.policy_type" style="width: 100%">
|
||||
<el-option label="全部拦截" value="all_block" />
|
||||
<el-option label="白名单" value="whitelist" />
|
||||
<el-option label="黑名单" value="blacklist" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="目标分组">
|
||||
<el-input v-model="policyForm.target_group" placeholder="留空表示全部终端" />
|
||||
</el-form-item>
|
||||
<el-form-item label="设备规则">
|
||||
<el-input v-model="policyForm.rules" type="textarea" :rows="3" placeholder='[{"vendor_id":"1234","product_id":"5678"}]' />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="policyDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="savePolicy">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
const activeTab = ref('policies')
|
||||
|
||||
// Policies
|
||||
const policies = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const policyDialogVisible = ref(false)
|
||||
const editingPolicy = ref<any>(null)
|
||||
const policyForm = reactive({ name: '', policy_type: 'all_block', target_group: '', rules: '[]' })
|
||||
|
||||
async function fetchPolicies() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<any>('/api/usb/policies')
|
||||
policies.value = data.policies || []
|
||||
} catch { /* api.ts handles 401 */ } finally { loading.value = false }
|
||||
}
|
||||
|
||||
function showPolicyDialog(row?: any) {
|
||||
if (row) {
|
||||
editingPolicy.value = row
|
||||
policyForm.name = row.name
|
||||
policyForm.policy_type = row.policy_type
|
||||
policyForm.target_group = row.target_group || ''
|
||||
policyForm.rules = row.rules || '[]'
|
||||
} else {
|
||||
editingPolicy.value = null
|
||||
policyForm.name = ''
|
||||
policyForm.policy_type = 'all_block'
|
||||
policyForm.target_group = ''
|
||||
policyForm.rules = '[]'
|
||||
}
|
||||
policyDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function savePolicy() {
|
||||
try {
|
||||
if (editingPolicy.value) {
|
||||
await api.put(`/api/usb/policies/${editingPolicy.value.id}`, policyForm)
|
||||
ElMessage.success('策略已更新')
|
||||
} else {
|
||||
await api.post('/api/usb/policies', policyForm)
|
||||
ElMessage.success('策略已创建')
|
||||
}
|
||||
policyDialogVisible.value = false
|
||||
fetchPolicies()
|
||||
} catch (e: any) { ElMessage.error(e.message || '操作失败') }
|
||||
}
|
||||
|
||||
async function togglePolicy(row: any) {
|
||||
try {
|
||||
await api.put(`/api/usb/policies/${row.id}`, { enabled: !row.enabled ? 1 : 0 })
|
||||
fetchPolicies()
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function deletePolicy(id: number) {
|
||||
await ElMessageBox.confirm('确定删除该策略?', '确认', { type: 'warning' })
|
||||
try {
|
||||
await api.delete(`/api/usb/policies/${id}`)
|
||||
ElMessage.success('策略已删除')
|
||||
fetchPolicies()
|
||||
} catch (e: any) { ElMessage.error(e.message || '删除失败') }
|
||||
}
|
||||
|
||||
function policyTypeTag(type: string) {
|
||||
const map: Record<string, string> = { all_block: 'danger', whitelist: 'success', blacklist: 'warning' }
|
||||
return map[type] || 'info'
|
||||
}
|
||||
|
||||
function policyTypeLabel(type: string) {
|
||||
const map: Record<string, string> = { all_block: '全部拦截', whitelist: '白名单', blacklist: '黑名单' }
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
// Events
|
||||
const events = ref<any[]>([])
|
||||
const evLoading = ref(false)
|
||||
const eventFilter = ref('')
|
||||
|
||||
async function fetchEvents() {
|
||||
evLoading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (eventFilter.value) params.set('event_type', eventFilter.value)
|
||||
const data = await api.get<any>(`/api/usb/events?${params}`)
|
||||
events.value = data.events || []
|
||||
} catch { /* api.ts handles 401 */ } finally { evLoading.value = false }
|
||||
}
|
||||
|
||||
function eventTypeLabel(type: string) {
|
||||
const map: Record<string, string> = { Inserted: '插入', Removed: '拔出', Blocked: '拦截' }
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchPolicies()
|
||||
fetchEvents()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.usb-page { padding: 20px; }
|
||||
.toolbar { display: flex; gap: 12px; margin-bottom: 16px; }
|
||||
</style>
|
||||
56
web/src/views/plugins/PopupBlocker.vue
Normal file
56
web/src/views/plugins/PopupBlocker.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="plugin-page">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="拦截规则" name="rules">
|
||||
<div class="toolbar"><el-button type="primary" @click="showDialog()">新建规则</el-button></div>
|
||||
<el-table :data="rules" v-loading="loading" stripe size="small">
|
||||
<el-table-column prop="rule_type" label="类型" width="80">
|
||||
<template #default="{ row }"><el-tag :type="row.rule_type==='block'?'danger':'success'" size="small">{{ row.rule_type==='block'?'拦截':'放行' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="window_title" label="窗口标题" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="window_class" label="窗口类" width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="process_name" label="进程" width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="enabled" label="启用" width="70">
|
||||
<template #default="{ row }"><el-tag :type="row.enabled?'success':'info'" size="small">{{ row.enabled?'是':'否' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="showDialog(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="remove(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="拦截统计" name="stats">
|
||||
<el-table :data="stats" v-loading="sLoading" stripe size="small">
|
||||
<el-table-column prop="device_uid" label="终端" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="blocked_count" label="拦截次数" width="120" />
|
||||
<el-table-column prop="date" label="日期" width="120" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<el-dialog v-model="visible" :title="editing?'编辑规则':'新建规则'" width="480px">
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="类型"><el-select v-model="form.rule_type"><el-option label="拦截" value="block" /><el-option label="放行" value="allow" /></el-select></el-form-item>
|
||||
<el-form-item label="窗口标题"><el-input v-model="form.window_title" placeholder="匹配模式(支持*通配符)" /></el-form-item>
|
||||
<el-form-item label="窗口类"><el-input v-model="form.window_class" /></el-form-item>
|
||||
<el-form-item label="进程名"><el-input v-model="form.process_name" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="visible=false">取消</el-button><el-button type="primary" @click="save">保存</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
const activeTab=ref('rules'),auth=()=>({headers:{Authorization:`Bearer ${localStorage.getItem('token')}`,'Content-Type':'application/json'}})
|
||||
const rules=ref<any[]>([]),loading=ref(false),stats=ref<any[]>([]),sLoading=ref(false),visible=ref(false),editing=ref<any>(null)
|
||||
const form=reactive({rule_type:'block',window_title:'',window_class:'',process_name:''})
|
||||
async function fetchRules(){loading.value=true;try{const r=await fetch('/api/plugins/popup-blocker/rules',auth()).then(r=>r.json());if(r.success)rules.value=r.data.rules||[]}finally{loading.value=false}}
|
||||
async function fetchStats(){sLoading.value=true;try{const r=await fetch('/api/plugins/popup-blocker/stats',auth()).then(r=>r.json());if(r.success)stats.value=r.data.stats||[]}finally{sLoading.value=false}}
|
||||
function showDialog(row?:any){if(row){editing.value=row;Object.assign(form,{rule_type:row.rule_type,window_title:row.window_title||'',window_class:row.window_class||'',process_name:row.process_name||''})}else{editing.value=null;Object.assign(form,{rule_type:'block',window_title:'',window_class:'',process_name:''})}visible.value=true}
|
||||
async function save(){const url=editing.value?`/api/plugins/popup-blocker/rules/${editing.value.id}`:'/api/plugins/popup-blocker/rules';const m=editing.value?'PUT':'POST';const r=await fetch(url,{method:m,...auth(),body:JSON.stringify(form)}).then(r=>r.json());if(r.success){ElMessage.success('已保存');visible.value=false;fetchRules()}else{ElMessage.error(r.error)}}
|
||||
async function remove(id:number){await ElMessageBox.confirm('确定删除?','确认',{type:'warning'});await fetch(`/api/plugins/popup-blocker/rules/${id}`,{method:'DELETE',...auth()});ElMessage.success('已删除');fetchRules()}
|
||||
onMounted(()=>{fetchRules();fetchStats()})
|
||||
</script>
|
||||
<style scoped>.plugin-page{padding:20px}.toolbar{display:flex;gap:12px;margin-bottom:16px}</style>
|
||||
71
web/src/views/plugins/SoftwareBlocker.vue
Normal file
71
web/src/views/plugins/SoftwareBlocker.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="plugin-page">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="软件黑名单" name="blacklist">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="showDialog()">添加规则</el-button>
|
||||
</div>
|
||||
<el-table :data="blacklist" v-loading="loading" stripe size="small">
|
||||
<el-table-column prop="name_pattern" label="软件名称匹配" min-width="200" />
|
||||
<el-table-column prop="category" label="分类" width="100">
|
||||
<template #default="{ row }"><el-tag size="small">{{ catLabel(row.category) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="action" label="动作" width="100">
|
||||
<template #default="{ row }"><el-tag :type="row.action==='block'?'danger':'warning'" size="small">{{ row.action==='block'?'阻止':'告警' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="target_type" label="范围" width="80" />
|
||||
<el-table-column prop="enabled" label="启用" width="70">
|
||||
<template #default="{ row }"><el-tag :type="row.enabled?'success':'info'" size="small">{{ row.enabled?'是':'否' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" size="small" @click="remove(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="违规记录" name="violations">
|
||||
<div class="toolbar"><el-input v-model="vFilter" placeholder="终端UID" style="width:200px" clearable @input="fetchViolations" /></div>
|
||||
<el-table :data="violations" v-loading="vLoading" stripe size="small">
|
||||
<el-table-column prop="device_uid" label="终端" width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="software_name" label="软件" min-width="200" />
|
||||
<el-table-column prop="action_taken" label="处理动作" width="150">
|
||||
<template #default="{ row }"><el-tag :type="actionTag(row.action_taken)" size="small">{{ actionLabel(row.action_taken) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="timestamp" label="时间" width="170" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<el-dialog v-model="dialogVisible" title="添加黑名单规则" width="480px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="软件名称"><el-input v-model="form.name_pattern" placeholder="*游戏* / *.exe" /></el-form-item>
|
||||
<el-form-item label="分类"><el-select v-model="form.category"><el-option label="游戏" value="game" /><el-option label="社交" value="social" /><el-option label="VPN" value="vpn" /><el-option label="挖矿" value="mining" /><el-option label="自定义" value="custom" /></el-select></el-form-item>
|
||||
<el-form-item label="动作"><el-select v-model="form.action"><el-option label="阻止安装" value="block" /><el-option label="仅告警" value="alert" /></el-select></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="dialogVisible=false">取消</el-button><el-button type="primary" @click="save">保存</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
const activeTab = ref('blacklist')
|
||||
const auth = () => ({ headers: { Authorization: `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json' } })
|
||||
const blacklist = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const violations = ref<any[]>([])
|
||||
const vLoading = ref(false)
|
||||
const vFilter = ref('')
|
||||
const dialogVisible = ref(false)
|
||||
const form = reactive({ name_pattern: '', category: 'custom', action: 'block' })
|
||||
function catLabel(c: string) { return { game:'游戏',social:'社交',vpn:'VPN',mining:'挖矿',custom:'自定义' }[c]||c }
|
||||
function actionLabel(a: string) { return { blocked_install:'已阻止',auto_uninstalled:'已卸载',alerted:'已告警' }[a]||a }
|
||||
function actionTag(a: string) { return { blocked_install:'danger',auto_uninstalled:'warning',alerted:'' }[a]||'info' }
|
||||
async function fetchBlacklist() { loading.value=true; try{const r=await fetch('/api/plugins/software-blocker/blacklist',auth()).then(r=>r.json());if(r.success)blacklist.value=r.data.blacklist||[]}finally{loading.value=false} }
|
||||
async function fetchViolations() { vLoading.value=true; try{const p=new URLSearchParams();if(vFilter.value)p.set('device_uid',vFilter.value);const r=await fetch(`/api/plugins/software-blocker/violations?${p}`,auth()).then(r=>r.json());if(r.success)violations.value=r.data.violations||[]}finally{vLoading.value=false} }
|
||||
function showDialog(){Object.assign(form,{name_pattern:'',category:'custom',action:'block'});dialogVisible.value=true}
|
||||
async function save(){const r=await fetch('/api/plugins/software-blocker/blacklist',{method:'POST',...auth(),body:JSON.stringify(form)}).then(r=>r.json());if(r.success){ElMessage.success('已添加');dialogVisible.value=false;fetchBlacklist()}else{ElMessage.error(r.error)}}
|
||||
async function remove(id:number){await ElMessageBox.confirm('确定删除?','确认',{type:'warning'});await fetch(`/api/plugins/software-blocker/blacklist/${id}`,{method:'DELETE',...auth()});ElMessage.success('已删除');fetchBlacklist()}
|
||||
onMounted(()=>{fetchBlacklist();fetchViolations()})
|
||||
</script>
|
||||
<style scoped>.plugin-page{padding:20px}.toolbar{display:flex;gap:12px;margin-bottom:16px}</style>
|
||||
74
web/src/views/plugins/UsageTimer.vue
Normal file
74
web/src/views/plugins/UsageTimer.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="plugin-page">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="每日使用统计" name="daily">
|
||||
<div class="toolbar">
|
||||
<el-input v-model="uidFilter" placeholder="终端UID" style="width:200px" clearable @input="fetchDaily" />
|
||||
</div>
|
||||
<el-table :data="dailyData" v-loading="loading" stripe size="small">
|
||||
<el-table-column prop="device_uid" label="终端" width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="date" label="日期" width="120" />
|
||||
<el-table-column label="活跃时间" width="120">
|
||||
<template #default="{ row }">{{ formatMinutes(row.total_active_minutes) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="空闲时间" width="120">
|
||||
<template #default="{ row }">{{ formatMinutes(row.total_idle_minutes) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="first_active_at" label="首次活跃" width="170" />
|
||||
<el-table-column prop="last_active_at" label="最后活跃" width="170" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="应用使用详情" name="apps">
|
||||
<div class="toolbar"><el-input v-model="appUid" placeholder="终端UID" style="width:200px" clearable @input="fetchApps" /></div>
|
||||
<el-table :data="appData" v-loading="appLoading" stripe size="small">
|
||||
<el-table-column prop="app_name" label="应用名称" min-width="200" />
|
||||
<el-table-column prop="date" label="日期" width="120" />
|
||||
<el-table-column label="使用时长" width="120">
|
||||
<template #default="{ row }">{{ formatMinutes(row.usage_minutes) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="使用排行" name="leaderboard">
|
||||
<el-table :data="board" v-loading="boardLoading" stripe size="small">
|
||||
<el-table-column type="index" label="#" width="60" />
|
||||
<el-table-column prop="device_uid" label="终端" min-width="200" />
|
||||
<el-table-column label="7天总时长" width="140">
|
||||
<template #default="{ row }">{{ formatMinutes(row.total_minutes) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
const activeTab = ref('daily')
|
||||
const auth = () => ({ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } })
|
||||
const uidFilter = ref('')
|
||||
const dailyData = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const appUid = ref('')
|
||||
const appData = ref<any[]>([])
|
||||
const appLoading = ref(false)
|
||||
const board = ref<any[]>([])
|
||||
const boardLoading = ref(false)
|
||||
|
||||
function formatMinutes(m: number) { if(m>=60) return `${Math.floor(m/60)}h${m%60}m`; return `${m}m` }
|
||||
|
||||
async function fetchDaily() {
|
||||
loading.value=true
|
||||
try{const params=new URLSearchParams();if(uidFilter.value)params.set('device_uid',uidFilter.value)
|
||||
const r=await fetch(`/api/plugins/usage-timer/daily?${params}`,auth()).then(r=>r.json());if(r.success)dailyData.value=r.data.daily||[]}finally{loading.value=false}
|
||||
}
|
||||
async function fetchApps() {
|
||||
appLoading.value=true
|
||||
try{const params=new URLSearchParams();if(appUid.value)params.set('device_uid',appUid.value)
|
||||
const r=await fetch(`/api/plugins/usage-timer/app-usage?${params}`,auth()).then(r=>r.json());if(r.success)appData.value=r.data.app_usage||[]}finally{appLoading.value=false}
|
||||
}
|
||||
async function fetchBoard() {
|
||||
boardLoading.value=true
|
||||
try{const r=await fetch('/api/plugins/usage-timer/leaderboard',auth()).then(r=>r.json());if(r.success)board.value=r.data.leaderboard||[]}finally{boardLoading.value=false}
|
||||
}
|
||||
onMounted(()=>{fetchDaily();fetchApps();fetchBoard()})
|
||||
</script>
|
||||
<style scoped>.plugin-page{padding:20px}.toolbar{display:flex;gap:12px;margin-bottom:16px}</style>
|
||||
53
web/src/views/plugins/UsbFileAudit.vue
Normal file
53
web/src/views/plugins/UsbFileAudit.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div class="plugin-page">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="文件操作日志" name="log">
|
||||
<div class="toolbar">
|
||||
<el-input v-model="filters.device_uid" placeholder="终端UID" style="width:200px" clearable @input="fetchLog" />
|
||||
<el-select v-model="filters.operation" placeholder="操作类型" clearable style="width:130px" @change="fetchLog">
|
||||
<el-option label="创建" value="create" /><el-option label="删除" value="delete" /><el-option label="修改" value="modify" /><el-option label="重命名" value="rename" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-table :data="log" v-loading="loading" stripe size="small">
|
||||
<el-table-column prop="device_uid" label="终端" width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="usb_serial" label="U盘序列号" width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="drive_letter" label="盘符" width="70" />
|
||||
<el-table-column prop="operation" label="操作" width="80">
|
||||
<template #default="{ row }"><el-tag :type="opTag(row.operation)" size="small">{{ opLabel(row.operation) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="file_path" label="文件路径" min-width="250" show-overflow-tooltip />
|
||||
<el-table-column label="大小" width="100">
|
||||
<template #default="{ row }">{{ row.file_size ? formatSize(row.file_size) : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="timestamp" label="时间" width="170" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="设备汇总" name="summary">
|
||||
<el-table :data="summaryData" v-loading="sLoading" stripe size="small">
|
||||
<el-table-column prop="device_uid" label="终端" width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="op_count" label="操作次数(7天)" width="130" />
|
||||
<el-table-column prop="usb_count" label="U盘数量" width="110" />
|
||||
<el-table-column prop="first_op" label="最早操作" width="170" />
|
||||
<el-table-column prop="last_op" label="最近操作" width="170" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
const activeTab = ref('log')
|
||||
const auth = () => ({ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } })
|
||||
const filters = reactive({ device_uid: '', operation: '' })
|
||||
const log = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const summaryData = ref<any[]>([])
|
||||
const sLoading = ref(false)
|
||||
function opTag(o:string){return{create:'success',delete:'danger',modify:'warning',rename:'info'}[o]||'info'}
|
||||
function opLabel(o:string){return{create:'创建',delete:'删除',modify:'修改',rename:'重命名'}[o]||o}
|
||||
function formatSize(b:number){if(b>=1073741824)return`${(b/1073741824).toFixed(1)}GB`;if(b>=1048576)return`${(b/1048576).toFixed(1)}MB`;if(b>=1024)return`${(b/1024).toFixed(1)}KB`;return`${b}B`}
|
||||
async function fetchLog(){loading.value=true;try{const p=new URLSearchParams();if(filters.device_uid)p.set('device_uid',filters.device_uid);if(filters.operation)p.set('operation',filters.operation);const r=await fetch(`/api/plugins/usb-file-audit/log?${p}`,auth()).then(r=>r.json());if(r.success)log.value=r.data.operations||[]}finally{loading.value=false}}
|
||||
async function fetchSummary(){sLoading.value=true;try{const r=await fetch('/api/plugins/usb-file-audit/summary',auth()).then(r=>r.json());if(r.success)summaryData.value=r.data.summary||[]}finally{sLoading.value=false}}
|
||||
onMounted(()=>{fetchLog();fetchSummary()})
|
||||
</script>
|
||||
<style scoped>.plugin-page{padding:20px}.toolbar{display:flex;gap:12px;margin-bottom:16px}</style>
|
||||
112
web/src/views/plugins/Watermark.vue
Normal file
112
web/src/views/plugins/Watermark.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="plugin-page">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<span class="card-title">水印配置</span>
|
||||
<el-button type="primary" size="small" @click="showDialog()">新建配置</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="configs" v-loading="loading" stripe size="small">
|
||||
<el-table-column prop="target_type" label="应用范围" width="100" />
|
||||
<el-table-column prop="target_id" label="目标" width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="content" label="水印内容" min-width="250" show-overflow-tooltip />
|
||||
<el-table-column prop="font_size" label="字号" width="70" />
|
||||
<el-table-column label="透明度" width="100">
|
||||
<template #default="{ row }">{{ (row.opacity * 100).toFixed(0) }}%</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="color" label="颜色" width="80" />
|
||||
<el-table-column prop="angle" label="角度" width="70" />
|
||||
<el-table-column prop="enabled" label="启用" width="70">
|
||||
<template #default="{ row }"><el-tag :type="row.enabled?'success':'info'" size="small">{{ row.enabled?'是':'否' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="showDialog(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="remove(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="hover" style="margin-top:20px">
|
||||
<template #header><span class="card-title">水印预览</span></template>
|
||||
<div class="preview-area">
|
||||
<div class="watermark-overlay" :style="watermarkStyle">
|
||||
<span v-for="i in 12" :key="i" class="wm-text">{{ previewContent }}</span>
|
||||
</div>
|
||||
<div class="preview-content">
|
||||
<p style="color:#606266;font-size:14px">此区域模拟用户桌面效果</p>
|
||||
<p style="color:#909399;font-size:12px">水印内容会以设定角度和透明度覆盖整个屏幕</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="visible" :title="editing?'编辑配置':'新建配置'" width="520px">
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="应用范围">
|
||||
<el-select v-model="form.target_type"><el-option label="全局" value="global" /><el-option label="分组" value="group" /><el-option label="指定设备" value="device" /></el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="目标ID" v-if="form.target_type!=='global'"><el-input v-model="form.target_id" /></el-form-item>
|
||||
<el-form-item label="水印内容"><el-input v-model="form.content" type="textarea" :rows="2" placeholder="支持变量: {company} {username} {hostname} {date} {time}" /></el-form-item>
|
||||
<el-form-item label="字号"><el-input-number v-model="form.font_size" :min="8" :max="48" /></el-form-item>
|
||||
<el-form-item label="透明度"><el-slider v-model="form.opacity" :min="5" :max="50" :step="1" :format-tooltip="(v:number)=>`${v}%`" /></el-form-item>
|
||||
<el-form-item label="颜色"><el-color-picker v-model="form.color" /></el-form-item>
|
||||
<el-form-item label="角度"><el-input-number v-model="form.angle" :min="-90" :max="90" /></el-form-item>
|
||||
<el-form-item label="启用"><el-switch v-model="form.enabled" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="visible=false">取消</el-button><el-button type="primary" @click="save">保存</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
const auth = () => ({ headers: { Authorization: `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json' } })
|
||||
const configs = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const visible = ref(false)
|
||||
const editing = ref<any>(null)
|
||||
const form = reactive({ target_type: 'global', target_id: '', content: '{company} | {username} | {date}', font_size: 14, opacity: 15, color: '#808080', angle: -30, enabled: true })
|
||||
|
||||
const previewContent = computed(() => form.content
|
||||
.replace('{company}', 'CSM Corp')
|
||||
.replace('{username}', 'admin')
|
||||
.replace('{hostname}', 'DESKTOP-01')
|
||||
.replace('{date}', new Date().toLocaleDateString())
|
||||
.replace('{time}', new Date().toLocaleTimeString()))
|
||||
|
||||
const watermarkStyle = computed(() => ({
|
||||
transform: `rotate(${form.angle}deg)`,
|
||||
opacity: form.opacity / 100,
|
||||
fontSize: `${form.font_size}px`,
|
||||
color: form.color,
|
||||
}))
|
||||
|
||||
async function fetchConfigs() {
|
||||
loading.value = true
|
||||
try { const r = await fetch('/api/plugins/watermark/config', auth()).then(r => r.json()); if (r.success) configs.value = r.data.configs || [] } finally { loading.value = false }
|
||||
}
|
||||
function showDialog(row?: any) {
|
||||
if (row) { editing.value = row; Object.assign(form, { target_type: row.target_type, target_id: row.target_id || '', content: row.content, font_size: row.font_size, opacity: Math.round(row.opacity * 100), color: row.color, angle: row.angle, enabled: row.enabled }) }
|
||||
else { editing.value = null; Object.assign(form, { target_type: 'global', target_id: '', content: '{company} | {username} | {date}', font_size: 14, opacity: 15, color: '#808080', angle: -30, enabled: true }) }
|
||||
visible.value = true
|
||||
}
|
||||
async function save() {
|
||||
const body = { ...form, opacity: form.opacity / 100 }
|
||||
const url = editing.value ? `/api/plugins/watermark/config/${editing.value.id}` : '/api/plugins/watermark/config'
|
||||
const method = editing.value ? 'PUT' : 'POST'
|
||||
const r = await fetch(url, { method, ...auth(), body: JSON.stringify(body) }).then(r => r.json())
|
||||
if (r.success) { ElMessage.success('已保存'); visible.value = false; fetchConfigs() } else { ElMessage.error(r.error) }
|
||||
}
|
||||
async function remove(id: number) { await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' }); await fetch(`/api/plugins/watermark/config/${id}`, { method: 'DELETE', ...auth() }); ElMessage.success('已删除'); fetchConfigs() }
|
||||
onMounted(() => fetchConfigs())
|
||||
</script>
|
||||
<style scoped>
|
||||
.plugin-page{padding:20px}
|
||||
.card-title{font-weight:600;font-size:15px}
|
||||
.preview-area{position:relative;height:200px;border:1px solid #e4e7ed;border-radius:8px;overflow:hidden;background:#f5f7fa}
|
||||
.watermark-overlay{position:absolute;inset:0;display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:80px;pointer-events:none}
|
||||
.wm-text{white-space:nowrap;user-select:none}
|
||||
.preview-content{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center}
|
||||
</style>
|
||||
105
web/src/views/plugins/WebFilter.vue
Normal file
105
web/src/views/plugins/WebFilter.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div class="plugin-page">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="过滤规则" name="rules">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="showRuleDialog()">新建规则</el-button>
|
||||
</div>
|
||||
<el-table :data="rules" v-loading="loading" stripe size="small">
|
||||
<el-table-column prop="rule_type" label="类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.rule_type==='blacklist'?'danger':row.rule_type==='whitelist'?'success':'info'" size="small">
|
||||
{{ ruleTypeLabel(row.rule_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="pattern" label="匹配模式" min-width="200" />
|
||||
<el-table-column prop="target_type" label="应用范围" width="100" />
|
||||
<el-table-column prop="enabled" label="启用" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.enabled?'success':'info'" size="small">{{ row.enabled?'是':'否' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="170" />
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="showRuleDialog(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="deleteRule(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="访问日志" name="log">
|
||||
<el-table :data="accessLog" v-loading="logLoading" stripe size="small">
|
||||
<el-table-column prop="device_uid" label="终端" width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="url" label="URL" min-width="300" show-overflow-tooltip />
|
||||
<el-table-column label="动作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.action==='blocked'?'danger':'success'" size="small">{{ row.action==='blocked'?'拦截':'放行' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="timestamp" label="时间" width="170" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editing?'编辑规则':'新建规则'" width="480px">
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="规则类型">
|
||||
<el-select v-model="form.rule_type"><el-option label="黑名单" value="blacklist" /><el-option label="白名单" value="whitelist" /><el-option label="分类" value="category" /></el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="匹配模式"><el-input v-model="form.pattern" placeholder="*.example.com" /></el-form-item>
|
||||
<el-form-item label="应用范围">
|
||||
<el-select v-model="form.target_type"><el-option label="全局" value="global" /><el-option label="分组" value="group" /><el-option label="指定设备" value="device" /></el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用"><el-switch v-model="form.enabled" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="dialogVisible=false">取消</el-button><el-button type="primary" @click="saveRule">保存</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
const activeTab = ref('rules')
|
||||
const auth = () => ({ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } })
|
||||
const rules = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const accessLog = ref<any[]>([])
|
||||
const logLoading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const editing = ref<any>(null)
|
||||
const form = reactive({ rule_type: 'blacklist', pattern: '', target_type: 'global', target_id: '', enabled: true })
|
||||
|
||||
function ruleTypeLabel(t: string) { return { blacklist: '黑名单', whitelist: '白名单', category: '分类' }[t] || t }
|
||||
|
||||
async function fetchRules() {
|
||||
loading.value = true
|
||||
try { const r = await fetch('/api/plugins/web-filter/rules', auth()).then(r=>r.json()); if(r.success) rules.value = r.data.rules||[] } finally { loading.value = false }
|
||||
}
|
||||
async function fetchLog() {
|
||||
logLoading.value = true
|
||||
try { const r = await fetch('/api/plugins/web-filter/log', auth()).then(r=>r.json()); if(r.success) accessLog.value = r.data.log||[] } finally { logLoading.value = false }
|
||||
}
|
||||
function showRuleDialog(row?: any) {
|
||||
if(row){ editing.value=row; Object.assign(form,{rule_type:row.rule_type,pattern:row.pattern,target_type:row.target_type,target_id:row.target_id||'',enabled:row.enabled}) }
|
||||
else{ editing.value=null; Object.assign(form,{rule_type:'blacklist',pattern:'',target_type:'global',target_id:'',enabled:true}) }
|
||||
dialogVisible.value=true
|
||||
}
|
||||
async function saveRule() {
|
||||
const url = editing.value ? `/api/plugins/web-filter/rules/${editing.value.id}` : '/api/plugins/web-filter/rules'
|
||||
const method = editing.value ? 'PUT' : 'POST'
|
||||
const res = await fetch(url,{method,...auth(),headers:{...auth().headers,'Content-Type':'application/json'},body:JSON.stringify(form)}).then(r=>r.json())
|
||||
if(res.success){ElMessage.success('已保存');dialogVisible.value=false;fetchRules()}else{ElMessage.error(res.error)}
|
||||
}
|
||||
async function deleteRule(id: number) {
|
||||
await ElMessageBox.confirm('确定删除?','确认',{type:'warning'})
|
||||
await fetch(`/api/plugins/web-filter/rules/${id}`,{method:'DELETE',...auth()})
|
||||
ElMessage.success('已删除'); fetchRules()
|
||||
}
|
||||
onMounted(()=>{fetchRules();fetchLog()})
|
||||
</script>
|
||||
|
||||
<style scoped>.plugin-page{padding:20px}.toolbar{display:flex;gap:12px;margin-bottom:16px}</style>
|
||||
1
web/src/vite-env.d.ts
vendored
Normal file
1
web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
25
web/tsconfig.json
Normal file
25
web/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
web/tsconfig.node.json
Normal file
10
web/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
62
web/vite.config.ts
Normal file
62
web/vite.config.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
}),
|
||||
Components({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
'element-plus',
|
||||
'@element-plus/icons-vue',
|
||||
'echarts',
|
||||
],
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8080',
|
||||
ws: true,
|
||||
},
|
||||
'/health': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
sourcemap: false,
|
||||
chunkSizeWarningLimit: 500,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'element-plus': ['element-plus'],
|
||||
'echarts': ['echarts'],
|
||||
'vendor': ['vue', 'vue-router', 'pinia'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user