A:重新调整虚拟列表组件

This commit is contained in:
jimmy committed 2025-07-07 15:16:54 +08:00
1 parent 7716850e16
commit e737713ef5
23 files changed
+3660 -1325

No files matched your search

+19 -20
View File
@@ -10,9 +10,6 @@ import { NConfigProvider, NGlobalStyle } from 'naive-ui'
import { useThemeStore } from '@/stores/theme'
import NetworkConnectionPanel1 from '@/components/NetworkConnectionPanel1.vue'
import MainLayout from './layouts/MainLayout.vue'
import NetworkConnectionPanel2 from '@/components/NetworkConnectionPanel2.vue'
import UnifiedConnectionsList from '@/views/MonitorView/components/UnifiedConnectionsList.vue'
import UnifiedConnectionsList1 from '@/views/MonitorView/components/UnifiedConnectionsList1.vue'
const router = useRouter()
// 是否展示生成路由
@@ -175,35 +172,37 @@ const getLayout = (route) => {
</div>
<!-- 展示主要内容 -->
<!-- <div class="main-content">-->
<!-- <router-view v-slot="{ Component, route }">-->
<!-- <transition name="fade-slide" mode="out-in">-->
<!-- <component :is="getLayout(route)">-->
<!-- <component :is="Component" />-->
<!-- </component>-->
<!-- </transition>-->
<!-- </router-view>-->
<!-- &lt;!&ndash; <network-connection-panel2/>&ndash;&gt;-->
<div class="main-content">
<router-view v-slot="{ Component, route }">
<transition name="fade-slide" mode="out-in">
<component :is="getLayout(route)">
<component :is="Component" />
</component>
</transition>
</router-view>
<!-- <network-connection-panel2/>-->
</div>
<!-- <div class="div1">-->
<!-- <div class="div2">-->
<!-- <UnifiedConnectionsList4 />-->
<!-- </div>-->
<!-- </div>-->
<div class="div1">
<div class="div2">
<UnifiedConnectionsList1/>
</div>
</div>
</n-message-provider>
</n-config-provider>
</template>
<style scoped>
.div1{
.div1 {
padding: 50px;
border: 1px solid red;
}
.div2{
.div2 {
border: 1px solid red;
margin: 50px;
height: 500px;
}
.app-wrapper {
position: relative;
width: 100%;
@@ -0,0 +1,162 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useLayoutStore = defineStore('layout', () => {
// 布局尺寸状态
const sidebarWidth = ref(400)
const inspectorWidth = ref(350)
const timelineHeight = ref(200)
const mainHeaderHeight = ref(80)
// 调整分割条范围限制
const MIN_SIDEBAR_WIDTH = 200
const MAX_SIDEBAR_WIDTH = 600
const MIN_INSPECTOR_WIDTH = 250
const MAX_INSPECTOR_WIDTH = 650
const MIN_TIMELINE_HEIGHT = 100
const MAX_TIMELINE_HEIGHT = 800
// 拖拽状态
const resizing = ref<{
type: 'sidebar' | 'inspector' | 'timeline' | null
startX: number
startY: number
startWidth: number
startHeight: number
}>({
type: null,
startX: 0,
startY: 0,
startWidth: 0,
startHeight: 0,
})
// 计算属性
const isResizing = computed(() => resizing.value.type !== null)
// 开始拖拽
const startResize = (type: 'sidebar' | 'inspector' | 'timeline', event: MouseEvent) => {
event.preventDefault()
resizing.value = {
type,
startX: event.clientX,
startY: event.clientY,
startWidth:
type === 'sidebar' ? sidebarWidth.value : type === 'inspector' ? inspectorWidth.value : 0,
startHeight: type === 'timeline' ? timelineHeight.value : 0,
}
document.addEventListener('mousemove', handleResize)
document.addEventListener('mouseup', stopResize)
document.body.style.cursor = type === 'timeline' ? 'row-resize' : 'col-resize'
document.body.style.userSelect = 'none'
}
// 处理拖拽
const handleResize = (event: MouseEvent) => {
if (!resizing.value.type) return
const { type, startX, startY, startWidth, startHeight } = resizing.value
if (type === 'sidebar') {
const deltaX = event.clientX - startX
const newWidth = Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, startWidth + deltaX))
sidebarWidth.value = newWidth
} else if (type === 'inspector') {
const deltaX = startX - event.clientX
const newWidth = Math.min(
MAX_INSPECTOR_WIDTH,
Math.max(MIN_INSPECTOR_WIDTH, startWidth + deltaX),
)
inspectorWidth.value = newWidth
} else if (type === 'timeline') {
const deltaY = startY - event.clientY
const newHeight = Math.min(
MAX_TIMELINE_HEIGHT,
Math.max(MIN_TIMELINE_HEIGHT, startHeight + deltaY),
)
timelineHeight.value = newHeight
}
}
// 停止拖拽
const stopResize = () => {
resizing.value.type = null
document.removeEventListener('mousemove', handleResize)
document.removeEventListener('mouseup', stopResize)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
// 重置布局
const resetLayout = () => {
sidebarWidth.value = 400
inspectorWidth.value = 350
timelineHeight.value = 200
mainHeaderHeight.value = 80
}
// 设置布局尺寸(用于恢复状态)
const setLayoutSizes = (sizes: {
sidebarWidth?: number
inspectorWidth?: number
timelineHeight?: number
mainHeaderHeight?: number
}) => {
if (sizes.sidebarWidth !== undefined) {
sidebarWidth.value = Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, sizes.sidebarWidth))
}
if (sizes.inspectorWidth !== undefined) {
inspectorWidth.value = Math.min(MAX_INSPECTOR_WIDTH, Math.max(MIN_INSPECTOR_WIDTH, sizes.inspectorWidth))
}
if (sizes.timelineHeight !== undefined) {
timelineHeight.value = Math.min(MAX_TIMELINE_HEIGHT, Math.max(MIN_TIMELINE_HEIGHT, sizes.timelineHeight))
}
if (sizes.mainHeaderHeight !== undefined) {
mainHeaderHeight.value = sizes.mainHeaderHeight
}
}
// 获取当前布局配置(用于持久化)
const getLayoutConfig = () => ({
sidebarWidth: sidebarWidth.value,
inspectorWidth: inspectorWidth.value,
timelineHeight: timelineHeight.value,
mainHeaderHeight: mainHeaderHeight.value,
})
// 清理资源
const cleanup = () => {
if (isResizing.value) {
stopResize()
}
}
return {
// 状态
sidebarWidth,
inspectorWidth,
timelineHeight,
mainHeaderHeight,
resizing,
isResizing,
// 常量
MIN_SIDEBAR_WIDTH,
MAX_SIDEBAR_WIDTH,
MIN_INSPECTOR_WIDTH,
MAX_INSPECTOR_WIDTH,
MIN_TIMELINE_HEIGHT,
MAX_TIMELINE_HEIGHT,
// 方法
startResize,
handleResize,
stopResize,
resetLayout,
setLayoutSizes,
getLayoutConfig,
cleanup,
}
})
@@ -1,5 +1,5 @@
<template>
<div class="main-view">
<div ref="mainViewRef" class="main-view">
<!-- 顶部 - 搜索和标题 -->
<div class="main-header" :style="{ height: headerHeight + 'px' }">
<div class="header-content">
@@ -23,18 +23,25 @@
</div>
<div class="filter-buttons">
<n-button size="small" type="primary" ghost @click="handleFilter">过滤</n-button>
<n-button size="small" ghost @click="handleRefresh">刷新</n-button>
<n-button size="small" type="primary" quaternary @click="handleFilter">
过滤
</n-button>
<n-button size="small" quaternary @click="handleRefresh">
刷新
</n-button>
</div>
</div>
</div>
</div>
<!-- 连接列表 -->
<connections-table1 :connections="filteredConnections" />
<connections-table1
:connections="filteredConnections"
:height="connectionsTableHeight"
/>
<!-- 时间轴分割线 -->
<div class="resize-handle-horizontal" @mousedown="$emit('resizeTimeline', $event)">
<div class="resize-handle-horizontal" @mousedown="startTimelineResize">
<div class="resize-handle-hover-h"></div>
</div>
@@ -50,10 +57,9 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, onUnmounted } from 'vue'
import { ref, computed, onMounted, watch, onUnmounted, nextTick } from 'vue'
import { NButton, NInput, NIcon } from 'naive-ui'
import { SearchOutline } from '@vicons/ionicons5'
import ConnectionsTable from './components/ConnectionsTable.vue'
import EventTimeline from './components/EventTimeline.vue'
import { FlashOutline } from '@vicons/ionicons5'
import { useApplicationStore } from '@/stores/application'
@@ -64,6 +70,7 @@ import { useWebSocketStore } from '@/stores/websocketStore'
import { useProcessStore } from '@/stores/processInfo'
import { ApplicationType } from '@/types/infoModel'
import ConnectionsTable1 from '@/views/MonitorView/components/ConnectionsTable1.vue'
import ConnectionsTable from '@/views/MonitorView/components/ConnectionsTable.vue'
import UnifiedConnectionsList from '@/views/MonitorView/components/UnifiedConnectionsList.vue'
const webSocketStore = useWebSocketStore()
@@ -76,19 +83,126 @@ const processStore = useProcessStore()
const { processInfos } = storeToRefs(processStore)
processStore.subscribe()
// 在父组件中
// 组件引用
const mainViewRef = ref<HTMLElement>()
// 布局尺寸控制
const headerHeight = ref(80)
const timelineHeight = ref(200)
const mainViewHeight = ref(600)
// Timeline拖拽限制
const MIN_TIMELINE_HEIGHT = 100
const MAX_TIMELINE_HEIGHT = 800
// Timeline拖拽状态
const timelineResizing = ref<{
isResizing: boolean
startY: number
startHeight: number
}>({
isResizing: false,
startY: 0,
startHeight: 0,
})
// 计算ConnectionsTable1的可用高度
const connectionsTableHeight = computed(() => {
const totalHeight = mainViewHeight.value
const usedHeight = headerHeight.value + timelineHeight.value + 4 // 4px为分割线高度
return `${Math.max(200, totalHeight - usedHeight)}px`
})
// Timeline拖拽逻辑
const startTimelineResize = (event: MouseEvent) => {
event.preventDefault()
timelineResizing.value = {
isResizing: true,
startY: event.clientY,
startHeight: timelineHeight.value,
}
document.addEventListener('mousemove', handleTimelineResize)
document.addEventListener('mouseup', stopTimelineResize)
document.body.style.cursor = 'row-resize'
document.body.style.userSelect = 'none'
}
const handleTimelineResize = (event: MouseEvent) => {
if (!timelineResizing.value.isResizing) return
const { startY, startHeight } = timelineResizing.value
const deltaY = startY - event.clientY // 向上拖拽为正值
const newHeight = Math.min(
MAX_TIMELINE_HEIGHT,
Math.max(MIN_TIMELINE_HEIGHT, startHeight + deltaY)
)
timelineHeight.value = newHeight
}
const stopTimelineResize = () => {
timelineResizing.value.isResizing = false
document.removeEventListener('mousemove', handleTimelineResize)
document.removeEventListener('mouseup', stopTimelineResize)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
// 更新主视图高度
const updateMainViewHeight = () => {
if (mainViewRef.value) {
mainViewHeight.value = mainViewRef.value.clientHeight
}
}
// 展开的进程ID列表
const expandedProcesses = ref<number[]>([])
// 过滤后的连接数据
const filteredConnections = computed(() => {
return processInfos.value || []
const connections = processInfos.value || []
// 如果有搜索条件,进行过滤
if (props.searchQuery) {
const query = props.searchQuery.toLowerCase()
return connections.filter(process => {
// 搜索进程名
if (process.processName.toLowerCase().includes(query)) {
return true
}
// 搜索连接信息
return process.connections.some(conn =>
conn.localEndpoint.address.includes(query) ||
conn.remoteEndpoint.address.includes(query) ||
conn.localEndpoint.port.toString().includes(query) ||
conn.remoteEndpoint.port.toString().includes(query)
)
})
}
return connections
})
watch(selectedApp, (newVal, oldVal) => {
// 进行更新,先把原始数据清空
processStore.clear()
// 重置展开状态
expandedProcesses.value = []
console.log(newVal.id, '==========')
})
onMounted(() => {
// 初始化主视图高度
nextTick(() => {
updateMainViewHeight()
})
// 监听窗口大小变化
window.addEventListener('resize', updateMainViewHeight)
// 监听 WebSocket 状态
watch(
[isOpen, selectedApp],
@@ -118,6 +232,11 @@ onMounted(() => {
})
onUnmounted(() => {
// 清理事件监听
window.removeEventListener('resize', updateMainViewHeight)
document.removeEventListener('mousemove', handleTimelineResize)
document.removeEventListener('mouseup', stopTimelineResize)
const subAppInfo: SubscriptionInfo = {
subscriptionType: 'ProcessInfo',
interval: 1,
@@ -133,19 +252,16 @@ onUnmounted(() => {
})
})
// Props
// Props - 移除不再需要的height相关props
const props = defineProps<{
headerHeight: number
timelineHeight: number
searchQuery: string
connections: Array<unknown>
events: Array<unknown>
}>()
// Emits
// Emits - 移除resize相关emit
const emit = defineEmits<{
'update:searchQuery': [value: string]
resizeTimeline: [event: MouseEvent]
selectConnection: [connection: unknown]
}>()
@@ -161,6 +277,8 @@ const handleFilter = () => {
// 处理刷新
const handleRefresh = () => {
console.log('刷新数据')
// 可以在这里触发数据重新加载
processStore.refresh?.()
}
// 清空事件
@@ -179,6 +297,7 @@ const handleClearEvents = () => {
min-height: 0;
background: var(--bg-card);
overflow: hidden;
height: 100%;
}
/* 顶部区域 */
@@ -45,264 +45,372 @@
</div>
</div>
<!-- 进程列表 -->
<div class="process-list">
<n-empty
v-if="connections.length === 0"
description="暂无进程数据"
:style="{ padding: '40px' }"
/>
<transition-group name="process-list" tag="div" v-else>
<!-- 统一连接列表容器 -->
<div class="unified-connections-container">
<!-- 吸顶元素容器 -->
<Transition name="sticky-slide">
<div
v-for="process in connections"
:key="process.processId"
class="process-item"
:class="{
'is-expanded': expandedItems.has(process.processId),
'is-exited': process.hasExited,
}"
v-show="shouldShowSticky && stickyItem"
class="sticky-header"
:style="stickyStyle"
>
<!-- 进程头部 -->
<div class="process-header" @click="toggleExpand(process.processId)">
<div class="header-left">
<div class="expand-indicator">
<n-icon
:component="ChevronForwardOutline"
:size="expandIconSize"
class="expand-arrow"
/>
</div>
<div class="process-info">
<div class="process-title">
<h4 class="process-name">{{ process.processName }}</h4>
<n-tag :type="getProcessStatus(process).type" :size="tagSize" round>
{{ getProcessStatus(process).text }}
</n-tag>
<n-tag
v-if="process.hasExited && process.exitCode !== undefined && !isCompact"
:type="process.exitCode === 0 ? 'success' : 'error'"
:size="tagSize"
>
退出代码: {{ process.exitCode }}
</n-tag>
<div class="sticky-content">
<template v-if="stickyItem?.isCollapsedIndicator">
<!-- 折叠状态的吸顶 -->
<div class="sticky-collapsed">
<div class="sticky-icon collapsed">
<n-icon :component="FolderOutline" size="20" />
</div>
<div class="process-details">
<span class="detail-item primary">
<n-icon :component="DesktopOutline" :size="detailIconSize" />
PID: {{ process.processId }}
</span>
<span class="detail-item" v-show="showProcessDetail('time')">
<n-icon :component="TimeOutline" :size="detailIconSize" />
{{ process.hasExited ? '运行时长' : '已运行' }}:
{{ formatDuration(new Date(process.startTime), process.exitTime ? new Date(process.exitTime) : undefined) }}
</span>
<span class="detail-item" v-show="showProcessDetail('thread')">
<n-icon :component="PeopleOutline" :size="detailIconSize" />
{{ process.threadCount }} 线程
</span>
<span class="detail-item" v-show="showProcessDetail('memory')">
<n-icon :component="HardwareChipOutline" :size="detailIconSize" />
内存: {{ formatBytes(process.useMemory) }}
</span>
<span class="detail-item primary">
<n-icon :component="GitNetworkOutline" :size="detailIconSize" />
{{ process.connections.length }} 个连接
</span>
<span
v-if="process.mainModuleName && showProcessDetail('module')"
class="detail-item"
:title="process.mainModulePath"
>
<n-icon :component="FileTrayFullOutline" :size="detailIconSize" />
{{ process.mainModuleName }}
</span>
</div>
</div>
</div>
<div class="header-right">
<div class="traffic-summary">
<div class="traffic-speed">
<div class="traffic-item upload">
<n-icon :component="ArrowUpOutline" :size="trafficIconSize" />
<span>{{ formatSpeed(process.uploadSpeed) }}</span>
</div>
<div class="traffic-item download">
<n-icon :component="ArrowDownOutline" :size="trafficIconSize" />
<span>{{ formatSpeed(process.downloadSpeed) }}</span>
<div class="sticky-info">
<div class="sticky-title">{{ stickyItem.processName }}</div>
<div class="sticky-subtitle">
<n-icon :component="LayersOutline" size="14" />
{{ stickyItem.collapsedCount }} 个连接已折叠
</div>
</div>
<div class="traffic-total" v-if="!isCompact">
<span>
总计: ↑{{ formatBytes(process.totalUploaded) }} ↓{{
formatBytes(process.totalDownloaded)
}}
</span>
<n-button
size="small"
type="primary"
@click="expandSection(stickyItem.processIndex)"
>
<template #icon>
<n-icon :component="ChevronDownOutline" />
</template>
展开
</n-button>
</div>
</template>
<template v-else-if="stickyItem?.isProcess">
<!-- 进程标题的吸顶 -->
<div class="sticky-process">
<div class="sticky-icon process">
<n-icon :component="DesktopOutline" size="20" />
</div>
<div class="sticky-info">
<div class="sticky-title">
{{ stickyItem.processName }}
<n-tag class="sticky-pid" size="tiny" round>
PID: {{ stickyItem.processId }}
</n-tag>
</div>
<div class="sticky-stats">
<span class="stat-item">
<n-icon :component="HardwareChipOutline" size="14" />
{{ formatBytes(stickyItem.useMemory) }}
</span>
<span class="stat-item">
<n-icon :component="GitBranchOutline" size="14" />
{{ stickyItem.threadCount }} 线程
</span>
<span class="stat-item connections">
<n-icon :component="GitNetworkOutline" size="14" />
{{ stickyItem.connectionCount }} 连接
</span>
</div>
</div>
<div class="sticky-actions">
<n-button
size="small"
quaternary
circle
@click="toggleCollapseWithScrollAdjust(stickyItem.processIndex)"
>
<template #icon>
<n-icon
:component="collapsedSections.has(stickyItem.processIndex) ? ChevronDownOutline : ChevronUpOutline"
size="18"
/>
</template>
</n-button>
</div>
</div>
</div>
</template>
</div>
</div>
</Transition>
<!-- 连接列表 -->
<n-collapse-transition :show="expandedItems.has(process.processId)">
<div class="connections-content">
<div class="content-wrapper">
<div class="connections-header">
<h5 class="connections-title">网络连接详情</h5>
<span class="connection-count">{{ process.connections.length }} 个连接</span>
<!-- 虚拟列表 -->
<n-virtual-list
ref="virtualListRef"
class="connections-list"
:style="{ maxHeight: virtualListHeight }"
:item-size="itemHeight"
:items="displayItems"
@scroll="handleScroll"
item-resizable
>
<template #default="{ item, index }">
<div
:key="item.key"
class="list-item"
:class="{
'is-process': item.isProcess,
'is-connection': item.isConnection,
'is-collapsed-indicator': item.isCollapsedIndicator,
'is-hidden': shouldHideItem(item, index),
'is-active': item.isConnection && item.isActive,
}"
:style="{ minHeight: `${itemHeight}px` }"
>
<template v-if="item.isCollapsedIndicator">
<!-- 折叠状态指示器 -->
<div class="collapsed-indicator">
<div class="indicator-left">
<div class="collapse-icon">
<n-icon :component="LayersOutline" size="18" />
</div>
<div class="indicator-content">
<span class="indicator-title">{{ item.processName }}</span>
<span class="indicator-count">{{ item.collapsedCount }} 个连接已折叠</span>
</div>
</div>
<n-button
size="small"
type="primary"
ghost
@click="expandSection(item.processIndex)"
>
<template #icon>
<n-icon :component="ChevronDownOutline" size="16" />
</template>
展开查看
</n-button>
</div>
</template>
<template v-else-if="item.isProcess">
<!-- 进程标题 -->
<div class="process-header">
<div class="process-icon-wrapper">
<n-icon :component="DesktopOutline" size="24" />
</div>
<div class="process-info">
<div class="process-title">
<h4 class="process-name">{{ item.processName }}</h4>
<n-tag type="info" size="small" round>
PID: {{ item.processId }}
</n-tag>
<n-tag
v-if="getProcessStatus(item).show"
:type="getProcessStatus(item).type"
size="small"
round
>
{{ getProcessStatus(item).text }}
</n-tag>
<n-tag
v-if="item.hasExited && item.exitCode !== undefined && !isCompact"
:type="item.exitCode === 0 ? 'success' : 'error'"
size="small"
>
退出代码: {{ item.exitCode }}
</n-tag>
</div>
<div class="process-meta">
<span class="meta-item">
<n-icon :component="HardwareChipOutline" size="14" />
<span>内存: {{ formatBytes(item.useMemory) }}</span>
</span>
<span class="meta-item">
<n-icon :component="GitBranchOutline" size="14" />
<span>{{ item.threadCount }} 线程</span>
</span>
<span class="meta-item primary">
<n-icon :component="GitNetworkOutline" size="14" />
<span>{{ item.connectionCount }} 个连接</span>
</span>
<span v-if="item.startTime" class="meta-item">
<n-icon :component="TimeOutline" size="14" />
<span>{{ item.hasExited ? '运行时长' : '已运行' }}: {{ formatDuration(new Date(item.startTime), item.exitTime ? new Date(item.exitTime) : undefined) }}</span>
</span>
<span
v-if="item.mainModuleName && showProcessDetail('module')"
class="meta-item"
:title="item.mainModulePath"
>
<n-icon :component="FileTrayFullOutline" size="14" />
{{ item.mainModuleName }}
</span>
</div>
</div>
<div class="process-actions">
<div class="traffic-summary">
<div class="traffic-speed">
<div class="traffic-item upload">
<n-icon :component="ArrowUpOutline" :size="trafficIconSize" />
<span>{{ formatSpeed(item.uploadSpeed) }}</span>
</div>
<div class="traffic-item download">
<n-icon :component="ArrowDownOutline" :size="trafficIconSize" />
<span>{{ formatSpeed(item.downloadSpeed) }}</span>
</div>
</div>
<div class="traffic-total" v-if="!isCompact">
<span>
总计: ↑{{ formatBytes(item.totalUploaded) }} ↓{{ formatBytes(item.totalDownloaded) }}
</span>
</div>
</div>
<n-button
size="small"
quaternary
@click="toggleCollapse(item.processIndex)"
class="collapse-btn"
>
<template #icon>
<n-icon
:component="collapsedSections.has(item.processIndex) ? ChevronDownOutline : ChevronUpOutline"
size="16"
/>
</template>
{{ collapsedSections.has(item.processIndex) ? '展开' : '折叠' }}
</n-button>
</div>
</div>
</template>
<template v-else-if="item.isConnection">
<!-- 连接详情 -->
<div class="connection-item">
<div class="connection-status">
<div
class="status-indicator"
:class="item.isActive ? 'active' : 'inactive'"
:title="item.isActive ? '活跃' : '空闲'"
></div>
</div>
<n-empty
v-if="process.connections.length === 0"
description="没有网络连接"
:style="{ padding: isCompact ? '12px' : '20px' }"
/>
<div v-else class="connection-list">
<div
v-for="connection in process.connections"
:key="connection.connectionKey"
class="connection-item"
:class="{ 'is-active': connection.isActive }"
>
<div class="connection-main">
<div class="connection-status">
<div
class="status-indicator"
:class="connection.isActive ? 'active' : 'inactive'"
:title="connection.isActive ? '活跃' : '空闲'"
></div>
<div class="connection-main">
<div class="connection-header">
<div class="connection-endpoints">
<div class="endpoint-item local">
<n-icon :component="LocationOutline" size="14" />
<span class="endpoint-address">{{ formatEndpoint(item.localEndpoint) }}</span>
</div>
<div class="connection-info">
<div class="connection-tags">
<n-tag
:type="getConnectionStateType(connection.state, connection.isActive)"
:size="tagSize"
>
{{ connection.state }}
</n-tag>
<n-tag :size="tagSize">{{ connection.protocol }}</n-tag>
<n-tag :size="tagSize" v-if="!isCompact">
<n-icon :component="getDirectionIcon(connection.direction)" size="12" />
{{ getDirectionText(connection.direction) }}
</n-tag>
</div>
<div class="connection-addresses">
<div class="address-section">
<n-icon :component="LocationOutline" :size="addressIconSize" />
<span class="address-value">
{{ formatEndpoint(connection.localEndpoint) }}
</span>
</div>
<div class="connection-arrow">
<n-icon :component="ArrowForwardOutline" :size="arrowIconSize" />
</div>
<div class="address-section">
<n-icon :component="GlobeOutline" :size="addressIconSize" />
<span class="address-value">
{{ formatEndpoint(connection.remoteEndpoint) }}
</span>
</div>
</div>
<div class="connection-meta" v-if="!isCompact">
<span class="meta-item">
<n-icon :component="TimeOutline" size="12" />
持续: {{ formatDuration(new Date(connection.startTime)) }}
</span>
<span v-if="!connection.isActive" class="meta-item inactive">
<n-icon :component="PauseCircleOutline" size="12" />
最后活跃: {{ getTimeSinceActive(new Date(connection.lastActiveTime)) }}
</span>
</div>
<n-icon :component="ArrowForwardOutline" size="16" class="connection-arrow" />
<div class="endpoint-item remote">
<n-icon :component="GlobeOutline" size="14" />
<span class="endpoint-address">{{ formatEndpoint(item.remoteEndpoint) }}</span>
</div>
</div>
<div class="connection-side">
<div class="connection-traffic">
<div class="traffic-stats">
<div class="traffic-row upload">
<n-icon :component="ArrowUpOutline" :size="trafficIconSize" />
<span class="traffic-value">{{
formatSpeed(connection.currentSendSpeed)
}}</span>
</div>
<div class="traffic-row download">
<n-icon :component="ArrowDownOutline" :size="trafficIconSize" />
<span class="traffic-value">{{
formatSpeed(connection.currentReceiveSpeed)
}}</span>
</div>
</div>
<div class="traffic-total-small" v-if="!isCompact">
总计: ↑{{ formatBytes(connection.bytesSent) }} ↓{{
formatBytes(connection.bytesReceived)
}}
</div>
</div>
<div class="connection-tags">
<n-tag
:type="getConnectionStateType(item.state, item.isActive)"
size="tiny"
round
>
{{ item.state }}
</n-tag>
<n-tag size="tiny" round>
{{ item.protocol }}
</n-tag>
<n-tag size="tiny" v-if="!isCompact && item.direction">
<n-icon :component="getDirectionIcon(item.direction)" size="12" />
{{ getDirectionText(item.direction) }}
</n-tag>
</div>
</div>
<div class="connection-actions" v-if="showActions">
<n-tooltip trigger="hover">
<template #trigger>
<n-button size="tiny" circle quaternary type="info" @click.stop="handleViewDetails(connection)">
<template #icon>
<n-icon :component="InformationCircleOutline" />
</template>
</n-button>
</template>
查看详情
</n-tooltip>
<n-tooltip trigger="hover">
<template #trigger>
<n-button size="tiny" circle quaternary type="warning" @click.stop="handleDisconnect(connection)">
<template #icon>
<n-icon :component="StopCircleOutline" />
</template>
</n-button>
</template>
断开连接
</n-tooltip>
<div class="connection-stats">
<div class="traffic-info">
<div class="traffic-item upload">
<n-icon :component="ArrowUpOutline" size="12" />
<span>{{ formatSpeed(item.currentSendSpeed || 0) }}</span>
</div>
<div class="traffic-item download">
<n-icon :component="ArrowDownOutline" size="12" />
<span>{{ formatSpeed(item.currentReceiveSpeed || 0) }}</span>
</div>
</div>
<div class="connection-meta-info">
<span v-if="item.startTime" class="meta-text">
<n-icon :component="TimeOutline" size="12" />
持续: {{ formatDuration(new Date(item.startTime)) }}
</span>
<span v-if="!item.isActive && item.lastActiveTime" class="meta-text inactive">
<n-icon :component="PauseCircleOutline" size="12" />
最后活跃: {{ getTimeSinceActive(new Date(item.lastActiveTime)) }}
</span>
</div>
</div>
</div>
<div class="connection-side" v-if="showActions">
<div class="connection-actions">
<n-tooltip trigger="hover">
<template #trigger>
<n-button size="tiny" circle quaternary type="info" @click.stop="handleViewDetails(item)">
<template #icon>
<n-icon :component="InformationCircleOutline" />
</template>
</n-button>
</template>
查看详情
</n-tooltip>
<n-tooltip trigger="hover">
<template #trigger>
<n-button size="tiny" circle quaternary type="warning" @click.stop="handleDisconnect(item)">
<template #icon>
<n-icon :component="StopCircleOutline" />
</template>
</n-button>
</template>
断开连接
</n-tooltip>
</div>
</div>
</div>
</div>
</n-collapse-transition>
</template>
</div>
</template>
</n-virtual-list>
<!-- 无数据提示 -->
<Transition name="fade">
<div v-if="displayItems.length === 0" class="empty-state">
<div class="empty-icon">
<n-icon :component="CloudOfflineOutline" size="64" />
</div>
<h3>暂无网络连接数据</h3>
<p>请检查数据源是否正确加载</p>
</div>
</transition-group>
</Transition>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { NButton, NIcon, NTag, NEmpty, NCollapseTransition, NTooltip } from 'naive-ui'
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
import { NButton, NIcon, NTag, NEmpty, NVirtualList, NTooltip } from 'naive-ui'
import {
FolderOutline,
ChevronDownOutline,
ChevronUpOutline,
DesktopOutline,
HardwareChipOutline,
GitBranchOutline,
GitNetworkOutline,
ArrowForwardOutline,
ArrowUpOutline,
ArrowDownOutline,
ChevronForwardOutline,
TimeOutline,
HardwareChipOutline,
CloudOfflineOutline,
LayersOutline,
LocationOutline,
GlobeOutline,
ArrowForwardOutline,
TimeOutline,
PauseCircleOutline,
InformationCircleOutline,
StopCircleOutline,
PeopleOutline,
FileTrayFullOutline,
PauseCircleOutline,
SwapHorizontalOutline,
} from '@vicons/ionicons5'
import type { ProcessType, ConnectionInfo, IPEndPoint } from '@/types/process'
import { ProtocolType, ConnectionState, TrafficDirection } from '@/constants/enums'
import { ProtocolType, ConnectionState, TrafficDirection, FILE_SIZE_UNIT_ENUM } from '@/constants/enums'
import { convertFileSize } from '@/utils/fileUtil'
// 尺寸类型
type SizeType = 'compact' | 'normal' | 'comfortable' | 'spacious'
@@ -313,6 +421,8 @@ interface Props {
showStats?: boolean
showActions?: boolean
defaultExpanded?: number[]
height?: string
itemHeight?: number
}
const props = withDefaults(defineProps<Props>(), {
@@ -320,6 +430,8 @@ const props = withDefaults(defineProps<Props>(), {
showStats: true,
showActions: true,
defaultExpanded: () => [],
height: '600px',
itemHeight: 80
})
// Emits
@@ -331,10 +443,21 @@ const emit = defineEmits<{
// 响应式相关
const containerRef = ref<HTMLElement>()
const virtualListRef = ref()
const containerWidth = ref(1200)
const resizeObserver = ref<ResizeObserver>()
const actualSizeType = ref<SizeType>('spacious')
// 虚拟列表相关
const scrollTop = ref(0)
const scrollbarWidth = ref(0)
const collapsedSections = ref(new Set<number>())
const beforeCollapseState = ref<{
scrollTop: number
visibleItemKey: string
offsetInItem: number
} | null>(null)
// 添加防抖处理
let resizeTimeout: number | undefined
@@ -360,11 +483,38 @@ const calculateSizeType = (width: number): SizeType => {
return currentType
}
// 检测滚动条宽度
const getScrollbarWidth = () => {
const outer = document.createElement('div')
outer.style.visibility = 'hidden'
outer.style.overflow = 'scroll'
outer.style.msOverflowStyle = 'scrollbar'
document.body.appendChild(outer)
const inner = document.createElement('div')
outer.appendChild(inner)
const width = outer.offsetWidth - inner.offsetWidth
outer.parentNode?.removeChild(outer)
return width
}
// 计算属性
const sizeType = computed(() => actualSizeType.value)
const sizeClass = computed(() => `size-${sizeType.value}`)
const isCompact = computed(() => sizeType.value === 'compact')
// 虚拟列表高度计算
const virtualListHeight = computed(() => {
// 减去统计卡片区域的高度
if (props.showStats) {
const statsHeight = isCompact.value ? 100 : 140
return `calc(${props.height} - ${statsHeight}px)`
}
return props.height
})
// 响应式图标大小
const statIconSize = computed(() => {
switch (sizeType.value) {
@@ -377,22 +527,7 @@ const statIconSize = computed(() => {
}
})
const expandIconSize = computed(() => (isCompact.value ? 14 : 16))
const detailIconSize = computed(() => (isCompact.value ? 12 : 14))
const trafficIconSize = computed(() => (isCompact.value ? 12 : 14))
const addressIconSize = computed(() => (isCompact.value ? 10 : 12))
const arrowIconSize = computed(() => (isCompact.value ? 12 : 14))
const tagSize = computed(() => {
switch (sizeType.value) {
case 'compact':
return 'tiny' as const
case 'normal':
return 'small' as const
default:
return 'small' as const
}
})
// 根据尺寸决定是否显示某些元素
const showStatCard = (type: string) => {
@@ -410,12 +545,9 @@ const showProcessDetail = (type: string) => {
return false
}
// 状态管理
const expandedItems = ref(new Set<number>())
// 初始化默认展开项
watch(() => props.defaultExpanded, (newVal) => {
expandedItems.value = new Set(newVal)
collapsedSections.value = new Set(newVal)
}, { immediate: true })
// 计算统计数据
@@ -426,8 +558,8 @@ const stats = computed(() => {
(sum, p) => sum + p.connections.filter((c) => c.isActive).length,
0,
)
const totalUploadSpeed = activeProcesses.reduce((sum, p) => sum + p.uploadSpeed, 0)
const totalDownloadSpeed = activeProcesses.reduce((sum, p) => sum + p.downloadSpeed, 0)
const totalUploadSpeed = activeProcesses.reduce((sum, p) => sum + (p.uploadSpeed || 0), 0)
const totalDownloadSpeed = activeProcesses.reduce((sum, p) => sum + (p.downloadSpeed || 0), 0)
return {
activeProcesses: activeProcesses.length,
@@ -439,8 +571,146 @@ const stats = computed(() => {
}
})
// 数据处理 - 将 ConnectionsTable1 的数据转换为 UnifiedConnectionsList4 的格式
const originalItems = computed(() => {
const result: any[] = []
if (!props.connections || !Array.isArray(props.connections) || props.connections.length === 0) {
return result
}
props.connections.forEach((process, processIndex) => {
if (!process) return
// 计算进程级别的统计信息
const uploadSpeed = process.uploadSpeed || 0
const downloadSpeed = process.downloadSpeed || 0
const totalUploaded = process.totalUploaded || 0
const totalDownloaded = process.totalDownloaded || 0
result.push({
key: `process-${processIndex}`,
isProcess: true,
processIndex,
processName: process.processName || 'Unknown Process',
processId: process.processId || 0,
useMemory: process.useMemory || 0,
threadCount: process.threadCount || 0,
connectionCount: process.connections?.length || 0,
startTime: process.startTime,
exitTime: process.exitTime,
hasExited: process.hasExited || false,
exitCode: process.exitCode,
mainModulePath: process.mainModulePath,
mainModuleName: process.mainModuleName,
connections: process.connections || [],
uploadSpeed,
downloadSpeed,
totalUploaded,
totalDownloaded,
})
if (process.connections && Array.isArray(process.connections)) {
process.connections.forEach((connection, connectionIndex) => {
result.push({
key: `connection-${processIndex}-${connectionIndex}`,
isConnection: true,
processIndex,
connectionIndex,
processName: process.processName || 'Unknown Process',
...connection,
})
})
}
})
return result
})
// 显示数据
const displayItems = computed(() => {
const result: any[] = []
const processGroups: { [key: number]: any[] } = {}
originalItems.value.forEach((item) => {
const processIndex = item.processIndex
if (!processGroups[processIndex]) {
processGroups[processIndex] = []
}
processGroups[processIndex].push(item)
})
Object.keys(processGroups).forEach((processIndexStr) => {
const processIndex = parseInt(processIndexStr)
const group = processGroups[processIndex]
const processItem = group.find((item) => item.isProcess)
const connectionItems = group.filter((item) => item.isConnection)
if (collapsedSections.value.has(processIndex)) {
result.push({
key: `collapsed-${processIndex}`,
isCollapsedIndicator: true,
processIndex,
processName: processItem.processName,
collapsedCount: connectionItems.length,
})
} else {
result.push(processItem)
connectionItems.forEach((connection) => {
result.push(connection)
})
}
})
return result
})
// 计算吸顶元素的样式
const stickyStyle = computed(() => ({
top: '0px',
right: `${scrollbarWidth.value}px`,
}))
// 吸顶逻辑
const getCurrentIndex = () => {
return Math.floor(scrollTop.value / props.itemHeight)
}
const stickyItem = computed(() => {
if (displayItems.value.length === 0) return null
const currentIndex = getCurrentIndex()
for (let i = currentIndex; i >= 0; i--) {
const item = displayItems.value[i]
if (item && (item.isProcess || item.isCollapsedIndicator)) {
return {
...item,
originalIndex: i,
}
}
}
return null
})
const shouldShowSticky = computed(() => {
if (!stickyItem.value) return false
const currentIndex = getCurrentIndex()
const stickyIndex = stickyItem.value.originalIndex
return currentIndex > stickyIndex
})
const shouldHideItem = (item: any, index: number) => {
if (!stickyItem.value || !shouldShowSticky.value) return false
return item.key === stickyItem.value.key
}
// 生命周期
onMounted(() => {
scrollbarWidth.value = getScrollbarWidth()
window.addEventListener('resize', handleResize)
if (containerRef.value) {
// 初始化尺寸类型
const initialWidth = containerRef.value.offsetWidth
@@ -478,27 +748,148 @@ onUnmounted(() => {
if (resizeObserver.value) {
resizeObserver.value.disconnect()
}
window.removeEventListener('resize', handleResize)
})
// 方法
const toggleExpand = (pid: number) => {
if (expandedItems.value.has(pid)) {
expandedItems.value.delete(pid)
} else {
expandedItems.value.add(pid)
// 响应式处理
const handleResize = () => {
scrollbarWidth.value = getScrollbarWidth()
}
// 交互逻辑
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement
scrollTop.value = target.scrollTop
}
const getFirstVisibleItem = () => {
const currentIndex = Math.floor(scrollTop.value / props.itemHeight)
const items = displayItems.value
if (currentIndex < items.length) {
return {
item: items[currentIndex],
index: currentIndex,
offsetInViewport: scrollTop.value % props.itemHeight,
}
}
return null
}
const toggleCollapseWithScrollAdjust = async (processIndex: number) => {
if (!collapsedSections.value.has(processIndex)) {
const visibleItem = getFirstVisibleItem()
if (visibleItem) {
beforeCollapseState.value = {
scrollTop: scrollTop.value,
visibleItemKey: visibleItem.item.key,
offsetInItem: visibleItem.offsetInViewport,
}
}
collapsedSections.value.add(processIndex)
collapsedSections.value = new Set(collapsedSections.value)
await nextTick()
if (beforeCollapseState.value && visibleItem) {
const newItems = displayItems.value
const newIndex = newItems.findIndex((item) => item.key === visibleItem.item.key)
if (newIndex !== -1) {
const newScrollTop = newIndex * props.itemHeight - visibleItem.offsetInViewport
virtualListRef.value?.scrollTo({ top: Math.max(0, newScrollTop) })
}
}
} else {
expandSection(processIndex)
}
}
const toggleCollapse = (processIndex: number) => {
if (collapsedSections.value.has(processIndex)) {
collapsedSections.value.delete(processIndex)
} else {
collapsedSections.value.add(processIndex)
}
collapsedSections.value = new Set(collapsedSections.value)
// 触发进程选择事件
const process = props.connections.find(p => p.processId === pid)
const process = props.connections.find(p => p.processId === processIndex)
if (process) {
emit('process-select', process)
}
}
const getProcessStatus = (process: ProcessType) => {
if (process.hasExited) return { type: 'error' as const, text: '已退出' }
if (process.connections.some((c) => c.isActive)) return { type: 'success' as const, text: '活跃' }
return { type: 'warning' as const, text: '空闲' }
const expandSection = (processIndex: number) => {
collapsedSections.value.delete(processIndex)
collapsedSections.value = new Set(collapsedSections.value)
}
// 格式化函数
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))
const value = parseFloat((bytes / Math.pow(k, i)).toFixed(2))
// 紧凑模式下简化单位
if (isCompact.value && i > 0) {
return value + sizes[i][0]
}
return value + ' ' + sizes[i]
}
const formatSpeed = (bytesPerSecond: number): string => {
const fileSize = convertFileSize(bytesPerSecond,FILE_SIZE_UNIT_ENUM.B)
return fileSize.size + fileSize.unit + '/s'
}
const formatEndpoint = (endpoint: IPEndPoint): string => {
if (isCompact.value && endpoint.address === '0.0.0.0') {
return `*:${endpoint.port}`
}
return `${endpoint.address}:${endpoint.port}`
}
const formatDuration = (start: Date, end?: Date): string => {
const endTime = end || new Date()
const diff = endTime.getTime() - start.getTime()
const hours = Math.floor(diff / 3600000)
const minutes = Math.floor((diff % 3600000) / 60000)
const seconds = Math.floor((diff % 60000) / 1000)
if (isCompact.value) {
return `${hours}h${minutes}m`
}
if (hours > 0) {
return `${hours}时${minutes}分`
} else if (minutes > 0) {
return `${minutes}分${seconds}秒`
} else {
return `${seconds}秒`
}
}
const getTimeSinceActive = (lastActive: Date): string => {
const diff = new Date().getTime() - lastActive.getTime()
if (diff < 1000) return '刚刚'
if (diff < 60000) return `${Math.floor(diff / 1000)}秒前`
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
return `${Math.floor(diff / 3600000)}小时前`
}
const getProcessStatus = (process: any) => {
if (process.hasExited) return { type: 'error' as const, text: '已退出', show: true }
const hasActiveConnections = process.connections?.some((c: any) => c.isActive)
if (hasActiveConnections) {
return { type: 'success' as const, text: '活跃', show: true }
}
return { type: 'warning' as const, text: '空闲', show: false }
}
const getConnectionStateType = (state: ConnectionState, isActive: boolean) => {
@@ -538,55 +929,6 @@ const getDirectionText = (direction: TrafficDirection) => {
}
}
// 格式化函数
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))
const value = parseFloat((bytes / Math.pow(k, i)).toFixed(2))
// 紧凑模式下简化单位
if (isCompact.value && i > 0) {
return value + sizes[i][0]
}
return value + ' ' + sizes[i]
}
const formatSpeed = (bytesPerSecond: number): string => {
return formatBytes(bytesPerSecond) + '/s'
}
const formatDuration = (start: Date, end?: Date): string => {
const endTime = end || new Date()
const diff = endTime.getTime() - start.getTime()
const hours = Math.floor(diff / 3600000)
const minutes = Math.floor((diff % 3600000) / 60000)
const seconds = Math.floor((diff % 60000) / 1000)
if (isCompact.value) {
return `${hours}h${minutes}m`
}
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
}
const getTimeSinceActive = (lastActive: Date): string => {
const diff = new Date().getTime() - lastActive.getTime()
if (diff < 1000) return '刚刚'
if (diff < 60000) return `${Math.floor(diff / 1000)}秒前`
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
return `${Math.floor(diff / 3600000)}小时前`
}
const formatEndpoint = (endpoint: IPEndPoint): string => {
if (isCompact.value && endpoint.address === '0.0.0.0') {
return `*:${endpoint.port}`
}
return `${endpoint.address}:${endpoint.port}`
}
// 事件处理
const handleViewDetails = (connection: ConnectionInfo) => {
emit('view-details', connection)
@@ -600,9 +942,11 @@ const handleDisconnect = (connection: ConnectionInfo) => {
<style scoped>
.connections-table-container {
height: 100%;
overflow-y: auto;
overflow: hidden;
padding: 16px;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
}
/* 响应式调整 */
@@ -738,86 +1082,161 @@ const handleDisconnect = (connection: ConnectionInfo) => {
margin-top: 2px;
}
/* 进程列表 */
.process-list {
/* 统一连接列表容器 */
.unified-connections-container {
position: relative;
height: 100%;
background: var(--bg-primary);
border-radius: 12px;
overflow: hidden;
box-shadow: var(--shadow-md);
flex: 1;
}
/* 吸顶元素 */
.sticky-header {
position: absolute;
left: 0;
z-index: 10;
background: var(--bg-sticky);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border-primary);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.sticky-content {
padding: 12px 20px;
}
.sticky-collapsed,
.sticky-process {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
gap: 16px;
}
.size-compact .process-list {
gap: 8px;
}
/* 过渡动画 */
.process-list-enter-active,
.process-list-leave-active {
.sticky-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 10px;
flex-shrink: 0;
transition: all 0.3s ease;
}
.process-list-enter-from {
opacity: 0;
transform: translateX(-20px);
.sticky-icon.process {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.process-list-leave-to {
opacity: 0;
transform: translateX(20px);
.sticky-icon.collapsed {
background: rgba(168, 85, 247, 0.1);
color: #a855f7;
}
.process-item {
background: var(--bg-card);
backdrop-filter: var(--backdrop-blur);
border: 1px solid var(--border-primary);
border-radius: 12px;
overflow: hidden;
margin-top: 5px;
.sticky-info {
flex: 1;
min-width: 0;
}
.sticky-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 10px;
}
.sticky-pid {
font-weight: 400;
}
.sticky-subtitle {
font-size: 13px;
color: var(--text-muted);
margin-top: 2px;
display: flex;
align-items: center;
gap: 4px;
}
.sticky-stats {
display: flex;
align-items: center;
gap: 20px;
margin-top: 4px;
font-size: 13px;
color: var(--text-secondary);
}
.stat-item {
display: flex;
align-items: center;
gap: 6px;
}
.stat-item.connections {
color: #3b82f6;
}
.sticky-actions {
flex-shrink: 0;
}
/* 虚拟列表 */
.connections-list {
background: var(--bg-secondary);
}
/* 列表项 */
.list-item {
background: var(--bg-primary);
border-bottom: 1px solid var(--border-secondary);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.size-compact .process-item {
border-radius: 8px;
.list-item:hover {
background: var(--bg-hover);
}
.process-item.is-exited {
opacity: 0.8;
.is-hidden {
visibility: hidden;
}
.process-item:hover {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
border-color: var(--border-hover);
.is-process {
background: var(--bg-process);
border-left: 3px solid #3b82f6;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.process-item.is-expanded {
border-color: #3b82f6;
box-shadow: 0 4px 20px rgba(59, 130, 246, 0.15);
.is-connection {
padding-left: 20px;
border-left: 3px solid transparent;
}
/* 进程头部 */
.process-header {
padding: 16px 20px;
.is-connection.is-active {
background: var(--bg-active);
border-left-color: #10b981;
}
.is-collapsed-indicator {
background: var(--bg-collapsed);
border-left: 3px solid #a855f7;
}
/* 折叠指示器 */
.collapsed-indicator {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
transition: all 0.2s ease;
border-bottom: 1px solid transparent;
justify-content: space-between;
width: 100%;
padding: 16px 20px;
}
.size-compact .process-header {
padding: 12px;
}
.size-normal .process-header {
padding: 14px 16px;
}
.process-item.is-expanded .process-header {
border-bottom-color: var(--border-primary);
background: rgba(59, 130, 246, 0.02);
}
.header-left {
.indicator-left {
display: flex;
align-items: center;
gap: 12px;
@@ -825,39 +1244,53 @@ const handleDisconnect = (connection: ConnectionInfo) => {
min-width: 0;
}
.size-compact .header-left {
gap: 8px;
}
.expand-indicator {
width: 24px;
height: 24px;
.collapse-icon {
width: 36px;
height: 36px;
background: rgba(168, 85, 247, 0.1);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-tertiary);
border-radius: 6px;
transition: all 0.3s ease;
flex-shrink: 0;
color: #a855f7;
}
.size-compact .expand-indicator {
width: 20px;
height: 20px;
border-radius: 4px;
.indicator-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.process-item.is-expanded .expand-indicator {
.indicator-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.indicator-count {
font-size: 12px;
color: var(--text-muted);
}
/* 进程头部 */
.process-header {
display: flex;
align-items: center;
width: 100%;
gap: 16px;
padding: 16px 20px;
}
.process-icon-wrapper {
width: 48px;
height: 48px;
background: rgba(59, 130, 246, 0.1);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #3b82f6;
}
.expand-arrow {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.process-item.is-expanded .expand-arrow {
transform: rotate(90deg);
flex-shrink: 0;
}
.process-info {
@@ -869,13 +1302,8 @@ const handleDisconnect = (connection: ConnectionInfo) => {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.size-compact .process-title {
gap: 6px;
margin-bottom: 6px;
flex-wrap: wrap;
}
.process-name {
@@ -885,45 +1313,29 @@ const handleDisconnect = (connection: ConnectionInfo) => {
margin: 0;
}
.size-compact .process-name {
font-size: 14px;
}
.size-normal .process-name {
font-size: 15px;
}
.process-details {
display: flex;
flex-wrap: wrap;
gap: 16px;
font-size: 12px;
color: var(--text-muted);
}
.size-compact .process-details {
gap: 8px;
font-size: 11px;
}
.size-normal .process-details {
gap: 12px;
}
.detail-item {
.process-meta {
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.detail-item.primary {
gap: 20px;
font-size: 13px;
color: var(--text-secondary);
flex-wrap: wrap;
}
.header-right {
.meta-item {
display: flex;
align-items: center;
gap: 6px;
}
.meta-item.primary {
color: #3b82f6;
}
.process-actions {
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
@@ -934,10 +1346,6 @@ const handleDisconnect = (connection: ConnectionInfo) => {
gap: 6px;
}
.size-compact .traffic-summary {
gap: 4px;
}
.traffic-speed {
display: flex;
gap: 12px;
@@ -945,11 +1353,6 @@ const handleDisconnect = (connection: ConnectionInfo) => {
font-weight: 500;
}
.size-compact .traffic-speed {
gap: 8px;
font-size: 12px;
}
.traffic-item {
display: flex;
align-items: center;
@@ -969,112 +1372,28 @@ const handleDisconnect = (connection: ConnectionInfo) => {
color: var(--text-muted);
}
/* 连接内容区域 */
.connections-content {
border-top: 1px solid var(--border-primary);
.collapse-btn {
transition: all 0.3s ease;
}
.content-wrapper {
padding: 16px 20px;
}
.size-compact .content-wrapper {
padding: 12px;
}
.size-normal .content-wrapper {
padding: 14px 16px;
}
.connections-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-secondary);
}
.size-compact .connections-header {
margin-bottom: 8px;
padding-bottom: 6px;
}
.connections-title {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
margin: 0;
}
.size-compact .connections-title {
font-size: 12px;
}
.connection-count {
font-size: 12px;
color: var(--text-muted);
}
/* 连接列表 */
.connection-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.size-compact .connection-list {
gap: 6px;
.collapse-btn:hover {
transform: translateY(-2px);
}
/* 连接项 */
.connection-item {
background: var(--bg-tertiary);
border: 1px solid var(--border-secondary);
border-radius: 8px;
padding: 12px;
display: flex;
justify-content: space-between;
gap: 12px;
transition: all 0.2s ease;
}
.size-compact .connection-item {
padding: 8px;
gap: 8px;
border-radius: 6px;
}
.connection-item.is-active {
background: rgba(52, 211, 153, 0.05);
border-color: rgba(52, 211, 153, 0.2);
}
.connection-item:hover {
background: var(--bg-hover);
border-color: var(--border-hover);
}
.connection-main {
display: flex;
align-items: flex-start;
width: 100%;
gap: 12px;
flex: 1;
min-width: 0;
}
.size-compact .connection-main {
gap: 8px;
padding: 12px 20px 12px 40px;
}
.connection-status {
display: flex;
align-items: center;
padding-top: 8px;
flex-shrink: 0;
padding-top: 6px;
}
.size-compact .connection-status {
padding-top: 4px;
}
.status-indicator {
@@ -1084,82 +1403,121 @@ const handleDisconnect = (connection: ConnectionInfo) => {
transition: all 0.3s ease;
}
.size-compact .status-indicator {
width: 6px;
height: 6px;
}
.status-indicator.active {
background: #52c41a;
box-shadow: 0 0 6px rgba(82, 196, 26, 0.4);
background: #10b981;
box-shadow: 0 0 8px rgba(16, 185, 129, 0.6);
animation: pulse 2s infinite;
}
.status-indicator.inactive {
background: #d9d9d9;
background: #d1d5db;
}
.connection-info {
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.6);
}
70% {
box-shadow: 0 0 0 6px rgba(16, 185, 129, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
}
}
.connection-main {
flex: 1;
min-width: 0;
}
.connection-tags {
.connection-header {
display: flex;
gap: 6px;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
flex-wrap: wrap;
gap: 12px;
}
.size-compact .connection-tags {
gap: 4px;
margin-bottom: 6px;
}
.connection-addresses {
.connection-endpoints {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
font-size: 12px;
color: var(--text-primary);
gap: 12px;
flex: 1;
flex-wrap: wrap;
}
.size-compact .connection-addresses {
font-size: 11px;
.endpoint-item {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
font-size: 13px;
}
.address-section {
display: flex;
align-items: center;
gap: 4px;
.endpoint-item.local {
color: var(--text-success);
}
.address-value {
font-family: 'Monaco', 'Menlo', monospace;
.endpoint-item.remote {
color: var(--text-primary);
}
.endpoint-address {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
font-weight: 500;
}
.connection-arrow {
color: var(--text-quaternary);
}
.connection-meta {
.connection-tags {
display: flex;
gap: 16px;
font-size: 11px;
color: var(--text-muted);
flex-wrap: wrap;
gap: 6px;
flex-shrink: 0;
}
.meta-item {
.connection-stats {
display: flex;
align-items: center;
gap: 20px;
font-size: 12px;
}
.traffic-info {
display: flex;
align-items: center;
gap: 16px;
}
.traffic-item {
display: flex;
align-items: center;
gap: 4px;
font-weight: 500;
}
.traffic-item.upload {
color: #ef4444;
}
.traffic-item.download {
color: #22c55e;
}
.connection-meta-info {
display: flex;
align-items: center;
gap: 16px;
color: var(--text-muted);
}
.meta-text {
display: flex;
align-items: center;
gap: 4px;
}
.meta-item.inactive {
.meta-text.inactive {
color: var(--text-quaternary);
}
@@ -1170,88 +1528,90 @@ const handleDisconnect = (connection: ConnectionInfo) => {
flex-shrink: 0;
}
.size-compact .connection-side {
gap: 8px;
}
.connection-traffic {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
min-width: 100px;
}
.size-compact .connection-traffic {
min-width: 80px;
gap: 2px;
}
.traffic-stats {
display: flex;
gap: 12px;
font-size: 12px;
font-weight: 500;
}
.size-compact .traffic-stats {
gap: 8px;
font-size: 11px;
}
.traffic-row {
display: flex;
align-items: center;
gap: 4px;
}
.size-compact .traffic-row {
gap: 2px;
}
.traffic-row.upload {
color: #ef4444;
}
.traffic-row.download {
color: #22c55e;
}
.traffic-value {
min-width: 60px;
text-align: right;
}
.size-compact .traffic-value {
min-width: 45px;
}
.traffic-total-small {
font-size: 10px;
color: var(--text-muted);
text-align: right;
}
.connection-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 48px;
text-align: center;
}
.empty-icon {
color: var(--text-quaternary);
margin-bottom: 20px;
}
.empty-state h3 {
margin: 0 0 8px;
font-size: 20px;
font-weight: 600;
color: var(--text-secondary);
}
.empty-state p {
margin: 0;
font-size: 14px;
color: var(--text-muted);
}
/* 动画 */
.sticky-slide-enter-active,
.sticky-slide-leave-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.sticky-slide-enter-from {
opacity: 0;
transform: translateY(-20px);
}
.sticky-slide-leave-to {
opacity: 0;
transform: translateY(-10px);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* CSS 变量定义 */
:root {
--bg-primary: #ffffff;
--bg-secondary: #f9fafb;
--bg-hover: #f3f4f6;
--bg-process: #f0f9ff;
--bg-active: rgba(16, 185, 129, 0.05);
--bg-collapsed: #faf5ff;
--bg-sticky: rgba(255, 255, 255, 0.95);
--bg-card: rgba(255, 255, 255, 0.9);
--bg-tertiary: #f1f5f9;
--bg-quaternary: #e2e8f0;
--bg-hover: #f8fafc;
--backdrop-blur: blur(8px);
--border-primary: #e2e8f0;
--border-secondary: #f1f5f9;
--border-primary: #e5e7eb;
--border-secondary: #f3f4f6;
--border-hover: #cbd5e1;
--text-primary: #0f172a;
--text-secondary: #475569;
--text-muted: #64748b;
--text-quaternary: #94a3b8;
--text-primary: #111827;
--text-secondary: #4b5563;
--text-muted: #6b7280;
--text-quaternary: #9ca3af;
--text-success: #059669;
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.1);
--transition: all 0.2s ease;
}
@@ -1259,22 +1619,128 @@ const handleDisconnect = (connection: ConnectionInfo) => {
/* 暗色模式 */
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #1f2937;
--bg-secondary: #111827;
--bg-hover: #374151;
--bg-process: rgba(59, 130, 246, 0.1);
--bg-active: rgba(16, 185, 129, 0.1);
--bg-collapsed: rgba(168, 85, 247, 0.1);
--bg-sticky: rgba(31, 41, 55, 0.95);
--bg-card: rgba(30, 41, 59, 0.9);
--bg-tertiary: #1e293b;
--bg-quaternary: #334155;
--bg-hover: #1e293b;
--border-primary: #334155;
--border-secondary: #1e293b;
--border-primary: #374151;
--border-secondary: #1f2937;
--border-hover: #475569;
--text-primary: #f8fafc;
--text-secondary: #cbd5e1;
--text-primary: #f9fafb;
--text-secondary: #d1d5db;
--text-muted: #94a3b8;
--text-quaternary: #64748b;
--text-success: #10b981;
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
}
.connection-item.is-active {
background: rgba(52, 211, 153, 0.08);
border-color: rgba(52, 211, 153, 0.3);
.sticky-header {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.is-process {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.sticky-content {
padding: 10px 16px;
}
.process-header,
.collapsed-indicator {
padding: 12px 16px;
}
.connection-item {
padding: 10px 16px 10px 32px;
}
.process-meta,
.sticky-stats {
gap: 12px;
}
.connection-stats {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.traffic-info {
gap: 12px;
}
}
@media (max-width: 480px) {
.process-title {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.connection-endpoints {
font-size: 12px;
}
.connection-header {
flex-direction: column;
gap: 8px;
}
.process-icon-wrapper {
width: 40px;
height: 40px;
}
.sticky-icon {
width: 32px;
height: 32px;
}
}
/* Naive UI 组件样式覆盖 */
:deep(.n-button) {
font-weight: 500;
}
:deep(.n-tag) {
font-weight: 500;
}
:deep(.n-virtual-list) {
--n-color: transparent;
}
/* 滚动条样式 */
:deep(.n-virtual-list__content)::-webkit-scrollbar {
width: 8px;
}
:deep(.n-virtual-list__content)::-webkit-scrollbar-track {
background: var(--scrollbar-track-bg);
}
:deep(.n-virtual-list__content)::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb-bg);
border-radius: 4px;
transition: background 0.3s ease;
}
:deep(.n-virtual-list__content)::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-bg-hover);
}
:deep(.n-virtual-list__content)::-webkit-scrollbar-thumb:active {
background: var(--scrollbar-thumb-bg-active);
}
</style>
@@ -1,634 +0,0 @@
<template>
<div class="virtual-list-container">
<!-- 吸顶元素 -->
<div v-if="stickyItem && shouldShowSticky" class="sticky-item" :style="{ top: '0px' }">
<div
class="item sticky-content"
:class="{ 'is-collapsed-indicator': stickyItem.isCollapsedIndicator }"
>
<template v-if="stickyItem.isCollapsedIndicator">
<!-- 折叠状态的吸顶 -->
<div class="collapsed-indicator">
<span
>📁 已折叠进程: {{ stickyItem.processName }} ({{
stickyItem.collapsedCount
}}个连接)</span
>
<button class="expand-btn" @click="expandSection(stickyItem.processIndex)">展开</button>
</div>
</template>
<template v-else-if="stickyItem.isProcess">
<!-- 进程标题的吸顶 -->
<div class="process-header">
<div class="process-icon">🖥️</div>
<div class="process-info">
<div class="process-name">{{ stickyItem.processName }} (PID: {{ stickyItem.processId }})</div>
<div class="process-details">
内存: {{ formatMemory(stickyItem.useMemory) }} |
线程: {{ stickyItem.threadCount }} |
连接: {{ stickyItem.connectionCount }}个
</div>
</div>
<button
class="collapse-btn"
@click="toggleCollapseWithScrollAdjust(stickyItem.processIndex)"
>
{{ getCollapseIcon(stickyItem.processIndex) }}
</button>
</div>
</template>
</div>
</div>
<n-virtual-list
ref="virtualListRef"
style="max-height: 500px"
:item-size="60"
:items="displayItems"
@scroll="handleScroll"
item-resizable
>
<template #default="{ item, index }">
<div
:key="item.key"
class="item"
:class="{
'is-process': item.isProcess,
'is-connection': item.isConnection,
'is-collapsed-indicator': item.isCollapsedIndicator,
'is-hidden': shouldHideItem(item, index),
}"
style="height: 60px"
>
<template v-if="item.isCollapsedIndicator">
<!-- 折叠状态指示器 -->
<div class="collapsed-indicator">
<span>📁 已折叠进程: {{ item.processName }} ({{ item.collapsedCount }}个连接)</span>
<button class="expand-btn" @click="expandSection(item.processIndex)">展开</button>
</div>
</template>
<template v-else-if="item.isProcess">
<!-- 进程标题 -->
<div class="process-header">
<div class="process-icon">🖥️</div>
<div class="process-info">
<div class="process-name">{{ item.processName }} (PID: {{ item.processId }})</div>
<div class="process-details">
内存: {{ formatMemory(item.useMemory) }} |
线程: {{ item.threadCount }} |
连接: {{ item.connectionCount }}个
</div>
</div>
<button
class="collapse-btn"
@click="toggleCollapse(item.processIndex)"
>
{{ getCollapseIcon(item.processIndex) }}
</button>
</div>
</template>
<template v-else-if="item.isConnection">
<!-- 连接详情 -->
<div class="connection-item">
<div class="connection-icon">
{{ item.protocol === 0 ? '🔗' : '📡' }}
</div>
<div class="connection-info">
<div class="connection-endpoints">
<span class="local">{{ item.localEndpoint.address }}:{{ item.localEndpoint.port }}</span>
<span class="arrow">→</span>
<span class="remote">{{ item.remoteEndpoint.address }}:{{ item.remoteEndpoint.port }}</span>
<span class="protocol">{{ item.protocol === 0 ? 'TCP' : 'UDP' }}</span>
</div>
<div class="connection-stats">
<span class="status" :class="getConnectionStatusClass(item.state)">
{{ getConnectionStatus(item.state) }}
</span>
<span class="traffic">
↑{{ formatBytes(item.bytesSent) }} ↓{{ formatBytes(item.bytesReceived) }}
</span>
<span class="active" :class="{ 'is-active': item.isActive }">
{{ item.isActive ? '活跃' : '非活跃' }}
</span>
</div>
</div>
</div>
</template>
</div>
</template>
</n-virtual-list>
<!-- 无数据提示 -->
<div v-if="displayItems.length === 0" class="no-data" style="text-align: center; padding: 40px; color: #999;">
<div style="font-size: 48px; margin-bottom: 16px;">📡</div>
<div style="font-size: 16px; margin-bottom: 8px;">暂无网络连接数据</div>
<div style="font-size: 14px;">请检查数据源是否正确加载</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { ap } from '@/json/test.js'
const virtualListRef = ref()
const scrollTop = ref(0)
const itemSize = 60
// 折叠状态管理 - key是进程索引
const collapsedSections = ref(new Set<number>())
// 记录折叠前的滚动信息
const beforeCollapseState = ref<{
scrollTop: number
visibleItemKey: string
offsetInItem: number
} | null>(null)
// 格式化内存大小
const formatMemory = (bytes: number) => {
const mb = bytes / (1024 * 1024)
return mb >= 1 ? `${mb.toFixed(1)}MB` : `${(bytes / 1024).toFixed(1)}KB`
}
// 格式化字节数
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(1)}${sizes[i]}`
}
// 获取连接状态
const getConnectionStatus = (state: number) => {
const states = {
0: '关闭',
1: '监听',
2: '已建立',
3: '等待关闭',
4: '关闭等待'
}
return states[state as keyof typeof states] || '未知'
}
// 获取连接状态样式类
const getConnectionStatusClass = (state: number) => {
const classes = {
0: 'status-closed',
1: 'status-listening',
2: 'status-established',
3: 'status-closing',
4: 'status-waiting'
}
return classes[state as keyof typeof classes] || 'status-unknown'
}
// 生成扁平化的显示项列表
const originalItems = computed(() => {
const result: any[] = []
// 数据检查和调试
console.log('ap data:', ap)
console.log('ap length:', ap?.length)
if (!ap || !Array.isArray(ap) || ap.length === 0) {
console.warn('No data available')
return result
}
ap.forEach((process, processIndex) => {
if (!process) return
// 添加进程标题项
result.push({
key: `process-${processIndex}`,
isProcess: true,
processIndex,
processName: process.processName || 'Unknown Process',
processId: process.processId || 0,
useMemory: process.useMemory || 0,
threadCount: process.threadCount || 0,
connectionCount: process.connections?.length || 0,
startTime: process.startTime,
mainModulePath: process.mainModulePath
})
// 添加连接项
if (process.connections && Array.isArray(process.connections)) {
process.connections.forEach((connection, connectionIndex) => {
result.push({
key: `connection-${processIndex}-${connectionIndex}`,
isConnection: true,
processIndex,
connectionIndex,
processName: process.processName || 'Unknown Process',
...connection
})
})
}
})
console.log('originalItems result:', result.length, 'items')
return result
})
// 获取当前可见的第一个实际元素
const getFirstVisibleItem = () => {
const currentIndex = Math.floor(scrollTop.value / itemSize)
const items = displayItems.value
if (currentIndex < items.length) {
return {
item: items[currentIndex],
index: currentIndex,
offsetInViewport: scrollTop.value % itemSize,
}
}
return null
}
// 切换折叠状态(从吸顶位置触发时使用)
const toggleCollapseWithScrollAdjust = async (processIndex: number) => {
if (!collapsedSections.value.has(processIndex)) {
// 折叠操作
const visibleItem = getFirstVisibleItem()
if (visibleItem) {
beforeCollapseState.value = {
scrollTop: scrollTop.value,
visibleItemKey: visibleItem.item.key,
offsetInItem: visibleItem.offsetInViewport,
}
}
collapsedSections.value.add(processIndex)
collapsedSections.value = new Set(collapsedSections.value)
// 等待DOM更新后调整滚动位置
await nextTick()
if (beforeCollapseState.value && visibleItem) {
// 找到同一个元素在新列表中的位置
const newItems = displayItems.value
const newIndex = newItems.findIndex(item => item.key === visibleItem.item.key)
if (newIndex !== -1) {
// 计算新的滚动位置
const newScrollTop = newIndex * itemSize - visibleItem.offsetInViewport
virtualListRef.value?.scrollTo({ top: Math.max(0, newScrollTop) })
}
}
} else {
// 展开操作
expandSection(processIndex)
}
}
// 切换折叠状态(普通情况)
const toggleCollapse = (processIndex: number) => {
if (collapsedSections.value.has(processIndex)) {
collapsedSections.value.delete(processIndex)
} else {
collapsedSections.value.add(processIndex)
}
collapsedSections.value = new Set(collapsedSections.value)
}
// 展开某个进程
const expandSection = (processIndex: number) => {
collapsedSections.value.delete(processIndex)
collapsedSections.value = new Set(collapsedSections.value)
}
// 获取折叠按钮图标
const getCollapseIcon = (processIndex: number) => {
return collapsedSections.value.has(processIndex) ? '📁' : '📂'
}
// 根据折叠状态生成显示的items
const displayItems = computed(() => {
const result: any[] = []
const processGroups: { [key: number]: any[] } = {}
// 按进程分组
originalItems.value.forEach(item => {
const processIndex = item.processIndex
if (!processGroups[processIndex]) {
processGroups[processIndex] = []
}
processGroups[processIndex].push(item)
})
// 生成显示列表
Object.keys(processGroups).forEach(processIndexStr => {
const processIndex = parseInt(processIndexStr)
const group = processGroups[processIndex]
const processItem = group.find(item => item.isProcess)
const connectionItems = group.filter(item => item.isConnection)
if (collapsedSections.value.has(processIndex)) {
// 折叠状态:只显示折叠指示器
result.push({
key: `collapsed-${processIndex}`,
isCollapsedIndicator: true,
processIndex,
processName: processItem.processName,
collapsedCount: connectionItems.length,
})
} else {
// 展开状态:显示进程和所有连接
result.push(processItem)
connectionItems.forEach(connection => {
result.push(connection)
})
}
})
return result
})
// 计算当前滚动位置对应的项目索引
const getCurrentIndex = () => {
return Math.floor(scrollTop.value / itemSize)
}
// 找到当前可见区域内最近的进程标题元素
const stickyItem = computed(() => {
if (displayItems.value.length === 0) return null
const currentIndex = getCurrentIndex()
// 向前查找最近的进程标题或折叠指示器
for (let i = currentIndex; i >= 0; i--) {
const item = displayItems.value[i]
if (item && (item.isProcess || item.isCollapsedIndicator)) {
return {
...item,
originalIndex: i
}
}
}
return null
})
// 控制是否显示吸顶元素
const shouldShowSticky = computed(() => {
if (!stickyItem.value) return false
const currentIndex = getCurrentIndex()
const stickyIndex = stickyItem.value.originalIndex
// 只有当前可见区域的第一项不是标题项时,才显示吸顶
return currentIndex > stickyIndex
})
// 控制是否隐藏列表项(避免与吸顶重复)
const shouldHideItem = (item: any, index: number) => {
if (!stickyItem.value || !shouldShowSticky.value) return false
// 如果当前项就是吸顶项,且吸顶正在显示,则隐藏这个项
return item.key === stickyItem.value.key
}
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement
scrollTop.value = target.scrollTop
}
</script>
<style scoped>
.virtual-list-container {
position: relative;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.sticky-item {
position: absolute;
left: 0;
right: 0;
z-index: 10;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
border-bottom: 1px solid #e0e0e0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.sticky-content {
background: #f5f5f5;
font-weight: 600;
}
.sticky-content.is-collapsed-indicator {
background: #f0f0f0;
border-left: 4px solid #999;
}
.item {
display: flex;
align-items: center;
padding: 8px 12px;
background: white;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.2s;
}
.item:hover {
background: #f8f8f8;
}
.is-hidden {
visibility: hidden;
}
.is-process {
background: #e6f4ff;
border-left: 4px solid #1890ff;
font-weight: 500;
}
.is-connection {
background: white;
border-left: 4px solid transparent;
padding-left: 20px;
}
.is-collapsed-indicator {
background: #f0f0f0;
border-left: 4px solid #999;
}
.process-header {
display: flex;
align-items: center;
width: 100%;
gap: 12px;
}
.process-icon {
font-size: 20px;
flex-shrink: 0;
}
.process-info {
flex: 1;
min-width: 0;
}
.process-name {
font-size: 14px;
font-weight: 600;
color: #1890ff;
margin-bottom: 2px;
}
.process-details {
font-size: 12px;
color: #666;
}
.connection-item {
display: flex;
align-items: center;
width: 100%;
gap: 12px;
}
.connection-icon {
font-size: 16px;
flex-shrink: 0;
}
.connection-info {
flex: 1;
min-width: 0;
}
.connection-endpoints {
font-size: 13px;
font-family: 'Courier New', monospace;
margin-bottom: 2px;
display: flex;
align-items: center;
gap: 8px;
}
.local {
color: #52c41a;
font-weight: 500;
}
.arrow {
color: #999;
}
.remote {
color: #1890ff;
font-weight: 500;
}
.protocol {
background: #f0f0f0;
padding: 1px 4px;
border-radius: 2px;
font-size: 11px;
color: #666;
}
.connection-stats {
font-size: 11px;
display: flex;
gap: 12px;
align-items: center;
}
.status {
padding: 1px 6px;
border-radius: 10px;
font-weight: 500;
}
.status-established {
background: #f6ffed;
color: #52c41a;
border: 1px solid #d9f7be;
}
.status-listening {
background: #e6f7ff;
color: #1890ff;
border: 1px solid #bae7ff;
}
.status-closed {
background: #fff2e8;
color: #fa8c16;
border: 1px solid #ffd591;
}
.status-closing, .status-waiting {
background: #fff1f0;
color: #ff4d4f;
border: 1px solid #ffccc7;
}
.traffic {
color: #666;
font-family: 'Courier New', monospace;
}
.active {
font-weight: 500;
}
.active.is-active {
color: #52c41a;
}
.active:not(.is-active) {
color: #999;
}
.collapsed-indicator {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
font-style: italic;
color: #666;
}
.collapse-btn,
.expand-btn {
background: none;
border: 1px solid #d9d9d9;
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
flex-shrink: 0;
}
.collapse-btn:hover,
.expand-btn:hover {
background: #f5f5f5;
border-color: #40a9ff;
}
.expand-btn {
background: #1890ff;
color: white;
border-color: #1890ff;
}
.expand-btn:hover {
background: #40a9ff;
}
</style>
@@ -0,0 +1,922 @@
<template>
<div class="unified-connections-container">
<!-- 吸顶元素容器 -->
<Transition name="sticky-fade">
<div
v-show="shouldShowSticky && stickyItem"
class="sticky-header"
:style="stickyStyle"
>
<div class="sticky-content">
<template v-if="stickyItem?.isCollapsedIndicator">
<!-- 折叠状态的吸顶 -->
<div class="sticky-collapsed">
<div class="sticky-icon">
<n-icon :component="FolderOutline" size="20" />
</div>
<div class="sticky-info">
<div class="sticky-title">{{ stickyItem.processName }}</div>
<div class="sticky-subtitle">{{ stickyItem.collapsedCount }} 个连接已折叠</div>
</div>
<n-button
size="small"
type="primary"
@click="expandSection(stickyItem.processIndex)"
>
<template #icon>
<n-icon :component="ChevronDownOutline" />
</template>
展开
</n-button>
</div>
</template>
<template v-else-if="stickyItem?.isProcess">
<!-- 进程标题的吸顶 -->
<div class="sticky-process">
<div class="sticky-icon">
<n-icon :component="DesktopOutline" size="20" />
</div>
<div class="sticky-info">
<div class="sticky-title">
{{ stickyItem.processName }}
<span class="sticky-pid">PID: {{ stickyItem.processId }}</span>
</div>
<div class="sticky-stats">
<span class="stat-item">
<n-icon :component="HardwareChipOutline" size="14" />
{{ formatMemory(stickyItem.useMemory) }}
</span>
<span class="stat-item">
<n-icon :component="GitBranchOutline" size="14" />
{{ stickyItem.threadCount }} 线程
</span>
<span class="stat-item">
<n-icon :component="LinkOutline" size="14" />
{{ stickyItem.connectionCount }} 连接
</span>
</div>
</div>
<n-button
size="small"
quaternary
circle
@click="toggleCollapseWithScrollAdjust(stickyItem.processIndex)"
>
<template #icon>
<n-icon
:component="collapsedSections.has(stickyItem.processIndex) ? ChevronDownOutline : ChevronUpOutline"
size="18"
/>
</template>
</n-button>
</div>
</template>
</div>
</div>
</Transition>
<!-- 虚拟列表 -->
<n-virtual-list
ref="virtualListRef"
class="connections-list"
:style="{ maxHeight: containerHeight }"
:item-size="itemHeight"
:items="displayItems"
@scroll="handleScroll"
item-resizable
>
<template #default="{ item, index }">
<div
:key="item.key"
class="list-item"
:class="{
'is-process': item.isProcess,
'is-connection': item.isConnection,
'is-collapsed-indicator': item.isCollapsedIndicator,
'is-hidden': shouldHideItem(item, index),
}"
:style="{ height: `${itemHeight}px` }"
>
<template v-if="item.isCollapsedIndicator">
<!-- 折叠状态指示器 -->
<div class="collapsed-indicator">
<div class="indicator-left">
<n-icon :component="FolderOutline" size="18" />
<span class="indicator-text">
{{ item.processName }} - {{ item.collapsedCount }} 个连接已折叠
</span>
</div>
<n-button
size="small"
type="primary"
ghost
@click="expandSection(item.processIndex)"
>
展开查看
</n-button>
</div>
</template>
<template v-else-if="item.isProcess">
<!-- 进程标题 -->
<div class="process-header">
<div class="process-icon">
<n-icon :component="DesktopOutline" size="22" />
</div>
<div class="process-info">
<div class="process-title">
<span class="process-name">{{ item.processName }}</span>
<n-tag size="small" :bordered="false">PID: {{ item.processId }}</n-tag>
</div>
<div class="process-stats">
<span class="stat">
<n-icon :component="HardwareChipOutline" size="12" />
{{ formatMemory(item.useMemory) }}
</span>
<span class="stat">
<n-icon :component="GitBranchOutline" size="12" />
{{ item.threadCount }} 线程
</span>
<span class="stat">
<n-icon :component="LinkOutline" size="12" />
{{ item.connectionCount }} 连接
</span>
</div>
</div>
<n-button
size="small"
quaternary
@click="toggleCollapse(item.processIndex)"
>
<template #icon>
<n-icon
:component="collapsedSections.has(item.processIndex) ? ChevronDownOutline : ChevronUpOutline"
size="16"
/>
</template>
{{ collapsedSections.has(item.processIndex) ? '展开' : '折叠' }}
</n-button>
</div>
</template>
<template v-else-if="item.isConnection">
<!-- 连接详情 -->
<div class="connection-item">
<div class="connection-icon">
<n-icon
:component="item.protocol === 0 ? LinkOutline : WifiOutline"
size="16"
/>
</div>
<div class="connection-main">
<div class="connection-endpoints">
<span class="endpoint local">{{ item.localEndpoint.address }}:{{ item.localEndpoint.port }}</span>
<n-icon :component="ArrowForwardOutline" size="14" class="arrow" />
<span class="endpoint remote">{{ item.remoteEndpoint.address }}:{{ item.remoteEndpoint.port }}</span>
<n-tag
size="tiny"
:type="item.protocol === 0 ? 'info' : 'warning'"
:bordered="false"
>
{{ item.protocol === 0 ? 'TCP' : 'UDP' }}
</n-tag>
</div>
<div class="connection-meta">
<n-tag
size="tiny"
:type="getConnectionStatusType(item.state)"
:bordered="false"
>
{{ getConnectionStatus(item.state) }}
</n-tag>
<span class="traffic">
<n-icon :component="ArrowUpOutline" size="12" />
{{ formatBytes(item.bytesSent) }}
<n-icon :component="ArrowDownOutline" size="12" />
{{ formatBytes(item.bytesReceived) }}
</span>
<span class="activity" :class="{ active: item.isActive }">
<n-icon :component="item.isActive ? PulseOutline : PauseOutline" size="12" />
{{ item.isActive ? '活跃' : '空闲' }}
</span>
</div>
</div>
</div>
</template>
</div>
</template>
</n-virtual-list>
<!-- 无数据提示 -->
<Transition name="fade">
<div v-if="displayItems.length === 0" class="empty-state">
<n-icon :component="CloudOfflineOutline" size="64" />
<h3>暂无网络连接数据</h3>
<p>请检查数据源是否正确加载</p>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ap } from '@/json/test.js'
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
import { NVirtualList, NButton, NIcon, NTag } from 'naive-ui'
import {
FolderOutline,
ChevronDownOutline,
ChevronUpOutline,
DesktopOutline,
HardwareChipOutline,
GitBranchOutline,
LinkOutline,
WifiOutline,
ArrowForwardOutline,
ArrowUpOutline,
ArrowDownOutline,
PulseOutline,
PauseOutline,
CloudOfflineOutline,
} from '@vicons/ionicons5'
// Props for container customization
const props = defineProps({
height: {
type: String,
default: '500px'
},
itemHeight: {
type: Number,
default: 64
}
})
// 容器高度
const containerHeight = computed(() => props.height)
const itemHeight = computed(() => props.itemHeight)
// 其他响应式数据
const virtualListRef = ref()
const scrollTop = ref(0)
const scrollbarWidth = ref(0)
const collapsedSections = ref(new Set<number>())
const beforeCollapseState = ref<{
scrollTop: number
visibleItemKey: string
offsetInItem: number
} | null>(null)
// 检测滚动条宽度
const getScrollbarWidth = () => {
const outer = document.createElement('div')
outer.style.visibility = 'hidden'
outer.style.overflow = 'scroll'
outer.style.msOverflowStyle = 'scrollbar'
document.body.appendChild(outer)
const inner = document.createElement('div')
outer.appendChild(inner)
const width = outer.offsetWidth - inner.offsetWidth
outer.parentNode?.removeChild(outer)
return width
}
// 生命周期
onMounted(() => {
scrollbarWidth.value = getScrollbarWidth()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
// 响应式处理
const handleResize = () => {
scrollbarWidth.value = getScrollbarWidth()
}
// 计算吸顶元素的样式
const stickyStyle = computed(() => ({
top: '0px',
right: `${scrollbarWidth.value}px`,
}))
// 格式化函数
const formatMemory = (bytes: number) => {
const mb = bytes / (1024 * 1024)
return mb >= 1 ? `${mb.toFixed(1)}MB` : `${(bytes / 1024).toFixed(1)}KB`
}
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(1)}${sizes[i]}`
}
const getConnectionStatus = (state: number) => {
const states = {
0: '关闭',
1: '监听',
2: '已建立',
3: '等待关闭',
4: '关闭等待',
}
return states[state as keyof typeof states] || '未知'
}
const getConnectionStatusType = (state: number) => {
const types = {
0: 'error',
1: 'info',
2: 'success',
3: 'warning',
4: 'warning',
}
return types[state as keyof typeof types] || 'default'
}
// 数据处理
const originalItems = computed(() => {
const result: any[] = []
if (!ap || !Array.isArray(ap) || ap.length === 0) {
return result
}
ap.forEach((process, processIndex) => {
if (!process) return
result.push({
key: `process-${processIndex}`,
isProcess: true,
processIndex,
processName: process.processName || 'Unknown Process',
processId: process.processId || 0,
useMemory: process.useMemory || 0,
threadCount: process.threadCount || 0,
connectionCount: process.connections?.length || 0,
startTime: process.startTime,
mainModulePath: process.mainModulePath,
})
if (process.connections && Array.isArray(process.connections)) {
process.connections.forEach((connection, connectionIndex) => {
result.push({
key: `connection-${processIndex}-${connectionIndex}`,
isConnection: true,
processIndex,
connectionIndex,
processName: process.processName || 'Unknown Process',
...connection,
})
})
}
})
return result
})
// 显示数据
const displayItems = computed(() => {
const result: any[] = []
const processGroups: { [key: number]: any[] } = {}
originalItems.value.forEach((item) => {
const processIndex = item.processIndex
if (!processGroups[processIndex]) {
processGroups[processIndex] = []
}
processGroups[processIndex].push(item)
})
Object.keys(processGroups).forEach((processIndexStr) => {
const processIndex = parseInt(processIndexStr)
const group = processGroups[processIndex]
const processItem = group.find((item) => item.isProcess)
const connectionItems = group.filter((item) => item.isConnection)
if (collapsedSections.value.has(processIndex)) {
result.push({
key: `collapsed-${processIndex}`,
isCollapsedIndicator: true,
processIndex,
processName: processItem.processName,
collapsedCount: connectionItems.length,
})
} else {
result.push(processItem)
connectionItems.forEach((connection) => {
result.push(connection)
})
}
})
return result
})
// 吸顶逻辑
const getCurrentIndex = () => {
return Math.floor(scrollTop.value / itemHeight.value)
}
const stickyItem = computed(() => {
if (displayItems.value.length === 0) return null
const currentIndex = getCurrentIndex()
for (let i = currentIndex; i >= 0; i--) {
const item = displayItems.value[i]
if (item && (item.isProcess || item.isCollapsedIndicator)) {
return {
...item,
originalIndex: i,
}
}
}
return null
})
const shouldShowSticky = computed(() => {
if (!stickyItem.value) return false
const currentIndex = getCurrentIndex()
const stickyIndex = stickyItem.value.originalIndex
return currentIndex > stickyIndex
})
const shouldHideItem = (item: any, index: number) => {
if (!stickyItem.value || !shouldShowSticky.value) return false
return item.key === stickyItem.value.key
}
// 交互逻辑
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement
scrollTop.value = target.scrollTop
}
const getFirstVisibleItem = () => {
const currentIndex = Math.floor(scrollTop.value / itemHeight.value)
const items = displayItems.value
if (currentIndex < items.length) {
return {
item: items[currentIndex],
index: currentIndex,
offsetInViewport: scrollTop.value % itemHeight.value,
}
}
return null
}
const toggleCollapseWithScrollAdjust = async (processIndex: number) => {
if (!collapsedSections.value.has(processIndex)) {
const visibleItem = getFirstVisibleItem()
if (visibleItem) {
beforeCollapseState.value = {
scrollTop: scrollTop.value,
visibleItemKey: visibleItem.item.key,
offsetInItem: visibleItem.offsetInViewport,
}
}
collapsedSections.value.add(processIndex)
collapsedSections.value = new Set(collapsedSections.value)
await nextTick()
if (beforeCollapseState.value && visibleItem) {
const newItems = displayItems.value
const newIndex = newItems.findIndex((item) => item.key === visibleItem.item.key)
if (newIndex !== -1) {
const newScrollTop = newIndex * itemHeight.value - visibleItem.offsetInViewport
virtualListRef.value?.scrollTo({ top: Math.max(0, newScrollTop) })
}
}
} else {
expandSection(processIndex)
}
}
const toggleCollapse = (processIndex: number) => {
if (collapsedSections.value.has(processIndex)) {
collapsedSections.value.delete(processIndex)
} else {
collapsedSections.value.add(processIndex)
}
collapsedSections.value = new Set(collapsedSections.value)
}
const expandSection = (processIndex: number) => {
collapsedSections.value.delete(processIndex)
collapsedSections.value = new Set(collapsedSections.value)
}
</script>
<style scoped>
.unified-connections-container {
position: relative;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
border-radius: 8px;
overflow: hidden;
}
/* 吸顶元素 */
.sticky-header {
position: absolute;
left: 0;
z-index: 10;
background: var(--bg-overlay);
backdrop-filter: var(--backdrop-blur);
border-bottom: 1px solid var(--border-primary);
box-shadow: var(--shadow-md);
}
.sticky-content {
padding: 12px 16px;
}
.sticky-collapsed,
.sticky-process {
display: flex;
align-items: center;
gap: 12px;
}
.sticky-icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: var(--bg-quaternary);
border-radius: 8px;
color: var(--accent-primary);
flex-shrink: 0;
}
.sticky-info {
flex: 1;
min-width: 0;
}
.sticky-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 8px;
}
.sticky-pid {
font-size: 12px;
font-weight: 400;
color: var(--text-muted);
}
.sticky-subtitle {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}
.sticky-stats {
display: flex;
align-items: center;
gap: 16px;
margin-top: 4px;
font-size: 12px;
color: var(--text-tertiary);
}
.stat-item {
display: flex;
align-items: center;
gap: 4px;
}
/* 虚拟列表 */
.connections-list {
background: var(--bg-secondary);
}
/* 列表项 */
.list-item {
display: flex;
align-items: center;
padding: 12px 16px;
background: var(--bg-primary);
border-bottom: 1px solid var(--border-secondary);
transition: var(--transition-fast);
}
.list-item:hover {
background: var(--bg-hover);
}
.is-hidden {
visibility: hidden;
}
.is-process {
background: var(--bg-tertiary);
border-left: 3px solid var(--accent-primary);
}
.is-connection {
padding-left: 32px;
}
.is-collapsed-indicator {
background: var(--bg-quaternary);
border-left: 3px solid var(--text-muted);
}
/* 折叠指示器 */
.collapsed-indicator {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
color: var(--text-secondary);
}
.indicator-left {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.indicator-text {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 进程头部 */
.process-header {
display: flex;
align-items: center;
width: 100%;
gap: 12px;
}
.process-icon {
color: var(--accent-primary);
flex-shrink: 0;
}
.process-info {
flex: 1;
min-width: 0;
}
.process-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.process-name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.process-stats {
display: flex;
align-items: center;
gap: 16px;
font-size: 12px;
color: var(--text-tertiary);
}
.stat {
display: flex;
align-items: center;
gap: 4px;
}
/* 连接项 */
.connection-item {
display: flex;
align-items: center;
width: 100%;
gap: 12px;
}
.connection-icon {
color: var(--text-quaternary);
flex-shrink: 0;
}
.connection-main {
flex: 1;
min-width: 0;
}
.connection-endpoints {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
font-size: 13px;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
}
.endpoint {
font-weight: 500;
}
.endpoint.local {
color: var(--accent-success);
}
.endpoint.remote {
color: var(--accent-primary);
}
.arrow {
color: var(--text-quaternary);
}
.connection-meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 11px;
}
.traffic {
display: flex;
align-items: center;
gap: 4px;
color: var(--text-tertiary);
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
}
.activity {
display: flex;
align-items: center;
gap: 4px;
color: var(--text-muted);
transition: var(--transition-fast);
}
.activity.active {
color: var(--accent-success);
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 48px;
text-align: center;
color: var(--text-muted);
}
.empty-state h3 {
margin: 16px 0 8px;
font-size: 18px;
font-weight: 600;
color: var(--text-secondary);
}
.empty-state p {
margin: 0;
font-size: 14px;
}
/* 动画 */
.sticky-fade-enter-active,
.sticky-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.sticky-fade-enter-from {
opacity: 0;
transform: translateY(-10px);
}
.sticky-fade-leave-to {
opacity: 0;
transform: translateY(-5px);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* 响应式设计 */
@media (max-width: 768px) {
.sticky-stats,
.process-stats {
flex-wrap: wrap;
gap: 8px;
}
.connection-endpoints {
flex-wrap: wrap;
}
.connection-meta {
flex-wrap: wrap;
gap: 8px;
}
.list-item {
padding: 10px 12px;
}
.is-connection {
padding-left: 20px;
}
}
@media (max-width: 480px) {
.sticky-content {
padding: 10px 12px;
}
.process-title {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.connection-endpoints {
font-size: 12px;
}
}
/* Naive UI 组件样式覆盖 */
:deep(.n-button) {
--n-border: 1px solid var(--border-tertiary);
--n-border-hover: 1px solid var(--border-hover);
--n-color: var(--bg-card);
--n-color-hover: var(--bg-hover);
--n-text-color: var(--text-primary);
--n-text-color-hover: var(--text-primary);
}
:deep(.n-tag) {
--n-color: var(--bg-quaternary);
--n-text-color: var(--text-secondary);
--n-border: none;
}
:deep(.n-virtual-list) {
--n-color: transparent;
}
/* 滚动条样式 */
:deep(.n-virtual-list__content)::-webkit-scrollbar {
width: 8px;
}
:deep(.n-virtual-list__content)::-webkit-scrollbar-track {
background: var(--scrollbar-track-bg);
}
:deep(.n-virtual-list__content)::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb-bg);
border-radius: 4px;
transition: var(--transition-fast);
}
:deep(.n-virtual-list__content)::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-bg-hover);
}
:deep(.n-virtual-list__content)::-webkit-scrollbar-thumb:active {
background: var(--scrollbar-thumb-bg-active);
}
</style>
@@ -0,0 +1,1240 @@
<template>
<div class="unified-connections-container">
<!-- 吸顶元素容器 -->
<Transition name="sticky-slide">
<div
v-show="shouldShowSticky && stickyItem"
class="sticky-header"
:style="stickyStyle"
>
<div class="sticky-content">
<template v-if="stickyItem?.isCollapsedIndicator">
<!-- 折叠状态的吸顶 -->
<div class="sticky-collapsed">
<div class="sticky-icon collapsed">
<n-icon :component="FolderOutline" size="20" />
</div>
<div class="sticky-info">
<div class="sticky-title">{{ stickyItem.processName }}</div>
<div class="sticky-subtitle">
<n-icon :component="LayersOutline" size="14" />
{{ stickyItem.collapsedCount }} 个连接已折叠
</div>
</div>
<n-button
size="small"
type="primary"
@click="expandSection(stickyItem.processIndex)"
>
<template #icon>
<n-icon :component="ChevronDownOutline" />
</template>
展开
</n-button>
</div>
</template>
<template v-else-if="stickyItem?.isProcess">
<!-- 进程标题的吸顶 -->
<div class="sticky-process">
<div class="sticky-icon process">
<n-icon :component="DesktopOutline" size="20" />
</div>
<div class="sticky-info">
<div class="sticky-title">
{{ stickyItem.processName }}
<n-tag class="sticky-pid" size="tiny" round>
PID: {{ stickyItem.processId }}
</n-tag>
</div>
<div class="sticky-stats">
<span class="stat-item">
<n-icon :component="HardwareChipOutline" size="14" />
{{ formatMemory(stickyItem.useMemory) }}
</span>
<span class="stat-item">
<n-icon :component="GitBranchOutline" size="14" />
{{ stickyItem.threadCount }} 线程
</span>
<span class="stat-item connections">
<n-icon :component="GitNetworkOutline" size="14" />
{{ stickyItem.connectionCount }} 连接
</span>
</div>
</div>
<div class="sticky-actions">
<n-button
size="small"
quaternary
circle
@click="toggleCollapseWithScrollAdjust(stickyItem.processIndex)"
>
<template #icon>
<n-icon
:component="collapsedSections.has(stickyItem.processIndex) ? ChevronDownOutline : ChevronUpOutline"
size="18"
/>
</template>
</n-button>
</div>
</div>
</template>
</div>
</div>
</Transition>
<!-- 虚拟列表 -->
<n-virtual-list
ref="virtualListRef"
class="connections-list"
:style="{ maxHeight: containerHeight }"
:item-size="itemHeight"
:items="displayItems"
@scroll="handleScroll"
item-resizable
>
<template #default="{ item, index }">
<div
:key="item.key"
class="list-item"
:class="{
'is-process': item.isProcess,
'is-connection': item.isConnection,
'is-collapsed-indicator': item.isCollapsedIndicator,
'is-hidden': shouldHideItem(item, index),
'is-active': item.isConnection && item.isActive,
}"
:style="{ minHeight: `${itemHeight}px` }"
>
<template v-if="item.isCollapsedIndicator">
<!-- 折叠状态指示器 -->
<div class="collapsed-indicator">
<div class="indicator-left">
<div class="collapse-icon">
<n-icon :component="LayersOutline" size="18" />
</div>
<div class="indicator-content">
<span class="indicator-title">{{ item.processName }}</span>
<span class="indicator-count">{{ item.collapsedCount }} 个连接已折叠</span>
</div>
</div>
<n-button
size="small"
type="primary"
ghost
@click="expandSection(item.processIndex)"
>
<template #icon>
<n-icon :component="ChevronDownOutline" size="16" />
</template>
展开查看
</n-button>
</div>
</template>
<template v-else-if="item.isProcess">
<!-- 进程标题 -->
<div class="process-header">
<div class="process-icon-wrapper">
<n-icon :component="DesktopOutline" size="24" />
</div>
<div class="process-info">
<div class="process-title">
<h4 class="process-name">{{ item.processName }}</h4>
<n-tag type="info" size="small" round>
PID: {{ item.processId }}
</n-tag>
<n-tag
v-if="getProcessStatus(item).show"
:type="getProcessStatus(item).type"
size="small"
round
>
{{ getProcessStatus(item).text }}
</n-tag>
</div>
<div class="process-meta">
<span class="meta-item">
<n-icon :component="HardwareChipOutline" size="14" />
<span>内存: {{ formatMemory(item.useMemory) }}</span>
</span>
<span class="meta-item">
<n-icon :component="GitBranchOutline" size="14" />
<span>{{ item.threadCount }} 线程</span>
</span>
<span class="meta-item primary">
<n-icon :component="GitNetworkOutline" size="14" />
<span>{{ item.connectionCount }} 个连接</span>
</span>
<span v-if="item.startTime" class="meta-item">
<n-icon :component="TimeOutline" size="14" />
<span>运行时长: {{ formatDuration(new Date(item.startTime)) }}</span>
</span>
</div>
</div>
<div class="process-actions">
<n-button
size="small"
quaternary
@click="toggleCollapse(item.processIndex)"
class="collapse-btn"
>
<template #icon>
<n-icon
:component="collapsedSections.has(item.processIndex) ? ChevronDownOutline : ChevronUpOutline"
size="16"
/>
</template>
{{ collapsedSections.has(item.processIndex) ? '展开' : '折叠' }}
</n-button>
</div>
</div>
</template>
<template v-else-if="item.isConnection">
<!-- 连接详情 -->
<div class="connection-item">
<div class="connection-status">
<div
class="status-indicator"
:class="item.isActive ? 'active' : 'inactive'"
:title="item.isActive ? '活跃' : '空闲'"
></div>
</div>
<div class="connection-main">
<div class="connection-header">
<div class="connection-endpoints">
<div class="endpoint-item local">
<n-icon :component="LocationOutline" size="14" />
<span class="endpoint-address">{{ formatEndpoint(item.localEndpoint) }}</span>
</div>
<n-icon :component="ArrowForwardOutline" size="16" class="connection-arrow" />
<div class="endpoint-item remote">
<n-icon :component="GlobeOutline" size="14" />
<span class="endpoint-address">{{ formatEndpoint(item.remoteEndpoint) }}</span>
</div>
</div>
<div class="connection-tags">
<n-tag
:type="item.protocol === 0 ? 'info' : 'warning'"
size="tiny"
round
>
{{ item.protocol === 0 ? 'TCP' : 'UDP' }}
</n-tag>
<n-tag
:type="getConnectionStateType(item.state, item.isActive)"
size="tiny"
round
>
{{ getConnectionStatus(item.state) }}
</n-tag>
</div>
</div>
<div class="connection-stats">
<div class="traffic-info">
<div class="traffic-item upload">
<n-icon :component="ArrowUpOutline" size="12" />
<span>{{ formatBytes(item.bytesSent) }}</span>
</div>
<div class="traffic-item download">
<n-icon :component="ArrowDownOutline" size="12" />
<span>{{ formatBytes(item.bytesReceived) }}</span>
</div>
</div>
<div class="connection-meta-info">
<span v-if="item.startTime" class="meta-text">
<n-icon :component="TimeOutline" size="12" />
持续: {{ formatDuration(new Date(item.startTime)) }}
</span>
<span v-if="!item.isActive && item.lastActiveTime" class="meta-text inactive">
<n-icon :component="PauseCircleOutline" size="12" />
最后活跃: {{ getTimeSinceActive(new Date(item.lastActiveTime)) }}
</span>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
</n-virtual-list>
<!-- 无数据提示 -->
<Transition name="fade">
<div v-if="displayItems.length === 0" class="empty-state">
<div class="empty-icon">
<n-icon :component="CloudOfflineOutline" size="64" />
</div>
<h3>暂无网络连接数据</h3>
<p>请检查数据源是否正确加载</p>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ap } from '@/json/test.js'
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
import { NVirtualList, NButton, NIcon, NTag } from 'naive-ui'
import {
FolderOutline,
ChevronDownOutline,
ChevronUpOutline,
DesktopOutline,
HardwareChipOutline,
GitBranchOutline,
GitNetworkOutline,
LinkOutline,
WifiOutline,
ArrowForwardOutline,
ArrowUpOutline,
ArrowDownOutline,
PulseOutline,
PauseOutline,
CloudOfflineOutline,
LayersOutline,
LocationOutline,
GlobeOutline,
TimeOutline,
PauseCircleOutline,
} from '@vicons/ionicons5'
// Props for container customization
const props = defineProps({
height: {
type: String,
default: '600px'
},
itemHeight: {
type: Number,
default: 80
}
})
// 容器高度
const containerHeight = computed(() => props.height)
const itemHeight = computed(() => props.itemHeight)
// 其他响应式数据
const virtualListRef = ref()
const scrollTop = ref(0)
const scrollbarWidth = ref(0)
const collapsedSections = ref(new Set<number>())
const beforeCollapseState = ref<{
scrollTop: number
visibleItemKey: string
offsetInItem: number
} | null>(null)
// 检测滚动条宽度
const getScrollbarWidth = () => {
const outer = document.createElement('div')
outer.style.visibility = 'hidden'
outer.style.overflow = 'scroll'
outer.style.msOverflowStyle = 'scrollbar'
document.body.appendChild(outer)
const inner = document.createElement('div')
outer.appendChild(inner)
const width = outer.offsetWidth - inner.offsetWidth
outer.parentNode?.removeChild(outer)
return width
}
// 生命周期
onMounted(() => {
scrollbarWidth.value = getScrollbarWidth()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
// 响应式处理
const handleResize = () => {
scrollbarWidth.value = getScrollbarWidth()
}
// 计算吸顶元素的样式
const stickyStyle = computed(() => ({
top: '0px',
right: `${scrollbarWidth.value}px`,
}))
// 格式化函数
const formatMemory = (bytes: number) => {
const mb = bytes / (1024 * 1024)
return mb >= 1 ? `${mb.toFixed(1)} MB` : `${(bytes / 1024).toFixed(1)} KB`
}
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}
const formatEndpoint = (endpoint: any) => {
return `${endpoint.address}:${endpoint.port}`
}
const formatDuration = (start: Date, end?: Date): string => {
const endTime = end || new Date()
const diff = endTime.getTime() - start.getTime()
const hours = Math.floor(diff / 3600000)
const minutes = Math.floor((diff % 3600000) / 60000)
const seconds = Math.floor((diff % 60000) / 1000)
if (hours > 0) {
return `${hours}时${minutes}分`
} else if (minutes > 0) {
return `${minutes}分${seconds}秒`
} else {
return `${seconds}秒`
}
}
const getTimeSinceActive = (lastActive: Date): string => {
const diff = new Date().getTime() - lastActive.getTime()
if (diff < 1000) return '刚刚'
if (diff < 60000) return `${Math.floor(diff / 1000)}秒前`
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
return `${Math.floor(diff / 3600000)}小时前`
}
const getConnectionStatus = (state: number) => {
const states = {
0: '关闭',
1: '监听',
2: '已建立',
3: '等待关闭',
4: '关闭等待',
}
return states[state as keyof typeof states] || '未知'
}
const getConnectionStateType = (state: number, isActive: boolean) => {
if (!isActive) return 'default'
const types = {
0: 'error',
1: 'info',
2: 'success',
3: 'warning',
4: 'warning',
}
return types[state as keyof typeof types] || 'default'
}
const getProcessStatus = (process: any) => {
const hasActiveConnections = process.connections?.some((c: any) => c.isActive)
if (hasActiveConnections) {
return { type: 'success', text: '活跃', show: true }
}
return { type: 'warning', text: '空闲', show: false }
}
// 数据处理
const originalItems = computed(() => {
const result: any[] = []
if (!ap || !Array.isArray(ap) || ap.length === 0) {
return result
}
ap.forEach((process, processIndex) => {
if (!process) return
result.push({
key: `process-${processIndex}`,
isProcess: true,
processIndex,
processName: process.processName || 'Unknown Process',
processId: process.processId || 0,
useMemory: process.useMemory || 0,
threadCount: process.threadCount || 0,
connectionCount: process.connections?.length || 0,
startTime: process.startTime,
mainModulePath: process.mainModulePath,
connections: process.connections || [],
})
if (process.connections && Array.isArray(process.connections)) {
process.connections.forEach((connection, connectionIndex) => {
result.push({
key: `connection-${processIndex}-${connectionIndex}`,
isConnection: true,
processIndex,
connectionIndex,
processName: process.processName || 'Unknown Process',
...connection,
})
})
}
})
return result
})
// 显示数据
const displayItems = computed(() => {
const result: any[] = []
const processGroups: { [key: number]: any[] } = {}
originalItems.value.forEach((item) => {
const processIndex = item.processIndex
if (!processGroups[processIndex]) {
processGroups[processIndex] = []
}
processGroups[processIndex].push(item)
})
Object.keys(processGroups).forEach((processIndexStr) => {
const processIndex = parseInt(processIndexStr)
const group = processGroups[processIndex]
const processItem = group.find((item) => item.isProcess)
const connectionItems = group.filter((item) => item.isConnection)
if (collapsedSections.value.has(processIndex)) {
result.push({
key: `collapsed-${processIndex}`,
isCollapsedIndicator: true,
processIndex,
processName: processItem.processName,
collapsedCount: connectionItems.length,
})
} else {
result.push(processItem)
connectionItems.forEach((connection) => {
result.push(connection)
})
}
})
return result
})
// 吸顶逻辑
const getCurrentIndex = () => {
return Math.floor(scrollTop.value / itemHeight.value)
}
const stickyItem = computed(() => {
if (displayItems.value.length === 0) return null
const currentIndex = getCurrentIndex()
for (let i = currentIndex; i >= 0; i--) {
const item = displayItems.value[i]
if (item && (item.isProcess || item.isCollapsedIndicator)) {
return {
...item,
originalIndex: i,
}
}
}
return null
})
const shouldShowSticky = computed(() => {
if (!stickyItem.value) return false
const currentIndex = getCurrentIndex()
const stickyIndex = stickyItem.value.originalIndex
return currentIndex > stickyIndex
})
const shouldHideItem = (item: any, index: number) => {
if (!stickyItem.value || !shouldShowSticky.value) return false
return item.key === stickyItem.value.key
}
// 交互逻辑
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement
scrollTop.value = target.scrollTop
}
const getFirstVisibleItem = () => {
const currentIndex = Math.floor(scrollTop.value / itemHeight.value)
const items = displayItems.value
if (currentIndex < items.length) {
return {
item: items[currentIndex],
index: currentIndex,
offsetInViewport: scrollTop.value % itemHeight.value,
}
}
return null
}
const toggleCollapseWithScrollAdjust = async (processIndex: number) => {
if (!collapsedSections.value.has(processIndex)) {
const visibleItem = getFirstVisibleItem()
if (visibleItem) {
beforeCollapseState.value = {
scrollTop: scrollTop.value,
visibleItemKey: visibleItem.item.key,
offsetInItem: visibleItem.offsetInViewport,
}
}
collapsedSections.value.add(processIndex)
collapsedSections.value = new Set(collapsedSections.value)
await nextTick()
if (beforeCollapseState.value && visibleItem) {
const newItems = displayItems.value
const newIndex = newItems.findIndex((item) => item.key === visibleItem.item.key)
if (newIndex !== -1) {
const newScrollTop = newIndex * itemHeight.value - visibleItem.offsetInViewport
virtualListRef.value?.scrollTo({ top: Math.max(0, newScrollTop) })
}
}
} else {
expandSection(processIndex)
}
}
const toggleCollapse = (processIndex: number) => {
if (collapsedSections.value.has(processIndex)) {
collapsedSections.value.delete(processIndex)
} else {
collapsedSections.value.add(processIndex)
}
collapsedSections.value = new Set(collapsedSections.value)
}
const expandSection = (processIndex: number) => {
collapsedSections.value.delete(processIndex)
collapsedSections.value = new Set(collapsedSections.value)
}
</script>
<style scoped>
.unified-connections-container {
position: relative;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
border-radius: 12px;
overflow: hidden;
box-shadow: var(--shadow-md);
}
/* 吸顶元素 */
.sticky-header {
position: absolute;
left: 0;
z-index: 10;
background: var(--bg-sticky);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border-primary);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.sticky-content {
padding: 12px 20px;
}
.sticky-collapsed,
.sticky-process {
display: flex;
align-items: center;
gap: 16px;
}
.sticky-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 10px;
flex-shrink: 0;
transition: all 0.3s ease;
}
.sticky-icon.process {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.sticky-icon.collapsed {
background: rgba(168, 85, 247, 0.1);
color: #a855f7;
}
.sticky-info {
flex: 1;
min-width: 0;
}
.sticky-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 10px;
}
.sticky-pid {
font-weight: 400;
}
.sticky-subtitle {
font-size: 13px;
color: var(--text-muted);
margin-top: 2px;
display: flex;
align-items: center;
gap: 4px;
}
.sticky-stats {
display: flex;
align-items: center;
gap: 20px;
margin-top: 4px;
font-size: 13px;
color: var(--text-secondary);
}
.stat-item {
display: flex;
align-items: center;
gap: 6px;
}
.stat-item.connections {
color: #3b82f6;
}
.sticky-actions {
flex-shrink: 0;
}
/* 虚拟列表 */
.connections-list {
background: var(--bg-secondary);
}
/* 列表项 */
.list-item {
background: var(--bg-primary);
border-bottom: 1px solid var(--border-secondary);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.list-item:hover {
background: var(--bg-hover);
}
.is-hidden {
visibility: hidden;
}
.is-process {
background: var(--bg-process);
border-left: 3px solid #3b82f6;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.is-connection {
padding-left: 20px;
border-left: 3px solid transparent;
}
.is-connection.is-active {
background: var(--bg-active);
border-left-color: #10b981;
}
.is-collapsed-indicator {
background: var(--bg-collapsed);
border-left: 3px solid #a855f7;
}
/* 折叠指示器 */
.collapsed-indicator {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 16px 20px;
}
.indicator-left {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.collapse-icon {
width: 36px;
height: 36px;
background: rgba(168, 85, 247, 0.1);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #a855f7;
}
.indicator-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.indicator-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.indicator-count {
font-size: 12px;
color: var(--text-muted);
}
/* 进程头部 */
.process-header {
display: flex;
align-items: center;
width: 100%;
gap: 16px;
padding: 16px 20px;
}
.process-icon-wrapper {
width: 48px;
height: 48px;
background: rgba(59, 130, 246, 0.1);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #3b82f6;
flex-shrink: 0;
}
.process-info {
flex: 1;
min-width: 0;
}
.process-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
flex-wrap: wrap;
}
.process-name {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.process-meta {
display: flex;
align-items: center;
gap: 20px;
font-size: 13px;
color: var(--text-secondary);
flex-wrap: wrap;
}
.meta-item {
display: flex;
align-items: center;
gap: 6px;
}
.meta-item.primary {
color: #3b82f6;
}
.process-actions {
flex-shrink: 0;
}
.collapse-btn {
transition: all 0.3s ease;
}
.collapse-btn:hover {
transform: translateY(-2px);
}
/* 连接项 */
.connection-item {
display: flex;
align-items: flex-start;
width: 100%;
gap: 12px;
padding: 12px 20px 12px 40px;
}
.connection-status {
display: flex;
align-items: center;
padding-top: 8px;
flex-shrink: 0;
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
transition: all 0.3s ease;
}
.status-indicator.active {
background: #10b981;
box-shadow: 0 0 8px rgba(16, 185, 129, 0.6);
animation: pulse 2s infinite;
}
.status-indicator.inactive {
background: #d1d5db;
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.6);
}
70% {
box-shadow: 0 0 0 6px rgba(16, 185, 129, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
}
}
.connection-main {
flex: 1;
min-width: 0;
}
.connection-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
gap: 12px;
}
.connection-endpoints {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
flex-wrap: wrap;
}
.endpoint-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.endpoint-item.local {
color: var(--text-success);
}
.endpoint-item.remote {
color: var(--text-primary);
}
.endpoint-address {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
font-weight: 500;
}
.connection-arrow {
color: var(--text-quaternary);
}
.connection-tags {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.connection-stats {
display: flex;
align-items: center;
gap: 20px;
font-size: 12px;
}
.traffic-info {
display: flex;
align-items: center;
gap: 16px;
}
.traffic-item {
display: flex;
align-items: center;
gap: 4px;
font-weight: 500;
}
.traffic-item.upload {
color: #ef4444;
}
.traffic-item.download {
color: #22c55e;
}
.connection-meta-info {
display: flex;
align-items: center;
gap: 16px;
color: var(--text-muted);
}
.meta-text {
display: flex;
align-items: center;
gap: 4px;
}
.meta-text.inactive {
color: var(--text-quaternary);
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 48px;
text-align: center;
}
.empty-icon {
color: var(--text-quaternary);
margin-bottom: 20px;
}
.empty-state h3 {
margin: 0 0 8px;
font-size: 20px;
font-weight: 600;
color: var(--text-secondary);
}
.empty-state p {
margin: 0;
font-size: 14px;
color: var(--text-muted);
}
/* 动画 */
.sticky-slide-enter-active,
.sticky-slide-leave-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.sticky-slide-enter-from {
opacity: 0;
transform: translateY(-20px);
}
.sticky-slide-leave-to {
opacity: 0;
transform: translateY(-10px);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* CSS 变量 - 亮色主题 */
:root {
--bg-primary: #ffffff;
--bg-secondary: #f9fafb;
--bg-hover: #f3f4f6;
--bg-process: #f0f9ff;
--bg-active: rgba(16, 185, 129, 0.05);
--bg-collapsed: #faf5ff;
--bg-sticky: rgba(255, 255, 255, 0.95);
--border-primary: #e5e7eb;
--border-secondary: #f3f4f6;
--text-primary: #111827;
--text-secondary: #4b5563;
--text-muted: #6b7280;
--text-quaternary: #9ca3af;
--text-success: #059669;
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--scrollbar-track-bg: #f3f4f6;
--scrollbar-thumb-bg: #d1d5db;
--scrollbar-thumb-bg-hover: #9ca3af;
--scrollbar-thumb-bg-active: #6b7280;
}
/* 暗色主题 */
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #1f2937;
--bg-secondary: #111827;
--bg-hover: #374151;
--bg-process: rgba(59, 130, 246, 0.1);
--bg-active: rgba(16, 185, 129, 0.1);
--bg-collapsed: rgba(168, 85, 247, 0.1);
--bg-sticky: rgba(31, 41, 55, 0.95);
--border-primary: #374151;
--border-secondary: #1f2937;
--text-primary: #f9fafb;
--text-secondary: #d1d5db;
--text-muted: #9ca3af;
--text-quaternary: #6b7280;
--text-success: #10b981;
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
--scrollbar-track-bg: #1f2937;
--scrollbar-thumb-bg: #4b5563;
--scrollbar-thumb-bg-hover: #6b7280;
--scrollbar-thumb-bg-active: #9ca3af;
}
.sticky-header {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.is-process {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.sticky-content {
padding: 10px 16px;
}
.process-header,
.collapsed-indicator {
padding: 12px 16px;
}
.connection-item {
padding: 10px 16px 10px 32px;
}
.process-meta,
.sticky-stats {
gap: 12px;
}
.connection-stats {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.traffic-info {
gap: 12px;
}
}
@media (max-width: 480px) {
.process-title {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.connection-endpoints {
font-size: 12px;
}
.connection-header {
flex-direction: column;
gap: 8px;
}
.process-icon-wrapper {
width: 40px;
height: 40px;
}
.sticky-icon {
width: 32px;
height: 32px;
}
}
/* Naive UI 组件样式覆盖 */
:deep(.n-button) {
font-weight: 500;
}
:deep(.n-tag) {
font-weight: 500;
}
:deep(.n-virtual-list) {
--n-color: transparent;
}
/* 滚动条样式 */
:deep(.n-virtual-list__content)::-webkit-scrollbar {
width: 8px;
}
:deep(.n-virtual-list__content)::-webkit-scrollbar-track {
background: var(--scrollbar-track-bg);
}
:deep(.n-virtual-list__content)::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb-bg);
border-radius: 4px;
transition: background 0.3s ease;
}
:deep(.n-virtual-list__content)::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-bg-hover);
}
:deep(.n-virtual-list__content)::-webkit-scrollbar-thumb:active {
background: var(--scrollbar-thumb-bg-active);
}
</style>
@@ -3,7 +3,9 @@
<!-- 主内容区域 - 可拖拽布局 -->
<div class="main-content">
<!-- 左侧边栏 -->
<MonitorSidebar :width="sidebarWidth" />
<div class="sidebar-wrapper" :style="{ width: sidebarWidth + 'px' }">
<MonitorSidebar :width="sidebarWidth" />
</div>
<!-- 左侧分割线 -->
<div class="resize-handle-vertical" @mousedown="startResize('sidebar', $event)">
@@ -11,16 +13,18 @@
</div>
<!-- 中间主视图区域 -->
<MonitorMainPanel
:headerHeight="mainHeaderHeight"
:timelineHeight="timelineHeight"
:searchQuery="searchQuery"
:connections="mockConnections"
:events="mockEvents"
@update:searchQuery="searchQuery = $event"
@resizeTimeline="startResize('timeline', $event)"
@selectConnection="handleConnectionSelect"
/>
<div class="main-panel-wrapper">
<MonitorMainPanel
:headerHeight="mainHeaderHeight"
:timelineHeight="timelineHeight"
:searchQuery="searchQuery"
:connections="mockConnections"
:events="mockEvents"
@update:searchQuery="searchQuery = $event"
@resizeTimeline="startResize('timeline', $event)"
@selectConnection="handleConnectionSelect"
/>
</div>
<!-- 右侧分割线 -->
<div class="resize-handle-vertical" @mousedown="startResize('inspector', $event)">
@@ -28,10 +32,12 @@
</div>
<!-- 右侧检查器面板 -->
<MonitorInspector
:width="inspectorWidth"
:selectedConnection="selectedConnection"
/>
<div class="inspector-wrapper" :style="{ width: inspectorWidth + 'px' }">
<MonitorInspector
:width="inspectorWidth"
:selectedConnection="selectedConnection"
/>
</div>
</div>
</div>
</template>
@@ -172,7 +178,7 @@ const handleConnectionSelect = (connection: any) => {
// 重置布局
const resetLayout = () => {
sidebarWidth.value = 300
sidebarWidth.value = 400
inspectorWidth.value = 350
timelineHeight.value = 200
}
@@ -199,29 +205,59 @@ onMounted(() => {
<style scoped>
/* 监控视图容器 */
.monitor-view {
height: 100%;
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
/* overflow: hidden;*/
overflow: hidden;
}
/* 主内容区域 */
.main-content {
flex: 1;
display: flex;
min-height: 0;
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
/* 侧边栏包装器 */
.sidebar-wrapper {
height: 100%;
flex-shrink: 0;
background: var(--bg-card);
border-right: 1px solid var(--border-primary);
overflow-y: auto;
}
/* 主面板包装器 */
.main-panel-wrapper {
flex: 1;
min-width: 0;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 检查器包装器 */
.inspector-wrapper {
height: 100%;
flex-shrink: 0;
background: var(--bg-card);
border-left: 1px solid var(--border-primary);
overflow-y: auto;
}
/* 拖拽分割线 */
.resize-handle-vertical {
width: 4px;
height: 100%;
background: var(--border-tertiary);
cursor: col-resize;
transition: background-color 0.2s;
position: relative;
z-index: 10;
z-index: 60;
flex-shrink: 0;
}
@@ -238,4 +274,29 @@ onMounted(() => {
.resize-handle-vertical:active {
background-color: var(--accent-primary) !important;
}
/* 响应式设计 */
@media (max-width: 1200px) {
.inspector-wrapper {
display: none;
}
.resize-handle-vertical:last-of-type {
display: none;
}
}
@media (max-width: 768px) {
.sidebar-wrapper {
display: none;
}
.resize-handle-vertical:first-of-type {
display: none;
}
.main-panel-wrapper {
width: 100%;
}
}
</style>