feat: 初始化项目基础架构和核心功能

- 添加项目基础结构:Cargo.toml、.gitignore、设备UID和密钥文件
- 实现前端Vue3项目结构:路由、登录页面、设备管理页面
- 添加核心协议定义(crates/protocol):设备状态、资产、USB事件等
- 实现客户端监控模块:系统状态收集、资产收集
- 实现服务端基础API和插件系统
- 添加数据库迁移脚本:设备管理、资产跟踪、告警系统等
- 实现前端设备状态展示和基本交互
- 添加使用时长统计和水印功能插件
This commit is contained in:
iven
2026-04-05 00:57:51 +08:00
commit fd6fb5cca0
87 changed files with 19576 additions and 0 deletions

9
web/auto-imports.d.ts vendored Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

30
web/package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>

View 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
View 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
View 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
View 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
View 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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View File

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

25
web/tsconfig.json Normal file
View 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
View 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
View 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'],
},
},
},
},
})