1025 lines
34 KiB
Vue
1025 lines
34 KiB
Vue
<!--
|
||
=============================================================================
|
||
企微IT智能服务台 — 终端安全管理页
|
||
=============================================================================
|
||
说明:从火绒API获取终端安全数据,提供管理员全局安全视图
|
||
1. 顶部:4个统计卡片(终端总数/在线终端/高危漏洞/病毒事件)
|
||
2. 中部:终端列表表格(支持搜索、分页、状态筛选)
|
||
3. 右侧抽屉:终端详细信息
|
||
4. 标签页切换:全部终端 / 高危漏洞 / 病毒事件
|
||
-->
|
||
<template>
|
||
<div class="terminal-security-page">
|
||
<!-- 页面标题 -->
|
||
<div class="page-title">终端安全</div>
|
||
<div class="page-desc">火绒企业版终端安全数据,实时查看终端状态、漏洞和病毒事件</div>
|
||
|
||
<!-- 连接状态提示 -->
|
||
<div v-if="connectionError" class="connection-warning">
|
||
<el-icon :size="16"><WarningFilled /></el-icon>
|
||
<span>{{ connectionError }}</span>
|
||
<el-button size="small" text type="primary" @click="$router.push('/integrations')">
|
||
去配置
|
||
</el-button>
|
||
</div>
|
||
|
||
<!-- 统计卡片行 -->
|
||
<div class="stats-grid">
|
||
<StatCard
|
||
label="终端总数"
|
||
:value="stats.total_terminals"
|
||
valueColor="var(--accent)"
|
||
icon="Monitor"
|
||
iconColor="var(--accent)"
|
||
/>
|
||
<StatCard
|
||
label="在线终端"
|
||
:value="stats.online_terminals"
|
||
valueColor="var(--success)"
|
||
icon="CircleCheckFilled"
|
||
iconColor="var(--success)"
|
||
/>
|
||
<StatCard
|
||
label="高危漏洞终端"
|
||
:value="stats.high_risk_terminals"
|
||
valueColor="var(--danger)"
|
||
icon="WarningFilled"
|
||
iconColor="var(--danger)"
|
||
/>
|
||
<StatCard
|
||
label="今日病毒事件"
|
||
:value="stats.virus_events_today"
|
||
valueColor="var(--warning)"
|
||
icon="Virus"
|
||
iconColor="var(--warning)"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 标签页 + 搜索栏 -->
|
||
<div class="table-wrapper">
|
||
<div class="table-header-row">
|
||
<!-- 标签页切换 -->
|
||
<div class="tab-group">
|
||
<el-button
|
||
v-for="tab in tabs"
|
||
:key="tab.key"
|
||
:type="activeTab === tab.key ? 'primary' : 'default'"
|
||
size="small"
|
||
@click="switchTab(tab.key)"
|
||
>
|
||
{{ tab.label }}
|
||
</el-button>
|
||
</div>
|
||
|
||
<!-- 搜索 -->
|
||
<div class="search-bar">
|
||
<el-input
|
||
v-model="searchQuery"
|
||
placeholder="搜索计算机名或IP"
|
||
:prefix-icon="Search"
|
||
clearable
|
||
size="small"
|
||
style="width: 220px"
|
||
@clear="loadData"
|
||
@keyup.enter="loadData"
|
||
/>
|
||
<el-button size="small" type="primary" @click="loadData">
|
||
<el-icon><Search /></el-icon>
|
||
刷新
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================================================================ -->
|
||
<!-- 全部终端 表格 -->
|
||
<!-- ================================================================ -->
|
||
<el-table
|
||
v-if="activeTab === 'terminals'"
|
||
:data="filteredTerminals"
|
||
style="width: 100%"
|
||
:header-cell-style="tableHeaderStyle"
|
||
:cell-style="tableCellStyle"
|
||
row-class-name="terminal-table-row"
|
||
v-loading="loading"
|
||
element-loading-text="正在加载终端数据..."
|
||
@row-click="showTerminalDetail"
|
||
>
|
||
<el-table-column label="计算机名" min-width="140">
|
||
<template #default="{ row }">
|
||
<span class="terminal-name">{{ row.computer_name }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="IP地址" width="130">
|
||
<template #default="{ row }">
|
||
{{ row.local_ip }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="分组" width="100">
|
||
<template #default="{ row }">
|
||
<el-tag size="small" effect="plain">{{ row.group_id || '—' }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作系统" width="110">
|
||
<template #default="{ row }">
|
||
<span class="os-text">{{ truncateOs(row.os_version) }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="80" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row.is_online ? 'success' : 'info'" size="small" effect="dark">
|
||
{{ row.is_online ? '在线' : '离线' }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="风险" width="80" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag
|
||
v-if="row.risk_level"
|
||
:type="getRiskTagType(row.risk_level)"
|
||
size="small"
|
||
>
|
||
{{ getRiskText(row.risk_level) }}
|
||
</el-tag>
|
||
<span v-else class="text-muted">—</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="最后活跃" width="110">
|
||
<template #default="{ row }">
|
||
<span class="time-text">{{ formatTimestamp(row.last_connect_time) }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<!-- ================================================================ -->
|
||
<!-- 高危漏洞 表格 -->
|
||
<!-- ================================================================ -->
|
||
<el-table
|
||
v-else-if="activeTab === 'leaks'"
|
||
:data="filteredLeaks"
|
||
style="width: 100%"
|
||
:header-cell-style="tableHeaderStyle"
|
||
:cell-style="tableCellStyle"
|
||
row-class-name="terminal-table-row"
|
||
v-loading="loading"
|
||
element-loading-text="正在加载漏洞数据..."
|
||
>
|
||
<el-table-column label="计算机名" min-width="140">
|
||
<template #default="{ row }">
|
||
<span class="terminal-name">{{ row.hostname }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="IP地址" width="130">
|
||
<template #default="{ row }">
|
||
{{ row.ip_addr }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="分组" width="100">
|
||
<template #default="{ row }">
|
||
<el-tag size="small" effect="plain">{{ row.group_name || row.group_id || '—' }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作系统" width="110">
|
||
<template #default="{ row }">
|
||
<span class="os-text">{{ truncateOs(row.osver) }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="80" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row.stat === 2 ? 'success' : 'info'" size="small" effect="dark">
|
||
{{ row.stat === 2 ? '在线' : row.stat === 3 ? '异常' : '离线' }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="客户端版本" width="110">
|
||
<template #default="{ row }">
|
||
<span class="os-text">{{ row.prodver || '—' }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<!-- ================================================================ -->
|
||
<!-- 病毒事件 表格 -->
|
||
<!-- ================================================================ -->
|
||
<el-table
|
||
v-else-if="activeTab === 'virus'"
|
||
:data="filteredVirusEvents"
|
||
style="width: 100%"
|
||
:header-cell-style="tableHeaderStyle"
|
||
:cell-style="tableCellStyle"
|
||
row-class-name="terminal-table-row"
|
||
v-loading="loading"
|
||
element-loading-text="正在加载病毒事件..."
|
||
>
|
||
<el-table-column label="计算机名" min-width="140">
|
||
<template #default="{ row }">
|
||
<span class="terminal-name">{{ row.computer_name }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="IP地址" width="130">
|
||
<template #default="{ row }">
|
||
{{ row.local_ip }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="病毒日志数" width="100" align="center">
|
||
<template #default="{ row }">
|
||
<span :class="{ 'text-danger': row.count > 0 }">{{ row.count }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="处理结果" min-width="200">
|
||
<template #default="{ row }">
|
||
<div v-if="row.result" class="virus-result-tags">
|
||
<el-tag v-if="row.result.success > 0" type="success" size="small" effect="plain" style="margin: 2px">
|
||
成功 {{ row.result.success }}
|
||
</el-tag>
|
||
<el-tag v-if="row.result.fail > 0" type="danger" size="small" effect="plain" style="margin: 2px">
|
||
失败 {{ row.result.fail }}
|
||
</el-tag>
|
||
<el-tag v-if="row.result.ignored > 0" type="warning" size="small" effect="plain" style="margin: 2px">
|
||
忽略 {{ row.result.ignored }}
|
||
</el-tag>
|
||
<el-tag v-if="row.result.trusted > 0" type="info" size="small" effect="plain" style="margin: 2px">
|
||
信任 {{ row.result.trusted }}
|
||
</el-tag>
|
||
</div>
|
||
<span v-else class="text-muted">—</span>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<!-- 分页 -->
|
||
<div class="pagination-wrapper">
|
||
<el-pagination
|
||
v-model:current-page="currentPage"
|
||
v-model:page-size="pageSize"
|
||
:page-sizes="[20, 50, 100]"
|
||
:total="totalItems"
|
||
layout="total, sizes, prev, pager, next"
|
||
small
|
||
@size-change="loadData"
|
||
@current-change="loadData"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================================================================ -->
|
||
<!-- 终端详情抽屉 -->
|
||
<!-- ================================================================ -->
|
||
<el-drawer
|
||
v-model="detailVisible"
|
||
:title="detailData?.computer_name || '终端详情'"
|
||
direction="rtl"
|
||
size="420px"
|
||
destroy-on-close
|
||
>
|
||
<div v-if="detailLoading" style="text-align: center; padding: 40px">
|
||
<el-icon :size="24" class="is-loading"><Loading /></el-icon>
|
||
<div style="margin-top: 8px; color: var(--text-secondary)">加载中...</div>
|
||
</div>
|
||
|
||
<div v-else-if="detailData" class="detail-content">
|
||
<!-- 基本信息 -->
|
||
<div class="detail-section">
|
||
<div class="detail-section-title">基本信息</div>
|
||
<div class="detail-grid">
|
||
<div class="detail-item">
|
||
<span class="detail-label">计算机名</span>
|
||
<span class="detail-value">{{ detailData.computer_name }}</span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span class="detail-label">IP地址</span>
|
||
<span class="detail-value">{{ detailData.local_ip }}</span>
|
||
</div>
|
||
<div class="detail-item" v-if="detailData.mac">
|
||
<span class="detail-label">MAC地址</span>
|
||
<span class="detail-value mono">{{ detailData.mac }}</span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span class="detail-label">操作系统</span>
|
||
<span class="detail-value">{{ detailData.os_version }}</span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span class="detail-label">状态</span>
|
||
<el-tag :type="detailData.is_online ? 'success' : 'info'" size="small" effect="dark">
|
||
{{ detailData.is_online ? '在线' : '离线' }}
|
||
</el-tag>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span class="detail-label">客户端版本</span>
|
||
<span class="detail-value">{{ detailData.version || '—' }}</span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span class="detail-label">分组</span>
|
||
<span class="detail-value">{{ detailData.group_id || '—' }}</span>
|
||
</div>
|
||
<div class="detail-item" v-if="detailData.last_connect_time">
|
||
<span class="detail-label">最后活跃</span>
|
||
<span class="detail-value">{{ formatTimestampFull(detailData.last_connect_time) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 硬件信息 -->
|
||
<div v-if="detailData.cpu || detailData.memory" class="detail-section">
|
||
<div class="detail-section-title">硬件信息</div>
|
||
<div class="detail-grid">
|
||
<div v-if="detailData.cpu" class="detail-item">
|
||
<span class="detail-label">CPU</span>
|
||
<span class="detail-value">{{ detailData.cpu }}</span>
|
||
</div>
|
||
<div v-if="detailData.memory" class="detail-item">
|
||
<span class="detail-label">内存</span>
|
||
<span class="detail-value">{{ detailData.memory }}</span>
|
||
</div>
|
||
<div v-if="detailData.disk" class="detail-item">
|
||
<span class="detail-label">硬盘</span>
|
||
<span class="detail-value">{{ detailData.disk }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 安全状态 -->
|
||
<div class="detail-section">
|
||
<div class="detail-section-title">安全状态</div>
|
||
<div class="security-status-grid">
|
||
<div class="security-item" :class="detailData.virus_count > 0 ? 'danger' : 'safe'">
|
||
<div class="security-value">{{ detailData.virus_count }}</div>
|
||
<div class="security-label">病毒检测</div>
|
||
</div>
|
||
<div class="security-item" :class="detailData.leak_count > 0 ? 'danger' : 'safe'">
|
||
<div class="security-value">{{ detailData.leak_count }}</div>
|
||
<div class="security-label">高危漏洞</div>
|
||
</div>
|
||
<div class="security-item" :class="detailData.is_isolated ? 'warning' : 'safe'">
|
||
<div class="security-value">{{ detailData.is_isolated ? '是' : '否' }}</div>
|
||
<div class="security-label">网络隔离</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 操作按钮(P1功能,阶段一灰化) -->
|
||
<div class="detail-section">
|
||
<div class="detail-section-title">安全操作</div>
|
||
<div class="action-buttons">
|
||
<el-button size="small" disabled>
|
||
<el-icon><Refresh /></el-icon>
|
||
触发全盘扫描
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
:type="detailData.is_isolated ? 'success' : 'danger'"
|
||
disabled
|
||
>
|
||
<el-icon><Connection /></el-icon>
|
||
{{ detailData.is_isolated ? '解除隔离' : '网络隔离' }}
|
||
</el-button>
|
||
<el-button size="small" disabled>
|
||
<el-icon><Bell /></el-icon>
|
||
推送消息
|
||
</el-button>
|
||
</div>
|
||
<div class="action-hint">安全操作需火绒 API 配置完成后启用</div>
|
||
</div>
|
||
</div>
|
||
</el-drawer>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
// ==========================================================================
|
||
// 依赖导入
|
||
// ==========================================================================
|
||
import { ref, reactive, computed, onMounted } from 'vue'
|
||
import { Search } from '@element-plus/icons-vue'
|
||
import StatCard from '@/components/StatCard.vue'
|
||
import {
|
||
getHuorongTerminals,
|
||
getHuorongTerminalDetail,
|
||
getHuorongLeaks,
|
||
getHuorongVirusEvents,
|
||
} from '@/api/admin'
|
||
import type {
|
||
HuorongTerminal,
|
||
HuorongTerminalDetail,
|
||
HuorongLeakInfo,
|
||
HuorongVirusEvent,
|
||
TerminalSecurityStats,
|
||
} from '@/types'
|
||
|
||
// ==========================================================================
|
||
// 标签页定义
|
||
// ==========================================================================
|
||
const tabs = [
|
||
{ key: 'terminals', label: '全部终端' },
|
||
{ key: 'leaks', label: '高危漏洞' },
|
||
{ key: 'virus', label: '病毒事件' },
|
||
]
|
||
|
||
// ==========================================================================
|
||
// 状态
|
||
// ==========================================================================
|
||
const activeTab = ref<string>('terminals')
|
||
const loading = ref(false)
|
||
const connectionError = ref('')
|
||
|
||
// 统计概览
|
||
const stats = reactive<TerminalSecurityStats>({
|
||
total_terminals: 0,
|
||
online_terminals: 0,
|
||
high_risk_terminals: 0,
|
||
virus_events_today: 0,
|
||
isolated_terminals: 0,
|
||
})
|
||
|
||
// 终端列表
|
||
const terminals = ref<HuorongTerminal[]>([])
|
||
const leaks = ref<HuorongLeakInfo[]>([])
|
||
const virusEvents = ref<HuorongVirusEvent[]>([])
|
||
|
||
// 分页
|
||
const currentPage = ref(1)
|
||
const pageSize = ref(20)
|
||
const totalItems = ref(0)
|
||
|
||
// 搜索
|
||
const searchQuery = ref('')
|
||
|
||
// 终端详情抽屉
|
||
const detailVisible = ref(false)
|
||
const detailLoading = ref(false)
|
||
const detailData = ref<HuorongTerminalDetail | null>(null)
|
||
|
||
// 表格样式
|
||
const tableHeaderStyle = {
|
||
background: 'var(--bg-tertiary)',
|
||
color: 'var(--text-secondary)',
|
||
fontSize: '12px',
|
||
}
|
||
const tableCellStyle = {
|
||
color: 'var(--text-primary)',
|
||
fontSize: '13px',
|
||
}
|
||
|
||
// ==========================================================================
|
||
// 计算属性:搜索过滤
|
||
// ==========================================================================
|
||
const filteredTerminals = computed(() => {
|
||
if (!searchQuery.value) return terminals.value
|
||
const q = searchQuery.value.toLowerCase()
|
||
return terminals.value.filter(
|
||
(t) => t.computer_name.toLowerCase().includes(q) || t.local_ip.toLowerCase().includes(q)
|
||
)
|
||
})
|
||
|
||
const filteredLeaks = computed(() => {
|
||
if (!searchQuery.value) return leaks.value
|
||
const q = searchQuery.value.toLowerCase()
|
||
return leaks.value.filter(
|
||
(l) => l.hostname.toLowerCase().includes(q) || l.ip_addr.toLowerCase().includes(q)
|
||
)
|
||
})
|
||
|
||
const filteredVirusEvents = computed(() => {
|
||
if (!searchQuery.value) return virusEvents.value
|
||
const q = searchQuery.value.toLowerCase()
|
||
return virusEvents.value.filter(
|
||
(v) => v.computer_name.toLowerCase().includes(q) || v.local_ip.toLowerCase().includes(q)
|
||
)
|
||
})
|
||
|
||
// ==========================================================================
|
||
// 初始化
|
||
// ==========================================================================
|
||
onMounted(() => {
|
||
loadData()
|
||
loadStats() // 异步加载全局统计数据(不影响页面主内容加载)
|
||
})
|
||
|
||
// ==========================================================================
|
||
// 综合统计加载(异步,不阻塞主内容)
|
||
// ==========================================================================
|
||
|
||
/** 异步加载全局统计数据,填充顶部4个统计卡片 */
|
||
async function loadStats(): Promise<void> {
|
||
try {
|
||
// 1. 从 _leak 接口获取高危终端数和终端总数
|
||
const leakResp = await getHuorongLeaks({ page: 1, per_page: 1 })
|
||
const leakData = leakResp.data.data
|
||
if (!leakData.error) {
|
||
stats.high_risk_terminals = leakData.risk_client || 0
|
||
if (leakData.all_client) {
|
||
stats.total_terminals = leakData.all_client
|
||
}
|
||
}
|
||
|
||
// 2. 从 _virus_events 接口获取病毒事件统计(type=2=全部)
|
||
const virusResp = await getHuorongVirusEvents({ page: 1, per_page: 200 })
|
||
const virusData = virusResp.data.data
|
||
if (!virusData.error) {
|
||
const items: HuorongVirusEvent[] = virusData.items || []
|
||
// 统计有处理失败病毒事件的终端数
|
||
stats.virus_events_today = items.reduce(
|
||
(sum: number, v: HuorongVirusEvent) => sum + (v.result?.fail || 0), 0
|
||
)
|
||
}
|
||
} catch {
|
||
// 统计加载失败不影响主页面,静默忽略
|
||
}
|
||
}
|
||
|
||
// ==========================================================================
|
||
// 数据加载
|
||
// ==========================================================================
|
||
async function loadData(): Promise<void> {
|
||
loading.value = true
|
||
connectionError.value = ''
|
||
|
||
try {
|
||
if (activeTab.value === 'terminals') {
|
||
await loadTerminals()
|
||
} else if (activeTab.value === 'leaks') {
|
||
await loadLeaks()
|
||
} else if (activeTab.value === 'virus') {
|
||
await loadVirusEvents()
|
||
}
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
/** 加载终端列表 */
|
||
async function loadTerminals(): Promise<void> {
|
||
try {
|
||
const response = await getHuorongTerminals({
|
||
page: currentPage.value,
|
||
per_page: pageSize.value,
|
||
})
|
||
const data = response.data.data
|
||
|
||
// 检查是否有错误(API未配置等)
|
||
if (data.error) {
|
||
connectionError.value = data.error
|
||
// 使用 Demo 数据
|
||
loadDemoTerminals()
|
||
return
|
||
}
|
||
|
||
terminals.value = data.items || []
|
||
totalItems.value = data.total || 0
|
||
|
||
// 计算统计
|
||
stats.total_terminals = data.total || terminals.value.length
|
||
// 在线终端数:从当前页数据估算(精确统计需遍历所有分页)
|
||
const onlineInPage = terminals.value.filter((t) => t.is_online).length
|
||
const pageSize_ = terminals.value.length || 1
|
||
// 按当前页在线比例估算总数
|
||
stats.online_terminals = Math.round(
|
||
(onlineInPage / pageSize_) * stats.total_terminals
|
||
)
|
||
} catch {
|
||
connectionError.value = '无法连接火绒API,请检查集成配置'
|
||
loadDemoTerminals()
|
||
}
|
||
}
|
||
|
||
/** 加载漏洞信息 */
|
||
async function loadLeaks(): Promise<void> {
|
||
try {
|
||
const response = await getHuorongLeaks({
|
||
page: currentPage.value,
|
||
per_page: pageSize.value,
|
||
})
|
||
const data = response.data.data
|
||
|
||
if (data.error) {
|
||
connectionError.value = data.error
|
||
loadDemoLeaks()
|
||
return
|
||
}
|
||
|
||
leaks.value = data.items || []
|
||
// _leak 接口返回 risk_client(高危终端数) 和 all_client(终端总数)
|
||
totalItems.value = data.risk_client || data.total || leaks.value.length
|
||
stats.high_risk_terminals = data.risk_client || 0
|
||
// 同步更新终端总数统计
|
||
if (data.all_client) {
|
||
stats.total_terminals = data.all_client
|
||
}
|
||
} catch {
|
||
loadDemoLeaks()
|
||
}
|
||
}
|
||
|
||
/** 加载病毒事件 */
|
||
async function loadVirusEvents(): Promise<void> {
|
||
try {
|
||
const response = await getHuorongVirusEvents({
|
||
page: currentPage.value,
|
||
per_page: pageSize.value,
|
||
})
|
||
const data = response.data.data
|
||
|
||
if (data.error) {
|
||
connectionError.value = data.error
|
||
loadDemoVirusEvents()
|
||
return
|
||
}
|
||
|
||
virusEvents.value = data.items || []
|
||
totalItems.value = data.total || 0
|
||
// 计算今日病毒事件:有病毒日志的终端中,未处理(fail)的数量
|
||
stats.virus_events_today = virusEvents.value.reduce(
|
||
(sum, v) => sum + (v.result?.fail || 0), 0
|
||
)
|
||
} catch {
|
||
loadDemoVirusEvents()
|
||
}
|
||
}
|
||
|
||
// ==========================================================================
|
||
// Demo 数据(API未配置时使用)
|
||
// ==========================================================================
|
||
function loadDemoTerminals(): void {
|
||
terminals.value = [
|
||
{ client_id: 'd1', client_name: 'IT-SONGXIAN', computer_name: 'IT-SONGXIAN', local_ip: '192.168.3.101', connect_ip: '192.168.3.101', mac: 'AA-BB-CC-DD-EE-01', group_id: 1, os_version: 'Windows 11 Pro', version: '2.0.18.3', definitions: '2026-06-12', is_online: true, last_connect_time: Math.floor(Date.now() / 1000), risk_level: 'safe', virus_count: 0, leak_count: 0 },
|
||
{ client_id: 'd2', client_name: 'IT-WANGLI', computer_name: 'IT-WANGLI', local_ip: '192.168.3.102', connect_ip: '192.168.3.102', mac: 'AA-BB-CC-DD-EE-02', group_id: 1, os_version: 'Windows 10 Pro', version: '2.0.18.3', definitions: '2026-06-11', is_online: false, last_connect_time: Math.floor((Date.now() - 3600000) / 1000), risk_level: 'low', virus_count: 0, leak_count: 2 },
|
||
{ client_id: 'd3', client_name: 'FIN-ZHAOGANG', computer_name: 'FIN-ZHAOGANG', local_ip: '192.168.3.201', connect_ip: '192.168.3.201', mac: 'AA-BB-CC-DD-EE-03', group_id: 2, os_version: 'Windows 11 Pro', version: '2.0.18.2', definitions: '2026-06-10', is_online: true, last_connect_time: Math.floor(Date.now() / 1000), risk_level: 'high', virus_count: 1, leak_count: 5 },
|
||
{ client_id: 'd4', client_name: 'HR-SUNFANG', computer_name: 'HR-SUNFANG', local_ip: '192.168.3.301', connect_ip: '192.168.3.301', mac: 'AA-BB-CC-DD-EE-04', group_id: 3, os_version: 'Windows 10 Pro', version: '2.0.18.3', definitions: '2026-06-12', is_online: true, last_connect_time: Math.floor(Date.now() / 1000), risk_level: 'medium', virus_count: 0, leak_count: 3 },
|
||
{ client_id: 'd5', client_name: 'MKT-ZHOULEI', computer_name: 'MKT-ZHOULEI', local_ip: '192.168.3.401', connect_ip: '192.168.3.401', mac: 'AA-BB-CC-DD-EE-05', group_id: 4, os_version: 'macOS 14.5', version: '2.0.18.3', definitions: '2026-06-12', is_online: true, last_connect_time: Math.floor(Date.now() / 1000), risk_level: 'safe', virus_count: 0, leak_count: 0 },
|
||
{ client_id: 'd6', client_name: 'RD-WUTING', computer_name: 'RD-WUTING', local_ip: '192.168.3.501', connect_ip: '192.168.3.501', mac: 'AA-BB-CC-DD-EE-06', group_id: 5, os_version: 'Windows 11 Pro', version: '2.0.18.3', definitions: '2026-06-12', is_online: true, last_connect_time: Math.floor(Date.now() / 1000), risk_level: 'low', virus_count: 0, leak_count: 1 },
|
||
]
|
||
totalItems.value = terminals.value.length
|
||
stats.total_terminals = 6
|
||
stats.online_terminals = 5
|
||
stats.high_risk_terminals = 1
|
||
stats.virus_events_today = 1
|
||
}
|
||
|
||
function loadDemoLeaks(): void {
|
||
leaks.value = [
|
||
{ cid: 'd3', hostname: 'FIN-ZHAOGANG', client_name: 'FIN-ZHAOGANG', group_name: '财务部', group_id: 2, ip_addr: '192.168.3.201', call_ip: '192.168.3.201', mac: 'AA-BB-CC-DD-EE-03', osver: 'Windows 11 Pro', os_type: 'Windows', prodver: '2.0.18.2', virdb: 1718000000, stat: 2 },
|
||
{ cid: 'd4', hostname: 'HR-SUNFANG', client_name: 'HR-SUNFANG', group_name: '人力资源部', group_id: 3, ip_addr: '192.168.3.301', call_ip: '192.168.3.301', mac: 'AA-BB-CC-DD-EE-04', osver: 'Windows 10 Pro', os_type: 'Windows', prodver: '2.0.18.3', virdb: 1718000000, stat: 2 },
|
||
{ cid: 'd2', hostname: 'IT-WANGLI', client_name: 'IT-WANGLI', group_name: 'IT部', group_id: 1, ip_addr: '192.168.3.102', call_ip: '192.168.3.102', mac: 'AA-BB-CC-DD-EE-02', osver: 'Windows 10 Pro', os_type: 'Windows', prodver: '2.0.18.3', virdb: 1717900000, stat: 1 },
|
||
]
|
||
totalItems.value = leaks.value.length
|
||
stats.high_risk_terminals = 3
|
||
}
|
||
|
||
function loadDemoVirusEvents(): void {
|
||
virusEvents.value = [
|
||
{ group_id: 2, client_id: 'd3', client_name: 'FIN-ZHAOGANG', computer_name: 'FIN-ZHAOGANG', local_ip: '192.168.3.201', connect_ip: '192.168.3.201', mac: 'AA-BB-CC-DD-EE-03', count: 3, result: { success: 2, fail: 1, ignored: 0, trusted: 0 } },
|
||
]
|
||
totalItems.value = virusEvents.value.length
|
||
stats.virus_events_today = 1
|
||
}
|
||
|
||
// ==========================================================================
|
||
// 标签页切换
|
||
// ==========================================================================
|
||
function switchTab(tab: string): void {
|
||
activeTab.value = tab
|
||
currentPage.value = 1
|
||
searchQuery.value = ''
|
||
loadData()
|
||
}
|
||
|
||
// ==========================================================================
|
||
// 终端详情
|
||
// ==========================================================================
|
||
async function showTerminalDetail(row: HuorongTerminal): Promise<void> {
|
||
detailVisible.value = true
|
||
detailLoading.value = true
|
||
detailData.value = null
|
||
|
||
try {
|
||
const response = await getHuorongTerminalDetail(row.client_id)
|
||
const data = response.data.data
|
||
|
||
if (data.error) {
|
||
// API错误,用基本数据展示
|
||
detailData.value = {
|
||
client_id: row.client_id,
|
||
computer_name: row.computer_name,
|
||
local_ip: row.local_ip,
|
||
mac: row.mac,
|
||
os_version: row.os_version,
|
||
version: row.version,
|
||
is_online: row.is_online,
|
||
last_connect_time: row.last_connect_time,
|
||
risk_level: row.risk_level || 'safe',
|
||
virus_count: row.virus_count || 0,
|
||
leak_count: row.leak_count || 0,
|
||
is_isolated: false,
|
||
}
|
||
} else {
|
||
detailData.value = data
|
||
}
|
||
} catch {
|
||
// 回退到基本数据
|
||
detailData.value = {
|
||
client_id: row.client_id,
|
||
computer_name: row.computer_name,
|
||
local_ip: row.local_ip,
|
||
mac: row.mac,
|
||
os_version: row.os_version,
|
||
version: row.version,
|
||
is_online: row.is_online,
|
||
last_connect_time: row.last_connect_time,
|
||
risk_level: row.risk_level || 'safe',
|
||
virus_count: row.virus_count || 0,
|
||
leak_count: row.leak_count || 0,
|
||
is_isolated: false,
|
||
}
|
||
} finally {
|
||
detailLoading.value = false
|
||
}
|
||
}
|
||
|
||
// ==========================================================================
|
||
// 辅助方法
|
||
// ==========================================================================
|
||
|
||
/** 截断操作系统名称 */
|
||
function truncateOs(os: string): string {
|
||
if (!os) return '—'
|
||
if (os.length > 16) return os.substring(0, 15) + '...'
|
||
return os
|
||
}
|
||
|
||
/** 格式化Unix时间戳(简短) */
|
||
function formatTimestamp(ts?: number): string {
|
||
if (!ts) return '—'
|
||
try {
|
||
const d = new Date(ts * 1000) // Unix时间戳转毫秒
|
||
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}小时前`
|
||
return `${Math.floor(diffHour / 24)}天前`
|
||
} catch {
|
||
return '—'
|
||
}
|
||
}
|
||
|
||
/** 格式化Unix时间戳(完整) */
|
||
function formatTimestampFull(ts?: number): string {
|
||
if (!ts) return '—'
|
||
try {
|
||
const d = new Date(ts * 1000)
|
||
return d.toLocaleString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})
|
||
} catch {
|
||
return '—'
|
||
}
|
||
}
|
||
|
||
/** 格式化时间(简短) — 兼容ISO字符串格式 */
|
||
function formatTime(timeStr: string): string {
|
||
if (!timeStr) return '—'
|
||
try {
|
||
const d = new Date(timeStr)
|
||
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}小时前`
|
||
return `${Math.floor(diffHour / 24)}天前`
|
||
} catch {
|
||
return '—'
|
||
}
|
||
}
|
||
|
||
/** 格式化时间(完整) */
|
||
function formatTimeFull(timeStr: string): string {
|
||
if (!timeStr) return '—'
|
||
try {
|
||
const d = new Date(timeStr)
|
||
return d.toLocaleString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})
|
||
} catch {
|
||
return '—'
|
||
}
|
||
}
|
||
|
||
/** 风险等级标签类型 */
|
||
function getRiskTagType(level: string): string {
|
||
switch (level) {
|
||
case 'high': return 'danger'
|
||
case 'medium': return 'warning'
|
||
case 'low': return 'info'
|
||
case 'safe': return 'success'
|
||
default: return 'info'
|
||
}
|
||
}
|
||
|
||
/** 风险等级文本 */
|
||
function getRiskText(level: string): string {
|
||
switch (level) {
|
||
case 'high': return '高危'
|
||
case 'medium': return '中危'
|
||
case 'low': return '低危'
|
||
case 'safe': return '安全'
|
||
default: return level
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 连接警告 */
|
||
.connection-warning {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px 16px;
|
||
background: rgba(234, 179, 8, 0.1);
|
||
border: 1px solid rgba(234, 179, 8, 0.3);
|
||
border-radius: var(--radius);
|
||
margin-bottom: 16px;
|
||
color: var(--text-secondary);
|
||
font-size: 13px;
|
||
}
|
||
|
||
/* 标签页组 */
|
||
.tab-group {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
/* 搜索栏 */
|
||
.search-bar {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
/* 分页 */
|
||
.pagination-wrapper {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
/* 终端名称 */
|
||
.terminal-name {
|
||
color: var(--accent);
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
}
|
||
.terminal-name:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
/* 操作系统文本 */
|
||
.os-text {
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
/* 时间文本 */
|
||
.time-text {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
/* 危险文本 */
|
||
.text-danger {
|
||
color: var(--danger);
|
||
font-weight: 600;
|
||
}
|
||
.text-muted {
|
||
color: var(--text-muted);
|
||
font-size: 12px;
|
||
}
|
||
|
||
/* ========== 终端详情抽屉样式 ========== */
|
||
|
||
/* 详情内容 */
|
||
.detail-content {
|
||
padding: 0 4px;
|
||
}
|
||
|
||
/* 详情区块 */
|
||
.detail-section {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.detail-section-title {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
margin-bottom: 12px;
|
||
padding-bottom: 6px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
/* 详情网格 */
|
||
.detail-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 10px;
|
||
}
|
||
|
||
.detail-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.detail-label {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.detail-value {
|
||
font-size: 13px;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.mono {
|
||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||
font-size: 12px;
|
||
}
|
||
|
||
/* 安全状态网格 */
|
||
.security-status-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr 1fr;
|
||
gap: 10px;
|
||
}
|
||
|
||
.security-item {
|
||
text-align: center;
|
||
padding: 12px 8px;
|
||
border-radius: var(--radius);
|
||
border: 1px solid var(--border);
|
||
}
|
||
.security-item.safe {
|
||
background: rgba(34, 197, 94, 0.08);
|
||
border-color: rgba(34, 197, 94, 0.2);
|
||
}
|
||
.security-item.danger {
|
||
background: rgba(239, 68, 68, 0.08);
|
||
border-color: rgba(239, 68, 68, 0.2);
|
||
}
|
||
.security-item.warning {
|
||
background: rgba(234, 179, 8, 0.08);
|
||
border-color: rgba(234, 179, 8, 0.2);
|
||
}
|
||
|
||
.security-value {
|
||
font-size: 20px;
|
||
font-weight: 700;
|
||
color: var(--text-primary);
|
||
}
|
||
.security-item.danger .security-value {
|
||
color: var(--danger);
|
||
}
|
||
.security-item.warning .security-value {
|
||
color: var(--warning);
|
||
}
|
||
|
||
.security-label {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
margin-top: 4px;
|
||
}
|
||
|
||
/* 操作按钮 */
|
||
.action-buttons {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.action-hint {
|
||
margin-top: 8px;
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
font-style: italic;
|
||
}
|
||
</style>
|
||
|
||
<style>
|
||
/* 终端表格行悬停 */
|
||
.terminal-table-row:hover td {
|
||
background-color: var(--bg-tertiary) !important;
|
||
cursor: pointer;
|
||
}
|
||
</style>
|