Files
wecom_it_smart_desk/frontend-admin/src/views/TerminalSecurity.vue
T

1025 lines
34 KiB
Vue
Raw Normal View History

<!--
=============================================================================
企微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>