前端重构: - 重构Layout为左侧导航+顶栏的现代管理后台布局 - 重构设备管理页面(Devices.vue):左侧分组面板+右侧设备列表 - 重构设备详情(DeviceDetail.vue):集成硬件资产/软件资产/变更记录标签页 - 移除独立资产管理页面,功能合并至设备详情 - 重构Dashboard/登录/设置/告警/水印/上网管控等页面样式 - 新增全局CSS变量和统一样式系统 - 添加分组管理UI:新建/重命名/删除分组,移动设备到分组 后端完善: - 新增分组CRUD API(groups.rs):创建/重命名/删除分组,设备分组移动 - 客户端硬件采集:完善GPU/主板/序列号/磁盘信息采集(Windows PowerShell) - 客户端软件采集:通过Windows注册表读取已安装软件列表 - 新增SoftwareAssetReport消息类型(0x09)及处理链路 - 数据库新增upsert_software方法处理软件资产存储 - 服务端推送软件资产配置给新注册设备 - 修复密码修改功能,添加旧密码验证
447 lines
15 KiB
Vue
447 lines
15 KiB
Vue
<template>
|
|
<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>
|
|
|
|
<!-- 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">
|
|
<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-empty v-else description="暂无状态数据" :image-size="64" />
|
|
</div>
|
|
|
|
<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 }"><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>
|
|
</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, 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()
|
|
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 '#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([
|
|
api.get<any>(`/api/devices/${deviceUid}`),
|
|
api.get<any>(`/api/devices/${deviceUid}/status`),
|
|
])
|
|
device.value = devData
|
|
status.value = statData
|
|
} catch { /* api.ts handles 401 */ } finally {
|
|
loading.value = false
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.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>
|