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

1025 lines
34 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
=============================================================================
企微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>