A:重新调整虚拟列表组件
This commit is contained in:
1 parent
7716850e16
commit
e737713ef5
23 files changed
+3660
-1325
No files matched your search
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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>-->
|
||||
<!-- <!– <network-connection-panel2/>–>-->
|
||||
<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>
|
||||
Reference in new issue
Block a user