feat: add openconnect
This commit is contained in:
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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') }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -65,6 +65,7 @@ const showTerminalDialog = ref(false)
|
||||
const runCommand = ref('')
|
||||
const scriptsDir = ref('')
|
||||
|
||||
|
||||
async function fetchInstalledLangs() {
|
||||
try {
|
||||
installedLangs.value = await api.mise.list()
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user