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 开发阶段直接使用固定节点
|
// todo: 2025/6/25 08:32 开发阶段直接使用固定节点
|
||||||
webSocketStore.initialize('ws://127.0.0.1:8267')
|
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(() => {
|
const interfaceSelectWidth = computed(() => {
|
||||||
if (interfaceOptions.value.length === 0) return '120px'
|
if (interfaceOptions.value.length === 0) return '120px'
|
||||||
|
|
||||||
const maxLength = Math.max(
|
const maxLength = Math.max(
|
||||||
...interfaceOptions.value.map(option => option.label.length)
|
...interfaceOptions.value.map(option => option.label.length)
|
||||||
)
|
)
|
||||||
|
|
||||||
// 根据字符长度计算宽度,中文字符按2倍宽度计算
|
// 根据字符长度计算宽度,中文字符按2倍宽度计算
|
||||||
const estimatedWidth = maxLength * 8 + 60 // 基础60px + 字符宽度
|
const estimatedWidth = maxLength * 8 + 60 // 基础60px + 字符宽度
|
||||||
return Math.max(estimatedWidth, 120) + 'px' // 最小120px
|
return Math.max(estimatedWidth, 120) + 'px' // 最小120px
|
||||||
@@ -274,7 +274,7 @@ const getNetworkInterfaces = async () => {
|
|||||||
const params: Record<string, any> = {
|
const params: Record<string, any> = {
|
||||||
timeRange: selectedTimeRange.value
|
timeRange: selectedTimeRange.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const res: ApiResponse<NetworkInterface[]> = await httpClient.get('/statistics/interfaces', params)
|
const res: ApiResponse<NetworkInterface[]> = await httpClient.get('/statistics/interfaces', params)
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
// 清空现有选项(保留全部网卡选项)
|
// 清空现有选项(保留全部网卡选项)
|
||||||
@@ -329,7 +329,7 @@ const getTrafficTrends = async () => {
|
|||||||
downloadSpeed: point.downloadSpeed
|
downloadSpeed: point.downloadSpeed
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a.timestamp - b.timestamp) // 按时间升序排列,最新时间在右侧
|
.sort((a, b) => a.timestamp - b.timestamp) // 按时间升序排列,最新时间在右侧
|
||||||
|
|
||||||
// 限制显示的数据点数量,避免图表过于密集
|
// 限制显示的数据点数量,避免图表过于密集
|
||||||
const maxPoints = 250 // 最多显示250个点
|
const maxPoints = 250 // 最多显示250个点
|
||||||
if (processedData.length > maxPoints) {
|
if (processedData.length > maxPoints) {
|
||||||
@@ -337,7 +337,7 @@ const getTrafficTrends = async () => {
|
|||||||
const step = Math.floor(processedData.length / maxPoints)
|
const step = Math.floor(processedData.length / maxPoints)
|
||||||
processedData = processedData.filter((_, index) => index % step === 0)
|
processedData = processedData.filter((_, index) => index % step === 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
trafficTrendData.value = processedData
|
trafficTrendData.value = processedData
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -423,9 +423,11 @@ const selectedAppId = ref<string | null>(null)
|
|||||||
|
|
||||||
// 事件处理
|
// 事件处理
|
||||||
const showSoftwareDetail = (software: any) => {
|
const showSoftwareDetail = (software: any) => {
|
||||||
|
console.log(software);
|
||||||
selectedAppId.value = software.appId
|
selectedAppId.value = software.appId
|
||||||
selectedSoftware.value = software // 保留用于兼容性
|
selectedSoftware.value = software // 保留用于兼容性
|
||||||
showDetailModal.value = true
|
showDetailModal.value = true
|
||||||
|
console.log(selectedAppId.value,selectedSoftware.value,showDetailModal.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshData = async () => {
|
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>
|
<template>
|
||||||
<n-modal
|
<n-modal v-model:show="showModal" class="software-detail-modal">
|
||||||
v-model:show="visible"
|
<div class="modal-card">
|
||||||
:mask-closable="false"
|
<!-- 弹窗头部 -->
|
||||||
preset="card"
|
<div class="modal-header">
|
||||||
style="width: 90%; max-width: 1000px"
|
<div class="header-left">
|
||||||
:title="softwareDetail?.displayName || '软件详情'"
|
<img
|
||||||
size="huge"
|
v-if="analysisData?.appInfo.icon"
|
||||||
:bordered="false"
|
:src="'data:image/png;base64,' + analysisData.appInfo.icon"
|
||||||
:segmented="false"
|
class="app-icon"
|
||||||
>
|
@error="handleIconError"
|
||||||
<n-spin :show="loading" style="min-height: 400px">
|
/>
|
||||||
<div v-if="softwareDetail" class="software-detail-content">
|
<n-icon v-else :component="DesktopOutline" class="app-icon-fallback" />
|
||||||
<!-- 基本信息和统计 -->
|
|
||||||
<div class="detail-header">
|
<div class="header-info">
|
||||||
<div class="software-icon">
|
<h3 class="app-name">{{ analysisData?.appInfo.name || 'Unknown Application' }}</h3>
|
||||||
<img
|
<p class="app-details">
|
||||||
v-if="softwareDetail.icon"
|
{{ analysisData?.appInfo.company || 'Unknown Company' }}
|
||||||
:src="`data:image/png;base64,${softwareDetail.icon}`"
|
<span v-if="analysisData?.appInfo.version">• v{{ analysisData.appInfo.version }}</span>
|
||||||
class="app-icon"
|
</p>
|
||||||
:alt="softwareDetail.displayName"
|
</div>
|
||||||
/>
|
</div>
|
||||||
<div v-else class="icon-placeholder">
|
|
||||||
{{ softwareDetail.displayName.charAt(0).toUpperCase() }}
|
<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>
|
</div>
|
||||||
<div class="software-summary">
|
|
||||||
<h2 class="software-name">{{ softwareDetail.displayName }}</h2>
|
<!-- 协议分布图 -->
|
||||||
<div class="software-meta">
|
<div class="chart-section protocol-chart">
|
||||||
<span class="process-name">{{ softwareDetail.processName }}</span>
|
<div class="section-header">
|
||||||
<span class="traffic-info"
|
<h4 class="section-title">
|
||||||
>{{ formatBytes(softwareDetail.totalBytes) }} ({{
|
<n-icon :component="StatsChartOutline" class="section-icon" />
|
||||||
softwareDetail.percentage.toFixed(1)
|
协议分布
|
||||||
}}%)</span
|
</h4>
|
||||||
>
|
</div>
|
||||||
<span class="connection-info">{{ softwareDetail.connectionCount }} 个连接</span>
|
<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>
|
</div>
|
||||||
<div class="detail-stats">
|
|
||||||
<div class="stat-item">
|
<!-- 时间趋势图 -->
|
||||||
<span class="stat-label">流量占比</span>
|
<div class="chart-section trend-chart">
|
||||||
<span class="stat-value">{{ softwareDetail.percentage.toFixed(1) }}%</span>
|
<div class="section-header">
|
||||||
|
<h4 class="section-title">
|
||||||
|
<n-icon :component="TrendingUpOutline" class="section-icon" />
|
||||||
|
流量趋势
|
||||||
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item">
|
<div class="chart-container">
|
||||||
<span class="stat-label">连接数</span>
|
<TimeTrendChart v-if="timeTrendData.length > 0" :data="timeTrendData" />
|
||||||
<span class="stat-value">{{ softwareDetail.connectionCount }}</span>
|
<div v-else class="empty-chart">
|
||||||
|
<n-icon :component="BarChartOutline" size="32" />
|
||||||
|
<span>暂无趋势数据</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 选项卡内容 -->
|
<!-- 详细数据表格 -->
|
||||||
<div class="detail-tabs">
|
<div class="data-tables">
|
||||||
<n-tabs default-value="overview" type="line">
|
<div class="table-tabs">
|
||||||
<!-- 概览 -->
|
<n-tabs v-model:value="activeTab" type="line" animated>
|
||||||
<n-tab-pane name="overview" tab="概览">
|
<n-tab-pane name="connections" tab="连接详情">
|
||||||
<div class="tab-content">
|
<ConnectionsTable :data="analysisData?.topConnections || []" />
|
||||||
<div class="overview-grid">
|
</n-tab-pane>
|
||||||
<!-- 软件信息卡片 -->
|
<n-tab-pane name="ports" tab="端口统计">
|
||||||
<div class="info-section">
|
<PortStatsTable :data="portStatsData" />
|
||||||
<h4 class="section-title">软件信息</h4>
|
</n-tab-pane>
|
||||||
<SoftwareInfoCard :software-info="softwareInfo" />
|
<n-tab-pane name="timeline" tab="时间线">
|
||||||
</div>
|
<TimelineView :data="analysisData?.timeTrends || []" />
|
||||||
|
</n-tab-pane>
|
||||||
<!-- 协议分布图 -->
|
</n-tabs>
|
||||||
<div class="protocol-section">
|
</div>
|
||||||
<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>
|
</div>
|
||||||
</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>
|
|
||||||
</n-modal>
|
</n-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
import { NModal, NTabs, NTabPane, NButton, NIcon, NSpin } from 'naive-ui'
|
import { NModal, NButton, NIcon, NTabs, NTabPane } from 'naive-ui'
|
||||||
import { TrendingUpOutline, DownloadOutline, RefreshOutline } from '@vicons/ionicons5'
|
import {
|
||||||
|
DesktopOutline,
|
||||||
|
RefreshOutline,
|
||||||
|
CloseOutline,
|
||||||
|
GitNetworkOutline,
|
||||||
|
StatsChartOutline,
|
||||||
|
TrendingUpOutline,
|
||||||
|
PieChartOutline,
|
||||||
|
BarChartOutline
|
||||||
|
} from '@vicons/ionicons5'
|
||||||
import { httpClient } from '@/utils/http'
|
import { httpClient } from '@/utils/http'
|
||||||
import type { ApiResponse } from '@/types/http'
|
import type { ApiResponse } from '@/types/http'
|
||||||
|
|
||||||
// 导入子组件
|
// 导入子组件
|
||||||
import SoftwareInfoCard from './SoftwareInfoCard.vue'
|
import NetworkAnalysisChart from './NetworkAnalysisChart.vue'
|
||||||
import NetworkRelationChart from './NetworkRelationChart.vue'
|
|
||||||
import PortStatsTable from './PortStatsTable.vue'
|
|
||||||
import ProtocolChart from './ProtocolChart.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 {
|
interface TopConnection {
|
||||||
appId: string
|
localIP: string
|
||||||
processName: string
|
localPort: number
|
||||||
displayName: string
|
remoteIP: string
|
||||||
processPath: string
|
remotePort: number
|
||||||
icon: string
|
protocol: 'TCP' | 'UDP'
|
||||||
version: string
|
totalUpload: number
|
||||||
company: string
|
totalDownload: number
|
||||||
totalBytes: number
|
totalTraffic: number
|
||||||
uploadBytes: number
|
|
||||||
downloadBytes: number
|
|
||||||
connectionCount: number
|
connectionCount: number
|
||||||
percentage: number
|
firstSeen: string
|
||||||
|
lastSeen: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SoftwareInfo {
|
interface NetworkAnalysisData {
|
||||||
processName: string
|
appInfo: {
|
||||||
displayName: string
|
appId: string
|
||||||
version: string
|
|
||||||
company: string
|
|
||||||
processPath: string
|
|
||||||
fileSize: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NetworkRelationData {
|
|
||||||
nodes: Array<{
|
|
||||||
id: string
|
|
||||||
name: string
|
name: string
|
||||||
type: string
|
company?: string
|
||||||
size: number
|
version?: string
|
||||||
category: number
|
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<{
|
timeTrends: Array<{
|
||||||
source: string
|
timestamp: number
|
||||||
target: string
|
timeStr: string
|
||||||
value: number
|
upload: number
|
||||||
label: string
|
download: number
|
||||||
|
connections: number
|
||||||
|
}>
|
||||||
|
portAnalysis: Array<{
|
||||||
|
port: number
|
||||||
|
serviceName: string
|
||||||
|
connectionCount: number
|
||||||
|
totalTraffic: number
|
||||||
|
protocols: string[]
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PortStats {
|
// Props和Emits
|
||||||
port: number
|
interface Props {
|
||||||
protocol: string
|
|
||||||
connectionCount: number
|
|
||||||
totalBytes: number
|
|
||||||
remoteHosts: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProtocolData {
|
|
||||||
protocol: string
|
|
||||||
bytes: number
|
|
||||||
percentage: number
|
|
||||||
color: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Props定义
|
|
||||||
const props = defineProps<{
|
|
||||||
show: boolean
|
show: boolean
|
||||||
appId?: string | null
|
appId: string | null
|
||||||
timeRange?: string
|
timeRange: string
|
||||||
}>()
|
}
|
||||||
|
|
||||||
// Emits
|
const props = defineProps<Props>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:show': [value: boolean]
|
'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 loading = ref(false)
|
||||||
const softwareDetail = ref<SoftwareDetail | null>(null)
|
const activeTab = ref('connections')
|
||||||
const softwareInfo = ref<SoftwareInfo | null>(null)
|
|
||||||
const networkRelationData = ref<NetworkRelationData>({ nodes: [], links: [] })
|
|
||||||
const portStatsData = ref<PortStats[]>([])
|
|
||||||
const protocolData = ref<ProtocolData[]>([])
|
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
const visible = computed({
|
const timeRangeText = computed(() => {
|
||||||
get: () => props.show,
|
const rangeMap: Record<string, string> = {
|
||||||
set: (value) => emit('update:show', value),
|
'1hour': '1小时',
|
||||||
|
'1day': '24小时',
|
||||||
|
'7days': '7天',
|
||||||
|
'30days': '30天'
|
||||||
|
}
|
||||||
|
return rangeMap[props.timeRange] || props.timeRange
|
||||||
})
|
})
|
||||||
|
|
||||||
// 计算平均速度
|
const protocolChartData = computed(() => {
|
||||||
const averageSpeed = computed(() => {
|
if (!analysisData.value?.protocolStats) return []
|
||||||
if (!softwareDetail.value?.totalBytes) return 0
|
|
||||||
// 模拟计算,实际应根据时间范围计算
|
const colors = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6']
|
||||||
return softwareDetail.value.totalBytes / 3600 // 假设1小时的数据
|
return analysisData.value.protocolStats.map((stat, index) => ({
|
||||||
|
protocol: stat.protocol,
|
||||||
|
bytes: stat.totalTraffic,
|
||||||
|
percentage: stat.percentage,
|
||||||
|
color: colors[index % colors.length]
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
// API调用函数
|
const timeTrendData = computed(() => {
|
||||||
const getSoftwareDetail = async () => {
|
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
|
if (!props.appId) return
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const params = {
|
const params = {
|
||||||
timeRange: props.timeRange || '1hour',
|
appId: props.appId,
|
||||||
|
timeRange: props.timeRange
|
||||||
}
|
}
|
||||||
|
|
||||||
const res: ApiResponse<SoftwareDetail> = await httpClient.get(
|
const response: ApiResponse<NetworkAnalysisData> = await httpClient.get(
|
||||||
`/apps/${props.appId}/detail`,
|
'/apps/network-analysis',
|
||||||
params,
|
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) {
|
} catch (error) {
|
||||||
console.error('获取软件详情失败:', error)
|
console.error('获取网络分析数据异常:', error)
|
||||||
|
analysisData.value = null
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSoftwareInfo = async () => {
|
// 事件处理
|
||||||
if (!props.appId) return
|
const refreshData = () => {
|
||||||
|
fetchAnalysisData()
|
||||||
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 getNetworkRelation = async () => {
|
const closeModal = () => {
|
||||||
if (!props.appId) return
|
showModal.value = false
|
||||||
|
|
||||||
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 getPortStats = async () => {
|
const handleIconError = (event: Event) => {
|
||||||
if (!props.appId) return
|
const target = event.target as HTMLImageElement
|
||||||
|
target.style.display = 'none'
|
||||||
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 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 => {
|
const formatBytes = (bytes: number): string => {
|
||||||
if (bytes === 0) return '0 B'
|
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]}`
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式化速度
|
// 监听器
|
||||||
const formatSpeed = (bytesPerSecond: number): string => {
|
watch(() => props.show, (newShow) => {
|
||||||
return formatBytes(bytesPerSecond) + '/s'
|
if (newShow && props.appId) {
|
||||||
}
|
nextTick(() => {
|
||||||
|
fetchAnalysisData()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 导出数据
|
watch(() => [props.appId, props.timeRange], () => {
|
||||||
const exportData = () => {
|
if (props.show && props.appId) {
|
||||||
// 实现数据导出功能
|
fetchAnalysisData()
|
||||||
console.log('Export data for:', props.software?.processName)
|
}
|
||||||
}
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.software-detail-content {
|
/* 弹窗基础样式 */
|
||||||
max-height: 70vh;
|
.software-detail-modal :deep(.n-modal) {
|
||||||
overflow-y: auto;
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 详情头部 */
|
.modal-card {
|
||||||
.detail-header {
|
width: 1200px;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
flex-direction: column;
|
||||||
gap: 16px;
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
margin-bottom: 24px;
|
|
||||||
padding-bottom: 16px;
|
|
||||||
border-bottom: 1px solid var(--border-secondary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
border-radius: 12px;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: white;
|
font-size: 24px;
|
||||||
font-weight: 600;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.software-summary {
|
.header-info h3 {
|
||||||
flex: 1;
|
margin: 0;
|
||||||
}
|
|
||||||
|
|
||||||
.software-name {
|
|
||||||
font-size: 20px;
|
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;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 选项卡内容 */
|
.header-info p {
|
||||||
.detail-tabs {
|
margin: 4px 0 0 0;
|
||||||
margin-top: 8px;
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-content {
|
.header-actions {
|
||||||
padding: 16px 0;
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 概览页面 */
|
/* 统计概览 */
|
||||||
.overview-grid {
|
.stats-overview {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 300px;
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 24px;
|
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 {
|
.section-title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-secondary);
|
color: var(--text-primary);
|
||||||
margin: 0 0 12px 0;
|
margin: 0;
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.protocol-section {
|
|
||||||
display: flex;
|
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;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-icon {
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-extra {
|
||||||
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder-desc {
|
.connection-count {
|
||||||
font-size: 12px;
|
background: var(--accent-primary);
|
||||||
margin: 0;
|
color: white;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-grid {
|
.chart-container {
|
||||||
display: grid;
|
flex: 1;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-radius: 8px;
|
overflow: hidden;
|
||||||
border: 1px solid var(--border-secondary);
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card .stat-label {
|
.empty-chart {
|
||||||
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 {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 80px 20px;
|
height: 100%;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-data-icon {
|
.empty-chart span {
|
||||||
font-size: 64px;
|
font-size: 13px;
|
||||||
margin-bottom: 16px;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-data-text {
|
/* 数据表格 */
|
||||||
font-size: 16px;
|
.data-tables {
|
||||||
color: var(--text-secondary);
|
flex: 1;
|
||||||
|
background: var(--bg-card);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 应用图标 */
|
.table-tabs {
|
||||||
.app-icon {
|
height: 100%;
|
||||||
width: 48px;
|
display: flex;
|
||||||
height: 48px;
|
flex-direction: column;
|
||||||
border-radius: 8px;
|
|
||||||
object-fit: contain;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 响应式 */
|
.table-tabs :deep(.n-tabs) {
|
||||||
@media (max-width: 768px) {
|
height: 100%;
|
||||||
.detail-header {
|
display: flex;
|
||||||
flex-direction: column;
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Naive UI 覆盖样式 */
|
.table-tabs :deep(.n-tabs-nav) {
|
||||||
:deep(.n-tabs .n-tab-pane) {
|
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;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.n-tabs .n-tabs-nav) {
|
/* 响应式设计 */
|
||||||
--n-tab-color-hover: var(--bg-hover);
|
@media (max-width: 1024px) {
|
||||||
--n-tab-text-color-active: var(--accent-primary);
|
.modal-card {
|
||||||
--n-bar-color: var(--accent-primary);
|
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>
|
</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