U:调用真实UI

This commit is contained in:
jimmy committed 2025-08-10 14:50:59 +08:00
1 parent 3e7ca51ab0
commit 1732d279d9
3 files changed
+475 -205

No files matched your search

+1
View File
@@ -15,6 +15,7 @@ declare module 'vue' {
IconEcosystem: typeof import('./src/components/icons/IconEcosystem.vue')['default']
IconSupport: typeof import('./src/components/icons/IconSupport.vue')['default']
IconTooling: typeof import('./src/components/icons/IconTooling.vue')['default']
NDataTable: typeof import('naive-ui')['NDataTable']
NetworkConnectionPanel2: typeof import('./src/components/NetworkConnectionPanel2.vue')['default']
NHighlight: typeof import('naive-ui')['NHighlight']
NInput: typeof import('naive-ui')['NInput']
@@ -92,7 +92,7 @@
v-model:value="selectedInterface"
:options="interfaceOptions"
size="small"
style="min-width: 120px;"
:style="{ minWidth: interfaceSelectWidth }"
/>
</div>
</div>
@@ -121,26 +121,14 @@
<!-- 软件流量分析区域 -->
<div class="software-analysis-area">
<div class="software-ranking-panel">
<div class="software-ranking-panel" :class="{ 'large-screen': isLargeScreen }">
<div class="panel-header">
<h3 class="panel-title">软件流量TOP榜</h3>
<div class="panel-controls">
<n-button-group size="small">
<n-button
v-for="range in rankingTimeRanges"
:key="range"
:type="selectedRankingRange === range ? 'primary' : 'default'"
@click="selectedRankingRange = range"
>
{{ range }}
</n-button>
</n-button-group>
</div>
</div>
<div class="ranking-content">
<SoftwareRankingList
:data="softwareRankingData"
:time-range="selectedRankingRange"
:time-range="selectedTimeRange"
@select-software="showSoftwareDetail"
/>
</div>
@@ -155,7 +143,7 @@
:network-relation-data="networkRelationData"
:port-stats-data="portStatsData"
:protocol-data="protocolData"
:time-range="selectedRankingRange"
:time-range="selectedTimeRange"
/>
</div>
</div>
@@ -172,6 +160,8 @@ import {
AlertCircleOutline,
ServerOutline,
} from '@vicons/ionicons5'
import { httpClient } from '@/utils/http'
import type { ApiResponse } from '@/types/http'
// 导入组件
import TrafficTrendChart from './components/TrafficTrendChart.vue'
@@ -179,68 +169,255 @@ import TopAppsChart from './components/TopAppsChart.vue'
import SoftwareRankingList from './components/SoftwareRankingList.vue'
import SoftwareDetailModal from './components/SoftwareDetailModal.vue'
// API 接口类型定义
interface TimeRange {
type: string
name: string
available: boolean
startTime?: string
}
interface NetworkInterface {
id: string
name: string
displayName: string
isActive: boolean
macAddress: string
}
interface TrafficTrend {
interface: string
timeRange: string
points: {
timestamp: string
uploadSpeed: number
downloadSpeed: number
}[]
}
interface TopApp {
processName: string
displayName: string
icon: string
totalBytes: number
}
interface TopTrafficApp {
rank: number
processName: string
displayName: string
processPath: string
icon: string
version: string
company: string
totalBytes: number
uploadBytes: number
connectionCount: number
}
// 时间范围选项
const timeRanges = ref([
const timeRanges = ref<TimeRange[]>([
{ type: '1hour', name: '1小时', available: true },
{ type: '1day', name: '24小时', available: true },
{ type: '7days', name: '7天', available: true },
{ type: '30days', name: '30天', available: false },
])
const selectedTimeRange = ref('1day')
// API 调用函数
const getAvailableRanges = async () => {
try {
const res: ApiResponse<TimeRange[]> = await httpClient.get('/statistics/available-ranges')
if (res.success && res.data) {
// 根据 API 返回的数据更新时间范围选项
const ranges = Array.isArray(res.data) ? res.data : [res.data]
const rangeMap: Record<string, string> = {
'hour': '1hour',
'day': '1day',
'week': '7days',
'month': '30days'
}
// 更新时间范围的可用状态
timeRanges.value.forEach(range => {
const matchedRange = ranges.find(r => rangeMap[r.type] === range.type)
if (matchedRange) {
range.available = matchedRange.available
} else {
range.available = false
}
})
}
} catch (error) {
console.error('获取可用时间范围失败:', error)
}
}
const selectedTimeRange = ref('1hour') // 默认选择1小时,暂不支持10分钟选项
// 网络接口选项
const interfaceOptions = ref([
{ label: '全部网卡', value: 'all' },
{ label: '以太网', value: 'eth0' },
{ label: 'WiFi', value: 'wifi0' },
])
// 计算网卡选择框的最小宽度
const interfaceSelectWidth = computed(() => {
if (interfaceOptions.value.length === 0) return '120px'
const maxLength = Math.max(
...interfaceOptions.value.map(option => option.label.length)
)
// 根据字符长度计算宽度,中文字符按2倍宽度计算
const estimatedWidth = maxLength * 8 + 60 // 基础60px + 字符宽度
return Math.max(estimatedWidth, 120) + 'px' // 最小120px
})
const getNetworkInterfaces = async () => {
try {
const params: Record<string, any> = {
timeRange: selectedTimeRange.value
}
const res: ApiResponse<NetworkInterface[]> = await httpClient.get('/statistics/interfaces', params)
if (res.success && res.data) {
// 清空现有选项(保留全部网卡选项)
interfaceOptions.value = [{ label: '全部网卡', value: 'all' }]
// 添加从 API 获取的网络接口
res.data.forEach(iface => {
if (iface.isActive) {
interfaceOptions.value.push({
label: iface.displayName,
value: iface.id
})
}
})
}
} catch (error) {
console.error('获取网络接口失败:', error)
}
}
const selectedInterface = ref('all')
// 软件排行时间范围
const rankingTimeRanges = ref(['1小时', '1天', '7天', '30天'])
const selectedRankingRange = ref('1天')
// 大屏幕检测
const isLargeScreen = computed(() => {
if (typeof window === 'undefined') return false
return window.innerHeight >= 900 // 高度大于900px认为是大屏幕
})
// 移除单独的软件排行时间范围,使用顶部统一的时间选择
// 选中的软件和弹窗状态
const selectedSoftware = ref<any>(null)
const showDetailModal = ref(false)
// Mock数据
const trafficTrendData = ref([
{ timestamp: Date.now() - 3600000, uploadSpeed: 1024000, downloadSpeed: 5120000 },
{ timestamp: Date.now() - 2400000, uploadSpeed: 2048000, downloadSpeed: 8192000 },
{ timestamp: Date.now() - 1200000, uploadSpeed: 1536000, downloadSpeed: 6144000 },
{ timestamp: Date.now(), uploadSpeed: 3072000, downloadSpeed: 12288000 },
])
// 流量趋势数据
const trafficTrendData = ref<any[]>([])
const topAppsData = ref([
{ processName: 'chrome.exe', displayName: 'Google Chrome', totalBytes: 1073741824, percentage: 45.2 },
{ processName: 'teams.exe', displayName: 'Microsoft Teams', totalBytes: 536870912, percentage: 22.5 },
{ processName: 'spotify.exe', displayName: 'Spotify', totalBytes: 268435456, percentage: 11.3 },
{ processName: 'code.exe', displayName: 'VS Code', totalBytes: 134217728, percentage: 5.6 },
{ processName: 'slack.exe', displayName: 'Slack', totalBytes: 67108864, percentage: 2.8 },
])
const getTrafficTrends = async () => {
try {
const params: Record<string, any> = {
timeRange: selectedTimeRange.value,
interfaceId: selectedInterface.value
}
const softwareRankingData = ref([
{
rank: 1,
processName: 'chrome.exe',
displayName: 'Google Chrome',
totalBytes: 1073741824,
percentage: 45.2,
connectionCount: 23
},
{
rank: 2,
processName: 'teams.exe',
displayName: 'Microsoft Teams',
totalBytes: 536870912,
percentage: 22.5,
connectionCount: 12
},
// ... 更多数据
])
const res: ApiResponse<TrafficTrend> = await httpClient.get('/traffic/trends', params)
if (res.success && res.data) {
// 转换 API 数据为图表需要的格式并按时间排序(最新时间在右侧)
let processedData = res.data.points
.map(point => ({
timestamp: parseInt(point.timestamp) * 1000, // 转换为毫秒时间戳
uploadSpeed: point.uploadSpeed,
downloadSpeed: point.downloadSpeed
}))
.sort((a, b) => a.timestamp - b.timestamp) // 按时间升序排列,最新时间在右侧
// 限制显示的数据点数量,避免图表过于密集
const maxPoints = 250 // 最多显示250个点
if (processedData.length > maxPoints) {
// 等间隔采样数据点
const step = Math.floor(processedData.length / maxPoints)
processedData = processedData.filter((_, index) => index % step === 0)
}
trafficTrendData.value = processedData
}
} catch (error) {
console.error('获取流量趋势失败:', error)
}
}
// Top 应用流量数据
const topAppsData = ref<any[]>([])
const getTopApps = async () => {
try {
const params: Record<string, any> = {
timeRange: selectedTimeRange.value,
limit: 10
}
const res: ApiResponse<TopApp[]> = await httpClient.get('/traffic/top-apps', params)
if (res.success && res.data) {
// 计算总流量用于计算百分比
const totalBytes = res.data.reduce((sum, app) => sum + app.totalBytes, 0)
// 转换数据格式
topAppsData.value = res.data.map(app => ({
processName: app.processName,
displayName: app.displayName,
icon: app.icon,
totalBytes: app.totalBytes,
percentage: totalBytes > 0 ? (app.totalBytes / totalBytes * 100) : 0
}))
}
} catch (error) {
console.error('获取Top应用流量失败:', error)
}
}
// 软件流量排行数据
const softwareRankingData = ref<any[]>([])
const getSoftwareRanking = async () => {
try {
const params: Record<string, any> = {
timeRange: selectedTimeRange.value,
page: 1,
pageSize: 100
}
const res: ApiResponse<{
total: number
page: number
pageSize: number
items: TopTrafficApp[]
}> = await httpClient.get('/apps/top-traffic', params)
if (res.success && res.data) {
// 计算总流量用于计算百分比
const totalBytes = res.data.items.reduce((sum, app) => sum + app.totalBytes, 0)
// 转换数据格式适配组件
softwareRankingData.value = res.data.items.map(app => ({
rank: app.rank,
processName: app.processName,
displayName: app.displayName,
processPath: app.processPath,
icon: app.icon,
version: app.version,
company: app.company,
totalBytes: app.totalBytes,
uploadBytes: app.uploadBytes,
connectionCount: app.connectionCount,
percentage: totalBytes > 0 ? (app.totalBytes / totalBytes * 100) : 0
}))
}
} catch (error) {
console.error('获取软件流量排行失败:', error)
}
}
// 选中软件的详细信息
const selectedSoftwareInfo = computed(() => {
@@ -295,22 +472,35 @@ const showSoftwareDetail = (software: any) => {
showDetailModal.value = true
}
const refreshData = () => {
// 刷新数据的逻辑
const refreshData = async () => {
// 刷新所有数据
console.log('Refreshing data...')
await Promise.all([
getAvailableRanges(),
getNetworkInterfaces(),
getTrafficTrends(),
getTopApps(),
getSoftwareRanking()
])
}
// 监听时间范围变化
watch(selectedTimeRange, () => {
// 重新加载数据
// 重新加载相关数据
getTrafficTrends()
getTopApps()
getSoftwareRanking()
})
watch(selectedInterface, () => {
// 重新加载趋势数据
getTrafficTrends()
})
onMounted(() => {
// 页面初始化,不再默认选择软件
onMounted(async () => {
// 页面初始化,加载所有数据
await refreshData()
})
</script>
@@ -632,6 +822,11 @@ onMounted(() => {
flex-direction: column;
}
.software-ranking-panel.large-screen {
height: calc(100vh - 400px); /* 大屏幕下使用更多高度 */
min-height: 600px;
}
.panel-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border-primary);
@@ -15,57 +15,34 @@
</div>
</div>
<div class="ranking-list" ref="listContainer">
<div
v-for="(item, index) in filteredData"
:key="item.processName"
:class="['ranking-item', { 'selected': selectedSoftware?.processName === item.processName }]"
@click="selectSoftware(item)"
>
<div class="rank-badge" :class="getRankClass(item.rank)">
{{ item.rank }}
</div>
<div class="software-icon">
<div class="icon-placeholder">
{{ item.displayName.charAt(0).toUpperCase() }}
</div>
</div>
<div class="software-info">
<div class="software-name">{{ item.displayName }}</div>
<div class="software-details">
<span class="process-name">{{ item.processName }}</span>
<span class="connection-count">{{ item.connectionCount }} 个连接</span>
</div>
</div>
<div class="traffic-info">
<div class="traffic-size">{{ formatBytes(item.totalBytes) }}</div>
<div class="traffic-percentage">{{ item.percentage.toFixed(1) }}%</div>
<div class="traffic-bar">
<div
class="traffic-fill"
:style="{ width: Math.min(item.percentage, 100) + '%' }"
></div>
</div>
</div>
</div>
<div class="table-container" ref="listContainer">
<n-data-table
:columns="columns"
:data="filteredData"
:row-key="(row: SoftwareRankingItem) => `${row.processName}_${row.rank}_${row.totalBytes}`"
:row-class-name="(row: SoftwareRankingItem) => selectedSoftware?.processName === row.processName ? 'selected-row' : ''"
:on-update:checked-row-keys="() => {}"
@row-click="selectSoftware"
striped
size="small"
:scroll-x="800"
:max-height="containerHeight"
/>
<!-- 加载更多指示器 -->
<div v-if="hasMore" class="load-more" @click="loadMore">
<n-button text size="small">
加载更多...
</n-button>
<!-- 数据统计信息 -->
<div class="data-info">
<span>共 {{ filteredData.length }} 条数据</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { NInput, NIcon, NButton } from 'naive-ui'
import { ref, computed, watch, nextTick, h } from 'vue'
import { NInput, NIcon, NButton, NDataTable, NAvatar, NTag, NTooltip } from 'naive-ui'
import { SearchOutline } from '@vicons/ionicons5'
import type { DataTableColumns } from 'naive-ui'
import { getGradientColor } from '@/utils/colorUtils'
// 接口定义
interface SoftwareRankingItem {
@@ -73,6 +50,9 @@ interface SoftwareRankingItem {
processName: string
displayName: string
processPath?: string
icon?: string
version?: string
company?: string
totalBytes: number
uploadBytes?: number
downloadBytes?: number
@@ -95,8 +75,130 @@ const emit = defineEmits<{
// 响应式数据
const searchQuery = ref('')
const listContainer = ref<HTMLElement>()
const displayCount = ref(20) // 初始显示20个
const hasMore = computed(() => displayCount.value < filteredData.value.length)
// 计算表格容器高度
const containerHeight = computed(() => {
if (typeof window === 'undefined') return '400px'
const windowHeight = window.innerHeight
// 大屏幕下使用更多高度,小屏幕下保持400px
if (windowHeight >= 900) {
return Math.max(windowHeight - 500, 400) + 'px'
}
return '400px'
})
// 表格列定义
const columns: DataTableColumns<SoftwareRankingItem> = [
{
title: '排名',
key: 'rank',
width: 60,
render: (row) => {
const rankClass = row.rank <= 3 ? 'top-three' : row.rank <= 10 ? 'top-ten' : 'normal'
return h('div', { class: `rank-badge ${rankClass}` }, row.rank.toString())
}
},
{
title: '图标',
key: 'icon',
width: 60,
render: (row) => {
return h('div', { class: 'icon-column' }, [
row.icon
? h('img', {
src: `data:image/png;base64,${row.icon}`,
class: 'app-icon',
alt: row.displayName
})
: h('div', {
class: 'app-icon-span',
style: getGradientColor(row.displayName.charAt(0).toUpperCase())
}, [
h('span', row.displayName.charAt(0).toUpperCase())
])
])
}
},
{
title: '软件名称',
key: 'displayName',
width: 150,
render: (row) => {
return h('div', { class: 'software-name-cell' }, row.displayName)
}
},
{
title: '公司',
key: 'company',
width: 120,
render: (row) => row.company || '-'
},
{
title: '版本',
key: 'version',
width: 100,
render: (row) => row.version || '-'
},
{
title: '路径',
key: 'processPath',
width: 200,
ellipsis: {
tooltip: true
},
render: (row) => row.processPath || '-'
},
{
title: '总流量',
key: 'totalBytes',
width: 100,
render: (row) => formatBytes(row.totalBytes),
sorter: {
compare: (a, b) => Number(a.totalBytes) - Number(b.totalBytes),
multiple: 1
}
},
{
title: '上传',
key: 'uploadBytes',
width: 100,
render: (row) => formatBytes(row.uploadBytes || 0),
sorter: {
compare: (a, b) => Number(a.uploadBytes || 0) - Number(b.uploadBytes || 0),
multiple: 1
}
},
{
title: '连接数',
key: 'connectionCount',
width: 80,
render: (row) => row.connectionCount.toString(),
sorter: {
compare: (a, b) => Number(a.connectionCount) - Number(b.connectionCount),
multiple: 1
}
},
{
title: '占比',
key: 'percentage',
width: 80,
render: (row) => {
return h('div', { class: 'percentage-cell' }, [
h('div', { class: 'percentage-text' }, `${row.percentage.toFixed(1)}%`),
h('div', { class: 'percentage-bar' }, [
h('div', {
class: 'percentage-fill',
style: { width: `${Math.min(row.percentage, 100)}%` }
})
])
])
},
sorter: {
compare: (a, b) => Number(a.percentage) - Number(b.percentage),
multiple: 1
}
}
]
// 搜索过滤
const filteredData = computed(() => {
@@ -106,11 +208,13 @@ const filteredData = computed(() => {
const query = searchQuery.value.toLowerCase()
filtered = filtered.filter(item =>
item.displayName.toLowerCase().includes(query) ||
item.processName.toLowerCase().includes(query)
item.processName.toLowerCase().includes(query) ||
(item.company && item.company.toLowerCase().includes(query))
)
}
return filtered.slice(0, displayCount.value)
// 不再限制显示数量,显示所有数据
return filtered
})
// 选择软件
@@ -118,11 +222,6 @@ const selectSoftware = (software: SoftwareRankingItem) => {
emit('selectSoftware', software)
}
// 加载更多
const loadMore = () => {
displayCount.value += 20
}
// 获取排名样式类
const getRankClass = (rank: number): string => {
if (rank <= 3) return 'top-three'
@@ -141,20 +240,14 @@ const formatBytes = (bytes: number): string => {
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
}
// 重置显示数量当时间范围改变时
// 重置滚动位置当时间范围改变时
watch(() => props.timeRange, () => {
displayCount.value = 20
nextTick(() => {
if (listContainer.value) {
listContainer.value.scrollTop = 0
}
})
})
// 重置显示数量当搜索查询改变时
watch(searchQuery, () => {
displayCount.value = 20
})
</script>
<style scoped>
@@ -174,42 +267,38 @@ watch(searchQuery, () => {
width: 100%;
}
.ranking-list {
.table-container {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.ranking-item {
.data-info {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 8px;
border: 1px solid transparent;
transition: all 0.2s ease;
cursor: pointer;
margin-bottom: 4px;
justify-content: center;
padding: 8px;
border-top: 1px solid var(--border-secondary);
margin-top: 8px;
font-size: 12px;
color: var(--text-muted);
}
.ranking-item:hover {
background: var(--bg-hover);
border-color: var(--border-hover);
/* 表格内容样式 */
:deep(.n-data-table-td) {
padding: 8px 12px !important;
}
.ranking-item.selected {
background: var(--bg-selected);
border-color: var(--accent-primary);
:deep(.selected-row) {
background-color: var(--bg-selected) !important;
}
.rank-badge {
width: 28px;
height: 28px;
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
}
@@ -217,7 +306,7 @@ watch(searchQuery, () => {
.rank-badge.top-three {
background: linear-gradient(135deg, #ffd700, #ffed4e);
color: #92400e;
box-shadow: 0 0 10px rgba(255, 215, 0, 0.3);
box-shadow: 0 0 8px rgba(255, 215, 0, 0.3);
}
.rank-badge.top-ten {
@@ -230,109 +319,94 @@ watch(searchQuery, () => {
color: var(--text-muted);
}
.software-icon {
flex-shrink: 0;
.icon-column {
display: flex;
justify-content: center;
align-items: center;
}
.icon-placeholder {
width: 36px;
height: 36px;
border-radius: 8px;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
.app-icon {
width: 28px;
height: 28px;
border-radius: 6px;
object-fit: contain;
}
.app-icon-span {
width: 28px;
height: 28px;
background: linear-gradient(135deg, var(--accent-primary) 0%, #1d4ed8 100%);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
}
.app-icon-span span {
font-size: 12px;
font-weight: 700;
color: white;
font-weight: 600;
font-size: 14px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.software-info {
flex: 1;
min-width: 0;
}
.software-name {
font-size: 14px;
.software-name-cell {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.software-details {
.percentage-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.percentage-text {
font-size: 11px;
color: var(--text-muted);
}
.process-name {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.traffic-info {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
min-width: 80px;
}
.traffic-size {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
font-family: 'JetBrains Mono', 'Fira Code', monospace;
text-align: center;
}
.traffic-percentage {
font-size: 11px;
color: var(--text-muted);
}
.traffic-bar {
width: 60px;
height: 4px;
.percentage-bar {
width: 40px;
height: 3px;
background: var(--bg-tertiary);
border-radius: 2px;
overflow: hidden;
margin: 0 auto;
}
.traffic-fill {
.percentage-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
border-radius: 2px;
transition: width 0.3s ease;
}
.load-more {
display: flex;
justify-content: center;
padding: 16px;
border-top: 1px solid var(--border-secondary);
margin-top: 8px;
/* 表格样式覆盖 */
:deep(.n-data-table) {
--n-border-color: var(--border-secondary);
--n-th-color: var(--bg-tertiary);
--n-td-color: var(--bg-card);
--n-td-color-hover: var(--bg-hover);
--n-text-color: var(--text-primary);
--n-th-text-color: var(--text-secondary);
}
/* 滚动条样式 */
.ranking-list::-webkit-scrollbar {
width: 6px;
:deep(.n-data-table-thead) {
background: var(--bg-secondary);
}
.ranking-list::-webkit-scrollbar-track {
background: var(--bg-tertiary);
border-radius: 3px;
:deep(.n-data-table-tr:hover) {
cursor: pointer;
}
.ranking-list::-webkit-scrollbar-thumb {
background: var(--border-secondary);
border-radius: 3px;
}
.ranking-list::-webkit-scrollbar-thumb:hover {
background: var(--border-hover);
:deep(.n-data-table-td) {
border-bottom: 1px solid var(--border-tertiary) !important;
}
</style>