A:分析页面调整

This commit is contained in:
jimmy committed 2025-08-09 14:34:33 +08:00
1 parent 46fea9a788
commit 588a518d90
11 files changed
+1563 -197

No files matched your search

+1 -2
View File
@@ -1,6 +1,6 @@
# 项目介绍
这是一个网络管理项目,它用的是Vue进行展示的,集成防火墙API,提供HTTP和WebSocket接口来实现数据的传递和接收。使用Vue作为前端并通过WebView2套壳实现主页面渲染
我使用了 TS 和Vue,UI样式使用的 Naive UI
我所有的环境并非全部在默认位置安装,比如部分环境如 Git 之类的在 D:\Env 下
@@ -14,4 +14,3 @@
Binary file not shown.
+1
View File
@@ -19,6 +19,7 @@ declare module 'vue' {
NHighlight: typeof import('naive-ui')['NHighlight']
NInput: typeof import('naive-ui')['NInput']
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NSelect: typeof import('naive-ui')['NSelect']
NTooltip: typeof import('naive-ui')['NTooltip']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
+1 -1
View File
@@ -11,7 +11,7 @@ import PathUtils from '@/utils/pathUtils'
import NotFound from '@/views/NotFoundView.vue'
import AboutView from '@/views/AboutView.vue'
import FirewallView from '@/views/FirewallView.vue'
import AnalyseView from '@/views/AnalyseView.vue'
import AnalyseView from '@/views/AnalyseView/AnalyseView.vue'
import MonitorView from '@/views/MonitorView/index.vue'
// 导入所有 .vue 文件
@@ -1,20 +1,27 @@
<template>
<div class="analyse-view">
<div class="analyse-container">
<!-- 顶部概览 -->
<!-- 顶部时间选择栏 -->
<div class="analyse-header">
<div class="header-info">
<h2 class="view-title">流量分析</h2>
<p class="view-subtitle">深入分析网络流量模式和安全威胁</p>
<p class="view-subtitle">深入分析网络流量模式和应用程序使用情况</p>
</div>
<div class="header-actions">
<n-date-picker
v-model:value="dateRange"
type="daterange"
clearable
size="medium"
/>
<n-button type="primary" size="medium">
<div class="header-controls">
<div class="time-selector">
<n-button-group>
<n-button
v-for="range in timeRanges"
:key="range.type"
:type="selectedTimeRange === range.type ? 'primary' : 'default'"
:disabled="!range.available"
@click="selectedTimeRange = range.type"
>
{{ range.name }}
</n-button>
</n-button-group>
</div>
<n-button type="primary" size="medium" @click="refreshData">
<template #icon>
<n-icon :component="RefreshOutline" />
</template>
@@ -81,37 +88,19 @@
<div class="chart-header">
<h3 class="chart-title">流量趋势</h3>
<div class="chart-controls">
<n-button-group size="tiny">
<n-button>1小时</n-button>
<n-button type="primary">24小时</n-button>
<n-button>7天</n-button>
<n-button>30天</n-button>
</n-button-group>
<n-select
v-model:value="selectedInterface"
:options="interfaceOptions"
size="small"
style="min-width: 120px;"
/>
</div>
</div>
<div class="chart-body">
<div class="chart-placeholder">
<div class="chart-line"></div>
<svg viewBox="0 0 400 200" class="chart-svg">
<path
d="M 0 180 Q 100 100 200 120 T 400 80"
fill="none"
stroke="var(--accent-primary)"
stroke-width="2"
/>
<path
d="M 0 180 Q 100 100 200 120 T 400 80 L 400 200 L 0 200 Z"
fill="url(#gradient)"
opacity="0.2"
/>
<defs>
<linearGradient id="gradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:var(--accent-primary);stop-opacity:0.8" />
<stop offset="100%" style="stop-color:var(--accent-primary);stop-opacity:0" />
</linearGradient>
</defs>
</svg>
</div>
<TrafficTrendChart
:data="trafficTrendData"
:interface-id="selectedInterface"
/>
</div>
</div>
@@ -191,65 +180,80 @@
</div>
</div>
<!-- Top 应用 -->
<!-- Top应用流量图表 -->
<div class="chart-card">
<div class="chart-header">
<h3 class="chart-title">Top 应用流量</h3>
<h3 class="chart-title">Top应用流量</h3>
</div>
<div class="chart-body">
<div class="app-list">
<div v-for="(app, index) in topApps" :key="index" class="app-item">
<div class="app-info">
<span class="app-rank">{{ index + 1 }}</span>
<span class="app-name">{{ app.name }}</span>
</div>
<div class="app-traffic">
<div class="traffic-bar">
<div
class="traffic-fill"
:style="{ width: app.percentage + '%' }"
></div>
</div>
<span class="traffic-text">{{ app.traffic }}</span>
</div>
</div>
</div>
<TopAppsChart
:data="topAppsData"
:time-range="selectedTimeRange"
/>
</div>
</div>
</div>
<!-- 详细表格 -->
<div class="detail-panel">
<div class="panel-header">
<h3 class="panel-title">异常流量检测</h3>
<div class="panel-controls">
<n-tag type="error" size="small">
{{ anomalies.length }} 个异常
</n-tag>
<!-- 底部软件流量分析区域 -->
<div class="bottom-analysis-area">
<div class="traffic-ranking-panel">
<div class="panel-header">
<h3 class="panel-title">软件流量TOP榜</h3>
<div class="panel-controls">
<n-button-group size="small">
<n-button
v-for="range in rankingTimeRanges"
:key="range"
:type="selectedRankingRange === range ? 'primary' : 'default'"
@click="selectedRankingRange = range"
>
{{ range }}
</n-button>
</n-button-group>
</div>
</div>
<div class="ranking-content">
<SoftwareRankingList
:data="softwareRankingData"
:time-range="selectedRankingRange"
:selected-software="selectedSoftware"
@select-software="onSelectSoftware"
/>
</div>
</div>
<div class="anomaly-list">
<div v-for="anomaly in anomalies" :key="anomaly.id" class="anomaly-item">
<div class="anomaly-severity" :class="'severity-' + anomaly.severity">
<n-icon
:component="anomaly.severity === 'high' ? AlertOutline :
anomaly.severity === 'medium' ? WarningOutline :
InformationCircleOutline"
size="16"
/>
</div>
<div class="anomaly-info">
<div class="anomaly-title">{{ anomaly.title }}</div>
<div class="anomaly-details">
<span>{{ anomaly.time }}</span>
<span>•</span>
<span>{{ anomaly.source }}</span>
<span>→</span>
<span>{{ anomaly.destination }}</span>
<div class="software-detail-panel">
<div class="panel-header">
<h3 class="panel-title">
{{ selectedSoftware?.displayName || '选择软件查看详情' }}
</h3>
</div>
<div class="detail-content">
<div v-if="selectedSoftware" class="software-details">
<!-- 软件基本信息 -->
<div class="detail-section">
<h4 class="section-title">软件信息</h4>
<SoftwareInfoCard :software-info="selectedSoftwareInfo" />
</div>
<!-- 网络连接关系图 -->
<div class="detail-section">
<h4 class="section-title">网络连接</h4>
<NetworkRelationChart
:data="networkRelationData"
:software="selectedSoftware"
/>
</div>
<!-- 端口统计 -->
<div class="detail-section">
<h4 class="section-title">端口统计</h4>
<PortStatsTable :data="portStatsData" />
</div>
</div>
<div class="anomaly-action">
<n-button size="tiny" quaternary>查看详情</n-button>
<div v-else class="empty-state">
<n-icon :component="ServerOutline" size="48" />
<p>请从左侧列表选择软件查看详情</p>
</div>
</div>
</div>
@@ -259,57 +263,148 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { NButton, NButtonGroup, NIcon, NDatePicker, NTag } from 'naive-ui'
import { ref, computed, onMounted, watch } from 'vue'
import { NButton, NButtonGroup, NIcon, NSelect } from 'naive-ui'
import {
RefreshOutline,
TrendingUpOutline,
WarningOutline,
SpeedometerOutline,
AlertCircleOutline,
AlertOutline,
InformationCircleOutline,
ServerOutline,
} from '@vicons/ionicons5'
// 日期范围
const dateRange = ref<[number, number] | null>(null)
// 导入组件
import TrafficTrendChart from './components/TrafficTrendChart.vue'
import TopAppsChart from './components/TopAppsChart.vue'
import SoftwareRankingList from './components/SoftwareRankingList.vue'
import SoftwareInfoCard from './components/SoftwareInfoCard.vue'
import NetworkRelationChart from './components/NetworkRelationChart.vue'
import PortStatsTable from './components/PortStatsTable.vue'
// Top 应用数据
const topApps = ref([
{ name: 'Chrome', traffic: '523 GB', percentage: 85 },
{ name: 'Microsoft Teams', traffic: '387 GB', percentage: 63 },
{ name: 'Spotify', traffic: '256 GB', percentage: 42 },
{ name: 'Visual Studio Code', traffic: '198 GB', percentage: 32 },
{ name: 'Slack', traffic: '142 GB', percentage: 23 },
// 时间范围选项
const timeRanges = ref([
{ type: '1hour', name: '1小时', available: true },
{ type: '1day', name: '24小时', available: true },
{ type: '7days', name: '7天', available: true },
{ type: '30days', name: '30天', available: false },
])
// 异常数据
const anomalies = ref([
{
id: 1,
severity: 'high',
title: '异常大量数据传输',
time: '10:23:45',
source: '192.168.1.105',
destination: '104.21.58.93',
},
{
id: 2,
severity: 'medium',
title: '可疑端口扫描活动',
time: '10:15:32',
source: '10.0.0.158',
destination: '192.168.1.1',
},
{
id: 3,
severity: 'low',
title: '非标准协议使用',
time: '09:58:21',
source: '192.168.1.201',
destination: '172.16.0.1',
},
const selectedTimeRange = ref('1day')
// 网络接口选项
const interfaceOptions = ref([
{ label: '全部网卡', value: 'all' },
{ label: '以太网', value: 'eth0' },
{ label: 'WiFi', value: 'wifi0' },
])
const selectedInterface = ref('all')
// 软件排行时间范围
const rankingTimeRanges = ref(['1小时', '1天', '7天', '30天'])
const selectedRankingRange = ref('1天')
// 选中的软件
const selectedSoftware = ref<any>(null)
// Mock数据
const trafficTrendData = ref([
{ timestamp: Date.now() - 3600000, uploadSpeed: 1024000, downloadSpeed: 5120000 },
{ timestamp: Date.now() - 2400000, uploadSpeed: 2048000, downloadSpeed: 8192000 },
{ timestamp: Date.now() - 1200000, uploadSpeed: 1536000, downloadSpeed: 6144000 },
{ timestamp: Date.now(), uploadSpeed: 3072000, downloadSpeed: 12288000 },
])
const topAppsData = ref([
{ processName: 'chrome.exe', displayName: 'Google Chrome', totalBytes: 1073741824, percentage: 45.2 },
{ processName: 'teams.exe', displayName: 'Microsoft Teams', totalBytes: 536870912, percentage: 22.5 },
{ processName: 'spotify.exe', displayName: 'Spotify', totalBytes: 268435456, percentage: 11.3 },
{ processName: 'code.exe', displayName: 'VS Code', totalBytes: 134217728, percentage: 5.6 },
{ processName: 'slack.exe', displayName: 'Slack', totalBytes: 67108864, percentage: 2.8 },
])
const softwareRankingData = ref([
{
rank: 1,
processName: 'chrome.exe',
displayName: 'Google Chrome',
totalBytes: 1073741824,
percentage: 45.2,
connectionCount: 23
},
{
rank: 2,
processName: 'teams.exe',
displayName: 'Microsoft Teams',
totalBytes: 536870912,
percentage: 22.5,
connectionCount: 12
},
// ... 更多数据
])
// 选中软件的详细信息
const selectedSoftwareInfo = computed(() => {
if (!selectedSoftware.value) return null
return {
processName: 'chrome.exe',
displayName: 'Google Chrome',
version: '119.0.6045.160',
company: 'Google LLC',
processPath: 'C:\\Program Files\\Google\\Chrome\\chrome.exe',
fileSize: 2467840,
}
})
const networkRelationData = computed(() => {
if (!selectedSoftware.value) return { nodes: [], links: [] }
return {
nodes: [
{ id: 'app_chrome', name: 'Chrome', type: 'application', size: 45.2, category: 0 },
{ id: 'port_443', name: '443/TCP', type: 'port', size: 30.5, category: 1 },
{ id: 'host_google', name: 'google.com', type: 'remote_host', size: 25.8, category: 2 },
],
links: [
{ source: 'app_chrome', target: 'port_443', value: 30.5, label: '30.5MB' },
{ source: 'port_443', target: 'host_google', value: 25.8, label: '25.8MB' },
]
}
})
const portStatsData = computed(() => {
if (!selectedSoftware.value) return []
return [
{ port: 443, protocol: 'TCP', connectionCount: 15, totalBytes: 52428800, remoteHosts: ['142.251.42.227', '172.217.164.46'] },
{ port: 80, protocol: 'TCP', connectionCount: 5, totalBytes: 10485760, remoteHosts: ['93.184.216.34'] },
]
})
// 事件处理
const onSelectSoftware = (software: any) => {
selectedSoftware.value = software
}
const refreshData = () => {
// 刷新数据的逻辑
console.log('Refreshing data...')
}
// 监听时间范围变化
watch(selectedTimeRange, () => {
// 重新加载数据
})
watch(selectedInterface, () => {
// 重新加载趋势数据
})
onMounted(() => {
// 初始化时选择第一个软件
if (softwareRankingData.value.length > 0) {
selectedSoftware.value = softwareRankingData.value[0]
}
})
</script>
<style scoped>
@@ -331,6 +426,10 @@ const anomalies = ref([
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding: 16px 20px;
background: var(--bg-card);
border-radius: 12px;
border: 1px solid var(--border-primary);
}
.header-info {
@@ -350,9 +449,14 @@ const anomalies = ref([
margin: 4px 0 0 0;
}
.header-actions {
.header-controls {
display: flex;
gap: 16px;
align-items: center;
}
.time-selector {
display: flex;
gap: 12px;
align-items: center;
}
@@ -605,98 +709,98 @@ const anomalies = ref([
text-align: right;
}
/* 详细面板 */
.detail-panel {
/* 底部分析区域 */
.bottom-analysis-area {
display: grid;
grid-template-columns: 400px 1fr;
gap: 20px;
margin-top: 24px;
}
.traffic-ranking-panel,
.software-detail-panel {
background: var(--bg-card);
backdrop-filter: var(--backdrop-blur);
border: 1px solid var(--border-primary);
border-radius: 12px;
overflow: hidden;
height: 600px;
display: flex;
flex-direction: column;
}
.panel-header {
padding: 20px 24px;
padding: 16px 20px;
border-bottom: 1px solid var(--border-primary);
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.panel-title {
font-size: 18px;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
/* 异常列表 */
.anomaly-list {
padding: 12px;
}
.anomaly-item {
.panel-controls {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 16px;
background: var(--bg-tertiary);
border-radius: 8px;
margin-bottom: 8px;
transition: var(--transition);
}
.anomaly-item:hover {
background: var(--bg-hover);
}
.anomaly-item:last-child {
margin-bottom: 0;
}
.anomaly-severity {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.severity-high {
background: rgba(239, 68, 68, 0.1);
color: var(--accent-error);
}
.severity-medium {
background: rgba(245, 158, 11, 0.1);
color: var(--accent-warning);
}
.severity-low {
background: rgba(59, 130, 246, 0.1);
color: var(--accent-primary);
}
.anomaly-info {
flex: 1;
}
.anomaly-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 4px;
}
.anomaly-details {
font-size: 12px;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 8px;
}
.ranking-content,
.detail-content {
flex: 1;
overflow: hidden;
}
.software-details {
padding: 20px;
display: flex;
flex-direction: column;
gap: 24px;
height: 100%;
overflow-y: auto;
}
.detail-section {
flex-shrink: 0;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
margin: 0 0 12px 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
gap: 16px;
}
.empty-state p {
margin: 0;
font-size: 14px;
}
/* 响应式 */
@media (max-width: 1400px) {
.bottom-analysis-area {
grid-template-columns: 350px 1fr;
}
}
@media (max-width: 1200px) {
.charts-grid {
grid-template-columns: 1fr 1fr;
@@ -705,6 +809,16 @@ const anomalies = ref([
.chart-card.large {
grid-column: span 2;
}
.bottom-analysis-area {
grid-template-columns: 1fr;
gap: 16px;
}
.traffic-ranking-panel,
.software-detail-panel {
height: 500px;
}
}
@media (max-width: 768px) {
@@ -714,9 +828,10 @@ const anomalies = ref([
gap: 16px;
}
.header-actions {
.header-controls {
width: 100%;
flex-wrap: wrap;
gap: 12px;
}
.metrics-grid {
@@ -0,0 +1,186 @@
<template>
<div class="network-relation-chart">
<v-chart
ref="chartRef"
class="chart"
:option="chartOption"
:autoresize="true"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { GraphChart } from 'echarts/charts'
import {
TitleComponent,
TooltipComponent,
LegendComponent
} from 'echarts/components'
import type { EChartsOption } from 'echarts'
// 注册ECharts组件
use([
CanvasRenderer,
GraphChart,
TitleComponent,
TooltipComponent,
LegendComponent
])
// 接口定义
interface NetworkNode {
id: string
name: string
type: 'application' | 'port' | 'remote_host'
size: number
category: number
}
interface NetworkLink {
source: string
target: string
value: number
label: string
}
interface NetworkRelationData {
nodes: NetworkNode[]
links: NetworkLink[]
}
// Props定义
const props = defineProps<{
data: NetworkRelationData
software?: any
}>()
// 生成图表配置
const chartOption = computed<EChartsOption>(() => {
const categories = [
{ name: '应用程序', itemStyle: { color: '#3b82f6' } },
{ name: '端口', itemStyle: { color: '#10b981' } },
{ name: '远程主机', itemStyle: { color: '#f59e0b' } }
]
return {
tooltip: {
trigger: 'item',
formatter: (params: any) => {
if (params.dataType === 'node') {
const node = params.data
return `
<div style="margin-bottom: 4px; font-weight: bold;">${node.name}</div>
<div>类型: ${getCategoryName(node.category)}</div>
<div>流量: ${node.size.toFixed(1)} MB</div>
`
} else if (params.dataType === 'edge') {
const edge = params.data
return `
<div style="margin-bottom: 4px; font-weight: bold;">连接</div>
<div>${edge.source} → ${edge.target}</div>
<div>流量: ${edge.label}</div>
`
}
return ''
},
backgroundColor: 'rgba(0, 0, 0, 0.8)',
borderColor: 'rgba(255, 255, 255, 0.1)',
textStyle: {
color: '#ffffff',
fontSize: 12
}
},
legend: {
data: categories.map(c => c.name),
orient: 'horizontal',
left: 'center',
top: 10,
textStyle: {
color: 'var(--text-secondary)',
fontSize: 11
}
},
series: [
{
name: '网络连接',
type: 'graph',
layout: 'circular',
circular: {
rotateLabel: true
},
data: props.data.nodes.map(node => ({
...node,
symbolSize: Math.max(20, Math.min(60, node.size * 2)),
itemStyle: {
color: categories[node.category]?.itemStyle.color || '#6b7280'
},
label: {
show: true,
position: 'right',
formatter: '{b}',
fontSize: 10,
color: 'var(--text-secondary)'
}
})),
links: props.data.links.map(link => ({
...link,
lineStyle: {
color: '#6b7280',
width: Math.max(1, Math.min(4, link.value / 10)),
opacity: 0.6
},
label: {
show: true,
fontSize: 9,
color: 'var(--text-muted)'
}
})),
categories: categories,
roam: true,
focusNodeAdjacency: true,
itemStyle: {
borderColor: '#fff',
borderWidth: 1,
shadowBlur: 10,
shadowColor: 'rgba(0, 0, 0, 0.3)'
},
lineStyle: {
color: 'source',
curveness: 0.2
},
emphasis: {
focus: 'adjacency',
lineStyle: {
width: 4
}
},
animationDuration: 1500,
animationEasingUpdate: 'quinticInOut'
}
]
}
})
// 获取分类名称
const getCategoryName = (category: number): string => {
const names = ['应用程序', '端口', '远程主机']
return names[category] || '未知'
}
</script>
<style scoped>
.network-relation-chart {
width: 100%;
height: 200px;
min-height: 200px;
}
.chart {
width: 100%;
height: 100%;
}
</style>
@@ -0,0 +1,235 @@
<template>
<div class="port-stats-table">
<div class="table-container">
<table class="stats-table">
<thead>
<tr>
<th>端口</th>
<th>协议</th>
<th>连接数</th>
<th>流量</th>
<th>远程主机</th>
</tr>
</thead>
<tbody>
<tr v-for="(stat, index) in data" :key="`${stat.port}-${stat.protocol}`">
<td>
<span class="port-number">{{ stat.port }}</span>
</td>
<td>
<span class="protocol-badge" :class="stat.protocol.toLowerCase()">
{{ stat.protocol }}
</span>
</td>
<td>
<span class="connection-count">{{ stat.connectionCount }}</span>
</td>
<td>
<span class="traffic-size">{{ formatBytes(stat.totalBytes) }}</span>
</td>
<td>
<div class="remote-hosts">
<span
v-for="(host, hostIndex) in stat.remoteHosts.slice(0, 3)"
:key="hostIndex"
class="host-badge"
:title="host"
>
{{ truncateHost(host) }}
</span>
<span
v-if="stat.remoteHosts.length > 3"
class="host-more"
:title="stat.remoteHosts.slice(3).join(', ')"
>
+{{ stat.remoteHosts.length - 3 }}
</span>
</div>
</td>
</tr>
</tbody>
</table>
<!-- 空状态 -->
<div v-if="data.length === 0" class="empty-state">
<n-icon :component="GlobeOutline" size="32" />
<p>暂无端口统计数据</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { NIcon } from 'naive-ui'
import { GlobeOutline } from '@vicons/ionicons5'
// 接口定义
interface PortStat {
port: number
protocol: string
connectionCount: number
totalBytes: number
remoteHosts: string[]
}
// Props定义
const props = defineProps<{
data: PortStat[]
}>()
// 格式化字节数
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
}
// 截断主机名
const truncateHost = (host: string): string => {
if (host.length <= 16) return host
return host.substring(0, 16) + '...'
}
</script>
<style scoped>
.port-stats-table {
height: 100%;
overflow: hidden;
}
.table-container {
height: 100%;
overflow-y: auto;
}
.stats-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.stats-table th {
background: var(--bg-tertiary);
color: var(--text-secondary);
font-weight: 600;
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid var(--border-secondary);
position: sticky;
top: 0;
z-index: 1;
}
.stats-table td {
padding: 8px 12px;
border-bottom: 1px solid var(--border-tertiary);
vertical-align: middle;
}
.stats-table tr:hover {
background: var(--bg-hover);
}
.port-number {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-weight: 600;
color: var(--accent-primary);
}
.protocol-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
}
.protocol-badge.tcp {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.protocol-badge.udp {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.connection-count {
font-weight: 600;
color: var(--text-primary);
}
.traffic-size {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-weight: 500;
color: var(--text-primary);
}
.remote-hosts {
display: flex;
flex-wrap: wrap;
gap: 4px;
max-width: 200px;
}
.host-badge {
display: inline-block;
padding: 2px 6px;
background: var(--bg-tertiary);
border-radius: 3px;
font-size: 10px;
color: var(--text-secondary);
white-space: nowrap;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.host-more {
display: inline-block;
padding: 2px 6px;
background: var(--accent-primary);
color: white;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
cursor: help;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 120px;
color: var(--text-muted);
gap: 12px;
}
.empty-state p {
margin: 0;
font-size: 13px;
}
/* 滚动条样式 */
.table-container::-webkit-scrollbar {
width: 6px;
}
.table-container::-webkit-scrollbar-track {
background: var(--bg-tertiary);
border-radius: 3px;
}
.table-container::-webkit-scrollbar-thumb {
background: var(--border-secondary);
border-radius: 3px;
}
.table-container::-webkit-scrollbar-thumb:hover {
background: var(--border-hover);
}
</style>
@@ -0,0 +1,138 @@
<template>
<div class="software-info-card">
<div class="info-grid">
<div class="info-item">
<label>进程名称</label>
<span class="process-name">{{ softwareInfo?.processName || '-' }}</span>
</div>
<div class="info-item">
<label>显示名称</label>
<span>{{ softwareInfo?.displayName || '-' }}</span>
</div>
<div class="info-item">
<label>版本</label>
<span>{{ softwareInfo?.version || '-' }}</span>
</div>
<div class="info-item">
<label>公司</label>
<span>{{ softwareInfo?.company || '-' }}</span>
</div>
<div class="info-item full-width">
<label>路径</label>
<span class="file-path" :title="softwareInfo?.processPath">
{{ softwareInfo?.processPath || '-' }}
</span>
</div>
<div class="info-item">
<label>文件大小</label>
<span>{{ formatBytes(softwareInfo?.fileSize || 0) }}</span>
</div>
<div class="info-item">
<label>创建时间</label>
<span>{{ formatDate(softwareInfo?.createdTime) }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
// Props定义
interface SoftwareInfo {
processName: string
displayName: string
version?: string
company?: string
processPath?: string
fileSize?: number
createdTime?: string
modifiedTime?: string
}
const props = defineProps<{
softwareInfo: SoftwareInfo | null
}>()
// 格式化字节数
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
}
// 格式化日期
const formatDate = (dateString?: string): string => {
if (!dateString) return '-'
const date = new Date(dateString)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
</script>
<style scoped>
.software-info-card {
background: var(--bg-tertiary);
border-radius: 8px;
padding: 16px;
border: 1px solid var(--border-secondary);
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 16px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.info-item.full-width {
grid-column: span 2;
}
.info-item label {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.info-item span {
font-size: 13px;
color: var(--text-primary);
word-break: break-word;
}
.process-name {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-weight: 500;
color: var(--accent-primary);
}
.file-path {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 12px;
color: var(--text-secondary);
word-break: break-all;
line-height: 1.4;
}
</style>
@@ -0,0 +1,338 @@
<template>
<div class="software-ranking-list">
<div class="ranking-header">
<div class="search-box">
<n-input
v-model:value="searchQuery"
placeholder="搜索软件..."
size="small"
clearable
>
<template #prefix>
<n-icon :component="SearchOutline" />
</template>
</n-input>
</div>
</div>
<div class="ranking-list" ref="listContainer">
<div
v-for="(item, index) in filteredData"
:key="item.processName"
:class="['ranking-item', { 'selected': selectedSoftware?.processName === item.processName }]"
@click="selectSoftware(item)"
>
<div class="rank-badge" :class="getRankClass(item.rank)">
{{ item.rank }}
</div>
<div class="software-icon">
<div class="icon-placeholder">
{{ item.displayName.charAt(0).toUpperCase() }}
</div>
</div>
<div class="software-info">
<div class="software-name">{{ item.displayName }}</div>
<div class="software-details">
<span class="process-name">{{ item.processName }}</span>
<span class="connection-count">{{ item.connectionCount }} 个连接</span>
</div>
</div>
<div class="traffic-info">
<div class="traffic-size">{{ formatBytes(item.totalBytes) }}</div>
<div class="traffic-percentage">{{ item.percentage.toFixed(1) }}%</div>
<div class="traffic-bar">
<div
class="traffic-fill"
:style="{ width: Math.min(item.percentage, 100) + '%' }"
></div>
</div>
</div>
</div>
<!-- 加载更多指示器 -->
<div v-if="hasMore" class="load-more" @click="loadMore">
<n-button text size="small">
加载更多...
</n-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { NInput, NIcon, NButton } from 'naive-ui'
import { SearchOutline } from '@vicons/ionicons5'
// 接口定义
interface SoftwareRankingItem {
rank: number
processName: string
displayName: string
processPath?: string
totalBytes: number
uploadBytes?: number
downloadBytes?: number
percentage: number
connectionCount: number
}
// Props
const props = defineProps<{
data: SoftwareRankingItem[]
timeRange: string
selectedSoftware?: SoftwareRankingItem | null
}>()
// Emits
const emit = defineEmits<{
selectSoftware: [software: SoftwareRankingItem]
}>()
// 响应式数据
const searchQuery = ref('')
const listContainer = ref<HTMLElement>()
const displayCount = ref(20) // 初始显示20个
const hasMore = computed(() => displayCount.value < filteredData.value.length)
// 搜索过滤
const filteredData = computed(() => {
let filtered = props.data
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
filtered = filtered.filter(item =>
item.displayName.toLowerCase().includes(query) ||
item.processName.toLowerCase().includes(query)
)
}
return filtered.slice(0, displayCount.value)
})
// 选择软件
const selectSoftware = (software: SoftwareRankingItem) => {
emit('selectSoftware', software)
}
// 加载更多
const loadMore = () => {
displayCount.value += 20
}
// 获取排名样式类
const getRankClass = (rank: number): string => {
if (rank <= 3) return 'top-three'
if (rank <= 10) return 'top-ten'
return 'normal'
}
// 格式化字节数
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
}
// 重置显示数量当时间范围改变时
watch(() => props.timeRange, () => {
displayCount.value = 20
nextTick(() => {
if (listContainer.value) {
listContainer.value.scrollTop = 0
}
})
})
// 重置显示数量当搜索查询改变时
watch(searchQuery, () => {
displayCount.value = 20
})
</script>
<style scoped>
.software-ranking-list {
height: 100%;
display: flex;
flex-direction: column;
}
.ranking-header {
padding: 16px;
border-bottom: 1px solid var(--border-secondary);
flex-shrink: 0;
}
.search-box {
width: 100%;
}
.ranking-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.ranking-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 8px;
border: 1px solid transparent;
transition: all 0.2s ease;
cursor: pointer;
margin-bottom: 4px;
}
.ranking-item:hover {
background: var(--bg-hover);
border-color: var(--border-hover);
}
.ranking-item.selected {
background: var(--bg-selected);
border-color: var(--accent-primary);
}
.rank-badge {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
flex-shrink: 0;
}
.rank-badge.top-three {
background: linear-gradient(135deg, #ffd700, #ffed4e);
color: #92400e;
box-shadow: 0 0 10px rgba(255, 215, 0, 0.3);
}
.rank-badge.top-ten {
background: linear-gradient(135deg, #c0c0c0, #e5e7eb);
color: #374151;
}
.rank-badge.normal {
background: var(--bg-tertiary);
color: var(--text-muted);
}
.software-icon {
flex-shrink: 0;
}
.icon-placeholder {
width: 36px;
height: 36px;
border-radius: 8px;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 14px;
}
.software-info {
flex: 1;
min-width: 0;
}
.software-name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.software-details {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 11px;
color: var(--text-muted);
}
.process-name {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.traffic-info {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
min-width: 80px;
}
.traffic-size {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.traffic-percentage {
font-size: 11px;
color: var(--text-muted);
}
.traffic-bar {
width: 60px;
height: 4px;
background: var(--bg-tertiary);
border-radius: 2px;
overflow: hidden;
}
.traffic-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
border-radius: 2px;
transition: width 0.3s ease;
}
.load-more {
display: flex;
justify-content: center;
padding: 16px;
border-top: 1px solid var(--border-secondary);
margin-top: 8px;
}
/* 滚动条样式 */
.ranking-list::-webkit-scrollbar {
width: 6px;
}
.ranking-list::-webkit-scrollbar-track {
background: var(--bg-tertiary);
border-radius: 3px;
}
.ranking-list::-webkit-scrollbar-thumb {
background: var(--border-secondary);
border-radius: 3px;
}
.ranking-list::-webkit-scrollbar-thumb:hover {
background: var(--border-hover);
}
</style>
@@ -0,0 +1,144 @@
<template>
<div class="top-apps-chart">
<v-chart
ref="chartRef"
class="chart"
:option="chartOption"
:autoresize="true"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { PieChart } from 'echarts/charts'
import {
TitleComponent,
TooltipComponent,
LegendComponent
} from 'echarts/components'
import type { EChartsOption } from 'echarts'
// 注册ECharts组件
use([
CanvasRenderer,
PieChart,
TitleComponent,
TooltipComponent,
LegendComponent
])
// Props定义
interface TopAppData {
processName: string
displayName: string
totalBytes: number
percentage: number
}
const props = defineProps<{
data: TopAppData[]
timeRange: string
}>()
// 生成玫瑰图配置
const chartOption = computed<EChartsOption>(() => {
const topData = props.data.slice(0, 10) // 只显示前10个
return {
tooltip: {
trigger: 'item',
formatter: (params: any) => {
const data = params.data
return `
<div style="margin-bottom: 4px; font-weight: bold;">${data.name}</div>
<div>流量占比: ${data.value}%</div>
<div>流量大小: ${formatBytes(data.totalBytes)}</div>
`
},
backgroundColor: 'rgba(0, 0, 0, 0.8)',
borderColor: 'rgba(255, 255, 255, 0.1)',
textStyle: {
color: '#ffffff',
fontSize: 12
}
},
series: [
{
name: 'Top应用流量',
type: 'pie',
radius: ['30%', '70%'],
center: ['50%', '50%'],
roseType: 'area', // 玫瑰图
itemStyle: {
borderRadius: 2,
borderColor: '#fff',
borderWidth: 1
},
label: {
show: true,
position: 'outside',
formatter: '{b}\n{d}%',
fontSize: 10,
color: 'var(--text-secondary)'
},
labelLine: {
show: true,
length: 15,
length2: 8,
lineStyle: {
color: 'var(--border-secondary)'
}
},
data: topData.map((item, index) => ({
value: item.percentage,
name: item.displayName,
totalBytes: item.totalBytes,
itemStyle: {
color: getColorByIndex(index)
}
})),
animationType: 'scale',
animationEasing: 'elasticOut',
animationDelayUpdate: (idx: number) => idx * 50
}
]
}
})
// 格式化字节数
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
}
// 根据索引获取颜色
const getColorByIndex = (index: number): string => {
const colors = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
'#06b6d4', '#84cc16', '#f97316', '#ec4899', '#6366f1'
]
return colors[index % colors.length]
}
</script>
<style scoped>
.top-apps-chart {
width: 100%;
height: 100%;
min-height: 240px;
}
.chart {
width: 100%;
height: 100%;
}
</style>
@@ -0,0 +1,210 @@
<template>
<div class="traffic-trend-chart">
<v-chart
ref="chartRef"
class="chart"
:option="chartOption"
:autoresize="true"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart } from 'echarts/charts'
import {
TitleComponent,
TooltipComponent,
GridComponent,
DataZoomComponent
} from 'echarts/components'
import type { EChartsOption } from 'echarts'
// 注册ECharts组件
use([
CanvasRenderer,
LineChart,
TitleComponent,
TooltipComponent,
GridComponent,
DataZoomComponent
])
// Props定义
interface TrafficDataPoint {
timestamp: number
uploadSpeed: number
downloadSpeed: number
}
const props = defineProps<{
data: TrafficDataPoint[]
interfaceId: string
}>()
// 生成图表配置
const chartOption = computed<EChartsOption>(() => {
const timeLabels = props.data.map(d => new Date(d.timestamp).toLocaleTimeString())
const uploadData = props.data.map(d => +(d.uploadSpeed / 1024 / 1024).toFixed(2))
const downloadData = props.data.map(d => +(d.downloadSpeed / 1024 / 1024).toFixed(2))
return {
grid: {
top: 20,
left: 40,
right: 20,
bottom: 60
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
},
formatter: (params: any) => {
let tooltip = `<div style="margin-bottom: 4px; font-weight: bold;">${params[0].axisValue}</div>`
params.forEach((param: any) => {
tooltip += `
<div style="margin-bottom: 2px;">
<span style="display:inline-block;margin-right:4px;border-radius:50%;width:10px;height:10px;background-color:${param.color};"></span>
${param.seriesName}: ${param.value} MB/s
</div>
`
})
return tooltip
},
backgroundColor: 'rgba(0, 0, 0, 0.8)',
borderColor: 'rgba(255, 255, 255, 0.1)',
textStyle: {
color: '#ffffff',
fontSize: 12
}
},
xAxis: {
type: 'category',
data: timeLabels,
axisLine: {
lineStyle: {
color: 'var(--border-secondary)'
}
},
axisLabel: {
color: 'var(--text-muted)',
fontSize: 11
}
},
yAxis: {
type: 'value',
name: 'MB/s',
nameTextStyle: {
color: 'var(--text-muted)'
},
axisLine: {
lineStyle: {
color: 'var(--border-secondary)'
}
},
axisLabel: {
color: 'var(--text-muted)',
fontSize: 11
},
splitLine: {
lineStyle: {
color: 'var(--border-tertiary)',
type: 'dashed'
}
}
},
dataZoom: [
{
type: 'inside'
},
{
type: 'slider',
height: 20,
bottom: 10,
textStyle: {
color: 'var(--text-muted)'
}
}
],
series: [
{
name: '上传',
type: 'line',
data: uploadData,
smooth: true,
lineStyle: {
color: '#10b981',
width: 2
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0,
color: 'rgba(16, 185, 129, 0.3)'
},
{
offset: 1,
color: 'rgba(16, 185, 129, 0.05)'
}
]
}
},
symbol: 'none'
},
{
name: '下载',
type: 'line',
data: downloadData,
smooth: true,
lineStyle: {
color: '#3b82f6',
width: 2
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0,
color: 'rgba(59, 130, 246, 0.3)'
},
{
offset: 1,
color: 'rgba(59, 130, 246, 0.05)'
}
]
}
},
symbol: 'none'
}
]
}
})
</script>
<style scoped>
.traffic-trend-chart {
width: 100%;
height: 100%;
min-height: 240px;
}
.chart {
width: 100%;
height: 100%;
}
</style>