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

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

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>