A:增加页面调用

This commit is contained in:
jimmy committed 2025-08-10 19:34:10 +08:00
1 parent e5cf954798
commit 1cd54ad507
7 files changed
+1821 -546

No files matched your search

+5
View File
@@ -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>