feat: add openconnect

This commit is contained in:
duorameng
2026-06-25 20:06:38 +08:00
parent fac1582fe0
commit 90f06de298
49 changed files with 3854 additions and 969 deletions
+21
View File
@@ -2,6 +2,13 @@
import { onMounted } from 'vue'
import { RouterView } from 'vue-router'
import { Toaster } from '@/components/ui/sonner'
import { LogOut } from 'lucide-vue-next'
import { activeInterconnectNodeId, setActiveInterconnectNodeId } from '@/api'
function exitTravel() {
setActiveInterconnectNodeId('')
window.location.href = '/'
}
onMounted(() => {
// 全局应用设备差异化垂直抗锯齿
@@ -19,4 +26,18 @@ onMounted(() => {
<template>
<RouterView />
<Toaster position="bottom-right" :duration="2000" />
<!-- 穿越子节点时显示的悬浮返回主节点控制条 -->
<div v-if="activeInterconnectNodeId" class="fixed bottom-4 left-4 z-[9999] group">
<button
@click="exitTravel"
class="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-medium
bg-amber-500/85 hover:bg-amber-500 text-white shadow-md shadow-amber-500/10
transition-all duration-300 border border-amber-400/10 backdrop-blur-sm
hover:scale-105 active:scale-95 cursor-pointer"
>
<LogOut class="h-3 w-3 transition-transform group-hover:-translate-x-0.5" />
<span>返回主节点</span>
</button>
</div>
</template>
+27 -9
View File
@@ -65,16 +65,34 @@ export interface MonitorStats {
}[]
}
}
export let activeInterconnectNodeId = localStorage.getItem('activeInterconnectNodeId') || ''
async function request<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE_URL}${url}`, {
...options,
credentials: 'include', // 携带 Cookie
headers: {
'Content-Type': 'application/json',
...options?.headers
}
})
export function setActiveInterconnectNodeId(id: string) {
activeInterconnectNodeId = id
localStorage.removeItem('site_settings_cache')
if (id) {
localStorage.setItem('activeInterconnectNodeId', id)
document.cookie = `active_interconnect_node_id=${id}; path=/; max-age=${7 * 24 * 3600}`
} else {
localStorage.removeItem('activeInterconnectNodeId')
document.cookie = `active_interconnect_node_id=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC`
}
}
export async function request<T>(url: string, options?: RequestInit): Promise<T> {
let res: Response
try {
res = await fetch(`${API_BASE_URL}${url}`, {
...options,
credentials: 'include', // 携带 Cookie
headers: {
'Content-Type': 'application/json',
...options?.headers
}
})
} catch (err: any) {
throw err
}
const json: ApiResponse<T> = await res.json()
+69
View File
@@ -0,0 +1,69 @@
import { request } from '@/api'
export interface InterconnectNode {
id: string
name: string
url: string
token: string
remark: string
created_at: string
updated_at: string
status?: string
metrics?: {
cpu_percent: number
mem_percent: number
disk_percent: number
}
last_heartbeat_at?: string
}
export function getNodes() {
return request<InterconnectNode[]>('/interconnect/nodes', { method: 'GET' })
}
export function createNode(data: Partial<InterconnectNode>) {
return request<InterconnectNode>('/interconnect/nodes', {
method: 'POST',
body: JSON.stringify(data)
})
}
export function updateNode(id: string, data: Partial<InterconnectNode>) {
return request<InterconnectNode>(`/interconnect/nodes/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
})
}
export function deleteNode(id: string) {
return request<void>(`/interconnect/nodes/${id}`, { method: 'DELETE' })
}
export function getNodeStatus(id: string) {
return request<any>(`/interconnect/nodes/${id}/status`, { method: 'GET' })
}
export function syncScript(node_ids: string[], filename: string, content: string) {
return request<any[]>('/interconnect/sync/script', {
method: 'POST',
body: JSON.stringify({ node_ids, filename, content })
})
}
export function syncEnv(node_ids: string[], envs: any[]) {
return request<any[]>('/interconnect/sync/env', {
method: 'POST',
body: JSON.stringify({ node_ids, envs })
})
}
export function syncTask(node_ids: string[], tasks: any[]) {
return request<any[]>('/interconnect/sync/task', {
method: 'POST',
body: JSON.stringify({ node_ids, tasks })
})
}
export function getChildStatus() {
return request<{ parent_url: string; parent_token: string; connected: boolean }>('/interconnect/child/status', { method: 'GET' })
}
@@ -0,0 +1,102 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { toast } from 'vue-sonner'
import * as interconnectApi from '@/api/interconnect'
const props = defineProps<{
open: boolean
title: string
description?: string
actionLabel?: string
}>()
const emit = defineEmits<{
(e: 'update:open', val: boolean): void
(e: 'confirm', nodeIds: string[]): void
}>()
const nodes = ref<interconnectApi.InterconnectNode[]>([])
const selectedNodeIds = ref<Set<string>>(new Set())
const loading = ref(false)
const syncing = ref(false)
watch(() => props.open, async (val) => {
if (val) {
selectedNodeIds.value.clear()
loading.value = true
try {
nodes.value = await interconnectApi.getNodes()
} catch {
toast.error('获取互联节点失败')
} finally {
loading.value = false
}
}
})
function toggleNode(id: string) {
if (selectedNodeIds.value.has(id)) {
selectedNodeIds.value.delete(id)
} else {
selectedNodeIds.value.add(id)
}
}
function handleConfirm() {
if (selectedNodeIds.value.size === 0) {
toast.error('请至少选择一个目标节点')
return
}
emit('confirm', Array.from(selectedNodeIds.value))
}
defineExpose({
setSyncing: (val: boolean) => syncing.value = val,
close: () => emit('update:open', false)
})
</script>
<template>
<Dialog :open="open" @update:open="emit('update:open', $event)">
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{{ title }}</DialogTitle>
<DialogDescription v-if="description">{{ description }}</DialogDescription>
</DialogHeader>
<div class="py-4 max-h-[300px] overflow-y-auto">
<div v-if="loading" class="text-center text-sm text-muted-foreground py-4">
加载中...
</div>
<div v-else-if="nodes.length === 0" class="text-center text-sm text-muted-foreground py-4">
暂无互联节点请先在互联管理中添加节点
</div>
<div v-else class="space-y-3">
<div
v-for="node in nodes"
:key="node.id"
class="flex items-center space-x-3 p-2 rounded-md border cursor-pointer hover:bg-secondary/50 transition-colors"
@click="toggleNode(node.id)"
>
<Checkbox :checked="selectedNodeIds.has(node.id)" @update:checked="toggleNode(node.id)" />
<div class="flex-1">
<p class="text-sm font-medium leading-none">{{ node.name }}</p>
<p class="text-xs text-muted-foreground mt-1">{{ node.url }}</p>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="emit('update:open', false)" :disabled="syncing">取消</Button>
<Button @click="handleConfirm" :disabled="nodes.length === 0 || syncing">
<span v-if="syncing">处理中...</span>
<span v-else>{{ actionLabel || '确认分发' }}</span>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
+118
View File
@@ -0,0 +1,118 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger } from '@/components/ui/select'
import { MonitorDot } from 'lucide-vue-next'
import { request, activeInterconnectNodeId, setActiveInterconnectNodeId, api } from '@/api'
import type { InterconnectNode } from '@/api/interconnect'
import { useEventBus } from '@vueuse/core'
const nodes = ref<InterconnectNode[]>([])
const selectedNodeId = ref<string>('local')
const isMaster = ref(false)
let timer: any = null
async function loadNodes() {
try {
const res = await request<InterconnectNode[]>('/interconnect/nodes')
nodes.value = res || []
} catch (e) {
console.error('Failed to load interconnect nodes', e)
}
}
function handleNodeChange(val: any) {
if (!val) return
const strVal = String(val)
selectedNodeId.value = strVal
if (strVal === 'local') {
setActiveInterconnectNodeId('')
} else {
setActiveInterconnectNodeId(strVal)
}
// Refresh the current page to reload data from the new node
window.location.reload()
}
function startPolling() {
stopPolling()
loadNodes()
timer = setInterval(loadNodes, 30000)
}
function stopPolling() {
if (timer) {
clearInterval(timer)
timer = null
}
}
async function checkRoleAndInit() {
try {
const role = await api.settings.get('interconnect', 'interconnect_role')
updateRoleState(role)
} catch (error) {
updateRoleState('none')
}
}
function updateRoleState(role: string) {
const master = role === 'master'
isMaster.value = master
if (master) {
if (activeInterconnectNodeId) {
selectedNodeId.value = activeInterconnectNodeId
} else {
selectedNodeId.value = 'local'
}
startPolling()
} else {
stopPolling()
nodes.value = []
selectedNodeId.value = 'local'
// 只有在非“穿越状态”下,才可以因为角色改变而清除 LocalStorage/Cookie
const isTraveling = !!document.cookie.match(new RegExp('(^| )active_interconnect_node_id=([^;]*)'))
if (activeInterconnectNodeId && !isTraveling) {
setActiveInterconnectNodeId('')
}
}
}
const roleBus = useEventBus<string>('interconnect-role-changed')
roleBus.on((newRole) => {
updateRoleState(newRole)
})
onMounted(async () => {
await checkRoleAndInit()
})
onUnmounted(() => {
stopPolling()
})
</script>
<template>
<div v-if="isMaster" class="flex items-center gap-2">
<Select :model-value="selectedNodeId" @update:model-value="handleNodeChange">
<SelectTrigger class="h-6 px-1.5 py-0 text-xs font-medium rounded-md border border-input/60 bg-background/50 hover:bg-accent hover:text-accent-foreground shadow-sm transition-all focus:ring-0 focus:ring-offset-0 w-auto gap-0.5 min-w-[70px] max-w-[120px]" :class="{'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/25 hover:bg-amber-500/15': selectedNodeId !== 'local'}">
<div class="flex items-center gap-0.5 min-w-0">
<MonitorDot class="h-2.5 w-2.5 shrink-0" />
<span class="truncate pr-0.5 text-[10px] leading-none">
{{ selectedNodeId === 'local' ? '本机节点' : (nodes.find(n => n.id === selectedNodeId)?.name || '未知子节点') }}
</span>
</div>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="local" class="text-xs">
本机节点
</SelectItem>
<SelectItem v-for="node in nodes" :key="node.id" :value="node.id" class="text-xs">
{{ node.name }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</template>
+11 -3
View File
@@ -34,7 +34,7 @@ const siteSettings = ref<SiteSettings>(cachedSettings || {
cookie_days: '7'
})
// 立即应用缓存的设置
// 立即应用缓存的设置,防止标题闪烁或空白
if (cachedSettings) {
if (cachedSettings.title) {
document.title = cachedSettings.title
@@ -42,6 +42,8 @@ if (cachedSettings) {
if (cachedSettings.icon) {
updateFavicon(cachedSettings.icon)
}
} else {
document.title = siteSettings.value.title
}
let loaded = false
@@ -72,10 +74,16 @@ export function useSiteSettings() {
siteSettings.value = res
saveToCache(res) // 保存到缓存
document.title = res.title || '白虎面板'
if (res.icon) updateFavicon(res.icon)
if (res.icon) {
updateFavicon(res.icon)
}
loaded = true
} catch {
} catch (error) {
// 使用默认值或缓存值
document.title = siteSettings.value.title || '白虎面板'
if (siteSettings.value.icon) {
updateFavicon(siteSettings.value.icon)
}
}
}
+5
View File
@@ -77,3 +77,8 @@ export const TASK_EVENTS = {
export const LOG_EVENTS = {
ADDED: 'app_log_added',
} as const
// 系统事件类型 (对应后端的 system_ws_service.go)
export const SYSTEM_EVENTS = {
INTERCONNECT_CHILD_STATUS: 'interconnect_child_status',
} as const
+5 -2
View File
@@ -2,10 +2,11 @@
import { ref, onMounted, computed } from 'vue'
import { RouterLink, RouterView, useRoute } from 'vue-router'
import { resetAuthCache } from '@/router'
import { LayoutDashboard, ListTodo, FileCode, Settings, LogOut, ScrollText, Terminal, Variable, KeyRound, Menu, X, Server, Globe, Bell, Activity } from 'lucide-vue-next'
import { LayoutDashboard, ListTodo, FileCode, Settings, LogOut, ScrollText, Terminal, Variable, KeyRound, Menu, X, Server, Globe, Bell, Activity, Network } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import ThemeToggle from '@/components/ThemeToggle.vue'
import SystemNotice from '@/components/SystemNotice.vue'
import NodeSwitcher from '@/components/NodeSwitcher.vue'
import { api } from '@/api'
import { useSiteSettings } from '@/composables/useSiteSettings'
@@ -64,6 +65,7 @@ const navItems = [
{ to: '/notify', icon: Bell, label: '消息推送', exact: true },
{ to: '/logs', icon: KeyRound, label: '运行日志', exact: true },
{ to: '/monitor', icon: Activity, label: '系统监控', exact: true },
{ to: '/interconnect', icon: Network, label: '互联管理', exact: true },
{ to: '/settings', icon: Settings, label: '系统设置', exact: true },
]
@@ -164,7 +166,7 @@ onMounted(() => {
<Button variant="ghost" size="icon" class="h-9 w-9 lg:hidden shrink-0" @click="mobileMenuOpen = true">
<Menu class="h-5 w-5 text-muted-foreground" />
</Button>
<div class="flex flex-col sm:flex-row sm:items-baseline sm:gap-2 truncate">
<div class="flex flex-col sm:flex-row sm:items-baseline sm:gap-2 truncate min-w-0 flex-1">
<span class="text-sm text-muted-foreground truncate font-normal poem-sentence" :title="sentence">
<span class="hidden sm:inline">{{ sentence }}</span>
<span class="sm:hidden">{{ sentenceContent }}</span>
@@ -172,6 +174,7 @@ onMounted(() => {
</div>
</div>
<div class="flex items-center gap-1 sm:gap-2.5 shrink-0">
<NodeSwitcher />
<SystemNotice />
<ThemeToggle />
</div>
+1
View File
@@ -55,6 +55,7 @@ const router = createRouter({
{ path: 'terminal', name: 'terminal', component: () => import('@/views/terminal/Terminal.vue') },
{ path: 'notify', name: 'notify', component: () => import('@/views/notify/Notify.vue') },
{ path: 'monitor', name: 'monitor', component: () => import('@/views/monitor/Monitor.vue') },
{ path: 'interconnect', name: 'interconnect', component: () => import('@/views/interconnect/index.vue') },
{ path: 'settings', name: 'settings', component: () => import('@/views/settings/Settings.vue') }
]
},
+1
View File
@@ -65,6 +65,7 @@ const showTerminalDialog = ref(false)
const runCommand = ref('')
const scriptsDir = ref('')
async function fetchInstalledLangs() {
try {
installedLangs.value = await api.mise.list()
+1 -4
View File
@@ -272,9 +272,6 @@ onMounted(() => {
<Button variant="ghost" size="icon" class="h-6 w-6" @click="openEdit(env)" title="编辑">
<Pencil class="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" class="h-6 w-6 text-destructive" @click="confirmDelete(env.id)" title="删除">
<Trash2 class="h-3 w-3" />
</Button>
</div>
</div>
</div>
@@ -381,7 +378,7 @@ onMounted(() => {
</div>
</div>
<div class="grid grid-cols-4 items-center pt-2 mt-2 border-t border-border/40 -mx-1">
<div class="grid grid-cols-5 items-center pt-2 mt-2 border-t border-border/40 -mx-1">
<Button variant="ghost" class="h-9 px-0 text-xs gap-1.5 hover:bg-primary/5 rounded-none" @click="toggleShow(env.id)">
<Eye v-if="!showValues[env.id]" class="h-3.5 w-3.5" />
<EyeOff v-else class="h-3.5 w-3.5" />
@@ -0,0 +1,215 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { Network, RefreshCw, CheckCircle2, AlertTriangle, ChevronDown, ChevronUp } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { toast } from 'vue-sonner'
import { api } from '@/api'
import { getChildStatus } from '@/api/interconnect'
import { eventBus } from '@/utils/event-bus'
import { SYSTEM_EVENTS } from '@/constants'
const emit = defineEmits<{
(e: 'cancel'): void
}>()
const parentConfig = ref({ url: '', token: '' })
const savingSetting = ref(false)
const connectionStatus = ref<{ parent_url: string; parent_token: string; connected: boolean; tunnel_url?: string; tx_bytes?: number; rx_bytes?: number } | null>(null)
const statusLoading = ref(false)
const configExpanded = ref(false)
let unsubEventBus: (() => void) | null = null
function formatBytes(bytes?: number): string {
if (bytes === undefined || bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
async function fetchStatus() {
statusLoading.value = true
try {
const res = await getChildStatus()
connectionStatus.value = res
} catch (error) {
// Ignore error
} finally {
statusLoading.value = false
}
}
onMounted(async () => {
try {
parentConfig.value.url = await api.settings.get('interconnect', 'interconnect_parent_url') || ''
parentConfig.value.token = await api.settings.get('interconnect', 'interconnect_parent_token') || ''
await fetchStatus()
configExpanded.value = !connectionStatus.value?.parent_url
unsubEventBus = eventBus.subscribe((msg) => {
if (msg.type === SYSTEM_EVENTS.INTERCONNECT_CHILD_STATUS) {
if (connectionStatus.value) {
connectionStatus.value.connected = msg.payload.connected
} else {
fetchStatus()
}
}
})
} catch (error) {
// Ignore error
}
})
onUnmounted(() => {
if (unsubEventBus) unsubEventBus()
})
async function handleSaveParentConfig() {
if (!parentConfig.value.url || !parentConfig.value.token) {
toast.error('请填写完整的主面板地址 and 互联密钥')
return
}
savingSetting.value = true
try {
await api.settings.setSection('interconnect', {
interconnect_parent_url: parentConfig.value.url,
interconnect_parent_token: parentConfig.value.token
})
toast.success('配置已保存,正在主动建立反向安全隧道')
await fetchStatus()
configExpanded.value = false
} catch (error: any) {
toast.error(error.message || '保存失败')
} finally {
savingSetting.value = false
}
}
</script>
<template>
<div class="max-w-2xl mx-auto pt-2 space-y-4">
<!-- 顶部状态标题 -->
<div class="flex flex-col items-center justify-center text-center space-y-2 mb-2">
<div class="inline-flex h-10 w-10 items-center justify-center rounded-full bg-green-500/10 text-green-500">
<Network class="h-5 w-5" />
</div>
<div>
<h2 class="text-lg font-bold tracking-tight">本机作为子节点运行</h2>
<p class="text-muted-foreground text-xs mt-1 max-w-lg">本机正在受控模式下运行将定期向主节点汇报状态并允许主节点穿越到本面板</p>
</div>
</div>
<!-- 连接状态展示面板 (仅在配置存在时显示) -->
<div v-if="connectionStatus?.parent_url" class="rounded-xl border p-5 md:p-6 bg-card shadow-sm space-y-3.5 animate-in fade-in slide-in-from-bottom-2 duration-300">
<div class="flex items-center justify-between border-b pb-2">
<h3 class="text-xs font-bold text-muted-foreground uppercase tracking-wider">连接状态</h3>
<Button variant="ghost" size="icon" class="h-7 w-7 rounded-full" @click="fetchStatus" :disabled="statusLoading" title="刷新状态">
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': statusLoading }" />
</Button>
</div>
<!-- 在线状态 -->
<div v-if="connectionStatus.connected" class="flex items-start gap-3 p-3.5 rounded-lg bg-green-500/5 border border-green-500/10 text-green-600 dark:text-green-400">
<CheckCircle2 class="h-4.5 w-4.5 shrink-0 mt-0.5" />
<div class="space-y-1">
<div class="text-xs font-bold">已成功连接至主控端</div>
<div class="text-[10px] opacity-90 leading-relaxed">
与主控端的反向安全物理隧道已打通网络路径畅通主控端现可无缝穿越并集中监控本机
</div>
</div>
</div>
<!-- 离线状态 -->
<div v-else class="flex items-start gap-3 p-3.5 rounded-lg bg-amber-500/5 border border-amber-500/10 text-amber-600 dark:text-amber-400">
<AlertTriangle class="h-4.5 w-4.5 shrink-0 mt-0.5" />
<div class="space-y-1">
<div class="text-xs font-bold">未建立与主控的物理连接</div>
<div class="text-[10px] opacity-90 leading-relaxed">
物理隧道连接中断系统正在后台进行自动重连可能原因为主控地址配置错误专属密钥与主控不匹配或者主控节点未处于在线状态
</div>
</div>
</div>
<!-- 信息详情 -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-2 text-xs pt-1">
<div class="flex justify-between py-1 border-b border-border/40">
<span class="text-muted-foreground">主控地址</span>
<span class="font-medium truncate max-w-[180px]" :title="connectionStatus.parent_url">{{ connectionStatus.parent_url }}</span>
</div>
<div class="flex justify-between py-1 border-b border-border/40" v-if="connectionStatus.tunnel_url">
<span class="text-muted-foreground">物理隧道地址</span>
<span class="font-medium truncate max-w-[180px]" :title="connectionStatus.tunnel_url">{{ connectionStatus.tunnel_url }}</span>
</div>
<div class="flex justify-between py-1 border-b border-border/40">
<span class="text-muted-foreground">物理隧道状态</span>
<span class="font-bold flex items-center gap-1.5" :class="connectionStatus.connected ? 'text-green-500' : 'text-amber-500'">
<span class="h-1.5 w-1.5 rounded-full" :class="connectionStatus.connected ? 'bg-green-500' : 'bg-amber-500 animate-pulse'"></span>
{{ connectionStatus.connected ? '正常在线' : '离线 / 尝试重连' }}
</span>
</div>
<div class="flex justify-between py-1 border-b border-border/40" v-if="connectionStatus.tx_bytes !== undefined || connectionStatus.rx_bytes !== undefined">
<span class="text-muted-foreground">物理隧道流量</span>
<span class="font-medium">
<span class="text-green-500" title="发送 (TX)">{{ formatBytes(connectionStatus.tx_bytes) }}</span>
<span class="mx-1 text-muted-foreground">/</span>
<span class="text-blue-500" title="接收 (RX)">{{ formatBytes(connectionStatus.rx_bytes) }}</span>
</span>
</div>
</div>
</div>
<!-- 连接配置面板 -->
<div class="rounded-xl border bg-card p-5 md:p-6 shadow-sm space-y-4">
<!-- 头部如果已配置且未展开显示简化的修改配置触发栏 -->
<div v-if="connectionStatus?.parent_url && !configExpanded" class="flex items-center justify-between">
<div class="flex flex-col">
<span class="text-sm font-semibold">主控连接配置</span>
<span class="text-[10px] text-muted-foreground mt-0.5">配置已保存若需更新连接凭证请点击展开</span>
</div>
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" class="text-xs h-8 px-3" @click="configExpanded = true">
展开配置
<ChevronDown class="h-3.5 w-3.5 ml-1" />
</Button>
<Button variant="outline" size="sm" class="text-destructive hover:bg-destructive/10 text-xs px-2.5 h-8" @click="emit('cancel')">取消子节点</Button>
</div>
</div>
<!-- 展开状态的完整配置面板 -->
<template v-else>
<div class="flex items-center justify-between border-b pb-2.5">
<h3 class="text-sm font-semibold">主控连接配置</h3>
<div class="flex items-center gap-2">
<Button v-if="connectionStatus?.parent_url" variant="ghost" size="sm" class="text-xs h-7 px-2.5" @click="configExpanded = false">
收起
<ChevronUp class="h-3.5 w-3.5 ml-1" />
</Button>
<Button variant="outline" size="sm" class="text-destructive hover:bg-destructive/10 text-xs px-2.5 h-7" @click="emit('cancel')">取消子节点角色</Button>
</div>
</div>
<div class="space-y-3">
<div class="grid gap-1.5">
<Label for="parentUrl" class="text-xs">主面板地址 (URL) <span class="text-destructive">*</span></Label>
<Input id="parentUrl" v-model="parentConfig.url" placeholder="例如:http://main-panel.com:8052" autocomplete="off" class="h-8 text-xs" />
<p class="text-[10px] text-muted-foreground">填写主面板的访问地址包含协议和端口</p>
</div>
<div class="grid gap-1.5">
<Label for="parentToken" class="text-xs">专属接入密钥 (Token) <span class="text-destructive">*</span></Label>
<Input id="parentToken" v-model="parentConfig.token" type="password" placeholder="粘贴从主面板生成的专属接入密钥" autocomplete="new-password" class="h-8 text-xs" />
<p class="text-[10px] text-muted-foreground">主面板添加节点时自动生成的随机高强度密钥</p>
</div>
</div>
<div class="pt-3 border-t">
<Button @click="handleSaveParentConfig" :disabled="savingSetting" class="w-full h-9 text-xs">
<RefreshCw v-if="savingSetting" class="h-3.5 w-3.5 mr-1.5 animate-spin" />
保存并连接主节点
</Button>
</div>
</template>
</div>
</div>
</template>
@@ -0,0 +1,603 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import StatusDot from '@/components/StatusDot.vue'
import { Plus, Edit2, Trash2, RefreshCw, ExternalLink, Eye, Copy } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { toast } from 'vue-sonner'
import * as interconnectApi from '@/api/interconnect'
import { copyToClipboard } from '@/utils/clipboard'
import { setActiveInterconnectNodeId } from '@/api'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
const props = defineProps<{
nodes: interconnectApi.InterconnectNode[]
loading: boolean
searchQuery: string
}>()
const emit = defineEmits<{
(e: 'refresh'): void
}>()
const copied = ref(false)
const detailOpen = ref(false)
const detailLoading = ref(false)
const selectedNodeDetail = ref<any>(null)
const selectedNodeName = ref('')
const nodeStatuses = ref<Record<string, any>>({})
const filteredNodes = computed(() => {
if (!props.searchQuery) return props.nodes
const lowerKeyword = props.searchQuery.toLowerCase()
return props.nodes.filter(node =>
node.name.toLowerCase().includes(lowerKeyword) ||
node.url.toLowerCase().includes(lowerKeyword) ||
(node.remark && node.remark.toLowerCase().includes(lowerKeyword))
)
})
const dialogOpen = ref(false)
const isEditing = ref(false)
const currentForm = ref<Partial<interconnectApi.InterconnectNode>>({
name: '',
url: '',
token: '',
remark: ''
})
const showDeleteConfirm = ref(false)
const deleteId = ref('')
function formatUptime(seconds: number | undefined): string {
if (!seconds) return '-'
const days = Math.floor(seconds / (24 * 3600))
const hours = Math.floor((seconds % (24 * 3600)) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const parts = []
if (days > 0) parts.push(`${days}`)
if (hours > 0) parts.push(`${hours}小时`)
if (minutes > 0 || parts.length === 0) parts.push(`${minutes}分钟`)
return parts.join('')
}
function formatBytes(bytes: number | undefined): string {
if (bytes === undefined) return '-'
const gb = bytes / (1024 * 1024 * 1024)
if (gb >= 1) return `${gb.toFixed(2)} GB`
const mb = bytes / (1024 * 1024)
return `${mb.toFixed(2)} MB`
}
function getLoadColor(percent: number | undefined): string {
if (percent === undefined) return 'text-muted-foreground'
if (percent < 50) return 'text-green-500'
if (percent < 80) return 'text-yellow-500'
return 'text-destructive'
}
function generateRandomToken(length = 32) {
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
let res = ""
for (let i = 0; i < length; ++i) {
res += charset[Math.floor(Math.random() * charset.length)]
}
return res
}
async function handleCopy(text: string) {
const success = await copyToClipboard(text)
if (success) {
copied.value = true
toast.success('已复制到剪贴板')
setTimeout(() => {
copied.value = false
}, 2000)
} else {
toast.error('复制失败,请手动复制')
}
}
async function fetchNodeStatus(id: string) {
try {
nodeStatuses.value[id] = { status: 'loading' }
const res = await interconnectApi.getNodeStatus(id)
nodeStatuses.value[id] = Object.assign({ status: 'online' }, res)
} catch (error: any) {
nodeStatuses.value[id] = { status: 'offline', error: error.message || '离线' }
}
}
watch(() => props.nodes, (newNodes) => {
newNodes.forEach(node => {
if (!nodeStatuses.value[node.id] || nodeStatuses.value[node.id].status === 'offline') {
fetchNodeStatus(node.id)
}
})
}, { immediate: true })
async function showDetail(node: interconnectApi.InterconnectNode) {
selectedNodeName.value = node.name
selectedNodeDetail.value = null
detailLoading.value = true
detailOpen.value = true
try {
const res = await interconnectApi.getNodeStatus(node.id)
selectedNodeDetail.value = res
} catch (error: any) {
toast.error('获取子节点详细信息失败')
} finally {
detailLoading.value = false
}
}
function handleTravel(nodeId: string) {
setActiveInterconnectNodeId(nodeId)
window.location.href = '/'
}
function openAddDialog() {
isEditing.value = false
currentForm.value = { name: '', url: '', token: generateRandomToken(), remark: '' }
dialogOpen.value = true
}
function openEditDialog(node: interconnectApi.InterconnectNode) {
isEditing.value = true
currentForm.value = { ...node }
dialogOpen.value = true
}
async function handleSave() {
if (!currentForm.value.name || (!isEditing.value && !currentForm.value.token)) {
toast.error('请填写必要信息')
return
}
try {
if (isEditing.value && currentForm.value.id) {
await interconnectApi.updateNode(currentForm.value.id, currentForm.value)
toast.success('更新成功')
} else {
await interconnectApi.createNode(currentForm.value)
toast.success('添加成功')
}
dialogOpen.value = false
emit('refresh')
} catch (error: any) {
toast.error(isEditing.value ? '更新失败' : '添加失败')
}
}
function confirmDelete(id: string) {
deleteId.value = id
showDeleteConfirm.value = true
}
async function handleDelete() {
if (!deleteId.value) return
showDeleteConfirm.value = false
try {
await interconnectApi.deleteNode(deleteId.value)
toast.success('删除成功')
emit('refresh')
} catch (error: any) {
toast.error('删除失败')
}
}
defineExpose({
openAddDialog
})
</script>
<template>
<div class="space-y-4">
<div class="rounded-lg border bg-card overflow-hidden">
<!-- ========== 1. 大屏布局 (Large >= 1280px) ========== -->
<div class="hidden xl:block">
<div class="flex items-center gap-4 px-4 py-1.5 border-b bg-muted/20 text-xs text-muted-foreground font-medium">
<span class="w-12 shrink-0 pl-1">序号</span>
<span class="w-56 shrink-0">节点名称</span>
<span class="flex-1 min-w-0">隧道地址 </span>
<span class="w-48 shrink-0">运行状态 & 负载</span>
<span class="w-48 shrink-0">备注</span>
<span class="w-40 shrink-0 text-center">操作</span>
</div>
<div class="divide-y text-sm">
<div v-if="loading" class="py-8 text-center text-muted-foreground">加载中...</div>
<div v-else-if="filteredNodes.length === 0" class="py-8 text-center text-muted-foreground">暂无连接的子节点</div>
<div v-for="(node, index) in filteredNodes" :key="`large-${node.id}`" class="flex items-center gap-2 px-4 py-2 hover:bg-muted/30 transition-colors">
<StatusDot
:state="nodeStatuses[node.id]?.status === 'loading' ? 'pending' : (nodeStatuses[node.id]?.status === 'online' || node.status === 'online' ? 'online' : 'failed')"
:title="nodeStatuses[node.id]?.status === 'loading' ? '检测中' : (nodeStatuses[node.id]?.status === 'online' || node.status === 'online' ? '在线' : '离线')"
/>
<div class="w-12 shrink-0 text-muted-foreground tabular-nums text-[11px]">#{{ index + 1 }}</div>
<div class="w-56 shrink-0 flex items-center">
<span class="font-medium truncate" :title="node.name">{{ node.name }}</span>
</div>
<div class="flex-1 min-w-0 text-muted-foreground truncate text-xs bg-muted/40 px-2 py-1 rounded" style="font-family: Inter, sans-serif;" :title="node.url">
{{ node.url || '-' }}
</div>
<div class="w-48 shrink-0 flex flex-col justify-center cursor-pointer gap-1" @click="fetchNodeStatus(node.id)" title="点击刷新状态">
<template v-if="nodeStatuses[node.id]?.status === 'loading'">
<div class="flex items-center gap-1.5">
<RefreshCw class="h-3.5 w-3.5 animate-spin text-muted-foreground" />
<span class="text-xs text-muted-foreground">检测中...</span>
</div>
</template>
<template v-else-if="nodeStatuses[node.id]?.status === 'online' || node.status === 'online'">
<div class="flex items-center gap-1.5" v-if="nodeStatuses[node.id]?.version">
<span class="text-xs text-muted-foreground font-normal">v{{ nodeStatuses[node.id].version }}</span>
</div>
<div class="flex items-center gap-2 text-[10px]" v-if="node.metrics?.cpu_percent !== undefined && node.metrics?.mem_percent !== undefined && node.metrics?.cpu_percent !== 0">
<span :class="getLoadColor(node.metrics?.cpu_percent)">CPU: {{ node.metrics?.cpu_percent.toFixed(1) }}%</span>
<span :class="getLoadColor(node.metrics?.mem_percent)">Mem: {{ node.metrics?.mem_percent.toFixed(1) }}%</span>
</div>
</template>
<template v-else>
<div class="flex items-center gap-1.5">
<span class="text-xs text-destructive font-medium">离线</span>
</div>
</template>
</div>
<div class="w-48 shrink-0 truncate text-xs text-muted-foreground" :title="node.remark">
{{ node.remark || '-' }}
</div>
<div class="w-40 shrink-0 flex justify-center gap-1">
<Button variant="ghost" size="icon" class="h-7 w-7" @click="showDetail(node)" title="查看详情">
<Eye class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="handleTravel(node.id)" title="穿越到此子节点">
<ExternalLink class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="openEditDialog(node)" title="编辑">
<Edit2 class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="confirmDelete(node.id)" title="删除">
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
</div>
<!-- ========== 2. 中屏布局 (Medium 640px - 1280px) ========== -->
<div class="hidden sm:block xl:hidden">
<div class="flex items-center gap-4 px-4 py-1.5 border-b bg-muted/20 text-xs text-muted-foreground font-medium">
<span class="w-12 shrink-0 pl-1">序号</span>
<span class="w-48 shrink-0">节点信息</span>
<span class="flex-1 min-w-0">隧道地址 </span>
<span class="w-32 shrink-0">状态与负载</span>
<span class="w-36 shrink-0 text-center">操作</span>
</div>
<div class="divide-y text-sm">
<div v-if="loading" class="py-8 text-center text-muted-foreground">加载中...</div>
<div v-else-if="filteredNodes.length === 0" class="py-8 text-center text-muted-foreground">暂无连接的子节点</div>
<div v-for="(node, index) in filteredNodes" :key="`medium-${node.id}`" class="flex items-center gap-2 px-4 py-2.5 hover:bg-muted/30 transition-colors">
<StatusDot
:state="nodeStatuses[node.id]?.status === 'loading' ? 'pending' : (nodeStatuses[node.id]?.status === 'online' || node.status === 'online' ? 'online' : 'failed')"
/>
<div class="w-12 shrink-0 text-muted-foreground tabular-nums text-[10px]">#{{ index + 1 }}</div>
<div class="w-48 shrink-0 flex items-center overflow-hidden">
<div class="flex flex-col min-w-0">
<span class="font-medium truncate text-sm">{{ node.name }}</span>
<span v-if="node.remark" class="text-[10px] text-muted-foreground truncate">{{ node.remark }}</span>
</div>
</div>
<div class="flex-1 min-w-0 text-[11px] text-muted-foreground bg-muted/20 px-2 py-1 rounded truncate" style="font-family: Inter, sans-serif;" :title="node.url">
{{ node.url || '-' }}
</div>
<div class="w-32 shrink-0 flex flex-col justify-center cursor-pointer gap-0.5" @click="fetchNodeStatus(node.id)">
<template v-if="nodeStatuses[node.id]?.status === 'loading'">
<RefreshCw class="h-4 w-4 animate-spin text-muted-foreground" />
</template>
<template v-else-if="nodeStatuses[node.id]?.status === 'online' || node.status === 'online'">
<div class="flex items-center gap-1.5" v-if="nodeStatuses[node.id]?.version">
<span class="text-[10px] text-muted-foreground font-normal">v{{ nodeStatuses[node.id].version }}</span>
</div>
<div class="flex items-center gap-1 text-[9px]" v-if="node.metrics?.cpu_percent !== undefined && node.metrics?.mem_percent !== undefined && node.metrics?.cpu_percent !== 0">
<span :class="getLoadColor(node.metrics?.cpu_percent)">C:{{ node.metrics?.cpu_percent.toFixed(0) }}%</span>
<span :class="getLoadColor(node.metrics?.mem_percent)">M:{{ node.metrics?.mem_percent.toFixed(0) }}%</span>
</div>
</template>
<template v-else>
<div class="flex items-center gap-1.5">
<span class="text-xs text-destructive font-medium">离线</span>
</div>
</template>
</div>
<div class="w-36 shrink-0 flex justify-center gap-0.5">
<Button variant="ghost" size="icon" class="h-7 w-7" @click="showDetail(node)" title="查看详情"><Eye class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="handleTravel(node.id)" title="穿越到此子节点"><ExternalLink class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="openEditDialog(node)" title="编辑"><Edit2 class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="confirmDelete(node.id)" title="删除"><Trash2 class="h-3.5 w-3.5" /></Button>
</div>
</div>
</div>
</div>
<!-- ========== 3. 小屏布局 (Small < 640px) ========== -->
<div class="divide-y sm:hidden">
<div v-if="loading" class="text-sm text-muted-foreground text-center py-8">加载中...</div>
<div v-else-if="filteredNodes.length === 0" class="text-sm text-muted-foreground text-center py-8">暂无连接的子节点</div>
<div v-for="(node, index) in filteredNodes" :key="`small-${node.id}`" class="p-3 hover:bg-muted/50 transition-colors">
<div class="flex items-start justify-between mb-3 border-b border-border/40 pb-2">
<div class="flex items-center gap-2 flex-1 min-w-0 pr-2">
<StatusDot
:state="nodeStatuses[node.id]?.status === 'loading' ? 'pending' : (nodeStatuses[node.id]?.status === 'online' || node.status === 'online' ? 'online' : 'failed')"
class="mt-0.5"
/>
<span class="text-[10px] text-muted-foreground tabular-nums flex-shrink-0">#{{ index + 1 }}</span>
<div class="flex items-center gap-1.5 min-w-0 flex-1">
<span class="font-bold text-sm truncate">{{ node.name }}</span>
</div>
</div>
</div>
<div class="space-y-1.5 text-xs text-muted-foreground mb-3 px-1">
<div class="flex items-start gap-3">
<span class="w-10 shrink-0 font-medium mt-0.5 opacity-70">隧道地址:</span>
<div class="flex-1 min-w-0 overflow-hidden text-foreground">
<div class="text-[11px] bg-muted/40 px-1 py-0.5 rounded break-all" style="font-family: Inter, sans-serif;">{{ node.url || '-' }}</div>
</div>
</div>
<div v-if="node.remark" class="flex items-start gap-3">
<span class="w-10 shrink-0 font-medium mt-0.5 opacity-70">备注:</span>
<span class="flex-1 text-[11px] truncate">{{ node.remark }}</span>
</div>
<div class="flex items-start gap-3 cursor-pointer" @click="fetchNodeStatus(node.id)">
<span class="w-10 shrink-0 font-medium mt-0.5 opacity-70">状态:</span>
<div class="flex-1 text-[11px] flex items-center gap-2 flex-wrap">
<template v-if="nodeStatuses[node.id]?.status === 'loading'">
<RefreshCw class="h-3 w-3 animate-spin" />检测中...
</template>
<template v-else-if="nodeStatuses[node.id]?.status === 'online' || node.status === 'online'">
<span v-if="nodeStatuses[node.id]?.version" class="text-muted-foreground">v{{ nodeStatuses[node.id].version }}</span>
<span v-if="node.metrics?.cpu_percent !== undefined && node.metrics?.cpu_percent !== 0" :class="getLoadColor(node.metrics?.cpu_percent)">CPU: {{ node.metrics?.cpu_percent.toFixed(1) }}%</span>
<span v-if="node.metrics?.mem_percent !== undefined && node.metrics?.mem_percent !== 0" :class="getLoadColor(node.metrics?.mem_percent)">Mem: {{ node.metrics?.mem_percent.toFixed(1) }}%</span>
</template>
<template v-else>
<span class="text-destructive font-medium">离线</span>
</template>
</div>
</div>
</div>
<div class="grid grid-cols-4 items-center pt-2 mt-2 border-t border-border/40 -mx-1">
<Button variant="ghost" class="h-9 px-0 text-xs gap-1 hover:bg-primary/5 rounded-none" @click="showDetail(node)">
<Eye class="h-3.5 w-3.5" />详情
</Button>
<Button variant="ghost" class="h-9 px-0 text-xs gap-1 hover:bg-primary/5 rounded-none border-l border-border/10" @click="handleTravel(node.id)">
<ExternalLink class="h-3.5 w-3.5" />穿越
</Button>
<Button variant="ghost" class="h-9 px-0 text-xs gap-1 hover:bg-primary/5 rounded-none border-l border-border/10" @click="openEditDialog(node)">
<Edit2 class="h-3.5 w-3.5" />编辑
</Button>
<Button variant="ghost" class="h-9 px-0 text-xs gap-1 hover:bg-primary/5 rounded-none border-l border-border/10" @click="confirmDelete(node.id)">
<Trash2 class="h-3.5 w-3.5" />删除
</Button>
</div>
</div>
</div>
</div>
<!-- 添加/编辑弹窗 -->
<Dialog :open="dialogOpen" @update:open="dialogOpen = $event">
<DialogContent class="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{{ isEditing ? '编辑子节点' : '生成子节点专属接入密钥' }}</DialogTitle>
<DialogDescription>
{{ isEditing ? '修改节点的备注名称信息。' : '保存后请将此密钥粘贴到子节点的配置界面中建立连接。' }}
</DialogDescription>
</DialogHeader>
<div class="grid gap-4 py-4">
<div class="grid gap-2">
<Label for="name">节点名称 <span class="text-destructive">*</span></Label>
<Input id="name" v-model="currentForm.name" placeholder="例如:海外节点-洛杉矶" autocomplete="off" />
</div>
<div class="grid gap-2" v-if="!isEditing">
<Label>专属互联密钥 (Token) <span class="text-destructive">*</span></Label>
<div class="flex items-center gap-2">
<Input v-model="currentForm.token" readonly class="font-mono text-sm bg-muted/30 placeholder:font-sans" />
<Button variant="outline" size="icon" @click="currentForm.token = generateRandomToken()" title="重新生成">
<RefreshCw class="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" @click="handleCopy(currentForm.token || '')" :title="copied ? '已复制' : '复制'">
<Copy class="h-4 w-4" :class="{ 'text-green-500': copied }" />
</Button>
</div>
<p class="text-xs text-orange-500 mt-1">请务必复制上方密钥一旦关闭窗口将无法再次查看完整密钥</p>
</div>
<div class="grid gap-2">
<Label for="remark">备注</Label>
<Input id="remark" v-model="currentForm.remark" placeholder="选填,关于该节点的附加说明" autocomplete="off" />
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="dialogOpen = false">取消</Button>
<Button @click="handleSave">保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- 删除确认弹窗 -->
<AlertDialog :open="showDeleteConfirm" @update:open="showDeleteConfirm = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确定删除该互联节点</AlertDialogTitle>
<AlertDialogDescription>
此操作不可恢复节点连接信息将被永久删除
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction @click="handleDelete" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">确认删除</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<!-- 详情弹窗 -->
<Dialog :open="detailOpen" @update:open="detailOpen = $event">
<DialogContent class="sm:max-w-[600px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<span>节点详情</span>
<span class="text-sm font-normal text-muted-foreground">({{ selectedNodeName }})</span>
</DialogTitle>
<DialogDescription>
查看子节点的系统环境硬件指标和任务调度器运行状态
</DialogDescription>
</DialogHeader>
<!-- 加载中 -->
<div v-if="detailLoading" class="py-12 flex flex-col items-center justify-center space-y-4 animate-in fade-in duration-300">
<div class="relative flex items-center justify-center">
<!-- 外圈渐变呼吸环 -->
<div class="absolute h-10 w-10 rounded-full border border-primary/25 animate-ping"></div>
<!-- 旋转环 -->
<div class="h-10 w-10 rounded-full border-2 border-primary/10 border-t-primary animate-spin"></div>
</div>
<span class="text-xs font-medium text-muted-foreground/80 tracking-wider animate-pulse">正在获取远程数据...</span>
</div>
<!-- 加载失败或无数据 -->
<div v-else-if="!selectedNodeDetail" class="py-12 flex flex-col items-center justify-center gap-2 text-destructive">
<p class="text-sm font-medium">获取数据失败</p>
<p class="text-xs text-muted-foreground">子节点可能处于离线状态或者网络连接超时</p>
</div>
<!-- 数据展示 -->
<div v-else class="space-y-6 py-2">
<!-- 1. 硬件状态 -->
<div class="space-y-3">
<h3 class="text-sm font-semibold border-b pb-1">系统资源负载</h3>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<!-- CPU -->
<div class="rounded-lg border p-3 bg-muted/10 space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="text-muted-foreground">CPU 使用率</span>
<span class="font-medium" :class="getLoadColor(selectedNodeDetail.host?.cpu_percent)">{{ selectedNodeDetail.host?.cpu_percent?.toFixed(1) }}%</span>
</div>
<div class="w-full bg-muted rounded-full h-2">
<div class="h-2 rounded-full transition-all duration-300" :class="selectedNodeDetail.host?.cpu_percent >= 80 ? 'bg-destructive' : selectedNodeDetail.host?.cpu_percent >= 50 ? 'bg-yellow-500' : 'bg-green-500'" :style="{ width: `${selectedNodeDetail.host?.cpu_percent || 0}%` }"></div>
</div>
</div>
<!-- 内存 -->
<div class="rounded-lg border p-3 bg-muted/10 space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="text-muted-foreground">内存使用率</span>
<span class="font-medium" :class="getLoadColor(selectedNodeDetail.host?.mem_percent)">{{ selectedNodeDetail.host?.mem_percent?.toFixed(1) }}%</span>
</div>
<div class="w-full bg-muted rounded-full h-2">
<div class="h-2 rounded-full transition-all duration-300" :class="selectedNodeDetail.host?.mem_percent >= 80 ? 'bg-destructive' : selectedNodeDetail.host?.mem_percent >= 50 ? 'bg-yellow-500' : 'bg-green-500'" :style="{ width: `${selectedNodeDetail.host?.mem_percent || 0}%` }"></div>
</div>
<div class="text-[10px] text-muted-foreground flex justify-between">
<span>{{ formatBytes(selectedNodeDetail.host?.mem_used) }}</span>
<span>{{ formatBytes(selectedNodeDetail.host?.mem_total) }}</span>
</div>
</div>
<!-- 磁盘 -->
<div class="rounded-lg border p-3 bg-muted/10 space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="text-muted-foreground">磁盘使用率</span>
<span class="font-medium" :class="getLoadColor(selectedNodeDetail.host?.disk_percent)">{{ selectedNodeDetail.host?.disk_percent?.toFixed(1) }}%</span>
</div>
<div class="w-full bg-muted rounded-full h-2">
<div class="h-2 rounded-full transition-all duration-300" :class="selectedNodeDetail.host?.disk_percent >= 80 ? 'bg-destructive' : selectedNodeDetail.host?.disk_percent >= 50 ? 'bg-yellow-500' : 'bg-green-500'" :style="{ width: `${selectedNodeDetail.host?.disk_percent || 0}%` }"></div>
</div>
<div class="text-[10px] text-muted-foreground flex justify-between">
<span>{{ formatBytes(selectedNodeDetail.host?.disk_used) }}</span>
<span>{{ formatBytes(selectedNodeDetail.host?.disk_total) }}</span>
</div>
</div>
</div>
</div>
<!-- 2. 环境信息 -->
<div class="space-y-3">
<h3 class="text-sm font-semibold border-b pb-1">运行环境与系统</h3>
<div class="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
<div class="flex justify-between py-1 border-b border-border/40">
<span class="text-muted-foreground">操作系统</span>
<span class="font-medium uppercase">{{ selectedNodeDetail.env?.os }} ({{ selectedNodeDetail.env?.arch }})</span>
</div>
<div class="flex justify-between py-1 border-b border-border/40">
<span class="text-muted-foreground">系统平台</span>
<span class="font-medium truncate max-w-[160px]" :title="selectedNodeDetail.host?.platform">{{ selectedNodeDetail.host?.platform || '-' }}</span>
</div>
<div class="flex justify-between py-1 border-b border-border/40">
<span class="text-muted-foreground">CPU 核心数</span>
<span class="font-medium">{{ selectedNodeDetail.env?.num_cpu }} </span>
</div>
<div class="flex justify-between py-1 border-b border-border/40">
<span class="text-muted-foreground">Goroutine 数量</span>
<span class="font-medium">{{ selectedNodeDetail.env?.goroutines }}</span>
</div>
<div class="flex justify-between py-1 border-b border-border/40">
<span class="text-muted-foreground">Go 编译版本</span>
<span class="font-medium">{{ selectedNodeDetail.env?.go_version }}</span>
</div>
<div class="flex justify-between py-1 border-b border-border/40">
<span class="text-muted-foreground">节点运行时间</span>
<span class="font-medium">{{ formatUptime(selectedNodeDetail.host?.uptime) }}</span>
</div>
</div>
</div>
<!-- 3. 物理隧道连接状态 -->
<div class="space-y-3" v-if="selectedNodeDetail.tunnel_connected">
<h3 class="text-sm font-semibold border-b pb-1">物理隧道连接状态</h3>
<div class="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
<div class="flex justify-between py-1 border-b border-border/40 col-span-2">
<span class="text-muted-foreground">隧道内部通讯地址</span>
<span class="font-medium truncate max-w-[280px]" :title="selectedNodeDetail.tunnel_url">{{ selectedNodeDetail.tunnel_url }}</span>
</div>
<div class="flex justify-between py-1 border-b border-border/40 col-span-2" v-if="selectedNodeDetail.host?.tx_bytes !== undefined || selectedNodeDetail.host?.rx_bytes !== undefined">
<span class="text-muted-foreground">实时累加隧道流量</span>
<span class="font-medium">
<span class="text-green-500" title="发送 (TX)">{{ formatBytes(selectedNodeDetail.host?.tx_bytes) }}</span>
<span class="mx-1 text-muted-foreground">/</span>
<span class="text-blue-500" title="接收 (RX)">{{ formatBytes(selectedNodeDetail.host?.rx_bytes) }}</span>
</span>
</div>
</div>
</div>
<!-- 4. 调度器与任务统计 -->
<div class="space-y-3" v-if="selectedNodeDetail.scheduler">
<h3 class="text-sm font-semibold border-b pb-1">任务调度统计</h3>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-center">
<div class="rounded border p-2 bg-muted/5">
<div class="text-[10px] text-muted-foreground">计划中任务</div>
<div class="text-lg font-bold tabular-nums mt-0.5">{{ selectedNodeDetail.scheduler.scheduled ?? 0 }}</div>
</div>
<div class="rounded border p-2 bg-muted/5">
<div class="text-[10px] text-muted-foreground">正在运行任务</div>
<div class="text-lg font-bold tabular-nums mt-0.5 text-green-500">{{ selectedNodeDetail.scheduler.running ?? 0 }}</div>
</div>
<div class="rounded border p-2 bg-muted/5">
<div class="text-[10px] text-muted-foreground">队列任务积压</div>
<div class="text-lg font-bold tabular-nums mt-0.5">{{ selectedNodeDetail.scheduler.queue_size ?? 0 }}</div>
</div>
<div class="rounded border p-2 bg-muted/5">
<div class="text-[10px] text-muted-foreground">并发工作协程</div>
<div class="text-lg font-bold tabular-nums mt-0.5">{{ selectedNodeDetail.scheduler.worker_count ?? 0 }}</div>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
</div>
</template>
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Plus, RefreshCw, Search, Server, ArrowRightLeft } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { toast } from 'vue-sonner'
import * as interconnectApi from '@/api/interconnect'
import SyncPanel from './SyncPanel.vue'
import MasterList from './MasterList.vue'
const emit = defineEmits<{
(e: 'cancel'): void
}>()
const nodes = ref<interconnectApi.InterconnectNode[]>([])
const loading = ref(false)
const activeTab = ref('nodes')
const searchQuery = ref('')
const masterListRef = ref<InstanceType<typeof MasterList> | null>(null)
async function fetchNodes() {
loading.value = true
try {
nodes.value = await interconnectApi.getNodes()
} catch (error: any) {
toast.error('获取节点列表失败')
} finally {
loading.value = false
}
}
onMounted(() => {
fetchNodes()
})
</script>
<template>
<div class="space-y-4">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div class="flex flex-col shrink-0">
<div class="flex items-center gap-2">
<h2 class="text-xl sm:text-2xl font-bold tracking-tight">互联管理</h2>
<span class="px-2 py-0.5 rounded text-xs font-medium bg-amber-500/10 text-amber-500 border border-amber-500/20">主节点 (Master)</span>
</div>
<p class="text-muted-foreground text-xs mt-0.5 ml-0.5">集中监控其他面板的状态并可无缝穿越到子节点进行管理</p>
</div>
<div class="flex flex-col sm:flex-row items-center gap-2 w-full md:w-auto md:ml-auto md:justify-end">
<!-- 搜索框 (仅节点管理时显示) -->
<div class="relative w-full sm:w-[200px] md:w-[240px] group shrink-0" v-if="activeTab === 'nodes'">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground group-focus-within:text-primary transition-colors" />
<Input v-model="searchQuery" placeholder="搜索节点..." class="h-9 pl-9 w-full bg-muted/20 border-muted-foreground/10 focus:bg-background text-sm" />
</div>
<!-- 按钮控制组 -->
<div class="flex items-center gap-2 w-full sm:w-auto sm:justify-end overflow-x-auto scrollbar-none pb-1 -mb-1 sm:pb-0 sm:mb-0">
<!-- 刷新按钮 -->
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="fetchNodes" :disabled="loading" title="刷新">
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': loading }" />
</Button>
<!-- 取消主控角色按钮 -->
<Button variant="outline" class="shrink-0 px-2 md:px-3 h-9 shadow-sm text-destructive border-destructive/20 hover:bg-destructive/10" @click="emit('cancel')">
取消主控
</Button>
<!-- 添加子节点按钮 -->
<Button v-if="activeTab === 'nodes'" @click="masterListRef?.openAddDialog()" class="shrink-0 px-2 md:px-3 h-9 shadow-sm font-medium" title="添加子节点">
<Plus class="h-4 w-4 md:mr-1.5" /> <span class="hidden md:inline">添加子节点</span>
</Button>
<!-- Tabs 切换 (暂时隐藏同步功能) -->
<!--
<Tabs :model-value="activeTab" @update:model-value="(v: string | number) => activeTab = String(v)" class="shrink-0 flex-1 sm:flex-none min-w-[120px]">
<TabsList class="h-9 p-0.5 bg-muted/20 border border-border/40 rounded-lg w-full flex">
<TabsTrigger value="nodes" class="flex-1 sm:flex-none px-3 h-8 text-xs gap-1.5 font-medium transition-all">
<Server class="w-3.5 h-3.5 opacity-70" />
<span>节点</span>
</TabsTrigger>
<TabsTrigger value="sync" class="flex-1 sm:flex-none px-3 h-8 text-xs gap-1.5 font-medium transition-all">
<ArrowRightLeft class="w-3.5 h-3.5 opacity-70" />
<span>同步</span>
</TabsTrigger>
</TabsList>
</Tabs>
-->
</div>
</div>
</div>
<!-- 内容区域 -->
<MasterList v-if="activeTab === 'nodes'" ref="masterListRef" :nodes="nodes" :loading="loading" :search-query="searchQuery" @refresh="fetchNodes" />
<!-- <SyncPanel v-if="activeTab === 'sync'" :nodes="nodes" /> -->
</div>
</template>
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { Network } from 'lucide-vue-next'
const emit = defineEmits<{
(e: 'select', role: 'master' | 'child'): void
}>()
</script>
<template>
<div class="flex flex-col items-center justify-center py-6 gap-4">
<div class="text-center space-y-1.5">
<div class="inline-flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary mb-1">
<Network class="h-4 w-4" />
</div>
<h2 class="text-lg font-bold tracking-tight">选择面板的互联角色</h2>
<p class="text-muted-foreground text-xs max-w-md mx-auto">
请根据集群架构分配互斥角色避免循环嵌套
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 w-full max-w-3xl px-4 mt-2">
<!-- Master Card -->
<div class="border rounded-xl p-5 hover:border-amber-500 hover:ring-1 hover:ring-amber-500/30 cursor-pointer transition-all hover:shadow-sm bg-card space-y-2.5 group" @click="emit('select', 'master')">
<div class="h-10 w-10 rounded-lg bg-amber-500/10 flex items-center justify-center text-amber-500 group-hover:scale-110 transition-transform duration-300">
<Network class="h-5 w-5" />
</div>
<h3 class="text-base font-semibold">我是主节点 (Master)</h3>
<p class="text-xs text-muted-foreground leading-relaxed">
集中监控其他面板的状态并可无缝穿越到子节点进行管理<strong class="font-medium text-foreground">即使子节点处于无公网 IP 的深层内网主节点依然可以通过反向隧道直连穿透</strong>选择此项后您将可以生成专属密钥并添加多个子节点
</p>
</div>
<!-- Child Card -->
<div class="border rounded-xl p-5 hover:border-green-500 hover:ring-1 hover:ring-green-500/30 cursor-pointer transition-all hover:shadow-sm bg-card space-y-2.5 group" @click="emit('select', 'child')">
<div class="h-10 w-10 rounded-lg bg-green-500/10 flex items-center justify-center text-green-500 group-hover:scale-110 transition-transform duration-300">
<Network class="h-5 w-5" />
</div>
<h3 class="text-base font-semibold">我是子节点 (Child)</h3>
<p class="text-xs text-muted-foreground leading-relaxed">
向主节点报告运行状态并允许主节点穿越到本面板进行管理<strong class="font-medium text-foreground">非常适合部署在家庭宽带或企业内网等无公网 IP 环境中</strong>选择此项后本机将主动连接到主节点建立安全的反向穿透隧道
</p>
</div>
</div>
</div>
</template>
@@ -0,0 +1,267 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { toast } from 'vue-sonner'
import * as interconnectApi from '@/api/interconnect'
import { api, type EnvVar, type Task } from '@/api'
import { Network, Search, HardDrive } from 'lucide-vue-next'
const props = defineProps<{
nodes: interconnectApi.InterconnectNode[]
}>()
const activeSyncType = ref<'task' | 'env'>('task')
const loading = ref(false)
const syncing = ref(false)
// Data
const localTasks = ref<Task[]>([])
const localEnvs = ref<EnvVar[]>([])
// Selection
const selectedNodes = ref<string[]>([])
const selectedTasks = ref<string[]>([])
const selectedEnvsIds = ref<string[]>([])
// Search
const taskSearch = ref('')
const envSearch = ref('')
// Load local data
async function loadLocalData() {
loading.value = true
try {
const taskRes = await api.tasks.list({ page: 1, page_size: 9999 })
localTasks.value = taskRes.data
const envRes = await api.env.list({ page: 1, page_size: 9999 })
localEnvs.value = envRes.data
} catch (error) {
toast.error('获取本地数据失败')
} finally {
loading.value = false
}
}
onMounted(() => {
loadLocalData()
})
const filteredTasks = computed(() => {
if (!taskSearch.value) return localTasks.value
const kw = taskSearch.value.toLowerCase()
return localTasks.value.filter((t: Task) => t.name.toLowerCase().includes(kw) || t.command.toLowerCase().includes(kw))
})
const filteredEnvs = computed(() => {
if (!envSearch.value) return localEnvs.value
const kw = envSearch.value.toLowerCase()
return localEnvs.value.filter((e: EnvVar) => e.name.toLowerCase().includes(kw) || e.value.toLowerCase().includes(kw))
})
function toggleNode(nodeId: string) {
const index = selectedNodes.value.indexOf(nodeId)
if (index > -1) {
selectedNodes.value.splice(index, 1)
} else {
selectedNodes.value.push(nodeId)
}
}
function toggleAllNodes() {
if (selectedNodes.value.length === props.nodes.length && props.nodes.length > 0) {
selectedNodes.value = []
} else {
selectedNodes.value = props.nodes.map(n => n.id)
}
}
function toggleTask(id: string) {
const index = selectedTasks.value.indexOf(id)
if (index > -1) {
selectedTasks.value.splice(index, 1)
} else {
selectedTasks.value.push(id)
}
}
function toggleAllTasks() {
if (selectedTasks.value.length === filteredTasks.value.length && filteredTasks.value.length > 0) {
selectedTasks.value = []
} else {
selectedTasks.value = filteredTasks.value.map((t: Task) => t.id)
}
}
function toggleEnv(id: string) {
const index = selectedEnvsIds.value.indexOf(id)
if (index > -1) {
selectedEnvsIds.value.splice(index, 1)
} else {
selectedEnvsIds.value.push(id)
}
}
function toggleAllEnvs() {
if (selectedEnvsIds.value.length === filteredEnvs.value.length && filteredEnvs.value.length > 0) {
selectedEnvsIds.value = []
} else {
selectedEnvsIds.value = filteredEnvs.value.map((e: EnvVar) => e.id)
}
}
async function handleSync() {
if (selectedNodes.value.length === 0) {
toast.warning('请先选择目标节点')
return
}
const targetNodeIds = selectedNodes.value
syncing.value = true
try {
if (activeSyncType.value === 'task') {
if (selectedTasks.value.length === 0) {
toast.warning('请先选择要同步的任务')
return
}
const tasksToSync = localTasks.value.filter((t: Task) => selectedTasks.value.includes(t.id))
const res = await interconnectApi.syncTask(targetNodeIds, tasksToSync)
showSyncResult(res)
} else {
if (selectedEnvsIds.value.length === 0) {
toast.warning('请先选择要同步的变量')
return
}
const envsToSync = localEnvs.value.filter((e: EnvVar) => selectedEnvsIds.value.includes(e.id))
const res = await interconnectApi.syncEnv(targetNodeIds, envsToSync)
showSyncResult(res)
}
} catch (error) {
toast.error('下发请求失败')
} finally {
syncing.value = false
}
}
function showSyncResult(res: any[]) {
const targetNodeIds = selectedNodes.value
const successCount = res.filter((r: any) => r.success).length
if (successCount === targetNodeIds.length) {
toast.success('全部分发成功')
} else {
toast.warning(`成功: ${successCount}, 失败: ${targetNodeIds.length - successCount}`)
}
}
</script>
<template>
<div class="flex flex-col lg:flex-row gap-6 h-[calc(100vh-16rem)] min-h-[500px]">
<!-- 左侧节点选择 -->
<div class="w-full lg:w-1/3 flex flex-col border rounded-lg bg-card overflow-hidden">
<div class="p-3 border-b bg-muted/20 flex items-center justify-between">
<h3 class="font-medium text-sm flex items-center gap-2">
<HardDrive class="w-4 h-4 text-muted-foreground" />
目标节点 ({{ selectedNodes.length }}/{{ nodes.length }})
</h3>
<Button variant="ghost" size="sm" class="h-6 text-xs px-2" @click="toggleAllNodes">
{{ selectedNodes.length === nodes.length && nodes.length > 0 ? '全不选' : '全选' }}
</Button>
</div>
<div class="flex-1 overflow-y-auto p-2">
<div v-if="nodes.length === 0" class="text-center py-8 text-sm text-muted-foreground">
暂无子节点
</div>
<div v-else class="space-y-1">
<div v-for="node in nodes" :key="node.id" class="flex items-center gap-3 p-2 hover:bg-muted/50 rounded-md cursor-pointer" @click="toggleNode(node.id)">
<Checkbox :checked="selectedNodes.includes(node.id)" @update:checked="toggleNode(node.id)" @click.stop />
<div class="flex flex-col flex-1 min-w-0">
<span class="text-sm font-medium truncate">{{ node.name }}</span>
<span class="text-xs text-muted-foreground truncate">{{ node.url }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 右侧数据选择 -->
<div class="w-full lg:w-2/3 flex flex-col border rounded-lg bg-card overflow-hidden">
<div class="p-3 border-b bg-muted/20 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div class="flex bg-muted p-1 rounded-md">
<button
class="px-3 py-1.5 text-xs font-medium rounded-sm transition-colors"
:class="activeSyncType === 'task' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="activeSyncType = 'task'"
>
任务配置
</button>
<button
class="px-3 py-1.5 text-xs font-medium rounded-sm transition-colors"
:class="activeSyncType === 'env' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="activeSyncType = 'env'"
>
变量配置
</button>
</div>
<div class="flex items-center gap-2">
<div class="relative w-48 hidden sm:block">
<Search class="w-3.5 h-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input v-model="taskSearch" v-if="activeSyncType === 'task'" placeholder="搜索任务..." class="h-8 pl-7 text-xs" />
<Input v-model="envSearch" v-if="activeSyncType === 'env'" placeholder="搜索变量..." class="h-8 pl-7 text-xs" />
</div>
<Button class="h-8 gap-1.5" size="sm" @click="handleSync" :disabled="syncing">
<Network class="w-3.5 h-3.5" :class="{'animate-pulse': syncing}" />
{{ syncing ? '正在下发...' : '执行下发' }}
</Button>
</div>
</div>
<div class="flex-1 overflow-y-auto p-2">
<!-- 任务列表 -->
<div v-if="activeSyncType === 'task'" class="space-y-1">
<div class="flex items-center justify-between px-2 py-1 mb-2">
<span class="text-xs text-muted-foreground">已选 {{ selectedTasks.length }} </span>
<Button variant="ghost" size="sm" class="h-6 text-xs px-2" @click="toggleAllTasks">
{{ selectedTasks.length === filteredTasks.length && filteredTasks.length > 0 ? '全不选' : '全选' }}
</Button>
</div>
<div v-if="loading" class="text-center py-8 text-sm text-muted-foreground">加载中...</div>
<div v-else-if="filteredTasks.length === 0" class="text-center py-8 text-sm text-muted-foreground">暂无任务</div>
<div v-for="task in filteredTasks" :key="task.id" class="flex items-start gap-3 p-2 hover:bg-muted/50 rounded-md cursor-pointer" @click="toggleTask(task.id)">
<Checkbox class="mt-1" :checked="selectedTasks.includes(task.id)" @update:checked="toggleTask(task.id)" @click.stop />
<div class="flex flex-col flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium truncate">{{ task.name }}</span>
<span class="text-[10px] bg-muted px-1.5 py-0.5 rounded text-muted-foreground">{{ task.type }}</span>
</div>
<span class="text-xs text-muted-foreground truncate font-mono mt-0.5">{{ task.command }}</span>
</div>
</div>
</div>
<!-- 变量列表 -->
<div v-if="activeSyncType === 'env'" class="space-y-1">
<div class="flex items-center justify-between px-2 py-1 mb-2">
<span class="text-xs text-muted-foreground">已选 {{ selectedEnvsIds.length }} </span>
<Button variant="ghost" size="sm" class="h-6 text-xs px-2" @click="toggleAllEnvs">
{{ selectedEnvsIds.length === filteredEnvs.length && filteredEnvs.length > 0 ? '全不选' : '全选' }}
</Button>
</div>
<div v-if="loading" class="text-center py-8 text-sm text-muted-foreground">加载中...</div>
<div v-else-if="filteredEnvs.length === 0" class="text-center py-8 text-sm text-muted-foreground">暂无变量</div>
<div v-for="env in filteredEnvs" :key="env.id" class="flex items-start gap-3 p-2 hover:bg-muted/50 rounded-md cursor-pointer" @click="toggleEnv(env.id)">
<Checkbox class="mt-1" :checked="selectedEnvsIds.includes(env.id)" @update:checked="toggleEnv(env.id)" @click.stop />
<div class="flex flex-col flex-1 min-w-0">
<span class="text-sm font-medium truncate font-mono">{{ env.name }}</span>
<span class="text-xs text-muted-foreground truncate font-mono mt-0.5">{{ env.value }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
+111
View File
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/api'
import { toast } from 'vue-sonner'
import RoleSelector from './components/RoleSelector.vue'
import MasterView from './components/MasterView.vue'
import ChildView from './components/ChildView.vue'
import { useEventBus } from '@vueuse/core'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
const interconnectRole = ref<'master' | 'child' | 'none'>('none')
const loadingRole = ref(true)
const roleBus = useEventBus<string>('interconnect-role-changed')
async function fetchRole() {
loadingRole.value = true
try {
const role = await api.settings.get('interconnect', 'interconnect_role') as string
interconnectRole.value = (role === 'master' || role === 'child') ? role : 'none'
} catch (error) {
interconnectRole.value = 'none'
} finally {
loadingRole.value = false
}
}
async function setRole(role: 'master' | 'child' | 'none') {
try {
await api.settings.setSection('interconnect', { interconnect_role: role })
interconnectRole.value = role
roleBus.emit(role)
if (role === 'none') {
await api.settings.setSection('interconnect', {
interconnect_parent_url: '',
interconnect_parent_token: ''
})
}
} catch (error: any) {
toast.error('角色设置失败')
}
}
const showCancelConfirm = ref(false)
function handleCancelRole() {
showCancelConfirm.value = true
}
async function confirmCancelRole() {
showCancelConfirm.value = false
await setRole('none')
}
onMounted(async () => {
await fetchRole()
})
</script>
<template>
<div class="space-y-6">
<div v-if="loadingRole" class="h-[60vh] w-full flex flex-col items-center justify-center space-y-4 animate-in fade-in duration-300">
<div class="relative flex items-center justify-center">
<!-- 外圈渐变呼吸环 -->
<div class="absolute h-10 w-10 rounded-full border border-primary/25 animate-ping"></div>
<!-- 旋转环 -->
<div class="h-10 w-10 rounded-full border-2 border-primary/10 border-t-primary animate-spin"></div>
</div>
<span class="text-xs font-medium text-muted-foreground/80 tracking-wider animate-pulse">正在载入配置...</span>
</div>
<!-- 状态一未选择角色 -->
<RoleSelector v-else-if="interconnectRole === 'none'" @select="setRole" />
<!-- 状态二主节点视图 -->
<MasterView v-else-if="interconnectRole === 'master'" @cancel="handleCancelRole" />
<!-- 状态三子节点视图 -->
<ChildView v-else-if="interconnectRole === 'child'" @cancel="handleCancelRole" />
<AlertDialog :open="showCancelConfirm" @update:open="showCancelConfirm = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认取消角色</AlertDialogTitle>
<AlertDialogDescription>
<template v-if="interconnectRole === 'master'">
切换为子节点或取消角色将导致所有连接的子节点失去控制是否继续
</template>
<template v-else-if="interconnectRole === 'child'">
取消配置将断开与主节点的连接是否继续
</template>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction @click="confirmCancelRole" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">确认</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>
+13 -20
View File
@@ -41,7 +41,6 @@ watch(activeTab, (newVal) => {
const stats = ref<MonitorStats | null>(null)
const loading = ref(false)
let timer: any = null
// --- 时序数据池 ---
const historySize = 60 // 保存最近60次请求(约3分钟@3s)
@@ -55,23 +54,25 @@ const runningData = ref<number[]>([])
const queueData = ref<number[]>([])
let lastPauseNs = 0
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
let ws: EventSource | null = null
const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:'
const baseUrl = (window as any).__BASE_URL__ || ''
const apiVersion = (window as any).__API_VERSION__ || '/api/v1'
const connectWS = () => {
if (timer) return
if (ws) return
loading.value = true
const host = window.location.host
const wsUrl = `${protocol}//${host}${baseUrl}${apiVersion}/monitor/ws`
const sseUrl = `${protocol}//${host}${baseUrl}${apiVersion}/monitor/sse`
timer = new WebSocket(wsUrl)
ws = new EventSource(sseUrl, { withCredentials: true })
timer.onopen = () => {
ws.onopen = () => {
loading.value = false
}
timer.onmessage = (event: MessageEvent) => {
ws.onmessage = (event: MessageEvent) => {
try {
const payload = JSON.parse(event.data)
if (payload.code === 200 && payload.data) {
@@ -110,24 +111,16 @@ const connectWS = () => {
}
}
timer.onclose = () => {
loading.value = false
timer = null
// 断线后2秒自动重连
setTimeout(connectWS, 2000)
}
timer.onerror = () => {
ws.onerror = () => {
loading.value = false
// EventSource 会自动指数退避重连,无需我们手动控制
}
}
const disconnectWS = () => {
if (timer) {
// 置空 onclose 避免触发自动重连
timer.onclose = null
timer.close()
timer = null
if (ws) {
ws.close()
ws = null
}
}
+2 -2
View File
@@ -120,12 +120,12 @@ export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:8052',
target: process.env.VITE_PROXY_TARGET || 'http://localhost:8052',
changeOrigin: true,
ws: true
},
'/openapi': {
target: 'http://localhost:8052',
target: process.env.VITE_PROXY_TARGET || 'http://localhost:8052',
changeOrigin: true
}
}