Files
LagZero/src/components/node/NodeSelector.vue
T

306 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<!-- 根容器:使用 surface-panel 背景,确保主题适应性 -->
<div class="flex flex-col h-full overflow-hidden gap-3 bg-surface-panel/50 rounded-2xl">
<!-- 工具栏:固定高度,不缩放 -->
<div class="flex-none flex flex-col gap-2 bg-surface/50 rounded-xl border border-border/50 backdrop-blur-md transition-all sticky top-0 z-10"
:class="compact ? 'p-2' : 'p-3 md:flex-row md:items-center md:justify-between shadow-sm'">
<div class="flex items-center gap-2 w-full" :class="compact ? '' : 'md:w-auto'">
<!-- 搜索框 -->
<div class="relative group flex-1" :class="compact ? '' : 'md:min-w-[200px]'">
<div class="absolute inset-y-0 left-0 pl-2.5 flex items-center pointer-events-none">
<div
class="i-material-symbols-search text-on-surface-muted group-focus-within:text-primary transition-colors text-base">
</div>
</div>
<input v-model="searchQuery" type="text" :placeholder="$t('common.search')"
class="pl-9 pr-3 py-1.5 bg-surface-overlay/50 rounded-lg border border-border focus:border-primary/50 focus:bg-surface-overlay outline-none text-on-surface text-xs w-full transition-all placeholder:text-on-surface-muted/50" />
</div>
<!-- 排序 (显示当前模式) -->
<button @click="cycleSort"
class="px-2 py-1.5 rounded-lg border border-transparent hover:border-border hover:bg-surface-overlay text-on-surface-muted hover:text-primary transition-colors flex items-center gap-1.5"
:title="$t('common.sort_by')">
<div class="i-material-symbols-sort text-lg"></div>
<span class="text-[10px] font-bold uppercase tracking-wider">{{ $t(`common.${sortBy === 'latency' ?
'latency_short' : sortBy}`) }}</span>
</button>
</div>
<!-- 过滤器 (非紧凑模式) -->
<div v-if="!compact" class="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto mt-2 md:mt-0">
<div class="flex gap-2 overflow-x-auto pb-1 md:pb-0 no-scrollbar w-full md:w-auto">
<button v-for="type in uniqueTypes" :key="type" @click="toggleTypeFilter(type)"
class="px-3 py-1 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all whitespace-nowrap border"
:class="selectedTypes.includes(type)
? 'bg-primary/10 text-primary border-primary/30'
: 'bg-surface-overlay/50 text-on-surface-muted border-border hover:border-primary/30 hover:text-on-surface'">
{{ type }}
</button>
</div>
</div>
</div>
<!-- 节点列表区域 -->
<div class="flex-1 min-h-0 overflow-y-auto custom-scrollbar px-1">
<div class="grid gap-2 pb-2" :class="compact ? 'grid-cols-1' : 'grid-cols-1 md:grid-cols-2 xl:grid-cols-3'">
<div v-for="node in sortedNodes" :key="node.id" @click="selectNode(node)"
class="group relative rounded-xl border transition-all duration-200 cursor-pointer overflow-hidden backdrop-blur-sm select-none"
:class="[
modelValue === (node.id || node.tag)
? 'bg-primary/5 border-primary shadow-[0_0_15px_-5px_rgba(var(--rgb-primary),0.3)]'
: 'bg-surface/50 border-border hover:border-primary/50 hover:bg-surface-overlay'
]">
<!-- 选中时的背景高亮指示 -->
<div v-if="modelValue === (node.id || node.tag)"
class="absolute inset-0 bg-gradient-to-r from-primary/10 to-transparent opacity-50 pointer-events-none">
</div>
<div class="relative z-10 flex items-center gap-3" :class="compact ? 'p-2.5 pl-3' : 'p-3'">
<!-- Protocol Icon -->
<div class="shrink-0 w-8 h-8 rounded-lg flex items-center justify-center transition-colors border"
:class="modelValue === (node.id || node.tag)
? 'bg-primary/20 text-primary border-primary/20'
: 'bg-surface-overlay border-border text-on-surface-muted group-hover:text-primary group-hover:border-primary/30'">
<div :class="getNodeIcon(node.type)" class="text-lg"></div>
</div>
<!-- Info -->
<div class="flex-1 min-w-0 flex flex-col gap-0.5">
<div class="flex items-center justify-between gap-2">
<span
class="text-xs font-bold text-on-surface truncate group-hover:text-primary transition-colors">
{{ node.tag || node.server }}
</span>
<!-- Latency Badge -->
<span class="shrink-0 font-mono text-[10px] font-bold"
:class="getLatencyTextColor(node.latency)">
{{ node.latency > 0 ? node.latency + 'ms' : '--' }}
</span>
</div>
<!-- Sub-info -->
<div class="flex items-center gap-2">
<span
class="text-[9px] uppercase tracking-wider text-on-surface-muted/70 border border-border px-1 rounded bg-surface-overlay/50 group-hover:border-primary/20 transition-colors">
{{ node.type }}
</span>
<span v-if="!compact" class="text-[10px] text-on-surface-muted/50 truncate">{{
node.server }}</span>
</div>
</div>
</div>
</div>
<!-- 空状态展示 -->
<div v-if="sortedNodes.length === 0"
class="col-span-full flex flex-col items-center justify-center py-10 text-center opacity-70">
<div class="w-16 h-16 rounded-full bg-surface-overlay flex items-center justify-center mb-3">
<div class="i-material-symbols-dns-outline text-3xl text-on-surface-muted/50"></div>
</div>
<span class="text-xs font-medium text-on-surface-muted">No nodes found</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { NodeConfig } from '@/utils/protocol'
import { useNodeStore } from '@/stores/nodes'
interface ExtendedNode extends NodeConfig {
latency: number
loss: number
load: number
bandwidth: number // bytes/sec
}
const props = defineProps<{
modelValue: string | null | undefined
nodes: NodeConfig[]
compact?: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
// 响应式状态
const searchQuery = ref('')
const selectedTypes = ref<string[]>([])
const sortBy = ref<'latency' | 'load' | 'bandwidth'>('latency')
const nodeStore = useNodeStore()
// 合并节点实时统计数据
const nodesWithStats = computed<ExtendedNode[]>(() => {
return props.nodes.map(node => {
const defaultStat = { latency: -1, loss: 0 }
const key = node.id || node.tag
const stat = (key && nodeStore.nodeStats[key]) || defaultStat
return {
...node,
latency: stat.latency,
loss: stat.loss,
load: 0, // 待实现
bandwidth: 0 // 待实现
}
})
})
// 提取不重复的节点类型
const uniqueTypes = computed(() => {
const types = new Set(props.nodes.map(n => n.type))
return Array.from(types).sort()
})
// 过滤与排序逻辑
const sortedNodes = computed(() => {
let result = [...nodesWithStats.value]
// 搜索过滤
if (searchQuery.value) {
const q = searchQuery.value.toLowerCase()
result = result.filter(n =>
(n.tag && n.tag.toLowerCase().includes(q)) ||
(n.server && n.server.toLowerCase().includes(q))
)
}
// 类型过滤
if (selectedTypes.value.length > 0) {
result = result.filter(n => selectedTypes.value.includes(n.type))
}
// 排序
result.sort((a, b) => {
if (sortBy.value === 'latency') {
const latA = a.latency <= 0 ? 999999 : a.latency
const latB = b.latency <= 0 ? 999999 : b.latency
return latA - latB
}
if (sortBy.value === 'load') return a.load - b.load
if (sortBy.value === 'bandwidth') return b.bandwidth - a.bandwidth
return 0
})
return result
})
// 切换类型过滤器
function toggleTypeFilter(type: string) {
const index = selectedTypes.value.indexOf(type)
if (index > -1) {
selectedTypes.value.splice(index, 1)
} else {
selectedTypes.value.push(type)
}
}
// 切换排序模式
function cycleSort() {
const modes: ('latency' | 'load' | 'bandwidth')[] = ['latency', 'load', 'bandwidth']
const currentIndex = modes.indexOf(sortBy.value)
const nextIndex = (currentIndex + 1) % modes.length
sortBy.value = modes[nextIndex]!
}
// 选择节点触发事件
function selectNode(node: ExtendedNode) {
const id = node.id || node.tag
if (id) {
emit('update:modelValue', id)
}
}
// 根据节点协议获取图标
function getNodeIcon(type: string) {
const t = type.toLowerCase()
if (t.includes('vmess')) return 'i-carbon-network-4'
if (t.includes('vless')) return 'i-carbon-ibm-cloud-vpc'
if (t.includes('shadowsocks') || t.includes('ss')) return 'i-carbon-send-alt'
if (t.includes('trojan')) return 'i-carbon-security'
if (t.includes('hysteria')) return 'i-carbon-flash'
if (t.includes('tuic')) return 'i-carbon-rocket'
return 'i-carbon-network-1'
}
// 延迟状态文字颜色
function getLatencyTextColor(ms: number) {
if (ms <= 0) return 'text-on-surface-muted/40'
if (ms < 100) return 'text-success'
if (ms < 200) return 'text-warning'
return 'text-error'
}
</script>
<style scoped>
/* 自定义滚动条样式 */
.custom-scrollbar::-webkit-scrollbar {
width: 5px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(var(--primary-rgb), 0.15);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(var(--primary-rgb), 0.35);
}
/* 缩放动画 */
@keyframes scale-in {
from {
transform: scale(0.6);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.animate-scale-in {
animation: scale-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* 脉冲动画 */
@keyframes pulse-slow {
0%,
100% {
opacity: 0.1;
transform: scale(1);
}
50% {
opacity: 0.2;
transform: scale(1.1);
}
}
.animate-pulse-slow {
animation: pulse-slow 4s infinite ease-in-out;
}
/* 隐藏滚动条 */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>