A:增加页面调用
This commit is contained in:
1 parent
e5cf954798
commit
1cd54ad507
7 files changed
+1821
-546
No files matched your search
@@ -73,3 +73,8 @@ if (bridge) {
|
||||
// todo: 2025/6/25 08:32 开发阶段直接使用固定节点
|
||||
webSocketStore.initialize('ws://127.0.0.1:8267')
|
||||
}
|
||||
// src\views\AnalyseView\AnalyseView.vue 中主要进行数据分析页面,而views\AnalyseView\components\SoftwareDetailModal.vue中可以获取到对应软件的AppId和时间信息,我们后端准备了
|
||||
// /apps/network-analysis 接口,通过Get调用,这是我的测试用http://localhost:8268/api/apps/network-analysis?appId=0e1b50411d632b2c,目前没问题的,返回的信息格式
|
||||
// 在C:\Users\charlatans\AppData\Roaming\JetBrains\WebStorm2025.1\scratches\scratch.json中有详细展示,你可以根据其定义内容以及你页面的展示方式,对于IP和端口信息我想使用
|
||||
// Echarts中的graph-circular-layout来进行呈现。并且我的数据也很详细,针对软件自身的信息,以及网络信息可以有很多展示的内容,并且这个信息我更多的是希望用图标,比如Echarts来进行展示。
|
||||
// 你帮我设计展示方式让其现代、优雅
|
||||
@@ -259,11 +259,11 @@ const interfaceOptions = ref([
|
||||
// 计算网卡选择框的最小宽度
|
||||
const interfaceSelectWidth = computed(() => {
|
||||
if (interfaceOptions.value.length === 0) return '120px'
|
||||
|
||||
|
||||
const maxLength = Math.max(
|
||||
...interfaceOptions.value.map(option => option.label.length)
|
||||
)
|
||||
|
||||
|
||||
// 根据字符长度计算宽度,中文字符按2倍宽度计算
|
||||
const estimatedWidth = maxLength * 8 + 60 // 基础60px + 字符宽度
|
||||
return Math.max(estimatedWidth, 120) + 'px' // 最小120px
|
||||
@@ -274,7 +274,7 @@ const getNetworkInterfaces = async () => {
|
||||
const params: Record<string, any> = {
|
||||
timeRange: selectedTimeRange.value
|
||||
}
|
||||
|
||||
|
||||
const res: ApiResponse<NetworkInterface[]> = await httpClient.get('/statistics/interfaces', params)
|
||||
if (res.success && res.data) {
|
||||
// 清空现有选项(保留全部网卡选项)
|
||||
@@ -329,7 +329,7 @@ const getTrafficTrends = async () => {
|
||||
downloadSpeed: point.downloadSpeed
|
||||
}))
|
||||
.sort((a, b) => a.timestamp - b.timestamp) // 按时间升序排列,最新时间在右侧
|
||||
|
||||
|
||||
// 限制显示的数据点数量,避免图表过于密集
|
||||
const maxPoints = 250 // 最多显示250个点
|
||||
if (processedData.length > maxPoints) {
|
||||
@@ -337,7 +337,7 @@ const getTrafficTrends = async () => {
|
||||
const step = Math.floor(processedData.length / maxPoints)
|
||||
processedData = processedData.filter((_, index) => index % step === 0)
|
||||
}
|
||||
|
||||
|
||||
trafficTrendData.value = processedData
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -423,9 +423,11 @@ const selectedAppId = ref<string | null>(null)
|
||||
|
||||
// 事件处理
|
||||
const showSoftwareDetail = (software: any) => {
|
||||
console.log(software);
|
||||
selectedAppId.value = software.appId
|
||||
selectedSoftware.value = software // 保留用于兼容性
|
||||
showDetailModal.value = true
|
||||
console.log(selectedAppId.value,selectedSoftware.value,showDetailModal.value);
|
||||
}
|
||||
|
||||
const refreshData = async () => {
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
<template>
|
||||
<div class="connections-table">
|
||||
<div class="table-container">
|
||||
<table class="stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>本地地址</th>
|
||||
<th>远程地址</th>
|
||||
<th>协议</th>
|
||||
<th>连接数</th>
|
||||
<th>上传</th>
|
||||
<th>下载</th>
|
||||
<th>总流量</th>
|
||||
<th>持续时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(conn, index) in data" :key="`${conn.localIP}-${conn.localPort}-${conn.remoteIP}-${conn.remotePort}`">
|
||||
<td>
|
||||
<div class="address-info">
|
||||
<span class="ip">{{ conn.localIP }}</span>
|
||||
<span class="port">:{{ conn.localPort }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="address-info">
|
||||
<span class="ip">{{ conn.remoteIP }}</span>
|
||||
<span class="port">:{{ conn.remotePort }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="protocol-badge" :class="conn.protocol.toLowerCase()">
|
||||
{{ conn.protocol }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="connection-count">{{ conn.connectionCount }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="traffic-size upload">{{ formatBytes(conn.totalUpload) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="traffic-size download">{{ formatBytes(conn.totalDownload) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="traffic-size total">{{ formatBytes(conn.totalTraffic) }}</span>
|
||||
<div class="traffic-bar">
|
||||
<div
|
||||
class="traffic-fill"
|
||||
:style="{ width: getTrafficPercentage(conn.totalTraffic) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="duration">{{ formatDuration(conn.firstSeen, conn.lastSeen) }}</span>
|
||||
</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 { computed } from 'vue'
|
||||
import { NIcon } from 'naive-ui'
|
||||
import { GlobeOutline } from '@vicons/ionicons5'
|
||||
|
||||
// 接口定义
|
||||
interface TopConnection {
|
||||
localIP: string
|
||||
localPort: number
|
||||
remoteIP: string
|
||||
remotePort: number
|
||||
protocol: 'TCP' | 'UDP'
|
||||
totalUpload: number
|
||||
totalDownload: number
|
||||
totalTraffic: number
|
||||
connectionCount: number
|
||||
firstSeen: string
|
||||
lastSeen: string
|
||||
}
|
||||
|
||||
// Props定义
|
||||
const props = defineProps<{
|
||||
data: TopConnection[]
|
||||
}>()
|
||||
|
||||
// 计算最大流量用于百分比显示
|
||||
const maxTraffic = computed(() => {
|
||||
if (props.data.length === 0) return 1
|
||||
return Math.max(...props.data.map(conn => conn.totalTraffic))
|
||||
})
|
||||
|
||||
// 格式化字节数
|
||||
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 getTrafficPercentage = (traffic: number): number => {
|
||||
return Math.max(5, (traffic / maxTraffic.value) * 100)
|
||||
}
|
||||
|
||||
// 格式化持续时间
|
||||
const formatDuration = (firstSeen: string, lastSeen: string): string => {
|
||||
const start = new Date(firstSeen).getTime()
|
||||
const end = new Date(lastSeen).getTime()
|
||||
const duration = Math.abs(end - start)
|
||||
|
||||
if (duration < 1000) {
|
||||
return '< 1秒'
|
||||
} else if (duration < 60000) {
|
||||
return `${Math.floor(duration / 1000)}秒`
|
||||
} else if (duration < 3600000) {
|
||||
return `${Math.floor(duration / 60000)}分钟`
|
||||
} else {
|
||||
return `${Math.floor(duration / 3600000)}小时`
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.connections-table {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.stats-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.stats-table th {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stats-table td {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border-tertiary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.stats-table tr:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.address-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.ip {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.port {
|
||||
color: var(--accent-primary);
|
||||
font-weight: 600;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.protocol-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.protocol-badge.tcp {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: #10b981;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.protocol-badge.udp {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.connection-count {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.traffic-size {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-weight: 500;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.traffic-size.upload {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.traffic-size.download {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.traffic-size.total {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.traffic-bar {
|
||||
width: 60px;
|
||||
height: 3px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 2px;
|
||||
margin-top: 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;
|
||||
}
|
||||
|
||||
.duration {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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,363 @@
|
||||
<template>
|
||||
<div class="network-analysis-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 TopConnection {
|
||||
localIP: string
|
||||
localPort: number
|
||||
remoteIP: string
|
||||
remotePort: number
|
||||
protocol: 'TCP' | 'UDP'
|
||||
totalUpload: number
|
||||
totalDownload: number
|
||||
totalTraffic: number
|
||||
connectionCount: number
|
||||
firstSeen: string
|
||||
lastSeen: string
|
||||
}
|
||||
|
||||
interface NetworkAnalysisData {
|
||||
appInfo: {
|
||||
appId: string
|
||||
name: string
|
||||
company?: string
|
||||
version?: string
|
||||
path?: string
|
||||
icon?: string
|
||||
hash?: string
|
||||
}
|
||||
summary: {
|
||||
timeRange: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
totalUpload: number
|
||||
totalDownload: number
|
||||
totalTraffic: number
|
||||
totalConnections: number
|
||||
uniqueRemoteIPs: number
|
||||
uniqueRemotePorts: number
|
||||
}
|
||||
topConnections: TopConnection[]
|
||||
protocolStats: Array<{
|
||||
protocol: string
|
||||
connectionCount: number
|
||||
totalTraffic: number
|
||||
percentage: number
|
||||
}>
|
||||
portAnalysis: Array<{
|
||||
port: number
|
||||
serviceName: string
|
||||
connectionCount: number
|
||||
totalTraffic: number
|
||||
protocols: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
// Props定义
|
||||
const props = defineProps<{
|
||||
data: NetworkAnalysisData | null
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
// 计算图表配置
|
||||
const chartOption = computed<EChartsOption>(() => {
|
||||
if (!props.data || props.loading) {
|
||||
return {
|
||||
title: {
|
||||
text: '暂无数据',
|
||||
left: 'center',
|
||||
top: 'center',
|
||||
textStyle: {
|
||||
color: 'var(--text-muted)',
|
||||
fontSize: 16
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { appInfo, topConnections } = props.data
|
||||
|
||||
// 创建节点和边
|
||||
const nodes: any[] = []
|
||||
const links: any[] = []
|
||||
const nodeSet = new Set<string>()
|
||||
|
||||
// 添加应用程序节点(中心节点)
|
||||
const appNodeId = `app_${appInfo.appId}`
|
||||
nodes.push({
|
||||
id: appNodeId,
|
||||
name: appInfo.name,
|
||||
type: 'application',
|
||||
category: 0,
|
||||
symbolSize: 80,
|
||||
itemStyle: {
|
||||
color: '#3b82f6'
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'inside',
|
||||
formatter: '{b}',
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
color: '#ffffff'
|
||||
}
|
||||
})
|
||||
nodeSet.add(appNodeId)
|
||||
|
||||
// 处理连接数据,创建IP节点和端口节点
|
||||
topConnections.slice(0, 20).forEach((conn, index) => {
|
||||
const isLocalhost = conn.remoteIP === '127.0.0.1'
|
||||
const isMulticast = conn.remoteIP.startsWith('224.') || conn.remoteIP.startsWith('239.')
|
||||
|
||||
// 创建远程IP节点
|
||||
const remoteIpNodeId = `ip_${conn.remoteIP}`
|
||||
if (!nodeSet.has(remoteIpNodeId)) {
|
||||
let ipCategory = 1 // 远程IP
|
||||
let ipColor = '#10b981' // 绿色
|
||||
|
||||
if (isLocalhost) {
|
||||
ipCategory = 3 // 本地回环
|
||||
ipColor = '#f59e0b' // 橙色
|
||||
} else if (isMulticast) {
|
||||
ipCategory = 4 // 组播地址
|
||||
ipColor = '#8b5cf6' // 紫色
|
||||
}
|
||||
|
||||
nodes.push({
|
||||
id: remoteIpNodeId,
|
||||
name: conn.remoteIP,
|
||||
type: 'remote_ip',
|
||||
category: ipCategory,
|
||||
symbolSize: Math.max(30, Math.min(60, conn.totalTraffic / 1000)),
|
||||
itemStyle: {
|
||||
color: ipColor
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'bottom',
|
||||
formatter: '{b}',
|
||||
fontSize: 10,
|
||||
color: 'var(--text-secondary)'
|
||||
}
|
||||
})
|
||||
nodeSet.add(remoteIpNodeId)
|
||||
}
|
||||
|
||||
// 创建端口节点
|
||||
const portNodeId = `port_${conn.remoteIP}_${conn.remotePort}`
|
||||
if (!nodeSet.has(portNodeId)) {
|
||||
nodes.push({
|
||||
id: portNodeId,
|
||||
name: `${conn.protocol}:${conn.remotePort}`,
|
||||
type: 'port',
|
||||
category: 2,
|
||||
symbolSize: Math.max(20, Math.min(40, conn.connectionCount * 2)),
|
||||
itemStyle: {
|
||||
color: conn.protocol === 'TCP' ? '#ef4444' : '#06b6d4'
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
formatter: `{b}`,
|
||||
fontSize: 9,
|
||||
color: 'var(--text-muted)'
|
||||
}
|
||||
})
|
||||
nodeSet.add(portNodeId)
|
||||
}
|
||||
|
||||
// 创建应用到IP的连接
|
||||
links.push({
|
||||
source: appNodeId,
|
||||
target: remoteIpNodeId,
|
||||
value: conn.totalTraffic,
|
||||
lineStyle: {
|
||||
width: Math.max(2, Math.min(6, conn.totalTraffic / 5000)),
|
||||
color: conn.protocol === 'TCP' ? '#ef4444' : '#06b6d4',
|
||||
opacity: 0.6
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
formatter: `${formatBytes(conn.totalTraffic)}`
|
||||
}
|
||||
})
|
||||
|
||||
// 创建IP到端口的连接
|
||||
links.push({
|
||||
source: remoteIpNodeId,
|
||||
target: portNodeId,
|
||||
value: conn.connectionCount,
|
||||
lineStyle: {
|
||||
width: Math.max(1, Math.min(3, conn.connectionCount)),
|
||||
color: '#6b7280',
|
||||
opacity: 0.4,
|
||||
type: 'dashed'
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
formatter: `${conn.connectionCount}连接`
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const categories = [
|
||||
{ name: '应用程序', itemStyle: { color: '#3b82f6' } },
|
||||
{ name: '远程IP', itemStyle: { color: '#10b981' } },
|
||||
{ name: '端口', itemStyle: { color: '#ef4444' } },
|
||||
{ name: '本地回环', itemStyle: { color: '#f59e0b' } },
|
||||
{ name: '组播地址', itemStyle: { color: '#8b5cf6' } }
|
||||
]
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params: any) => {
|
||||
if (params.dataType === 'node') {
|
||||
const node = params.data
|
||||
let typeText = '未知'
|
||||
switch (node.category) {
|
||||
case 0: typeText = '应用程序'; break
|
||||
case 1: typeText = '远程IP'; break
|
||||
case 2: typeText = '端口'; break
|
||||
case 3: typeText = '本地回环'; break
|
||||
case 4: typeText = '组播地址'; break
|
||||
}
|
||||
|
||||
return `
|
||||
<div style="margin-bottom: 4px; font-weight: bold;">${node.name}</div>
|
||||
<div>类型: ${typeText}</div>
|
||||
<div>节点大小: ${node.symbolSize}</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>流量: ${formatBytes(edge.value)}</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: nodes,
|
||||
links: links,
|
||||
categories: categories,
|
||||
roam: true,
|
||||
focusNodeAdjacency: true,
|
||||
draggable: true,
|
||||
itemStyle: {
|
||||
borderColor: '#fff',
|
||||
borderWidth: 1,
|
||||
shadowBlur: 10,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.3)'
|
||||
},
|
||||
lineStyle: {
|
||||
color: 'source',
|
||||
curveness: 0.1
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'adjacency',
|
||||
lineStyle: {
|
||||
width: 6
|
||||
}
|
||||
},
|
||||
animationDuration: 1500,
|
||||
animationEasingUpdate: 'quinticInOut'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// 格式化字节数
|
||||
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]}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.network-analysis-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 加载状态样式 */
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
||||
@@ -1,396 +1,353 @@
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="visible"
|
||||
:mask-closable="false"
|
||||
preset="card"
|
||||
style="width: 90%; max-width: 1000px"
|
||||
:title="softwareDetail?.displayName || '软件详情'"
|
||||
size="huge"
|
||||
:bordered="false"
|
||||
:segmented="false"
|
||||
>
|
||||
<n-spin :show="loading" style="min-height: 400px">
|
||||
<div v-if="softwareDetail" class="software-detail-content">
|
||||
<!-- 基本信息和统计 -->
|
||||
<div class="detail-header">
|
||||
<div class="software-icon">
|
||||
<img
|
||||
v-if="softwareDetail.icon"
|
||||
:src="`data:image/png;base64,${softwareDetail.icon}`"
|
||||
class="app-icon"
|
||||
:alt="softwareDetail.displayName"
|
||||
/>
|
||||
<div v-else class="icon-placeholder">
|
||||
{{ softwareDetail.displayName.charAt(0).toUpperCase() }}
|
||||
<n-modal v-model:show="showModal" class="software-detail-modal">
|
||||
<div class="modal-card">
|
||||
<!-- 弹窗头部 -->
|
||||
<div class="modal-header">
|
||||
<div class="header-left">
|
||||
<img
|
||||
v-if="analysisData?.appInfo.icon"
|
||||
:src="'data:image/png;base64,' + analysisData.appInfo.icon"
|
||||
class="app-icon"
|
||||
@error="handleIconError"
|
||||
/>
|
||||
<n-icon v-else :component="DesktopOutline" class="app-icon-fallback" />
|
||||
|
||||
<div class="header-info">
|
||||
<h3 class="app-name">{{ analysisData?.appInfo.name || 'Unknown Application' }}</h3>
|
||||
<p class="app-details">
|
||||
{{ analysisData?.appInfo.company || 'Unknown Company' }}
|
||||
<span v-if="analysisData?.appInfo.version">• v{{ analysisData.appInfo.version }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
<n-button quaternary circle @click="refreshData">
|
||||
<template #icon>
|
||||
<n-icon :component="RefreshOutline" />
|
||||
</template>
|
||||
</n-button>
|
||||
<n-button quaternary circle @click="closeModal">
|
||||
<template #icon>
|
||||
<n-icon :component="CloseOutline" />
|
||||
</template>
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计概览卡片 -->
|
||||
<div class="stats-overview">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ formatBytes(analysisData?.summary.totalTraffic || 0) }}</div>
|
||||
<div class="stat-label">总流量</div>
|
||||
<div class="stat-trend">
|
||||
<span class="upload">↑ {{ formatBytes(analysisData?.summary.totalUpload || 0) }}</span>
|
||||
<span class="download">↓ {{ formatBytes(analysisData?.summary.totalDownload || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ analysisData?.summary.totalConnections || 0 }}</div>
|
||||
<div class="stat-label">总连接数</div>
|
||||
<div class="stat-extra">{{ analysisData?.summary.uniqueRemoteIPs || 0 }} 个远程IP</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ analysisData?.summary.uniqueRemotePorts || 0 }}</div>
|
||||
<div class="stat-label">使用端口</div>
|
||||
<div class="stat-extra">{{ timeRangeText }}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ analysisData?.portAnalysis.length || 0 }}</div>
|
||||
<div class="stat-label">活跃端口</div>
|
||||
<div class="stat-extra">多协议通信</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<!-- 主要内容区域 -->
|
||||
<div class="content-grid">
|
||||
<!-- 网络关系图 - 主图表 -->
|
||||
<div class="chart-section main-chart">
|
||||
<div class="section-header">
|
||||
<h4 class="section-title">
|
||||
<n-icon :component="GitNetworkOutline" class="section-icon" />
|
||||
网络连接拓扑
|
||||
</h4>
|
||||
<div class="section-extra">
|
||||
<span class="connection-count">{{ analysisData?.topConnections.length || 0 }} 个连接</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<NetworkAnalysisChart
|
||||
v-if="analysisData"
|
||||
:data="analysisData"
|
||||
:loading="loading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="software-summary">
|
||||
<h2 class="software-name">{{ softwareDetail.displayName }}</h2>
|
||||
<div class="software-meta">
|
||||
<span class="process-name">{{ softwareDetail.processName }}</span>
|
||||
<span class="traffic-info"
|
||||
>{{ formatBytes(softwareDetail.totalBytes) }} ({{
|
||||
softwareDetail.percentage.toFixed(1)
|
||||
}}%)</span
|
||||
>
|
||||
<span class="connection-info">{{ softwareDetail.connectionCount }} 个连接</span>
|
||||
|
||||
<!-- 协议分布图 -->
|
||||
<div class="chart-section protocol-chart">
|
||||
<div class="section-header">
|
||||
<h4 class="section-title">
|
||||
<n-icon :component="StatsChartOutline" class="section-icon" />
|
||||
协议分布
|
||||
</h4>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<ProtocolChart v-if="protocolChartData.length > 0" :data="protocolChartData" />
|
||||
<div v-else class="empty-chart">
|
||||
<n-icon :component="PieChartOutline" size="32" />
|
||||
<span>暂无协议数据</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">流量占比</span>
|
||||
<span class="stat-value">{{ softwareDetail.percentage.toFixed(1) }}%</span>
|
||||
|
||||
<!-- 时间趋势图 -->
|
||||
<div class="chart-section trend-chart">
|
||||
<div class="section-header">
|
||||
<h4 class="section-title">
|
||||
<n-icon :component="TrendingUpOutline" class="section-icon" />
|
||||
流量趋势
|
||||
</h4>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">连接数</span>
|
||||
<span class="stat-value">{{ softwareDetail.connectionCount }}</span>
|
||||
<div class="chart-container">
|
||||
<TimeTrendChart v-if="timeTrendData.length > 0" :data="timeTrendData" />
|
||||
<div v-else class="empty-chart">
|
||||
<n-icon :component="BarChartOutline" size="32" />
|
||||
<span>暂无趋势数据</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 选项卡内容 -->
|
||||
<div class="detail-tabs">
|
||||
<n-tabs default-value="overview" type="line">
|
||||
<!-- 概览 -->
|
||||
<n-tab-pane name="overview" tab="概览">
|
||||
<div class="tab-content">
|
||||
<div class="overview-grid">
|
||||
<!-- 软件信息卡片 -->
|
||||
<div class="info-section">
|
||||
<h4 class="section-title">软件信息</h4>
|
||||
<SoftwareInfoCard :software-info="softwareInfo" />
|
||||
</div>
|
||||
|
||||
<!-- 协议分布图 -->
|
||||
<div class="protocol-section">
|
||||
<h4 class="section-title">协议分布</h4>
|
||||
<ProtocolChart :data="protocolData" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- 网络连接 -->
|
||||
<n-tab-pane name="network" tab="网络连接">
|
||||
<div class="tab-content">
|
||||
<div class="network-grid">
|
||||
<!-- 连接关系图 -->
|
||||
<div class="relation-section">
|
||||
<h4 class="section-title">连接关系</h4>
|
||||
<NetworkRelationChart :data="networkRelationData" :software="softwareDetail" />
|
||||
</div>
|
||||
|
||||
<!-- 端口统计表 -->
|
||||
<div class="ports-section">
|
||||
<h4 class="section-title">端口统计</h4>
|
||||
<PortStatsTable :data="portStatsData" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- 流量详情 -->
|
||||
<n-tab-pane name="traffic" tab="流量详情">
|
||||
<div class="tab-content">
|
||||
<div class="traffic-details">
|
||||
<div class="traffic-chart-section">
|
||||
<h4 class="section-title">流量趋势 ({{ timeRange }})</h4>
|
||||
<div class="traffic-chart-placeholder">
|
||||
<div class="placeholder-content">
|
||||
<n-icon :component="TrendingUpOutline" size="48" />
|
||||
<p>流量趋势图</p>
|
||||
<p class="placeholder-desc">展示该软件的上传/下载流量变化</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="traffic-stats-section">
|
||||
<h4 class="section-title">流量统计</h4>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">总流量</div>
|
||||
<div class="stat-value">{{ formatBytes(software.totalBytes || 0) }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">上传</div>
|
||||
<div class="stat-value">{{ formatBytes(software.uploadBytes || 0) }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">下载</div>
|
||||
<div class="stat-value">{{ formatBytes(software.downloadBytes || 0) }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">平均速度</div>
|
||||
<div class="stat-value">{{ formatSpeed(averageSpeed) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
<!-- 详细数据表格 -->
|
||||
<div class="data-tables">
|
||||
<div class="table-tabs">
|
||||
<n-tabs v-model:value="activeTab" type="line" animated>
|
||||
<n-tab-pane name="connections" tab="连接详情">
|
||||
<ConnectionsTable :data="analysisData?.topConnections || []" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="ports" tab="端口统计">
|
||||
<PortStatsTable :data="portStatsData" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="timeline" tab="时间线">
|
||||
<TimelineView :data="analysisData?.timeTrends || []" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!loading && !softwareDetail" class="no-data">
|
||||
<div class="no-data-icon">📊</div>
|
||||
<div class="no-data-text">暂无软件详情数据</div>
|
||||
</div>
|
||||
</n-spin>
|
||||
|
||||
<template #action>
|
||||
<n-button @click="visible = false">关闭</n-button>
|
||||
<n-button type="primary" @click="fetchAllData" :loading="loading">
|
||||
<template #icon>
|
||||
<n-icon :component="RefreshOutline" />
|
||||
</template>
|
||||
刷新数据
|
||||
</n-button>
|
||||
</template>
|
||||
</div>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { NModal, NTabs, NTabPane, NButton, NIcon, NSpin } from 'naive-ui'
|
||||
import { TrendingUpOutline, DownloadOutline, RefreshOutline } from '@vicons/ionicons5'
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { NModal, NButton, NIcon, NTabs, NTabPane } from 'naive-ui'
|
||||
import {
|
||||
DesktopOutline,
|
||||
RefreshOutline,
|
||||
CloseOutline,
|
||||
GitNetworkOutline,
|
||||
StatsChartOutline,
|
||||
TrendingUpOutline,
|
||||
PieChartOutline,
|
||||
BarChartOutline
|
||||
} from '@vicons/ionicons5'
|
||||
import { httpClient } from '@/utils/http'
|
||||
import type { ApiResponse } from '@/types/http'
|
||||
|
||||
// 导入子组件
|
||||
import SoftwareInfoCard from './SoftwareInfoCard.vue'
|
||||
import NetworkRelationChart from './NetworkRelationChart.vue'
|
||||
import PortStatsTable from './PortStatsTable.vue'
|
||||
import NetworkAnalysisChart from './NetworkAnalysisChart.vue'
|
||||
import ProtocolChart from './ProtocolChart.vue'
|
||||
import TimeTrendChart from './TimeTrendChart.vue'
|
||||
import ConnectionsTable from './ConnectionsTable.vue'
|
||||
import PortStatsTable from './PortStatsTable.vue'
|
||||
import TimelineView from './TimelineView.vue'
|
||||
|
||||
// 数据接口定义
|
||||
interface SoftwareDetail {
|
||||
appId: string
|
||||
processName: string
|
||||
displayName: string
|
||||
processPath: string
|
||||
icon: string
|
||||
version: string
|
||||
company: string
|
||||
totalBytes: number
|
||||
uploadBytes: number
|
||||
downloadBytes: number
|
||||
// 接口定义
|
||||
interface TopConnection {
|
||||
localIP: string
|
||||
localPort: number
|
||||
remoteIP: string
|
||||
remotePort: number
|
||||
protocol: 'TCP' | 'UDP'
|
||||
totalUpload: number
|
||||
totalDownload: number
|
||||
totalTraffic: number
|
||||
connectionCount: number
|
||||
percentage: number
|
||||
firstSeen: string
|
||||
lastSeen: string
|
||||
}
|
||||
|
||||
interface SoftwareInfo {
|
||||
processName: string
|
||||
displayName: string
|
||||
version: string
|
||||
company: string
|
||||
processPath: string
|
||||
fileSize: number
|
||||
}
|
||||
|
||||
interface NetworkRelationData {
|
||||
nodes: Array<{
|
||||
id: string
|
||||
interface NetworkAnalysisData {
|
||||
appInfo: {
|
||||
appId: string
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
category: number
|
||||
company?: string
|
||||
version?: string
|
||||
path?: string
|
||||
icon?: string
|
||||
hash?: string
|
||||
}
|
||||
summary: {
|
||||
timeRange: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
totalUpload: number
|
||||
totalDownload: number
|
||||
totalTraffic: number
|
||||
totalConnections: number
|
||||
uniqueRemoteIPs: number
|
||||
uniqueRemotePorts: number
|
||||
}
|
||||
topConnections: TopConnection[]
|
||||
protocolStats: Array<{
|
||||
protocol: string
|
||||
connectionCount: number
|
||||
totalTraffic: number
|
||||
percentage: number
|
||||
}>
|
||||
links: Array<{
|
||||
source: string
|
||||
target: string
|
||||
value: number
|
||||
label: string
|
||||
timeTrends: Array<{
|
||||
timestamp: number
|
||||
timeStr: string
|
||||
upload: number
|
||||
download: number
|
||||
connections: number
|
||||
}>
|
||||
portAnalysis: Array<{
|
||||
port: number
|
||||
serviceName: string
|
||||
connectionCount: number
|
||||
totalTraffic: number
|
||||
protocols: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
interface PortStats {
|
||||
port: number
|
||||
protocol: string
|
||||
connectionCount: number
|
||||
totalBytes: number
|
||||
remoteHosts: string[]
|
||||
}
|
||||
|
||||
interface ProtocolData {
|
||||
protocol: string
|
||||
bytes: number
|
||||
percentage: number
|
||||
color: string
|
||||
}
|
||||
|
||||
// Props定义
|
||||
const props = defineProps<{
|
||||
// Props和Emits
|
||||
interface Props {
|
||||
show: boolean
|
||||
appId?: string | null
|
||||
timeRange?: string
|
||||
}>()
|
||||
appId: string | null
|
||||
timeRange: string
|
||||
}
|
||||
|
||||
// Emits
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
'update:show': [value: boolean]
|
||||
}>()
|
||||
|
||||
// 响应式数据
|
||||
const showModal = computed({
|
||||
get: () => props.show,
|
||||
set: (value) => emit('update:show', value)
|
||||
})
|
||||
|
||||
const analysisData = ref<NetworkAnalysisData | null>(null)
|
||||
const loading = ref(false)
|
||||
const softwareDetail = ref<SoftwareDetail | null>(null)
|
||||
const softwareInfo = ref<SoftwareInfo | null>(null)
|
||||
const networkRelationData = ref<NetworkRelationData>({ nodes: [], links: [] })
|
||||
const portStatsData = ref<PortStats[]>([])
|
||||
const protocolData = ref<ProtocolData[]>([])
|
||||
const activeTab = ref('connections')
|
||||
|
||||
// 计算属性
|
||||
const visible = computed({
|
||||
get: () => props.show,
|
||||
set: (value) => emit('update:show', value),
|
||||
const timeRangeText = computed(() => {
|
||||
const rangeMap: Record<string, string> = {
|
||||
'1hour': '1小时',
|
||||
'1day': '24小时',
|
||||
'7days': '7天',
|
||||
'30days': '30天'
|
||||
}
|
||||
return rangeMap[props.timeRange] || props.timeRange
|
||||
})
|
||||
|
||||
// 计算平均速度
|
||||
const averageSpeed = computed(() => {
|
||||
if (!softwareDetail.value?.totalBytes) return 0
|
||||
// 模拟计算,实际应根据时间范围计算
|
||||
return softwareDetail.value.totalBytes / 3600 // 假设1小时的数据
|
||||
const protocolChartData = computed(() => {
|
||||
if (!analysisData.value?.protocolStats) return []
|
||||
|
||||
const colors = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6']
|
||||
return analysisData.value.protocolStats.map((stat, index) => ({
|
||||
protocol: stat.protocol,
|
||||
bytes: stat.totalTraffic,
|
||||
percentage: stat.percentage,
|
||||
color: colors[index % colors.length]
|
||||
}))
|
||||
})
|
||||
|
||||
// API调用函数
|
||||
const getSoftwareDetail = async () => {
|
||||
const timeTrendData = computed(() => {
|
||||
if (!analysisData.value?.timeTrends) return []
|
||||
|
||||
return analysisData.value.timeTrends.map(trend => ({
|
||||
timestamp: trend.timestamp * 1000, // 转换为毫秒
|
||||
timeStr: trend.timeStr,
|
||||
upload: trend.upload,
|
||||
download: trend.download,
|
||||
connections: trend.connections
|
||||
}))
|
||||
})
|
||||
|
||||
const portStatsData = computed(() => {
|
||||
if (!analysisData.value?.portAnalysis || !analysisData.value?.topConnections) return []
|
||||
|
||||
return analysisData.value.portAnalysis.map(port => {
|
||||
// 从连接数据中获取使用该端口的远程主机
|
||||
const connectionsForPort = analysisData.value!.topConnections.filter(
|
||||
conn => conn.remotePort === port.port
|
||||
)
|
||||
|
||||
return {
|
||||
port: port.port,
|
||||
protocol: port.protocols[0] || 'TCP', // 取第一个协议
|
||||
connectionCount: port.connectionCount,
|
||||
totalBytes: port.totalTraffic,
|
||||
remoteHosts: [...new Set(connectionsForPort.map(conn => conn.remoteIP))]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// API调用
|
||||
const fetchAnalysisData = async () => {
|
||||
if (!props.appId) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
timeRange: props.timeRange || '1hour',
|
||||
appId: props.appId,
|
||||
timeRange: props.timeRange
|
||||
}
|
||||
|
||||
const res: ApiResponse<SoftwareDetail> = await httpClient.get(
|
||||
`/apps/${props.appId}/detail`,
|
||||
params,
|
||||
const response: ApiResponse<NetworkAnalysisData> = await httpClient.get(
|
||||
'/apps/network-analysis',
|
||||
params
|
||||
)
|
||||
if (res.success && res.data) {
|
||||
softwareDetail.value = res.data
|
||||
|
||||
if (response.success && response.data) {
|
||||
analysisData.value = response.data
|
||||
} else {
|
||||
console.error('获取网络分析数据失败:', response.message)
|
||||
analysisData.value = null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取软件详情失败:', error)
|
||||
console.error('获取网络分析数据异常:', error)
|
||||
analysisData.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getSoftwareInfo = async () => {
|
||||
if (!props.appId) return
|
||||
|
||||
try {
|
||||
const res: ApiResponse<SoftwareInfo> = await httpClient.get(`/apps/${props.appId}/info`)
|
||||
if (res.success && res.data) {
|
||||
softwareInfo.value = res.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取软件信息失败:', error)
|
||||
}
|
||||
// 事件处理
|
||||
const refreshData = () => {
|
||||
fetchAnalysisData()
|
||||
}
|
||||
|
||||
const getNetworkRelation = async () => {
|
||||
if (!props.appId) return
|
||||
|
||||
try {
|
||||
const params = {
|
||||
timeRange: props.timeRange || '1hour',
|
||||
}
|
||||
|
||||
const res: ApiResponse<NetworkRelationData> = await httpClient.get(
|
||||
`/apps/${props.appId}/network-relation`,
|
||||
params,
|
||||
)
|
||||
if (res.success && res.data) {
|
||||
networkRelationData.value = res.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取网络关系数据失败:', error)
|
||||
}
|
||||
const closeModal = () => {
|
||||
showModal.value = false
|
||||
}
|
||||
|
||||
const getPortStats = async () => {
|
||||
if (!props.appId) return
|
||||
|
||||
try {
|
||||
const params = {
|
||||
timeRange: props.timeRange || '1hour',
|
||||
}
|
||||
|
||||
const res: ApiResponse<PortStats[]> = await httpClient.get(
|
||||
`/apps/${props.appId}/port-stats`,
|
||||
params,
|
||||
)
|
||||
if (res.success && res.data) {
|
||||
portStatsData.value = res.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取端口统计失败:', error)
|
||||
}
|
||||
const handleIconError = (event: Event) => {
|
||||
const target = event.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
}
|
||||
|
||||
const getProtocolData = async () => {
|
||||
if (!props.appId) return
|
||||
|
||||
try {
|
||||
const params = {
|
||||
timeRange: props.timeRange || '1hour',
|
||||
}
|
||||
|
||||
const res: ApiResponse<ProtocolData[]> = await httpClient.get(
|
||||
`/apps/${props.appId}/protocols`,
|
||||
params,
|
||||
)
|
||||
if (res.success && res.data) {
|
||||
protocolData.value = res.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取协议数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有数据
|
||||
const fetchAllData = async () => {
|
||||
if (!props.appId) return
|
||||
|
||||
await Promise.all([
|
||||
getSoftwareDetail(),
|
||||
getSoftwareInfo(),
|
||||
getNetworkRelation(),
|
||||
getPortStats(),
|
||||
getProtocolData(),
|
||||
])
|
||||
}
|
||||
|
||||
// 监听AppId和时间范围变化
|
||||
watch(
|
||||
[() => props.appId, () => props.timeRange],
|
||||
() => {
|
||||
if (props.show && props.appId) {
|
||||
fetchAllData()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 监听弹窗显示状态
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
if (newVal && props.appId) {
|
||||
fetchAllData()
|
||||
} else if (!newVal) {
|
||||
// 关闭弹窗时清理数据
|
||||
softwareDetail.value = null
|
||||
softwareInfo.value = null
|
||||
networkRelationData.value = { nodes: [], links: [] }
|
||||
portStatsData.value = []
|
||||
protocolData.value = []
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 格式化字节数
|
||||
// 工具函数
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
@@ -401,279 +358,337 @@ const formatBytes = (bytes: number): string => {
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
|
||||
}
|
||||
|
||||
// 格式化速度
|
||||
const formatSpeed = (bytesPerSecond: number): string => {
|
||||
return formatBytes(bytesPerSecond) + '/s'
|
||||
}
|
||||
// 监听器
|
||||
watch(() => props.show, (newShow) => {
|
||||
if (newShow && props.appId) {
|
||||
nextTick(() => {
|
||||
fetchAnalysisData()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 导出数据
|
||||
const exportData = () => {
|
||||
// 实现数据导出功能
|
||||
console.log('Export data for:', props.software?.processName)
|
||||
}
|
||||
watch(() => [props.appId, props.timeRange], () => {
|
||||
if (props.show && props.appId) {
|
||||
fetchAnalysisData()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.software-detail-content {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
/* 弹窗基础样式 */
|
||||
.software-detail-modal :deep(.n-modal) {
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
/* 详情头部 */
|
||||
.detail-header {
|
||||
.modal-card {
|
||||
width: 1200px;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
background: var(--bg-card);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.software-icon .icon-placeholder {
|
||||
/* 弹窗头部 */
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
background: linear-gradient(135deg, var(--bg-card) 0%, var(--bg-secondary) 100%);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.app-icon-fallback {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.software-summary {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.software-name {
|
||||
.header-info h3 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.software-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.process-name {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
color: var(--accent-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.traffic-info {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.connection-info {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.detail-stats {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 选项卡内容 */
|
||||
.detail-tabs {
|
||||
margin-top: 8px;
|
||||
.header-info p {
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding: 16px 0;
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 概览页面 */
|
||||
.overview-grid {
|
||||
/* 统计概览 */
|
||||
.stats-overview {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 300px;
|
||||
gap: 24px;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1px;
|
||||
background: var(--border-primary);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-trend {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-trend .upload {
|
||||
color: var(--accent-error);
|
||||
}
|
||||
|
||||
.stat-trend .download {
|
||||
color: var(--accent-success);
|
||||
}
|
||||
|
||||
.stat-extra {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 弹窗主体 */
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 内容网格 */
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
gap: 1px;
|
||||
height: 400px;
|
||||
background: var(--border-primary);
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
background: var(--bg-card);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-chart {
|
||||
grid-row: span 2;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
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;
|
||||
}
|
||||
|
||||
.protocol-section {
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 网络连接页面 */
|
||||
.network-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.relation-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ports-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 流量详情页面 */
|
||||
.traffic-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.traffic-chart-placeholder {
|
||||
height: 200px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px dashed var(--border-secondary);
|
||||
}
|
||||
|
||||
.placeholder-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.section-extra {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.placeholder-desc {
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
.connection-count {
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-tertiary);
|
||||
.chart-container {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-secondary);
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-card .stat-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-card .stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
/* 模态框操作 */
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* 无数据状态 */
|
||||
.no-data {
|
||||
.empty-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 20px;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.no-data-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.6;
|
||||
.empty-chart span {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.no-data-text {
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
/* 数据表格 */
|
||||
.data-tables {
|
||||
flex: 1;
|
||||
background: var(--bg-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 应用图标 */
|
||||
.app-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
.table-tabs {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.detail-header {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.detail-stats {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.overview-grid,
|
||||
.network-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.table-tabs :deep(.n-tabs) {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Naive UI 覆盖样式 */
|
||||
:deep(.n-tabs .n-tab-pane) {
|
||||
.table-tabs :deep(.n-tabs-nav) {
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
}
|
||||
|
||||
.table-tabs :deep(.n-tabs-content) {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-tabs :deep(.n-tab-pane) {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:deep(.n-tabs .n-tabs-nav) {
|
||||
--n-tab-color-hover: var(--bg-hover);
|
||||
--n-tab-text-color-active: var(--accent-primary);
|
||||
--n-bar-color: var(--accent-primary);
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1024px) {
|
||||
.modal-card {
|
||||
width: 95vw;
|
||||
max-height: 95vh;
|
||||
}
|
||||
|
||||
.stats-overview {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 200px 150px 150px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.main-chart {
|
||||
grid-row: span 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.modal-card {
|
||||
width: 100vw;
|
||||
max-height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.header-info h3 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.app-icon, .app-icon-fallback {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.stats-overview {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
height: auto;
|
||||
grid-template-rows: 180px 120px 120px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 暗色主题适配 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.modal-card {
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.4), 0 10px 10px -5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,226 @@
|
||||
<template>
|
||||
<div class="time-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
|
||||
])
|
||||
|
||||
// 接口定义
|
||||
interface TimeTrendData {
|
||||
timestamp: number
|
||||
timeStr: string
|
||||
upload: number
|
||||
download: number
|
||||
connections: number
|
||||
}
|
||||
|
||||
// Props定义
|
||||
const props = defineProps<{
|
||||
data: TimeTrendData[]
|
||||
}>()
|
||||
|
||||
// 计算图表配置
|
||||
const chartOption = computed<EChartsOption>(() => {
|
||||
if (!props.data.length) {
|
||||
return {
|
||||
title: {
|
||||
text: '暂无时间趋势数据',
|
||||
left: 'center',
|
||||
top: 'center',
|
||||
textStyle: {
|
||||
color: 'var(--text-muted)',
|
||||
fontSize: 12
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const times = props.data.map(item => item.timeStr)
|
||||
const uploadData = props.data.map(item => item.upload)
|
||||
const downloadData = props.data.map(item => item.download)
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
label: {
|
||||
backgroundColor: '#6a7985'
|
||||
}
|
||||
},
|
||||
formatter: (params: any) => {
|
||||
const time = params[0].name
|
||||
let content = `<div style="font-weight: bold; margin-bottom: 4px;">${time}</div>`
|
||||
|
||||
params.forEach((param: any) => {
|
||||
const value = formatBytes(param.value)
|
||||
const color = param.color
|
||||
content += `
|
||||
<div style="display: flex; align-items: center; gap: 6px;">
|
||||
<span style="display: inline-block; width: 8px; height: 8px; background: ${color}; border-radius: 50%;"></span>
|
||||
<span>${param.seriesName}: ${value}</span>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
return content
|
||||
},
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
textStyle: {
|
||||
color: '#ffffff',
|
||||
fontSize: 11
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: 10,
|
||||
right: 10,
|
||||
bottom: 15,
|
||||
top: 15,
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: times,
|
||||
axisLabel: {
|
||||
fontSize: 10,
|
||||
color: 'var(--text-muted)',
|
||||
formatter: (value: string) => {
|
||||
// 只显示时间部分
|
||||
return value.split(' ')[1] || value
|
||||
}
|
||||
},
|
||||
axisTick: {
|
||||
alignWithLabel: true
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
fontSize: 10,
|
||||
color: 'var(--text-muted)',
|
||||
formatter: (value: number) => formatBytes(value)
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: 'var(--border-tertiary)',
|
||||
opacity: 0.5
|
||||
}
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '上传流量',
|
||||
type: 'line',
|
||||
data: uploadData,
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 4,
|
||||
lineStyle: {
|
||||
color: '#ef4444',
|
||||
width: 2
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#ef4444'
|
||||
},
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(239, 68, 68, 0.3)' },
|
||||
{ offset: 1, color: 'rgba(239, 68, 68, 0.05)' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '下载流量',
|
||||
type: 'line',
|
||||
data: downloadData,
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 4,
|
||||
lineStyle: {
|
||||
color: '#10b981',
|
||||
width: 2
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#10b981'
|
||||
},
|
||||
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)' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
animation: true,
|
||||
animationDuration: 1000,
|
||||
animationEasing: 'cubicOut'
|
||||
}
|
||||
})
|
||||
|
||||
// 格式化字节数
|
||||
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]}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.time-trend-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,371 @@
|
||||
<template>
|
||||
<div class="timeline-view">
|
||||
<div class="timeline-container">
|
||||
<!-- 时间线 -->
|
||||
<div class="timeline" v-if="data.length > 0">
|
||||
<div
|
||||
v-for="(item, index) in sortedData"
|
||||
:key="`${item.timestamp}-${index}`"
|
||||
class="timeline-item"
|
||||
:class="{ 'active': item.connections > averageConnections }"
|
||||
>
|
||||
<div class="timeline-dot">
|
||||
<div class="dot-inner"></div>
|
||||
</div>
|
||||
|
||||
<div class="timeline-content">
|
||||
<div class="timeline-header">
|
||||
<h5 class="timeline-time">{{ item.timeStr }}</h5>
|
||||
<span class="timeline-connections">{{ item.connections }} 连接</span>
|
||||
</div>
|
||||
|
||||
<div class="timeline-stats">
|
||||
<div class="stat-item upload">
|
||||
<span class="stat-label">上传:</span>
|
||||
<span class="stat-value">{{ formatBytes(item.upload) }}</span>
|
||||
</div>
|
||||
<div class="stat-item download">
|
||||
<span class="stat-label">下载:</span>
|
||||
<span class="stat-value">{{ formatBytes(item.download) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="timeline-bar">
|
||||
<div class="bar-container">
|
||||
<div
|
||||
class="bar-fill upload-bar"
|
||||
:style="{ width: getPercentage(item.upload, maxUpload) + '%' }"
|
||||
></div>
|
||||
<div
|
||||
class="bar-fill download-bar"
|
||||
:style="{ width: getPercentage(item.download, maxDownload) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else class="empty-state">
|
||||
<n-icon :component="TimeOutline" size="32" />
|
||||
<p>暂无时间线数据</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { NIcon } from 'naive-ui'
|
||||
import { TimeOutline } from '@vicons/ionicons5'
|
||||
|
||||
// 接口定义
|
||||
interface TimeTrend {
|
||||
timestamp: number
|
||||
timeStr: string
|
||||
upload: number
|
||||
download: number
|
||||
connections: number
|
||||
}
|
||||
|
||||
// Props定义
|
||||
const props = defineProps<{
|
||||
data: TimeTrend[]
|
||||
}>()
|
||||
|
||||
// 计算属性
|
||||
const sortedData = computed(() => {
|
||||
return [...props.data].sort((a, b) => b.timestamp - a.timestamp) // 按时间倒序,最新的在顶部
|
||||
})
|
||||
|
||||
const maxUpload = computed(() => {
|
||||
if (props.data.length === 0) return 1
|
||||
return Math.max(...props.data.map(item => item.upload))
|
||||
})
|
||||
|
||||
const maxDownload = computed(() => {
|
||||
if (props.data.length === 0) return 1
|
||||
return Math.max(...props.data.map(item => item.download))
|
||||
})
|
||||
|
||||
const averageConnections = computed(() => {
|
||||
if (props.data.length === 0) return 0
|
||||
const totalConnections = props.data.reduce((sum, item) => sum + item.connections, 0)
|
||||
return totalConnections / props.data.length
|
||||
})
|
||||
|
||||
// 工具函数
|
||||
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 getPercentage = (value: number, max: number): number => {
|
||||
if (max === 0) return 0
|
||||
return Math.max(5, (value / max) * 100) // 最小5%确保可见
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.timeline-view {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.timeline-container {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
position: relative;
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
.timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 15px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: linear-gradient(to bottom, var(--accent-primary), var(--accent-secondary));
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.timeline-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
position: absolute;
|
||||
left: -22px;
|
||||
top: 4px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: var(--bg-card);
|
||||
border: 3px solid var(--accent-primary);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.timeline-item.active .timeline-dot {
|
||||
border-color: var(--accent-secondary);
|
||||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.2);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.dot-inner {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--accent-primary);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.timeline-item.active .dot-inner {
|
||||
background: var(--accent-secondary);
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.timeline-item.active .timeline-content {
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
background: linear-gradient(135deg, var(--bg-card) 0%, rgba(59, 130, 246, 0.02) 100%);
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.timeline-time {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
.timeline-connections {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-tertiary);
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.timeline-item.active .timeline-connections {
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.timeline-stats {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-item.upload .stat-value {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.stat-item.download .stat-value {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.timeline-bar {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.bar-container {
|
||||
position: relative;
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
.upload-bar {
|
||||
background: linear-gradient(90deg, rgba(239, 68, 68, 0.8), rgba(239, 68, 68, 0.4));
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.download-bar {
|
||||
background: linear-gradient(90deg, rgba(16, 185, 129, 0.8), rgba(16, 185, 129, 0.4));
|
||||
top: 50%;
|
||||
height: 50%;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 动画 */
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 8px rgba(59, 130, 246, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.timeline-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.timeline-container::-webkit-scrollbar-track {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.timeline-container::-webkit-scrollbar-thumb {
|
||||
background: var(--border-secondary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.timeline-container::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border-hover);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.timeline-stats {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
padding-left: 25px;
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
left: -18px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.dot-inner {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.timeline::before {
|
||||
left: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user