8ef0ff8507
Build and Deploy / Build and Push Docker Image (push) Successful in 2m1s
- All hooks (useState, useCallback, useEffect) must be called before any conditional return - Add document check in updateRoleState - Merge SSR and isMaster checks into single return at the end
188 lines
5.9 KiB
TypeScript
188 lines
5.9 KiB
TypeScript
import { useCallback, useEffect, useState, useRef } from 'react'
|
|
import { MonitorDot, ChevronDown } from 'lucide-react'
|
|
import { createPortal } from 'react-dom'
|
|
import {
|
|
activeInterconnectNodeId,
|
|
api,
|
|
setActiveInterconnectNodeId,
|
|
} from '@/api'
|
|
import { getNodes, type InterconnectNode } from '@/api/interconnect'
|
|
import { useEventBus } from '@/hooks/useEventBus'
|
|
import { SYSTEM_EVENTS } from '@/constants'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
export function NodeSwitcher() {
|
|
const [nodes, setNodes] = useState<InterconnectNode[]>([])
|
|
const [selected, setSelected] = useState('local')
|
|
const [isMaster, setIsMaster] = useState(false)
|
|
const [open, setOpen] = useState(false)
|
|
const [menuStyle, setMenuStyle] = useState<React.CSSProperties>({})
|
|
const [mounted, setMounted] = useState(false)
|
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
|
|
|
const fetchNodes = useCallback(async () => {
|
|
try {
|
|
const res = await getNodes()
|
|
setNodes(Array.isArray(res) ? res : [])
|
|
} catch {
|
|
setNodes([])
|
|
}
|
|
}, [])
|
|
|
|
const updateRoleState = useCallback(
|
|
(role: string) => {
|
|
const master = role === 'master'
|
|
setIsMaster(master)
|
|
if (master) {
|
|
setSelected(activeInterconnectNodeId || 'local')
|
|
fetchNodes()
|
|
} else {
|
|
setNodes([])
|
|
setSelected('local')
|
|
if (typeof document !== 'undefined') {
|
|
const traveling = !!document.cookie.match(/(?:^| )active_interconnect_node_id=([^;]*)/)
|
|
if (activeInterconnectNodeId && !traveling) {
|
|
setActiveInterconnectNodeId('')
|
|
}
|
|
}
|
|
}
|
|
},
|
|
[fetchNodes],
|
|
)
|
|
|
|
useEffect(() => {
|
|
setMounted(true)
|
|
// 在客户端初始化时从 localStorage 读取选中的节点
|
|
setSelected(activeInterconnectNodeId || 'local')
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
;(async () => {
|
|
try {
|
|
const role = await api.settings.get('interconnect', 'interconnect_role')
|
|
updateRoleState(role || 'none')
|
|
} catch {
|
|
updateRoleState('none')
|
|
}
|
|
})()
|
|
}, [updateRoleState])
|
|
|
|
useEventBus(SYSTEM_EVENTS.INTERCONNECT_ROLE_CHANGED, (role) => {
|
|
updateRoleState(String(role || 'none'))
|
|
})
|
|
|
|
useEffect(() => {
|
|
if (!isMaster) return
|
|
const t = window.setInterval(fetchNodes, 120000)
|
|
const onVis = () => {
|
|
if (document.visibilityState === 'visible') fetchNodes()
|
|
}
|
|
document.addEventListener('visibilitychange', onVis)
|
|
return () => {
|
|
clearInterval(t)
|
|
document.removeEventListener('visibilitychange', onVis)
|
|
}
|
|
}, [isMaster, fetchNodes])
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
const updateMenuPosition = () => {
|
|
const el = buttonRef.current
|
|
if (!el) return
|
|
const rect = el.getBoundingClientRect()
|
|
const viewportH = window.innerHeight
|
|
const spaceBelow = viewportH - rect.bottom
|
|
const openUp = spaceBelow < 220 && rect.top > spaceBelow
|
|
setMenuStyle({
|
|
position: 'fixed',
|
|
left: rect.left,
|
|
width: Math.max(rect.width, 140),
|
|
zIndex: 80,
|
|
...(openUp
|
|
? { bottom: viewportH - rect.top + 6, top: 'auto' }
|
|
: { top: rect.bottom + 6, bottom: 'auto' }),
|
|
})
|
|
}
|
|
updateMenuPosition()
|
|
const onDoc = (e: MouseEvent) => {
|
|
const t = e.target as Node
|
|
if (buttonRef.current?.contains(t)) return
|
|
setOpen(false)
|
|
}
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') setOpen(false)
|
|
}
|
|
const onReposition = () => updateMenuPosition()
|
|
document.addEventListener('mousedown', onDoc)
|
|
document.addEventListener('keydown', onKey)
|
|
window.addEventListener('resize', onReposition)
|
|
window.addEventListener('scroll', onReposition, true)
|
|
return () => {
|
|
document.removeEventListener('mousedown', onDoc)
|
|
document.removeEventListener('keydown', onKey)
|
|
window.removeEventListener('resize', onReposition)
|
|
window.removeEventListener('scroll', onReposition, true)
|
|
}
|
|
}, [open])
|
|
|
|
// SSR 时或非 master 节点时返回 null
|
|
if (!mounted || !isMaster) return null
|
|
|
|
const handleSelect = (val: string) => {
|
|
setSelected(val)
|
|
setOpen(false)
|
|
if (val === 'local') {
|
|
setActiveInterconnectNodeId('')
|
|
} else {
|
|
const name = nodes.find((n) => n.id === val)?.name || ''
|
|
setActiveInterconnectNodeId(val, name)
|
|
}
|
|
window.location.reload()
|
|
}
|
|
|
|
const allOptions = [
|
|
{ value: 'local', label: '本机节点' },
|
|
...nodes.map((n) => ({ value: n.id, label: n.name })),
|
|
]
|
|
|
|
const currentLabel = allOptions.find((o) => o.value === selected)?.label || '本机节点'
|
|
|
|
return (
|
|
<div className="relative">
|
|
<button
|
|
ref={buttonRef}
|
|
type="button"
|
|
onClick={() => setOpen(!open)}
|
|
className={cn(
|
|
'inline-flex h-8 max-w-[150px] shrink-0 items-center gap-1.5 rounded-md border px-2 text-[11px] leading-none transition-colors',
|
|
selected !== 'local'
|
|
? 'border-amber-500/30 bg-amber-500/10 text-amber-600 hover:bg-amber-500/15 dark:text-amber-400'
|
|
: 'border-[var(--border)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]',
|
|
)}
|
|
>
|
|
<MonitorDot size={12} className="shrink-0" />
|
|
<span className="max-w-[100px] truncate">{currentLabel}</span>
|
|
<ChevronDown size={12} className="shrink-0 opacity-60" />
|
|
</button>
|
|
|
|
{open &&
|
|
mounted &&
|
|
createPortal(
|
|
<div className="ui-select-menu" style={menuStyle}>
|
|
{allOptions.map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
type="button"
|
|
onClick={() => handleSelect(opt.value)}
|
|
className={cn('ui-select-item', opt.value === selected && 'is-active')}
|
|
>
|
|
<span className="min-w-0 flex-1 truncate">{opt.label}</span>
|
|
</button>
|
|
))}
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
</div>
|
|
)
|
|
}
|