feat: 全面重构前端UI及完善后端功能

前端重构:
- 重构Layout为左侧导航+顶栏的现代管理后台布局
- 重构设备管理页面(Devices.vue):左侧分组面板+右侧设备列表
- 重构设备详情(DeviceDetail.vue):集成硬件资产/软件资产/变更记录标签页
- 移除独立资产管理页面,功能合并至设备详情
- 重构Dashboard/登录/设置/告警/水印/上网管控等页面样式
- 新增全局CSS变量和统一样式系统
- 添加分组管理UI:新建/重命名/删除分组,移动设备到分组

后端完善:
- 新增分组CRUD API(groups.rs):创建/重命名/删除分组,设备分组移动
- 客户端硬件采集:完善GPU/主板/序列号/磁盘信息采集(Windows PowerShell)
- 客户端软件采集:通过Windows注册表读取已安装软件列表
- 新增SoftwareAssetReport消息类型(0x09)及处理链路
- 数据库新增upsert_software方法处理软件资产存储
- 服务端推送软件资产配置给新注册设备
- 修复密码修改功能,添加旧密码验证
This commit is contained in:
iven
2026-04-06 13:09:43 +08:00
parent fd6fb5cca0
commit e99ea53eba
30 changed files with 3493 additions and 856 deletions

5
web/components.d.ts vendored
View File

@@ -10,13 +10,11 @@ declare module 'vue' {
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']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
@@ -44,6 +42,7 @@ declare module 'vue' {
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}

View File

@@ -1,8 +1,3 @@
<template>
<router-view />
</template>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #app { height: 100%; }
</style>

View File

@@ -0,0 +1,205 @@
/* ========================================
CSM Global Styles
======================================== */
@import './variables.css';
/* ---- Reset & Base ---- */
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-size: 14px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html, body, #app {
height: 100%;
font-family: var(--csm-font-family);
color: var(--csm-text-primary);
background: var(--csm-bg-page);
}
/* ---- Scrollbar ---- */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
/* ---- Card Styles ---- */
.csm-card {
background: var(--csm-bg-card);
border-radius: var(--csm-border-radius-lg);
border: 1px solid var(--csm-border-color);
}
.csm-card-header {
font-weight: 600;
font-size: 14px;
color: var(--csm-text-primary);
padding: 14px 20px;
border-bottom: 1px solid var(--csm-border-color);
display: flex;
align-items: center;
justify-content: space-between;
}
.csm-card-body {
padding: var(--csm-card-padding);
}
/* ---- Stat Cards ---- */
.stat-card-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.stat-card {
background: var(--csm-bg-card);
border-radius: var(--csm-border-radius);
padding: 20px;
display: flex;
align-items: center;
gap: 14px;
border: 1px solid var(--csm-border-color);
transition: box-shadow var(--csm-transition-fast);
}
.stat-card:hover {
box-shadow: var(--csm-shadow-md);
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
flex-shrink: 0;
}
.stat-icon.online { background: #16a34a; }
.stat-icon.offline { background: #64748b; }
.stat-icon.warning { background: #d97706; }
.stat-icon.usb { background: #2563eb; }
.stat-value {
font-size: 26px;
font-weight: 700;
color: var(--csm-text-primary);
line-height: 1;
}
.stat-label {
font-size: 13px;
color: var(--csm-text-secondary);
margin-top: 2px;
}
/* ---- Page Container ---- */
.page-container {
padding: var(--csm-page-padding);
}
/* ---- Toolbar ---- */
.page-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.page-toolbar-right {
margin-left: auto;
}
/* ---- Table ---- */
.el-table th.el-table__cell {
font-weight: 600;
color: var(--csm-text-secondary);
font-size: 13px;
}
/* ---- Element Plus Overrides ---- */
.el-card {
border-radius: var(--csm-border-radius-lg) !important;
border-color: var(--csm-border-color) !important;
}
.el-button--primary {
background: var(--csm-primary);
border-color: var(--csm-primary);
}
.el-button--primary:hover {
background: var(--csm-primary-light);
border-color: var(--csm-primary-light);
}
.el-tag {
border-radius: 4px;
font-weight: 500;
}
.el-dialog {
border-radius: var(--csm-border-radius-xl) !important;
}
.el-dialog__header {
border-bottom: 1px solid var(--csm-border-color);
padding-bottom: 16px;
}
.el-dialog__footer {
border-top: 1px solid var(--csm-border-color);
padding-top: 16px;
}
/* ---- Page Transition ---- */
.page-enter-active,
.page-leave-active {
transition: opacity 0.15s ease;
}
.page-enter-from,
.page-leave-to {
opacity: 0;
}
/* ---- Responsive ---- */
@media (max-width: 1200px) {
.stat-card-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.stat-card-grid {
grid-template-columns: 1fr;
}
:root {
--csm-page-padding: 16px;
}
}

View File

@@ -0,0 +1,78 @@
/* ========================================
CSM Design System - CSS Variables
Enterprise Terminal Management System
======================================== */
:root {
/* ---- Brand Colors ---- */
--csm-primary: #1d4ed8;
--csm-primary-light: #3b6de0;
--csm-primary-dark: #1e40af;
--csm-primary-bg: rgba(29, 78, 216, 0.06);
/* ---- Sidebar ---- */
--csm-sidebar-bg: #1e293b;
--csm-sidebar-hover: #263548;
--csm-sidebar-active: rgba(29, 78, 216, 0.15);
--csm-sidebar-text: #94a3b8;
--csm-sidebar-text-active: #e2e8f0;
--csm-sidebar-width: 240px;
/* ---- Surface & Background ---- */
--csm-bg-page: #f5f6fa;
--csm-bg-card: #ffffff;
--csm-bg-header: #ffffff;
/* ---- Text Colors ---- */
--csm-text-primary: #1e293b;
--csm-text-secondary: #64748b;
--csm-text-tertiary: #94a3b8;
--csm-text-inverse: #ffffff;
/* ---- Status Colors ---- */
--csm-success: #16a34a;
--csm-success-bg: rgba(22, 163, 74, 0.06);
--csm-warning: #d97706;
--csm-warning-bg: rgba(217, 119, 6, 0.06);
--csm-danger: #dc2626;
--csm-danger-bg: rgba(220, 38, 38, 0.06);
--csm-info: #2563eb;
--csm-info-bg: rgba(37, 99, 235, 0.06);
/* ---- Border ---- */
--csm-border-color: #e5e7eb;
--csm-border-radius: 8px;
--csm-border-radius-lg: 12px;
--csm-border-radius-xl: 16px;
/* ---- Shadow ---- */
--csm-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.04);
--csm-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.06);
--csm-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.06), 0 2px 4px -2px rgba(0, 0, 0, 0.06);
/* ---- Spacing ---- */
--csm-page-padding: 24px;
--csm-card-padding: 20px;
/* ---- Transitions ---- */
--csm-transition-fast: 150ms ease;
--csm-transition: 250ms ease;
/* ---- Typography ---- */
--csm-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--csm-font-mono: 'Menlo', 'Consolas', monospace;
}
/* Element Plus Theme Overrides */
:root {
--el-color-primary: var(--csm-primary);
--el-color-primary-light-3: var(--csm-primary-light);
--el-color-primary-dark-2: var(--csm-primary-dark);
--el-color-success: var(--csm-success);
--el-color-warning: var(--csm-warning);
--el-color-danger: var(--csm-danger);
--el-color-info: #64748b;
--el-border-radius-base: 6px;
--el-font-family: var(--csm-font-family);
--el-bg-color-page: var(--csm-bg-page);
}

View File

@@ -1,6 +1,7 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import 'element-plus/dist/index.css'
import './assets/styles/global.css'
import App from './App.vue'
import router from './router'

View File

@@ -13,7 +13,6 @@ const router = createRouter({
{ 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') },

View File

@@ -1,8 +1,8 @@
<template>
<div class="alerts-page">
<el-tabs v-model="activeTab">
<div class="page-container">
<el-tabs v-model="activeTab" class="page-tabs">
<el-tab-pane label="告警记录" name="records">
<div class="toolbar">
<div class="page-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" />
@@ -14,63 +14,81 @@
<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>
<div class="csm-card">
<el-table :data="records" v-loading="recLoading" style="width:100%">
<el-table-column label="严重程度" width="110">
<template #default="{ row }">
<div style="display:flex;align-items:center;gap:6px">
<span class="severity-dot" :class="row.severity"></span>
<el-tag :type="severityTag(row.severity)" size="small" effect="light">{{ row.severity }}</el-tag>
</div>
</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">
<template #default="{ row }"><span class="secondary-text">{{ row.triggered_at }}</span></template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.handled ? 'success' : 'warning'" size="small" effect="light">
{{ row.handled ? '已处理' : '待处理' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="80" 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>
</div>
</el-tab-pane>
<el-tab-pane label="告警规则" name="rules">
<div class="toolbar">
<el-button type="primary" @click="showRuleDialog()">新建规则</el-button>
<div class="page-toolbar">
<el-button type="primary" @click="showRuleDialog()">
<el-icon><Plus /></el-icon>新建规则
</el-button>
</div>
<div class="csm-card">
<el-table :data="rules" v-loading="ruleLoading" style="width:100%">
<el-table-column prop="name" label="规则名称" min-width="180">
<template #default="{ row }"><span style="font-weight:500">{{ row.name }}</span></template>
</el-table-column>
<el-table-column prop="rule_type" label="规则类型" width="140" />
<el-table-column prop="severity" label="严重程度" width="110">
<template #default="{ row }">
<div style="display:flex;align-items:center;gap:6px">
<span class="severity-dot" :class="row.severity"></span>
<el-tag :type="severityTag(row.severity)" size="small" effect="light">{{ row.severity }}</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="condition" label="条件" min-width="200" show-overflow-tooltip>
<template #default="{ row }"><span class="mono-text">{{ row.condition }}</span></template>
</el-table-column>
<el-table-column prop="enabled" label="启用" width="80">
<template #default="{ row }">
<el-switch :model-value="row.enabled" :active-value="1" :inactive-value="0" @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>
</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-dialog v-model="ruleDialogVisible" :title="editingRule ? '编辑规则' : '新建规则'" width="520px" destroy-on-close>
<el-form :model="ruleForm" label-width="100px">
<el-form-item label="规则名称">
<el-input v-model="ruleForm.name" />
<el-input v-model="ruleForm.name" placeholder="输入规则名称" />
</el-form-item>
<el-form-item label="规则类型">
<el-select v-model="ruleForm.rule_type" style="width: 100%">
@@ -109,11 +127,11 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { api } from '@/lib/api'
const activeTab = ref('records')
// Records
const records = ref<any[]>([])
const recLoading = ref(false)
const severityFilter = ref('')
@@ -138,7 +156,6 @@ async function handleRecord(id: number) {
} catch (e: any) { ElMessage.error(e.message || '操作失败') }
}
// Rules
const rules = ref<any[]>([])
const ruleLoading = ref(false)
const ruleDialogVisible = ref(false)
@@ -211,6 +228,33 @@ onMounted(() => {
</script>
<style scoped>
.alerts-page { padding: 20px; }
.toolbar { display: flex; gap: 12px; margin-bottom: 16px; }
.severity-dot {
width: 7px;
height: 7px;
border-radius: 50%;
display: inline-block;
}
.severity-dot.critical { background: #ef4444; box-shadow: 0 0 6px rgba(239, 68, 68, 0.4); }
.severity-dot.high { background: #f59e0b; }
.severity-dot.medium { background: #2563eb; }
.severity-dot.low { background: #94a3b8; }
.mono-text {
font-family: var(--csm-font-mono);
font-size: 12px;
color: var(--csm-text-secondary);
}
.secondary-text {
font-size: 12px;
color: var(--csm-text-tertiary);
}
.page-tabs :deep(.el-tabs__header) {
margin: 0 0 20px 0;
padding: 0 24px;
background: #fff;
border-bottom: 1px solid var(--csm-border-color);
}
</style>

View File

@@ -1,123 +0,0 @@
<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>

View File

@@ -1,114 +1,137 @@
<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>
<div class="page-container">
<!-- Stat cards -->
<div class="stat-card-grid">
<div class="stat-card">
<div class="stat-icon online">
<el-icon :size="26"><Monitor /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.online }}</div>
<div class="stat-label">在线终端</div>
</div>
<div class="stat-trend" v-if="stats.online > 0">
<el-icon color="#10b981"><Top /></el-icon>
</div>
</div>
<div class="stat-card">
<div class="stat-icon offline">
<el-icon :size="26"><Platform /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.offline }}</div>
<div class="stat-label">离线终端</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon warning">
<el-icon :size="26"><Bell /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.alerts }}</div>
<div class="stat-label">待处理告警</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon usb">
<el-icon :size="26"><Connection /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.usbEvents }}</div>
<div class="stat-label">USB事件(24h)</div>
</div>
</div>
</div>
<!-- Charts 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>
<div class="csm-card">
<div class="csm-card-header">
<span>终端状态总览</span>
<el-tag size="small" type="info" effect="plain">TOP 10</el-tag>
</div>
<div class="csm-card-body">
<div ref="cpuChartRef" style="height: 320px"></div>
</div>
</div>
</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 class="csm-card" style="height: 100%">
<div class="csm-card-header">
<span>最近告警</span>
<el-button link type="primary" size="small" @click="$router.push('/alerts')">查看全部</el-button>
</div>
</el-card>
<div class="csm-card-body alert-list-container">
<div class="alert-list">
<div v-for="alert in recentAlerts" :key="alert.id" class="alert-item">
<div class="alert-severity" :class="alert.severity"></div>
<div class="alert-content">
<div class="alert-detail">{{ alert.detail }}</div>
<div class="alert-time">{{ formatTime(alert.triggered_at) }}</div>
</div>
</div>
<el-empty v-if="recentAlerts.length === 0" description="暂无告警" :image-size="64" />
</div>
</div>
</div>
</el-col>
</el-row>
<!-- Bottom 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>
<div class="csm-card">
<div class="csm-card-header">
<span>最近USB事件</span>
</div>
<div class="csm-card-body">
<el-table :data="recentUsbEvents" size="small" max-height="260" :show-header="true">
<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" effect="light"
>
{{ 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>
</div>
</div>
</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>
<div class="csm-card">
<div class="csm-card-header">
<span>Top 5 高负载终端</span>
</div>
<div class="csm-card-body">
<el-table :data="topDevices" size="small" max-height="260">
<el-table-column prop="hostname" label="主机名" min-width="120" />
<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" effect="light">
{{ row.status === 'online' ? '在线' : '离线' }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
</div>
</el-col>
</el-row>
</div>
@@ -116,7 +139,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { Monitor, Platform, Bell, Connection } from '@element-plus/icons-vue'
import { Monitor, Platform, Bell, Connection, Top } from '@element-plus/icons-vue'
import * as echarts from 'echarts'
import { api } from '@/lib/api'
@@ -165,14 +188,60 @@ 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}%' } },
tooltip: {
trigger: 'axis',
backgroundColor: '#fff',
borderColor: '#e2e8f0',
borderWidth: 1,
textStyle: { color: '#1e293b', fontSize: 13 },
axisPointer: { type: 'shadow' },
},
legend: {
data: ['CPU%', '内存%'],
top: 0,
right: 0,
textStyle: { color: '#64748b', fontSize: 12 },
itemWidth: 12,
itemHeight: 8,
itemGap: 16,
},
grid: { left: 48, right: 16, bottom: 24, top: 36 },
xAxis: {
type: 'category',
data: [],
axisLine: { lineStyle: { color: '#e2e8f0' } },
axisLabel: { color: '#64748b', fontSize: 11, rotate: 30 },
axisTick: { show: false },
},
yAxis: {
type: 'value',
max: 100,
axisLine: { show: false },
axisTick: { show: false },
splitLine: { lineStyle: { color: '#f1f5f9', type: 'dashed' } },
axisLabel: { color: '#94a3b8', fontSize: 11, formatter: '{value}%' },
},
series: [
{ name: 'CPU%', type: 'bar', data: [], itemStyle: { color: '#409EFF' } },
{ name: '内存%', type: 'bar', data: [], itemStyle: { color: '#67C23A' } },
{
name: 'CPU%',
type: 'bar',
data: [],
barWidth: '30%',
itemStyle: {
borderRadius: [4, 4, 0, 0],
color: '#1d4ed8',
},
},
{
name: '内存%',
type: 'bar',
data: [],
barWidth: '30%',
itemStyle: {
borderRadius: [4, 4, 0, 0],
color: '#16a34a',
},
},
],
})
}
@@ -192,9 +261,18 @@ function updateChart(devices: any[]) {
})
}
function severityTag(severity: string) {
const map: Record<string, string> = { critical: 'danger', high: 'warning', medium: '', low: 'info' }
return map[severity] || 'info'
function formatTime(timeStr: string): string {
if (!timeStr) return ''
const date = new Date(timeStr)
if (isNaN(date.getTime())) return timeStr
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60000)
if (diffMin < 1) return '刚刚'
if (diffMin < 60) return `${diffMin}分钟前`
const diffHr = Math.floor(diffMin / 60)
if (diffHr < 24) return `${diffHr}小时前`
return timeStr.slice(5, 16)
}
function eventTypeLabel(type: string) {
@@ -203,9 +281,9 @@ function eventTypeLabel(type: string) {
}
function progressColor(value: number) {
if (value > 90) return '#F56C6C'
if (value > 70) return '#E6A23C'
return '#67C23A'
if (value > 90) return '#dc2626'
if (value > 70) return '#d97706'
return '#16a34a'
}
onMounted(() => {
@@ -224,41 +302,76 @@ onUnmounted(() => {
</script>
<style scoped>
.dashboard { padding: 20px; }
.stat-cards .stat-card {
/* Card header with flex layout */
.csm-card-header {
font-weight: 600;
font-size: 15px;
color: var(--csm-text-primary);
padding: 16px 20px;
border-bottom: 1px solid var(--csm-border-color);
display: flex;
align-items: center;
padding: 20px;
justify-content: space-between;
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
.csm-card-body {
padding: 16px 20px;
}
/* Alert list */
.alert-list-container {
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
color: #fff;
flex-direction: column;
}
.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; }
.alert-list {
flex: 1;
overflow-y: auto;
max-height: 336px;
}
.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;
align-items: flex-start;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid #f1f5f9;
}
.alert-item:last-child {
border-bottom: none;
}
.alert-severity {
width: 8px;
height: 8px;
border-radius: 50%;
margin-top: 5px;
flex-shrink: 0;
}
.alert-severity.critical { background: #ef4444; box-shadow: 0 0 6px rgba(239, 68, 68, 0.4); }
.alert-severity.high { background: #f59e0b; }
.alert-severity.medium { background: #2563eb; }
.alert-severity.low { background: #94a3b8; }
.alert-content {
flex: 1;
min-width: 0;
}
.alert-detail {
font-size: 13px;
color: var(--csm-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.4;
}
.alert-time {
font-size: 11px;
color: var(--csm-text-tertiary);
margin-top: 2px;
}
.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

@@ -1,92 +1,177 @@
<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>
<div class="page-container" v-loading="loading">
<!-- Header -->
<div class="detail-header">
<el-page-header @back="$router.back()" title="返回列表">
<template #content>
<div class="header-device">
<div class="device-avatar-lg" :class="device?.status">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:24px;height:24px">
<rect x="2" y="3" width="20" height="14" rx="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
</div>
<div>
<div class="header-name">{{ device?.hostname || deviceUid }}</div>
<div class="header-meta">
<el-tag v-if="device" :type="device.status === 'online' ? 'success' : 'info'" size="small" effect="light">
{{ device.status === 'online' ? '在线' : '离线' }}
</el-tag>
<span class="meta-text">{{ device?.ip_address }}</span>
<el-tag v-if="device?.group_name" size="small" effect="plain" round>{{ device.group_name }}</el-tag>
</div>
</div>
</div>
</template>
</el-page-header>
</div>
<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>
<!-- Tabs -->
<el-tabs v-model="activeTab" class="detail-tabs">
<!-- Tab 1: 概览 -->
<el-tab-pane label="概览" name="overview">
<el-row :gutter="16">
<el-col :span="8">
<div class="csm-card">
<div class="card-section-title">基本信息</div>
<div class="info-list">
<div class="info-row" v-for="item in basicInfo" :key="item.label">
<span class="info-label">{{ item.label }}</span>
<span class="info-value" :title="item.value">{{ item.value || '-' }}</span>
</div>
</div>
</div>
</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)" />
<el-col :span="16">
<div class="csm-card" style="margin-bottom: 16px">
<div class="card-section-title">实时状态</div>
<div class="metrics-grid" v-if="status">
<div class="metric-card">
<div class="metric-label">CPU</div>
<el-progress type="dashboard" :percentage="Math.round(status.cpu_usage)" :width="90"
:color="progressColor(status.cpu_usage)" :stroke-width="6" />
</div>
<div class="metric-card">
<div class="metric-label">内存</div>
<el-progress type="dashboard" :percentage="Math.round(status.memory_usage)" :width="90"
:color="progressColor(status.memory_usage)" :stroke-width="6" />
<div class="metric-sub">{{ formatMB(status.memory_total_mb) }}</div>
</div>
<div class="metric-card">
<div class="metric-label">磁盘</div>
<el-progress type="dashboard" :percentage="Math.round(status.disk_usage)" :width="90"
:color="progressColor(status.disk_usage)" :stroke-width="6" />
<div class="metric-sub">{{ formatMB(status.disk_total_mb) }}</div>
</div>
<div class="metric-card">
<div class="metric-label">进程</div>
<div class="metric-big-number">{{ status.running_procs }}</div>
</div>
</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-empty v-else description="暂无状态数据" :image-size="64" />
</div>
<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>
<div class="csm-card">
<div class="card-section-title">Top 进程</div>
<el-table :data="status?.top_processes || []" size="small" max-height="200" style="width:100%">
<el-table-column prop="name" label="进程名" />
<el-table-column prop="pid" label="PID" width="80">
<template #default="{ row }"><span class="mono-text">{{ row.pid }}</span></template>
</el-table-column>
<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 }"><span class="mono-text">{{ formatMB(row.memory_mb) }}</span></template>
</el-table-column>
</el-table>
</div>
</el-col>
</el-row>
</el-tab-pane>
<!-- Tab 2: 硬件资产 -->
<el-tab-pane label="硬件资产" name="hardware">
<div class="csm-card">
<el-table :data="hardware" v-loading="hwLoading" style="width:100%">
<el-table-column prop="cpu_model" label="CPU型号" min-width="200" />
<el-table-column prop="cpu_cores" label="核心数" width="80" />
<el-table-column label="内存" width="100">
<template #default="{ row }">{{ formatMB(row.memory_mb) }}</template>
<template #default="{ row }"><span class="mono-text">{{ formatMB(row.memory_total_mb) }}</span></template>
</el-table-column>
<el-table-column prop="gpu_model" label="GPU" min-width="150">
<template #default="{ row }"><span>{{ row.gpu_model || '-' }}</span></template>
</el-table-column>
<el-table-column prop="motherboard" label="主板" min-width="140">
<template #default="{ row }"><span>{{ row.motherboard || '-' }}</span></template>
</el-table-column>
<el-table-column prop="disk_model" label="磁盘" min-width="140">
<template #default="{ row }"><span>{{ row.disk_model || '-' }}</span></template>
</el-table-column>
<el-table-column label="磁盘容量" width="100">
<template #default="{ row }"><span class="mono-text">{{ formatMB(row.disk_total_mb) }}</span></template>
</el-table-column>
<el-table-column prop="serial_number" label="序列号" width="160">
<template #default="{ row }"><span class="mono-text">{{ row.serial_number || '-' }}</span></template>
</el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
</div>
</el-tab-pane>
<!-- Tab 3: 软件资产 -->
<el-tab-pane label="软件资产" name="software">
<div class="toolbar-inline">
<el-input v-model="swSearch" placeholder="搜索软件名称 / 发行商" style="width: 260px" clearable :prefix-icon="Search" @input="fetchSoftware" />
</div>
<div class="csm-card">
<el-table :data="software" v-loading="swLoading" style="width:100%">
<el-table-column prop="name" label="软件名称" min-width="200">
<template #default="{ row }"><span style="font-weight:500">{{ row.name }}</span></template>
</el-table-column>
<el-table-column prop="version" label="版本" width="120">
<template #default="{ row }"><span class="mono-text">{{ row.version || '-' }}</span></template>
</el-table-column>
<el-table-column prop="publisher" label="发行商" min-width="150">
<template #default="{ row }"><span>{{ row.publisher || '-' }}</span></template>
</el-table-column>
<el-table-column prop="install_date" label="安装日期" width="120">
<template #default="{ row }"><span class="secondary-text">{{ row.install_date || '-' }}</span></template>
</el-table-column>
<el-table-column prop="install_path" label="安装路径" min-width="250" show-overflow-tooltip>
<template #default="{ row }"><span class="mono-text secondary-text">{{ row.install_path || '-' }}</span></template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<!-- Tab 4: 变更记录 -->
<el-tab-pane label="变更记录" name="changes">
<div class="csm-card">
<el-table :data="changes" v-loading="chLoading" style="width:100%">
<el-table-column prop="change_type" label="变更类型" width="120">
<template #default="{ row }">
<el-tag :type="changeTag(row.change_type)" size="small" effect="light">{{ changeLabel(row.change_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="change_detail" label="详情" min-width="400" show-overflow-tooltip />
<el-table-column prop="detected_at" label="检测时间" width="170">
<template #default="{ row }"><span class="secondary-text">{{ row.detected_at }}</span></template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { Search } from '@element-plus/icons-vue'
import { api } from '@/lib/api'
const route = useRoute()
@@ -95,18 +180,94 @@ const deviceUid = route.params.uid as string
const loading = ref(true)
const device = ref<any>(null)
const status = ref<any>(null)
const activeTab = ref('overview')
// --- Overview data ---
const basicInfo = computed(() => {
if (!device.value) return []
return [
{ label: '设备UID', value: device.value.device_uid },
{ label: '主机名', value: device.value.hostname },
{ label: 'IP地址', value: device.value.ip_address },
{ label: 'MAC地址', value: device.value.mac_address },
{ label: '操作系统', value: device.value.os_version },
{ label: '客户端版本', value: device.value.client_version },
{ label: '分组', value: device.value.group_name },
{ label: '注册时间', value: device.value.registered_at },
{ label: '最后心跳', value: device.value.last_heartbeat },
]
})
// --- Hardware data ---
const hardware = ref<any[]>([])
const hwLoading = ref(false)
async function fetchHardware() {
hwLoading.value = true
try {
const data = await api.get<any>(`/api/assets/hardware?device_uid=${deviceUid}`)
hardware.value = data.hardware || []
} catch { /* ignore */ } finally { hwLoading.value = false }
}
// --- Software data ---
const software = ref<any[]>([])
const swLoading = ref(false)
const swSearch = ref('')
async function fetchSoftware() {
swLoading.value = true
try {
const params = new URLSearchParams({ device_uid: deviceUid })
if (swSearch.value) params.set('search', swSearch.value)
const data = await api.get<any>(`/api/assets/software?${params}`)
software.value = data.software || []
} catch { /* ignore */ } finally { swLoading.value = false }
}
// --- Changes data ---
const changes = ref<any[]>([])
const chLoading = ref(false)
async function fetchChanges() {
chLoading.value = true
try {
const data = await api.get<any>(`/api/assets/changes?device_uid=${deviceUid}`)
changes.value = data.changes || []
} catch { /* ignore */ } finally { chLoading.value = false }
}
// --- Helpers ---
function progressColor(value: number) {
if (value > 90) return '#F56C6C'
if (value > 70) return '#E6A23C'
return '#67C23A'
if (value > 90) return '#dc2626'
if (value > 70) return '#d97706'
return '#16a34a'
}
function formatMB(mb: number) {
if (!mb) return '-'
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'
}
function changeLabel(type: string) {
const map: Record<string, string> = { hardware: '硬件变更', software_added: '软件安装', software_removed: '软件卸载' }
return map[type] || type
}
// --- Tab switching ---
watch(activeTab, (tab) => {
if (tab === 'hardware' && hardware.value.length === 0) fetchHardware()
else if (tab === 'software' && software.value.length === 0) fetchSoftware()
else if (tab === 'changes' && changes.value.length === 0) fetchChanges()
})
// --- Init ---
onMounted(async () => {
try {
const [devData, statData] = await Promise.all([
@@ -122,10 +283,164 @@ onMounted(async () => {
</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; }
.detail-header {
background: #fff;
padding: 16px 24px;
border-radius: 10px;
border: 1px solid var(--csm-border-color);
}
.header-device {
display: flex;
align-items: center;
gap: 12px;
}
.header-name {
font-weight: 600;
font-size: 16px;
color: var(--csm-text-primary);
}
.header-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 2px;
}
.meta-text {
font-size: 12px;
color: var(--csm-text-tertiary);
font-family: var(--csm-font-mono);
}
.device-avatar-lg {
width: 44px;
height: 44px;
border-radius: 10px;
background: #f1f5f9;
color: #94a3b8;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.device-avatar-lg.online {
background: #f0fdf4;
color: #16a34a;
}
.device-avatar-lg.offline {
background: #f1f5f9;
color: #94a3b8;
}
/* Tabs */
.detail-tabs {
margin-top: 16px;
}
.detail-tabs :deep(.el-tabs__header) {
margin: 0 0 16px 0;
background: #fff;
border-radius: 10px;
border: 1px solid var(--csm-border-color);
padding: 0 20px;
}
.detail-tabs :deep(.el-tabs__item) {
height: 46px;
line-height: 46px;
}
.toolbar-inline {
margin-bottom: 12px;
}
/* Cards */
.card-section-title {
font-weight: 600;
font-size: 14px;
color: var(--csm-text-primary);
padding: 14px 20px;
border-bottom: 1px solid var(--csm-border-color);
}
.info-list {
padding: 8px 20px 16px;
}
.info-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid #f8fafc;
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
font-size: 13px;
color: var(--csm-text-secondary);
flex-shrink: 0;
}
.info-value {
font-size: 13px;
font-weight: 500;
color: var(--csm-text-primary);
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 60%;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
padding: 20px;
}
.metric-card {
text-align: center;
padding: 8px 0;
}
.metric-label {
font-size: 13px;
color: var(--csm-text-secondary);
margin-bottom: 8px;
}
.metric-big-number {
font-size: 36px;
font-weight: 700;
color: var(--csm-text-primary);
margin-top: 16px;
line-height: 1;
}
.metric-sub {
font-size: 12px;
color: var(--csm-text-tertiary);
margin-top: 4px;
}
.mono-text {
font-family: var(--csm-font-mono);
font-size: 12px;
color: var(--csm-text-secondary);
}
.secondary-text {
font-size: 12px;
color: var(--csm-text-tertiary);
}
</style>

View File

@@ -1,108 +1,846 @@
<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 class="device-page">
<!-- Left: Group Panel -->
<div class="group-panel">
<div class="group-header">
<span>设备分组</span>
<el-icon class="group-add-btn" :size="16" @click="showCreateGroup"><Plus /></el-icon>
</div>
<div class="group-list">
<div
class="group-item"
:class="{ active: activeGroup === '' }"
@click="selectGroup('')"
>
<el-icon :size="16"><Monitor /></el-icon>
<span class="group-name">全部设备</span>
<span class="group-count">{{ totalCount }}</span>
</div>
<div class="group-section-title">
<span>组织分组</span>
</div>
<div
v-for="g in groups"
:key="g.name"
class="group-item"
:class="{ active: activeGroup === g.name }"
@click="selectGroup(g.name)"
@contextmenu.prevent="showGroupContext($event, g)"
>
<el-icon :size="16"><FolderOpened /></el-icon>
<span class="group-name">{{ g.name }}</span>
<span class="group-count">{{ g.count }}</span>
</div>
<div
v-if="ungroupedCount > 0"
class="group-item"
:class="{ active: activeGroup === '__ungrouped__' }"
@click="selectGroup('__ungrouped__')"
>
<el-icon :size="16"><Folder /></el-icon>
<span class="group-name">未分组</span>
<span class="group-count">{{ ungroupedCount }}</span>
</div>
</div>
<!-- Group context menu -->
<teleport to="body">
<div
v-if="contextMenu.visible"
class="group-context-menu"
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
>
<div class="context-item" @click="renameGroup">重命名</div>
<div class="context-item danger" @click="deleteGroup">删除分组</div>
</div>
</teleport>
</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>
<!-- Right: Main Content -->
<div class="device-main">
<!-- Stats Bar -->
<div class="stats-bar">
<div class="stat-item">
<span class="stat-value">{{ totalCount }}</span>
<span class="stat-label">设备总数</span>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<span class="stat-value online">{{ onlineCount }}</span>
<span class="stat-label">在线</span>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<span class="stat-value offline">{{ offlineCount }}</span>
<span class="stat-label">离线</span>
</div>
</div>
<el-pagination
style="margin-top: 20px; justify-content: flex-end"
:total="deviceStore.total"
:page-size="20"
layout="total, prev, pager, next"
@current-change="handlePageChange"
/>
<!-- Toolbar -->
<div class="toolbar">
<div class="toolbar-left">
<el-input
v-model="search"
placeholder="搜索主机名 / IP地址"
style="width: 260px"
clearable
:prefix-icon="Search"
@input="handleSearch"
/>
<el-select v-model="statusFilter" placeholder="状态" clearable style="width: 110px" @change="handleSearch">
<el-option label="在线" value="online" />
<el-option label="离线" value="offline" />
</el-select>
</div>
<div class="toolbar-right">
<el-button v-if="selectedDevices.length > 0" type="danger" plain size="default" @click="batchRemove">
<el-icon><Delete /></el-icon>批量移除 ({{ selectedDevices.length }})
</el-button>
</div>
</div>
<!-- Device Table -->
<div class="table-card">
<el-table
ref="tableRef"
:data="deviceStore.devices"
v-loading="deviceStore.loading"
@row-click="handleRowClick"
@selection-change="handleSelectionChange"
style="width: 100%"
>
<el-table-column type="selection" width="44" />
<el-table-column label="设备" min-width="220">
<template #default="{ row }">
<div class="device-cell">
<div class="device-avatar" :class="row.status">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:20px;height:20px">
<rect x="2" y="3" width="20" height="14" rx="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
</div>
<div class="device-info">
<div class="device-name">{{ row.hostname }}</div>
<div class="device-ip">{{ row.ip_address }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<div class="status-cell">
<span class="status-indicator" :class="row.status"></span>
<span :class="row.status">{{ row.status === 'online' ? '在线' : '离线' }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="group_name" label="分组" width="120">
<template #default="{ row }">
<el-tag size="small" effect="plain" round>{{ row.group_name || '默认' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="CPU" width="100">
<template #default="{ row }">
<div class="usage-cell">
<el-progress
:percentage="Math.round(row.cpu_usage ?? 0)"
:stroke-width="4"
:show-text="false"
:color="getProgressColor(row.cpu_usage)"
style="flex:1"
/>
<span class="usage-text">{{ Math.round(row.cpu_usage ?? 0) }}%</span>
</div>
</template>
</el-table-column>
<el-table-column label="内存" width="100">
<template #default="{ row }">
<div class="usage-cell">
<el-progress
:percentage="Math.round(row.memory_usage ?? 0)"
:stroke-width="4"
:show-text="false"
:color="getProgressColor(row.memory_usage)"
style="flex:1"
/>
<span class="usage-text">{{ Math.round(row.memory_usage ?? 0) }}%</span>
</div>
</template>
</el-table-column>
<el-table-column prop="os_version" label="系统" width="140">
<template #default="{ row }">
<span class="os-text">{{ row.os_version || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="last_heartbeat" label="最后活跃" width="160">
<template #default="{ row }">
<span class="time-text">{{ formatTime(row.last_heartbeat) }}</span>
</template>
</el-table-column>
<el-table-column label="" width="60" fixed="right">
<template #default="{ row }">
<el-dropdown trigger="click" @command="(cmd: string) => handleAction(cmd, row)">
<el-icon class="action-trigger"><MoreFilled /></el-icon>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="detail">查看详情</el-dropdown-item>
<el-dropdown-item command="move">移动到分组</el-dropdown-item>
<el-dropdown-item command="remove" divided>
<span style="color:#dc2626">移除设备</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-table-column>
</el-table>
</div>
<!-- Pagination -->
<div class="pagination-bar">
<el-pagination
:total="deviceStore.total"
:page-size="20"
layout="total, prev, pager, next"
@current-change="handlePageChange"
/>
</div>
</div>
<!-- Move Device Dialog -->
<el-dialog v-model="moveDialog.visible" title="移动到分组" width="400px" :close-on-click-modal="false">
<p style="margin-bottom:12px;color:var(--csm-text-secondary);font-size:13px">
设备<strong>{{ moveDialog.device?.hostname }}</strong>
</p>
<el-select v-model="moveDialog.target" placeholder="选择目标分组" style="width:100%">
<el-option
v-for="g in groups"
:key="g.name"
:label="g.name"
:value="g.name"
/>
<el-option label="+ 新建分组" value="__new__" />
</el-select>
<el-input
v-if="moveDialog.target === '__new__'"
v-model="newGroupName"
placeholder="新分组名称"
style="width:100%;margin-top:10px"
/>
<template #footer>
<el-button @click="moveDialog.visible = false">取消</el-button>
<el-button type="primary" :disabled="!moveDialog.target || moveDialog.target === '__new__' && !newGroupName" @click="handleMoveSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Search, Monitor, FolderOpened, Folder, Delete, MoreFilled, Plus
} from '@element-plus/icons-vue'
import { useDeviceStore } from '../stores/devices'
import type { Device } from '../stores/devices'
const apiBase = axios.create({
baseURL: '/api',
headers: { 'Content-Type': 'application/json' },
})
apiBase.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
const router = useRouter()
const deviceStore = useDeviceStore()
const search = ref('')
const statusFilter = ref('')
const groupFilter = ref('')
const activeGroup = ref('')
const currentPage = ref(1)
const selectedDevices = ref<Device[]>([])
// Fetch all devices for group stats (unfiltered)
const allDevices = ref<Device[]>([])
// Group context menu state
const contextMenu = ref<{ visible: boolean; x: number; y: number; group: { name: string; count: number } | null }>({
visible: false, x: 0, y: 0, group: null,
})
function showGroupContext(e: MouseEvent, g: { name: string; count: number }) {
contextMenu.value = { visible: true, x: e.clientX, y: e.clientY, group: g }
}
function hideContextMenu() {
contextMenu.value = { ...contextMenu.value, visible: false }
}
onMounted(() => {
deviceStore.fetchDevices()
document.addEventListener('click', hideContextMenu)
})
onUnmounted(() => {
document.removeEventListener('click', hideContextMenu)
})
const totalCount = computed(() => allDevices.value.length)
const onlineCount = computed(() => allDevices.value.filter(d => d.status === 'online').length)
const offlineCount = computed(() => allDevices.value.filter(d => d.status === 'offline').length)
const groups = computed(() => {
const map = new Map<string, number>()
allDevices.value.forEach(d => {
const name = d.group_name || '默认'
map.set(name, (map.get(name) || 0) + 1)
})
return Array.from(map.entries())
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count)
})
const ungroupedCount = computed(() => allDevices.value.filter(d => !d.group_name).length)
onMounted(async () => {
await deviceStore.fetchDevices()
allDevices.value = [...deviceStore.devices]
})
watch(() => deviceStore.devices, (val) => {
allDevices.value = [...val]
})
function selectGroup(group: string) {
activeGroup.value = group
currentPage.value = 1
doSearch()
}
function handleSearch() {
currentPage.value = 1
doSearch()
}
function doSearch() {
const groupVal = activeGroup.value === '__ungrouped__' ? '' : activeGroup.value
deviceStore.fetchDevices({
search: search.value,
status: statusFilter.value,
group: groupFilter.value,
group: groupVal,
page: '1',
})
}
function handlePageChange(page: number) {
currentPage.value = page
deviceStore.fetchDevices({ page: String(page) })
const groupVal = activeGroup.value === '__ungrouped__' ? '' : activeGroup.value
deviceStore.fetchDevices({
search: search.value,
status: statusFilter.value,
group: groupVal,
page: String(page),
})
}
function handleRowClick(row: Device) {
router.push(`/devices/${row.device_uid}`)
}
function handleSelectionChange(rows: Device[]) {
selectedDevices.value = rows
}
function handleAction(cmd: string, row: Device) {
if (cmd === 'detail') {
router.push(`/devices/${row.device_uid}`)
} else if (cmd === 'remove') {
handleDelete(row)
} else if (cmd === 'move') {
showMoveDeviceDialog(row)
}
}
async function handleDelete(row: Device) {
await ElMessageBox.confirm(`确定移除设备 ${row.hostname}?`, '确认', { type: 'warning' })
await deviceStore.removeDevice(row.device_uid)
ElMessage.success('设备已移除')
}
async function batchRemove() {
const count = selectedDevices.value.length
await ElMessageBox.confirm(`确定批量移除 ${count} 台设备?`, '确认', { type: 'warning' })
for (const d of selectedDevices.value) {
await deviceStore.removeDevice(d.device_uid)
}
ElMessage.success(`已移除 ${count} 台设备`)
selectedDevices.value = []
}
function getProgressColor(value?: number): string {
if (!value) return '#67C23A'
if (value > 90) return '#F56C6C'
if (value > 70) return '#E6A23C'
return '#67C23A'
if (!value) return '#16a34a'
if (value > 90) return '#dc2626'
if (value > 70) return '#d97706'
return '#16a34a'
}
function formatTime(t: string | null): string {
if (!t) return '-'
const d = new Date(t)
if (isNaN(d.getTime())) return t
const now = new Date()
const diffMs = now.getTime() - d.getTime()
const diffMin = Math.floor(diffMs / 60000)
if (diffMin < 1) return '刚刚'
if (diffMin < 60) return `${diffMin}分钟前`
const diffHour = Math.floor(diffMin / 60)
if (diffHour < 24) return `${diffHour}小时前`
const diffDay = Math.floor(diffHour / 24)
if (diffDay < 7) return `${diffDay}天前`
return t.slice(0, 16).replace('T', ' ')
}
// ---- Group Management ----
async function showCreateGroup() {
const { value: name } = await ElMessageBox.prompt('请输入分组名称', '新建分组', {
confirmButtonText: '创建',
cancelButtonText: '取消',
inputPattern: /^.{1,50}$/,
inputErrorMessage: '名称长度为1-50个字符',
})
if (!name) return
const { data } = await apiBase.post('/groups', { name: name.trim() })
if (data.success) {
ElMessage.success('分组创建成功')
doSearch() // refresh device list to reflect new group
} else {
ElMessage.error(data.error || '创建失败')
}
}
async function renameGroup() {
const g = contextMenu.value.group
if (!g) return
hideContextMenu()
const { value: newName } = await ElMessageBox.prompt(`重命名分组 "${g.name}"`, '重命名', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: g.name,
inputPattern: /^.{1,50}$/,
inputErrorMessage: '名称长度为1-50个字符',
})
if (!newName || newName.trim() === g.name) return
const { data } = await apiBase.put(`/groups/${encodeURIComponent(g.name)}`, { new_name: newName.trim() })
if (data.success) {
ElMessage.success('重命名成功')
if (activeGroup.value === g.name) activeGroup.value = newName.trim()
doSearch()
} else {
ElMessage.error(data.error || '重命名失败')
}
}
async function deleteGroup() {
const g = contextMenu.value.group
if (!g) return
hideContextMenu()
await ElMessageBox.confirm(
`删除分组 "${g.name}" 后,组内 ${g.count} 台设备将移至"默认"分组。继续?`,
'删除分组',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
)
const { data } = await apiBase.delete(`/groups/${encodeURIComponent(g.name)}`)
if (data.success) {
ElMessage.success('分组已删除')
if (activeGroup.value === g.name) activeGroup.value = ''
doSearch()
} else {
ElMessage.error(data.error || '删除失败')
}
}
// Move device dialog
const moveDialog = ref({ visible: false, device: null as Device | null, target: '' })
function showMoveDeviceDialog(row: Device) {
moveDialog.value = { visible: true, device: row, target: '' }
}
const newGroupName = ref('')
async function handleMoveSubmit() {
const d = moveDialog.value.device
if (!d) return
let target = moveDialog.value.target
if (target === '__new__') {
target = newGroupName.value.trim()
if (!target) return
// Create group first
const { data: createData } = await apiBase.post('/groups', { name: target })
if (!createData.success) {
ElMessage.error(createData.error || '创建分组失败')
return
}
}
const { data } = await apiBase.put(`/devices/${d.device_uid}/group`, { group_name: target })
if (data.success) {
ElMessage.success('已移动到分组')
moveDialog.value = { visible: false, device: null, target: '' }
newGroupName.value = ''
doSearch()
} else {
ElMessage.error(data.error || '移动失败')
}
}
</script>
<style scoped>
.devices-page { padding: 20px; }
.toolbar { display: flex; gap: 12px; margin-bottom: 20px; }
.device-page {
display: flex;
height: 100%;
overflow: hidden;
}
/* ---- Left: Group Panel ---- */
.group-panel {
width: 220px;
min-width: 220px;
background: #fff;
border-right: 1px solid var(--csm-border-color);
display: flex;
flex-direction: column;
overflow: hidden;
}
.group-header {
padding: 16px 20px;
font-size: 14px;
font-weight: 600;
color: var(--csm-text-primary);
border-bottom: 1px solid var(--csm-border-color);
display: flex;
align-items: center;
justify-content: space-between;
}
.group-add-btn {
cursor: pointer;
color: var(--csm-text-tertiary);
transition: color 0.15s;
border-radius: 4px;
padding: 2px;
}
.group-add-btn:hover {
color: var(--csm-primary);
background: #eff6ff;
}
.group-list {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
.group-section-title {
padding: 12px 20px 6px;
font-size: 11px;
color: var(--csm-text-tertiary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.group-item {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 20px;
cursor: pointer;
transition: all 0.15s;
color: var(--csm-text-secondary);
font-size: 13px;
}
.group-item:hover {
background: var(--csm-bg-page);
color: var(--csm-text-primary);
}
.group-item.active {
background: #eff6ff;
color: var(--csm-primary);
font-weight: 500;
}
.group-item .el-icon {
flex-shrink: 0;
}
.group-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group-count {
font-size: 11px;
color: var(--csm-text-tertiary);
min-width: 18px;
text-align: center;
}
.group-item.active .group-count {
color: var(--csm-primary);
}
/* ---- Right: Main Content ---- */
.device-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 20px 24px;
}
/* Stats Bar */
.stats-bar {
display: flex;
align-items: center;
gap: 24px;
padding: 16px 24px;
background: #fff;
border-radius: 10px;
border: 1px solid var(--csm-border-color);
margin-bottom: 16px;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.stat-value {
font-size: 22px;
font-weight: 700;
color: var(--csm-text-primary);
line-height: 1.2;
}
.stat-value.online { color: #16a34a; }
.stat-value.offline { color: #94a3b8; }
.stat-label {
font-size: 12px;
color: var(--csm-text-tertiary);
}
.stat-divider {
width: 1px;
height: 28px;
background: var(--csm-border-color);
}
/* Toolbar */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 10px;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
/* Table Card */
.table-card {
flex: 1;
background: #fff;
border-radius: 10px;
border: 1px solid var(--csm-border-color);
overflow: hidden;
}
.table-card :deep(.el-table) {
--el-table-border-color: var(--csm-border-color);
--el-table-header-bg-color: #fafbfc;
}
.table-card :deep(.el-table__row) {
cursor: pointer;
}
.table-card :deep(.el-table__row:hover > td) {
background: #f8fafc !important;
}
/* Device Cell */
.device-cell {
display: flex;
align-items: center;
gap: 12px;
}
.device-avatar {
width: 36px;
height: 36px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: #f1f5f9;
color: #94a3b8;
}
.device-avatar.online {
background: #f0fdf4;
color: #16a34a;
}
.device-avatar.offline {
background: #f1f5f9;
color: #94a3b8;
}
.device-info {
display: flex;
flex-direction: column;
gap: 1px;
}
.device-name {
font-size: 13px;
font-weight: 500;
color: var(--csm-text-primary);
}
.device-ip {
font-size: 11px;
font-family: var(--csm-font-mono);
color: var(--csm-text-tertiary);
}
/* Status Cell */
.status-cell {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.status-indicator {
width: 6px;
height: 6px;
border-radius: 50%;
}
.status-indicator.online {
background: #16a34a;
box-shadow: 0 0 4px rgba(22, 163, 74, 0.4);
}
.status-indicator.offline {
background: #cbd5e1;
}
.status-cell .online { color: #16a34a; }
.status-cell .offline { color: #94a3b8; }
/* Usage Cell */
.usage-cell {
display: flex;
align-items: center;
gap: 6px;
}
.usage-text {
font-size: 11px;
font-family: var(--csm-font-mono);
color: var(--csm-text-tertiary);
min-width: 30px;
text-align: right;
}
.os-text {
font-size: 12px;
color: var(--csm-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.time-text {
font-size: 12px;
color: var(--csm-text-tertiary);
}
.action-trigger {
cursor: pointer;
color: var(--csm-text-tertiary);
transition: color 0.15s;
}
.action-trigger:hover {
color: var(--csm-text-primary);
}
/* Pagination */
.pagination-bar {
display: flex;
justify-content: flex-end;
padding-top: 12px;
}
/* Group Context Menu */
.group-context-menu {
position: fixed;
z-index: 9999;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
border: 1px solid var(--csm-border-color);
padding: 4px 0;
min-width: 120px;
}
.context-item {
padding: 8px 16px;
font-size: 13px;
color: var(--csm-text-primary);
cursor: pointer;
transition: background 0.1s;
}
.context-item:hover {
background: #f5f5f5;
}
.context-item.danger {
color: #dc2626;
}
.context-item.danger:hover {
background: #fef2f2;
}
</style>

View File

@@ -1,36 +1,44 @@
<template>
<el-container class="app-container">
<el-aside width="220px" class="sidebar">
<div class="logo">
<h2>CSM</h2>
<span>终端管理系统</span>
<!-- Sidebar -->
<el-aside :width="sidebarCollapsed ? '64px' : '240px'" class="sidebar">
<div class="sidebar-header">
<div class="logo-icon" v-show="!sidebarCollapsed">
<svg viewBox="0 0 32 32" fill="none"><rect width="32" height="32" rx="8" fill="rgba(255,255,255,0.1)"/><path d="M16 5L27 11V21L16 27L5 21V11L16 5Z" stroke="rgba(255,255,255,0.6)" stroke-width="1.5" fill="rgba(255,255,255,0.05)"/><circle cx="16" cy="16" r="4" fill="rgba(255,255,255,0.7)"/></svg>
</div>
<h2 v-show="!sidebarCollapsed">CSM</h2>
<div class="collapse-btn" @click="sidebarCollapsed = !sidebarCollapsed">
<el-icon :size="18"><component :is="sidebarCollapsed ? Expand : Fold" /></el-icon>
</div>
</div>
<el-menu
:default-active="currentRoute"
:collapse="sidebarCollapsed"
:collapse-transition="false"
router
background-color="#1d1e2c"
text-color="#a0a3bd"
active-text-color="#409eff"
background-color="transparent"
text-color="var(--csm-sidebar-text)"
active-text-color="var(--csm-sidebar-text-active)"
>
<el-menu-item index="/dashboard">
<el-icon><Monitor /></el-icon>
<span>仪表盘</span>
<template #title><span>仪表盘</span></template>
</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>
<template #title><span>设备管理</span></template>
</el-menu-item>
<el-menu-item index="/usb">
<el-icon><Connection /></el-icon>
<span>U盘管控</span>
<template #title><span>U盘管控</span></template>
</el-menu-item>
<el-menu-item index="/alerts">
<el-icon><Bell /></el-icon>
<span>告警中心</span>
<template #title>
<span>告警中心</span>
<el-badge v-if="unreadAlerts > 0" :value="unreadAlerts" :max="99" class="menu-badge" />
</template>
</el-menu-item>
<el-sub-menu index="plugins">
@@ -38,45 +46,81 @@
<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-menu-item index="/plugins/web-filter">
<template #title><span>上网拦截</span></template>
</el-menu-item>
<el-menu-item index="/plugins/usage-timer">
<template #title><span>时长记录</span></template>
</el-menu-item>
<el-menu-item index="/plugins/software-blocker">
<template #title><span>软件管控</span></template>
</el-menu-item>
<el-menu-item index="/plugins/popup-blocker">
<template #title><span>弹窗拦截</span></template>
</el-menu-item>
<el-menu-item index="/plugins/usb-file-audit">
<template #title><span>U盘审计</span></template>
</el-menu-item>
<el-menu-item index="/plugins/watermark">
<template #title><span>水印管理</span></template>
</el-menu-item>
</el-sub-menu>
<el-menu-item index="/settings">
<el-icon><Setting /></el-icon>
<span>系统设置</span>
<template #title><span>系统设置</span></template>
</el-menu-item>
</el-menu>
<div class="sidebar-footer" v-show="!sidebarCollapsed">
<span class="version">v0.1.0</span>
</div>
</el-aside>
<!-- Main content -->
<el-container>
<el-header class="app-header">
<el-header class="app-header" height="60px">
<div class="header-left">
<span class="page-title">{{ pageTitle }}</span>
<div class="breadcrumb">
<span class="breadcrumb-home" @click="$router.push('/dashboard')">首页</span>
<span class="breadcrumb-sep" v-if="pageTitle !== '仪表盘'">/</span>
<span class="breadcrumb-current" v-if="pageTitle !== '仪表盘'">{{ pageTitle }}</span>
</div>
</div>
<div class="header-right">
<el-badge :value="unreadAlerts" :hidden="unreadAlerts === 0">
<el-icon :size="20"><Bell /></el-icon>
</el-badge>
<el-tooltip content="告警中心" placement="bottom">
<div class="header-action" @click="$router.push('/alerts')">
<el-badge :value="unreadAlerts" :hidden="unreadAlerts === 0" :max="99">
<el-icon :size="20"><Bell /></el-icon>
</el-badge>
</div>
</el-tooltip>
<el-dropdown>
<span class="user-info">
{{ username }} <el-icon><ArrowDown /></el-icon>
</span>
<div class="user-info">
<div class="user-avatar">{{ username.charAt(0).toUpperCase() }}</div>
<span class="user-name">{{ username }}</span>
<el-icon class="user-arrow"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handleLogout">退出登录</el-dropdown-item>
<el-dropdown-item @click="$router.push('/settings')">
<el-icon><Setting /></el-icon>系统设置
</el-dropdown-item>
<el-dropdown-item divided @click="handleLogout">
<el-icon><SwitchButton /></el-icon>退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-main>
<router-view />
<el-main class="app-main">
<router-view v-slot="{ Component }">
<transition name="page" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</el-main>
</el-container>
</el-container>
@@ -86,13 +130,15 @@
import { computed, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
Monitor, Platform, Box, Connection, Bell, Setting, ArrowDown, Grid
Monitor, Platform, Connection, Bell, Setting,
ArrowDown, Grid, Expand, Fold, SwitchButton
} from '@element-plus/icons-vue'
import { api } from '@/lib/api'
const route = useRoute()
const router = useRouter()
const sidebarCollapsed = ref(false)
const currentRoute = computed(() => route.path)
const unreadAlerts = ref(0)
const username = ref('')
@@ -120,7 +166,6 @@ async function fetchUnreadAlerts() {
const pageTitles: Record<string, string> = {
'/dashboard': '仪表盘',
'/devices': '设备管理',
'/assets': '资产管理',
'/usb': 'U盘管控',
'/alerts': '告警中心',
'/settings': '系统设置',
@@ -147,43 +192,233 @@ function handleLogout() {
</script>
<style scoped>
.app-container { height: 100vh; }
.app-container {
height: 100vh;
overflow: hidden;
}
/* ---- Sidebar ---- */
.sidebar {
background-color: #1d1e2c;
background: var(--csm-sidebar-bg);
overflow-y: auto;
overflow-x: hidden;
border-right: none;
display: flex;
flex-direction: column;
transition: width var(--csm-transition);
}
.logo {
padding: 20px;
text-align: center;
.sidebar::-webkit-scrollbar {
width: 0;
}
.sidebar-header {
display: flex;
align-items: center;
gap: 10px;
padding: 20px 16px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
min-height: 60px;
}
.logo-icon svg {
width: 32px;
height: 32px;
flex-shrink: 0;
}
.sidebar-header h2 {
font-size: 22px;
font-weight: 700;
color: #fff;
border-bottom: 1px solid #2d2e3e;
letter-spacing: -0.02em;
white-space: nowrap;
}
.logo h2 { font-size: 24px; margin-bottom: 4px; }
.logo span { font-size: 12px; color: #a0a3bd; }
.collapse-btn {
margin-left: auto;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
color: var(--csm-sidebar-text);
cursor: pointer;
transition: all var(--csm-transition-fast);
flex-shrink: 0;
}
.collapse-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
/* Menu overrides */
.sidebar :deep(.el-menu) {
border-right: none;
padding: 8px;
}
.sidebar :deep(.el-menu-item),
.sidebar :deep(.el-sub-menu__title) {
height: 42px;
line-height: 42px;
border-radius: 8px;
margin: 2px 0;
font-size: 13px;
font-weight: 500;
transition: all var(--csm-transition-fast);
}
.sidebar :deep(.el-menu-item:hover),
.sidebar :deep(.el-sub-menu__title:hover) {
background: var(--csm-sidebar-hover) !important;
}
.sidebar :deep(.el-menu-item.is-active) {
background: var(--csm-sidebar-active) !important;
color: var(--csm-sidebar-text-active) !important;
}
.sidebar :deep(.el-sub-menu .el-menu-item) {
padding-left: 48px !important;
height: 38px;
line-height: 38px;
font-size: 13px;
}
.sidebar :deep(.el-menu--collapse .el-menu-item),
.sidebar :deep(.el-menu--collapse .el-sub-menu__title) {
padding: 0 !important;
justify-content: center;
}
.menu-badge {
margin-left: 8px;
}
.menu-badge :deep(.el-badge__content) {
font-size: 10px;
height: 16px;
line-height: 16px;
padding: 0 4px;
}
.sidebar-footer {
margin-top: auto;
padding: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
text-align: center;
}
.sidebar-footer .version {
font-size: 11px;
color: rgba(255, 255, 255, 0.25);
}
/* ---- Header ---- */
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #e4e7ed;
background: #fff;
background: var(--csm-bg-header);
border-bottom: 1px solid var(--csm-border-color);
padding: 0 24px;
z-index: 10;
}
.page-title { font-size: 18px; font-weight: 600; }
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.breadcrumb-home {
color: var(--csm-text-secondary);
cursor: pointer;
transition: color var(--csm-transition-fast);
}
.breadcrumb-home:hover {
color: var(--csm-primary);
}
.breadcrumb-sep {
color: var(--csm-text-tertiary);
}
.breadcrumb-current {
color: var(--csm-text-primary);
font-weight: 600;
}
.header-right {
display: flex;
align-items: center;
gap: 20px;
gap: 8px;
}
.header-action {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: var(--csm-text-secondary);
cursor: pointer;
transition: all var(--csm-transition-fast);
}
.header-action:hover {
background: var(--csm-bg-page);
color: var(--csm-text-primary);
}
.user-info {
display: flex;
align-items: center;
gap: 4px;
gap: 8px;
cursor: pointer;
color: #606266;
padding: 4px 8px;
border-radius: 8px;
transition: background var(--csm-transition-fast);
}
.user-info:hover {
background: var(--csm-bg-page);
}
.user-avatar {
width: 30px;
height: 30px;
border-radius: 8px;
background: var(--csm-primary);
color: #fff;
font-size: 13px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
.user-name {
font-size: 13px;
font-weight: 500;
color: var(--csm-text-primary);
}
.user-arrow {
color: var(--csm-text-tertiary);
font-size: 12px;
}
/* ---- Main ---- */
.app-main {
background: var(--csm-bg-page);
padding: 0;
overflow-y: auto;
}
</style>

View File

@@ -1,43 +1,57 @@
<template>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<h2>CSM</h2>
<p>终端管理系统</p>
<!-- Left branding -->
<div class="login-brand">
<div class="brand-content">
<div class="brand-logo">
<h1>CSM</h1>
</div>
<p class="brand-tagline">企业终端管理系统</p>
<div class="brand-features">
<div class="feature-item">
<el-icon :size="16"><Monitor /></el-icon>
<span>终端设备统一管控</span>
</div>
<div class="feature-item">
<el-icon :size="16"><Connection /></el-icon>
<span>USB 策略实时下发</span>
</div>
<div class="feature-item">
<el-icon :size="16"><Stamp /></el-icon>
<span>安全水印防泄密</span>
</div>
<div class="feature-item">
<el-icon :size="16"><Grid /></el-icon>
<span>上网行为审计追踪</span>
</div>
</div>
</div>
<div class="brand-footer">
<span>CSM Terminal Management v0.1</span>
</div>
</div>
<!-- Right form -->
<div class="login-form-panel">
<div class="login-form-wrapper">
<div class="login-form-header">
<h2>欢迎回来</h2>
<p>请登录您的管理账号</p>
</div>
<el-form ref="formRef" :model="form" :rules="rules" @submit.prevent="handleLogin" class="login-form">
<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" class="login-btn" @click="handleLogin">
{{ loading ? '登录中...' : ' ' }}
</el-button>
</el-form-item>
</el-form>
</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>
@@ -46,17 +60,14 @@
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock } from '@element-plus/icons-vue'
import { User, Lock, Monitor, Connection, Stamp, Grid } 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 form = reactive({ username: '', password: '' })
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
@@ -88,32 +99,111 @@ async function handleLogin() {
.login-container {
height: 100vh;
display: flex;
overflow: hidden;
}
/* Left branding */
.login-brand {
flex: 1;
background: #1e293b;
display: flex;
flex-direction: column;
justify-content: center;
padding: 80px;
position: relative;
}
.brand-content {
position: relative;
z-index: 1;
}
.brand-logo h1 {
font-size: 36px;
font-weight: 700;
color: #fff;
letter-spacing: -0.02em;
margin-bottom: 4px;
}
.brand-tagline {
font-size: 16px;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 48px;
}
.brand-features {
display: flex;
flex-direction: column;
gap: 20px;
}
.feature-item {
display: flex;
align-items: center;
gap: 12px;
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
}
.feature-item .el-icon {
color: rgba(255, 255, 255, 0.4);
}
.brand-footer {
position: absolute;
bottom: 32px;
color: rgba(255, 255, 255, 0.2);
font-size: 12px;
}
/* Right form */
.login-form-panel {
width: 460px;
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);
padding: 60px;
}
.login-header {
text-align: center;
margin-bottom: 32px;
.login-form-wrapper {
width: 100%;
max-width: 340px;
}
.login-header h2 {
font-size: 32px;
color: #303133;
margin-bottom: 8px;
.login-form-header {
margin-bottom: 40px;
}
.login-header p {
.login-form-header h2 {
font-size: 24px;
font-weight: 600;
color: var(--csm-text-primary);
margin-bottom: 6px;
}
.login-form-header p {
font-size: 14px;
color: #909399;
color: var(--csm-text-secondary);
}
.login-form .el-form-item {
margin-bottom: 22px;
}
.login-btn {
width: 100%;
height: 42px;
font-size: 15px;
font-weight: 500;
border-radius: 6px;
}
/* Responsive */
@media (max-width: 900px) {
.login-brand { display: none; }
.login-form-panel { width: 100%; background: var(--csm-bg-page); }
.login-form-wrapper { background: #fff; padding: 40px; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
}
</style>

View File

@@ -1,55 +1,82 @@
<template>
<div class="settings-page">
<div class="page-container">
<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>
<div class="csm-card">
<div class="csm-card-header">系统信息</div>
<div class="csm-card-body">
<div class="info-grid">
<div class="info-item">
<span class="info-label">系统版本</span>
<span class="info-value">v{{ version }}</span>
</div>
<div class="info-item">
<span class="info-label">数据库</span>
<span class="info-value">{{ dbInfo }}</span>
</div>
<div class="info-item">
<span class="info-label">在线终端</span>
<span class="info-value">{{ health.connected_clients }} </span>
</div>
</div>
</div>
</div>
<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>
<div class="csm-card" style="margin-top: 20px">
<div class="csm-card-header">修改密码</div>
<div class="csm-card-body">
<el-form :model="pwdForm" label-width="100px" size="default">
<el-form-item label="当前密码">
<el-input v-model="pwdForm.oldPassword" type="password" show-password placeholder="输入当前密码" />
</el-form-item>
<el-form-item label="新密码">
<el-input v-model="pwdForm.newPassword" type="password" show-password placeholder="输入新密码" />
</el-form-item>
<el-form-item label="确认密码">
<el-input v-model="pwdForm.confirmPassword" type="password" show-password placeholder="再次输入新密码" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="pwdLoading" @click="changePassword">修改密码</el-button>
</el-form-item>
</el-form>
</div>
</div>
</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>
<div class="csm-card">
<div class="csm-card-header">数据维护</div>
<div class="csm-card-body">
<div class="maintenance-item">
<div>
<div style="font-weight:500">历史数据保留</div>
<div style="font-size:12px;color:var(--csm-text-tertiary);margin-top:4px">配置数据保留策略自动清理过期数据</div>
</div>
<el-button @click="showRetentionInfo">查看策略</el-button>
</div>
<el-divider style="margin:12px 0" />
<div class="maintenance-item">
<div>
<div style="font-weight:500">手动清理</div>
<div style="font-size:12px;color:var(--csm-text-tertiary);margin-top:4px">立即清理过期的历史数据</div>
</div>
<el-button type="warning" plain @click="manualCleanup">执行清理</el-button>
</div>
</div>
</div>
<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>
<div class="csm-card" style="margin-top: 20px">
<div class="csm-card-header">当前用户</div>
<div class="csm-card-body">
<div class="user-card">
<div class="user-avatar-large">{{ user.username.charAt(0).toUpperCase() }}</div>
<div class="user-detail">
<div style="font-weight:600;font-size:16px">{{ user.username }}</div>
<el-tag size="small" type="warning" effect="light" style="margin-top:4px">{{ user.role }}</el-tag>
</div>
</div>
</div>
</div>
</el-col>
</el-row>
</div>
@@ -66,9 +93,9 @@ const health = reactive({ connected_clients: 0, db_size_bytes: 0 })
const user = reactive({ username: 'admin', role: 'admin' })
const pwdForm = reactive({ oldPassword: '', newPassword: '', confirmPassword: '' })
const pwdLoading = ref(false)
onMounted(() => {
// Decode username from JWT token
try {
const token = localStorage.getItem('token')
if (token) {
@@ -83,21 +110,39 @@ onMounted(() => {
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`
dbInfo.value = `SQLite (WAL) - ${(bytes / 1024 / 1024).toFixed(2)} MB`
})
.catch(() => { /* ignore */ })
})
function changePassword() {
async function changePassword() {
if (!pwdForm.oldPassword) {
ElMessage.error('请输入当前密码')
return
}
if (pwdForm.newPassword.length < 6) {
ElMessage.error('新密码至少6位')
return
}
if (pwdForm.newPassword !== pwdForm.confirmPassword) {
ElMessage.error('两次输入的密码不一致')
return
}
if (pwdForm.newPassword.length < 6) {
ElMessage.error('密码至少6位')
return
pwdLoading.value = true
try {
await api.put('/api/auth/change-password', {
old_password: pwdForm.oldPassword,
new_password: pwdForm.newPassword,
})
ElMessage.success('密码修改成功')
pwdForm.oldPassword = ''
pwdForm.newPassword = ''
pwdForm.confirmPassword = ''
} catch (e: any) {
ElMessage.error(e.message || '密码修改失败')
} finally {
pwdLoading.value = false
}
ElMessage.success('密码修改功能待实现')
}
function showRetentionInfo() {
@@ -110,6 +155,69 @@ function manualCleanup() {
</script>
<style scoped>
.settings-page { padding: 20px; }
.card-title { font-weight: 600; font-size: 15px; }
.csm-card-header {
font-weight: 600;
font-size: 15px;
color: var(--csm-text-primary);
padding: 16px 20px;
border-bottom: 1px solid var(--csm-border-color);
}
.csm-card-body {
padding: 20px;
}
.info-grid {
display: flex;
flex-direction: column;
gap: 16px;
}
.info-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
}
.info-label {
font-size: 13px;
color: var(--csm-text-secondary);
}
.info-value {
font-size: 13px;
font-weight: 500;
color: var(--csm-text-primary);
}
.maintenance-item {
display: flex;
align-items: center;
justify-content: space-between;
}
.user-card {
display: flex;
align-items: center;
gap: 16px;
}
.user-avatar-large {
width: 48px;
height: 48px;
border-radius: 12px;
background: var(--csm-primary);
color: #fff;
font-size: 20px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.user-detail {
display: flex;
flex-direction: column;
}
</style>

View File

@@ -1,63 +1,91 @@
<template>
<div class="usb-page">
<el-tabs v-model="activeTab">
<div class="page-container">
<el-tabs v-model="activeTab" class="page-tabs">
<el-tab-pane label="策略管理" name="policies">
<div class="toolbar">
<el-button type="primary" @click="showPolicyDialog()">新建策略</el-button>
<div class="page-toolbar">
<el-button type="primary" @click="showPolicyDialog()">
<el-icon><Plus /></el-icon>新建策略
</el-button>
</div>
<div class="csm-card">
<el-table :data="policies" v-loading="loading" style="width:100%">
<el-table-column prop="name" label="策略名称" min-width="180">
<template #default="{ row }">
<span style="font-weight:500">{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column prop="policy_type" label="策略类型" width="130">
<template #default="{ row }">
<el-tag :type="policyTypeTag(row.policy_type)" size="small" effect="light">
{{ policyTypeLabel(row.policy_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="target_group" label="目标分组" width="120">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ row.target_group || '全部' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="enabled" label="启用" width="80">
<template #default="{ row }">
<el-switch :model-value="row.enabled" :active-value="1" :inactive-value="0" @change="togglePolicy(row)" size="small" />
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="170">
<template #default="{ row }">
<span class="secondary-text">{{ row.created_at }}</span>
</template>
</el-table-column>
<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>
</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">
<div class="page-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>
<div class="csm-card">
<el-table :data="events" v-loading="evLoading" style="width:100%">
<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" effect="light">
{{ eventTypeLabel(row.event_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="vendor_id" label="VID" width="100">
<template #default="{ row }"><span class="mono-text">{{ row.vendor_id }}</span></template>
</el-table-column>
<el-table-column prop="product_id" label="PID" width="100">
<template #default="{ row }"><span class="mono-text">{{ row.product_id }}</span></template>
</el-table-column>
<el-table-column prop="serial_number" label="序列号" width="160">
<template #default="{ row }"><span class="mono-text">{{ row.serial_number || '-' }}</span></template>
</el-table-column>
<el-table-column prop="device_uid" label="终端UID" min-width="160" show-overflow-tooltip />
<el-table-column prop="event_time" label="时间" width="170">
<template #default="{ row }"><span class="secondary-text">{{ row.event_time }}</span></template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
</el-tabs>
<el-dialog v-model="policyDialogVisible" :title="editingPolicy ? '编辑策略' : '新建策略'" width="500px">
<el-dialog v-model="policyDialogVisible" :title="editingPolicy ? '编辑策略' : '新建策略'" width="520px" destroy-on-close>
<el-form :model="policyForm" label-width="100px">
<el-form-item label="策略名称">
<el-input v-model="policyForm.name" />
<el-input v-model="policyForm.name" placeholder="输入策略名称" />
</el-form-item>
<el-form-item label="策略类型">
<el-select v-model="policyForm.policy_type" style="width: 100%">
@@ -84,11 +112,11 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { api } from '@/lib/api'
const activeTab = ref('policies')
// Policies
const policies = ref<any[]>([])
const loading = ref(false)
const policyDialogVisible = ref(false)
@@ -160,7 +188,6 @@ function policyTypeLabel(type: string) {
return map[type] || type
}
// Events
const events = ref<any[]>([])
const evLoading = ref(false)
const eventFilter = ref('')
@@ -187,6 +214,21 @@ onMounted(() => {
</script>
<style scoped>
.usb-page { padding: 20px; }
.toolbar { display: flex; gap: 12px; margin-bottom: 16px; }
.mono-text {
font-family: var(--csm-font-mono);
font-size: 12px;
color: var(--csm-text-secondary);
}
.secondary-text {
font-size: 12px;
color: var(--csm-text-tertiary);
}
.page-tabs :deep(.el-tabs__header) {
margin: 0 0 20px 0;
padding: 0 24px;
background: #fff;
border-bottom: 1px solid var(--csm-border-color);
}
</style>

View File

@@ -1,73 +1,126 @@
<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>
<div class="page-container">
<div class="csm-card">
<div class="csm-card-header">
<span>水印配置</span>
<el-button type="primary" size="small" @click="showDialog()">
<el-icon><Plus /></el-icon>新建配置
</el-button>
</div>
<div class="csm-card-body">
<el-table :data="configs" v-loading="loading" style="width:100%">
<el-table-column prop="target_type" label="应用范围" width="100">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ { global: '全局', group: '分组', device: '设备' }[row.target_type as string] || row.target_type }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="target_id" label="目标" width="140" show-overflow-tooltip />
<el-table-column prop="content" label="水印内容" min-width="250" show-overflow-tooltip>
<template #default="{ row }">
<span style="font-weight:500">{{ row.content }}</span>
</template>
</el-table-column>
<el-table-column prop="font_size" label="字号" width="70">
<template #default="{ row }"><span class="mono-text">{{ row.font_size }}px</span></template>
</el-table-column>
<el-table-column label="透明度" width="80">
<template #default="{ row }"><span class="mono-text">{{ (row.opacity * 100).toFixed(0) }}%</span></template>
</el-table-column>
<el-table-column prop="color" label="颜色" width="80">
<template #default="{ row }">
<div style="display:flex;align-items:center;gap:6px">
<span class="color-swatch" :style="{ background: row.color }"></span>
<span class="mono-text">{{ row.color }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="angle" label="角度" width="60">
<template #default="{ row }"><span class="mono-text">{{ row.angle }}&deg;</span></template>
</el-table-column>
<el-table-column prop="enabled" label="启用" width="70">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'" size="small" effect="light">{{ 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>
</div>
</div>
<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>
<!-- Preview card -->
<div class="csm-card" style="margin-top:20px">
<div class="csm-card-header">水印预览</div>
<div class="csm-card-body">
<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 class="preview-hint">此区域模拟用户桌面效果</p>
<p class="preview-sub">水印内容会以设定角度和透明度覆盖整个屏幕</p>
</div>
</div>
</div>
</el-card>
</div>
<el-dialog v-model="visible" :title="editing?'编辑配置':'新建配置'" width="520px">
<el-dialog v-model="visible" :title="editing ? '编辑配置' : '新建配置'" width="520px" destroy-on-close>
<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-select v-model="form.target_type" style="width:100%">
<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" placeholder="输入分组名或设备UID" />
</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-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>
<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' } })
import { Plus } from '@element-plus/icons-vue'
import { api } from '@/lib/api'
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 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')
@@ -85,28 +138,132 @@ const watermarkStyle = computed(() => ({
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 }
try {
const data = await api.get<any>('/api/plugins/watermark/config')
configs.value = data.configs || []
} catch { /* api.ts handles 401 */ } 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 }) }
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) }
try {
const body = { ...form, opacity: form.opacity / 100 }
if (editing.value) {
await api.put(`/api/plugins/watermark/config/${editing.value.id}`, body)
ElMessage.success('配置已更新')
} else {
await api.post('/api/plugins/watermark/config', body)
ElMessage.success('配置已创建')
}
visible.value = false
fetchConfigs()
} catch (e: any) { ElMessage.error(e.message || '保存失败') }
}
async function remove(id: number) { await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' }); await fetch(`/api/plugins/watermark/config/${id}`, { method: 'DELETE', ...auth() }); ElMessage.success('已删除'); fetchConfigs() }
async function remove(id: number) {
await ElMessageBox.confirm('确定删除该配置?', '确认', { type: 'warning' })
try {
await api.delete(`/api/plugins/watermark/config/${id}`)
ElMessage.success('配置已删除')
fetchConfigs()
} catch (e: any) { ElMessage.error(e.message || '删除失败') }
}
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}
.csm-card-header {
font-weight: 600;
font-size: 15px;
color: var(--csm-text-primary);
padding: 16px 20px;
border-bottom: 1px solid var(--csm-border-color);
display: flex;
align-items: center;
justify-content: space-between;
}
.csm-card-body {
padding: 16px 20px;
}
.color-swatch {
width: 14px;
height: 14px;
border-radius: 4px;
border: 1px solid rgba(0, 0, 0, 0.1);
display: inline-block;
flex-shrink: 0;
}
.mono-text {
font-family: var(--csm-font-mono);
font-size: 12px;
color: var(--csm-text-secondary);
}
.preview-area {
position: relative;
height: 220px;
border: 1px dashed var(--csm-border-color);
border-radius: var(--csm-border-radius);
overflow: hidden;
background: #f8fafc;
}
.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;
}
.preview-hint {
color: var(--csm-text-secondary);
font-size: 14px;
font-weight: 500;
}
.preview-sub {
color: var(--csm-text-tertiary);
font-size: 12px;
margin-top: 4px;
}
</style>

View File

@@ -1,61 +1,98 @@
<template>
<div class="plugin-page">
<el-tabs v-model="activeTab">
<div class="page-container">
<el-tabs v-model="activeTab" class="page-tabs">
<el-tab-pane label="过滤规则" name="rules">
<div class="toolbar">
<el-button type="primary" @click="showRuleDialog()">新建规则</el-button>
<div class="page-toolbar">
<el-button type="primary" @click="showRuleDialog()">
<el-icon><Plus /></el-icon>新建规则
</el-button>
</div>
<div class="csm-card">
<el-table :data="rules" v-loading="loading" style="width:100%">
<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" effect="light">
{{ ruleTypeLabel(row.rule_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="pattern" label="匹配模式" min-width="200">
<template #default="{ row }"><span class="mono-text">{{ row.pattern }}</span></template>
</el-table-column>
<el-table-column prop="target_type" label="应用范围" width="100">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ { global: '全局', group: '分组', device: '设备' }[row.target_type as string] || row.target_type }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="enabled" label="启用" width="80">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'" size="small" effect="light">{{ row.enabled ? '启用' : '停用' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="170">
<template #default="{ row }"><span class="secondary-text">{{ row.created_at }}</span></template>
</el-table-column>
<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>
</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>
<div class="csm-card">
<el-table :data="accessLog" v-loading="logLoading" style="width:100%">
<el-table-column prop="device_uid" label="终端" width="160" show-overflow-tooltip>
<template #default="{ row }"><span class="mono-text">{{ row.device_uid }}</span></template>
</el-table-column>
<el-table-column prop="url" label="URL" min-width="300" show-overflow-tooltip>
<template #default="{ row }"><span class="mono-text">{{ row.url }}</span></template>
</el-table-column>
<el-table-column label="动作" width="80">
<template #default="{ row }">
<el-tag :type="row.action === 'blocked' ? 'danger' : 'success'" size="small" effect="light">
{{ row.action === 'blocked' ? '拦截' : '放行' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="timestamp" label="时间" width="170">
<template #default="{ row }"><span class="secondary-text">{{ row.timestamp }}</span></template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
</el-tabs>
<el-dialog v-model="dialogVisible" :title="editing?'编辑规则':'新建规则'" width="480px">
<el-dialog v-model="dialogVisible" :title="editing ? '编辑规则' : '新建规则'" width="480px" destroy-on-close>
<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-select v-model="form.rule_type" style="width:100%">
<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-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-select v-model="form.target_type" style="width:100%">
<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-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>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveRule">保存</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -63,8 +100,10 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { api } from '@/lib/api'
const activeTab = ref('rules')
const auth = () => ({ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } })
const rules = ref<any[]>([])
const loading = ref(false)
const accessLog = ref<any[]>([])
@@ -73,33 +112,79 @@ 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 }
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 }
try {
const data = await api.get<any>('/api/plugins/web-filter/rules')
rules.value = data.rules || []
} catch { /* api.ts handles 401 */ } 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 }
try {
const data = await api.get<any>('/api/plugins/web-filter/log')
accessLog.value = data.log || []
} catch { /* api.ts handles 401 */ } 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
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)}
try {
if (editing.value) {
await api.put(`/api/plugins/web-filter/rules/${editing.value.id}`, form)
ElMessage.success('规则已更新')
} else {
await api.post('/api/plugins/web-filter/rules', form)
ElMessage.success('规则已创建')
}
dialogVisible.value = false
fetchRules()
} catch (e: any) { ElMessage.error(e.message || '保存失败') }
}
async function deleteRule(id: number) {
await ElMessageBox.confirm('确定删除?','确认',{type:'warning'})
await fetch(`/api/plugins/web-filter/rules/${id}`,{method:'DELETE',...auth()})
ElMessage.success('已删除'); fetchRules()
await ElMessageBox.confirm('确定删除该规则?', '确认', { type: 'warning' })
try {
await api.delete(`/api/plugins/web-filter/rules/${id}`)
ElMessage.success('规则已删除')
fetchRules()
} catch (e: any) { ElMessage.error(e.message || '删除失败') }
}
onMounted(()=>{fetchRules();fetchLog()})
onMounted(() => { fetchRules(); fetchLog() })
</script>
<style scoped>.plugin-page{padding:20px}.toolbar{display:flex;gap:12px;margin-bottom:16px}</style>
<style scoped>
.mono-text {
font-family: var(--csm-font-mono);
font-size: 12px;
color: var(--csm-text-secondary);
}
.secondary-text {
font-size: 12px;
color: var(--csm-text-tertiary);
}
.page-tabs :deep(.el-tabs__header) {
margin: 0 0 20px 0;
padding: 0 24px;
background: #fff;
border-bottom: 1px solid var(--csm-border-color);
}
</style>

View File

@@ -28,18 +28,18 @@ export default defineConfig({
],
},
server: {
port: 3000,
port: 9997,
proxy: {
'/api': {
target: 'http://localhost:8080',
target: 'http://localhost:9998',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:8080',
target: 'ws://localhost:9998',
ws: true,
},
'/health': {
target: 'http://localhost:8080',
target: 'http://localhost:9998',
changeOrigin: true,
},
},