feat(console): add root-only sandbox node binding and complete panel breadcrumbs

This commit is contained in:
Haitao Pan 2026-02-05 23:43:42 +08:00
parent ca90e45a8c
commit 64de5ff3d1
11 changed files with 280 additions and 25 deletions

View File

@ -8,6 +8,7 @@ import { Copy } from 'lucide-react'
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
import { useUserStore } from '@lib/userStore'
import { getSandboxNodeBinding } from '../lib/sandboxNodeBinding'
import Card from './Card'
import VlessQrCard from './VlessQrCard'
@ -48,6 +49,7 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
const refresh = useUserStore((state) => state.refresh)
const logout = useUserStore((state) => state.logout)
const [copied, setCopied] = useState(false)
const [sandboxBoundNodeAddress, setSandboxBoundNodeAddress] = useState<string | null>(null)
const displayName = useMemo(() => resolveDisplayName(user), [user])
const uuid = user?.proxyUuid ?? user?.uuid ?? user?.id ?? '—'
@ -120,6 +122,15 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
router.refresh()
}, [logout, router])
useEffect(() => {
if (!isGuestSandboxReadOnly) {
setSandboxBoundNodeAddress(null)
return
}
const binding = getSandboxNodeBinding()
setSandboxBoundNodeAddress(binding?.address ?? null)
}, [isGuestSandboxReadOnly])
useEffect(() => {
if (!isGuestSandboxReadOnly || !user?.proxyUuidExpiresAt) {
return
@ -202,7 +213,12 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
<p className="mt-3 text-xs text-[var(--color-text-subtle)]">{copy.cards.uuid.description}</p>
</Card>
<VlessQrCard uuid={vlessUuid} copy={copy.cards.vless} allowSandboxFallbackNode={isGuestSandboxReadOnly} />
<VlessQrCard
uuid={vlessUuid}
copy={copy.cards.vless}
allowSandboxFallbackNode={isGuestSandboxReadOnly}
boundNodeAddress={sandboxBoundNodeAddress}
/>
<Card>
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--color-primary)]">{copy.cards.username.label}</p>

View File

@ -55,6 +55,7 @@ interface VlessQrCardProps {
uuid: string | null | undefined
copy: VlessQrCopy
allowSandboxFallbackNode?: boolean
boundNodeAddress?: string | null
}
function buildSandboxFallbackNode(): VlessNode {
@ -76,7 +77,12 @@ function buildSandboxFallbackNode(): VlessNode {
}
}
export default function VlessQrCard({ uuid, copy, allowSandboxFallbackNode = false }: VlessQrCardProps) {
export default function VlessQrCard({
uuid,
copy,
allowSandboxFallbackNode = false,
boundNodeAddress,
}: VlessQrCardProps) {
const { data: nodes, error: nodesError } = useSWR<VlessNode[]>('/api/agent-server/v1/nodes', fetcher)
const [selectedNode, setSelectedNode] = useState<VlessNode | null>(null)
const [preferredTransport, setPreferredTransport] = useState<VlessTransport>('tcp')
@ -89,10 +95,16 @@ export default function VlessQrCard({ uuid, copy, allowSandboxFallbackNode = fal
const rawNode = useMemo(() => {
if (selectedNode) return selectedNode
if (boundNodeAddress && nodes?.length) {
const matched = nodes.find((node) => node.address === boundNodeAddress)
if (matched) {
return matched
}
}
if (nodes && nodes[0]) return nodes[0]
if (allowSandboxFallbackNode && uuid) return buildSandboxFallbackNode()
return undefined
}, [allowSandboxFallbackNode, nodes, selectedNode, uuid])
}, [allowSandboxFallbackNode, boundNodeAddress, nodes, selectedNode, uuid])
const effectiveNode = useMemo((): VlessNode | undefined => {
if (!rawNode) return undefined

View File

@ -0,0 +1,60 @@
'use client'
const SANDBOX_NODE_BINDING_KEY = 'xcontrol.sandbox.node.binding.v1'
export type SandboxNodeBinding = {
address: string
name?: string
updatedAt: number
updatedBy?: string
}
export function getSandboxNodeBinding(): SandboxNodeBinding | null {
if (typeof window === 'undefined') {
return null
}
const raw = window.localStorage.getItem(SANDBOX_NODE_BINDING_KEY)
if (!raw) {
return null
}
try {
const parsed = JSON.parse(raw) as SandboxNodeBinding
if (typeof parsed?.address === 'string' && parsed.address.trim().length > 0) {
return {
address: parsed.address.trim(),
name: typeof parsed.name === 'string' && parsed.name.trim().length > 0 ? parsed.name.trim() : undefined,
updatedAt: typeof parsed.updatedAt === 'number' ? parsed.updatedAt : Date.now(),
updatedBy: typeof parsed.updatedBy === 'string' && parsed.updatedBy.trim().length > 0 ? parsed.updatedBy.trim() : undefined,
}
}
} catch (error) {
console.warn('Failed to parse sandbox node binding', error)
}
return null
}
export function setSandboxNodeBinding(binding: { address: string; name?: string; updatedBy?: string }) {
if (typeof window === 'undefined') {
return
}
const payload: SandboxNodeBinding = {
address: binding.address.trim(),
name: binding.name?.trim() || undefined,
updatedAt: Date.now(),
updatedBy: binding.updatedBy?.trim() || undefined,
}
window.localStorage.setItem(SANDBOX_NODE_BINDING_KEY, JSON.stringify(payload))
}
export function clearSandboxNodeBinding() {
if (typeof window === 'undefined') {
return
}
window.localStorage.removeItem(SANDBOX_NODE_BINDING_KEY)
}

View File

@ -0,0 +1,110 @@
'use client'
import { useMemo, useState } from 'react'
import useSWR from 'swr'
import Card from '../../components/Card'
import type { VlessNode } from '../../lib/vless'
import {
clearSandboxNodeBinding,
getSandboxNodeBinding,
setSandboxNodeBinding,
} from '../../lib/sandboxNodeBinding'
async function fetcher(url: string): Promise<VlessNode[]> {
const response = await fetch(url, {
credentials: 'include',
cache: 'no-store',
headers: {
Accept: 'application/json',
},
})
const payload = await response.json().catch(() => null)
if (!response.ok) {
const message =
(payload && typeof payload.message === 'string' && payload.message) ||
(payload && typeof payload.error === 'string' && payload.error) ||
`Request failed (${response.status})`
throw new Error(message)
}
if (Array.isArray(payload)) {
return payload as VlessNode[]
}
if (payload && Array.isArray((payload as { nodes?: unknown }).nodes)) {
return (payload as { nodes: VlessNode[] }).nodes
}
return []
}
export default function SandboxNodeBindingPanel() {
const { data: nodes, error, isLoading } = useSWR<VlessNode[]>('/api/agent-server/v1/nodes', fetcher, {
revalidateOnFocus: false,
})
const [message, setMessage] = useState<string | null>(null)
const current = getSandboxNodeBinding()
const selectedAddress = current?.address ?? ''
const selectedNode = useMemo(
() => nodes?.find((node) => node.address === selectedAddress) ?? null,
[nodes, selectedAddress],
)
return (
<Card>
<div className="space-y-3">
<h2 className="text-lg font-semibold text-gray-900">Root Sandbox Node </h2>
<p className="text-sm text-gray-600"> 1 Sandbox@svc.plus 使 VLESS </p>
<label className="flex flex-col gap-2 text-sm text-gray-700">
<select
value={selectedAddress}
disabled={isLoading || !nodes || nodes.length === 0}
onChange={(event) => {
const address = event.target.value.trim()
if (!address) {
clearSandboxNodeBinding()
setMessage('已清空绑定节点')
return
}
const node = nodes?.find((item) => item.address === address)
if (!node) {
setMessage('节点不存在,无法绑定')
return
}
setSandboxNodeBinding({
address: node.address,
name: node.name,
updatedBy: 'root',
})
setMessage(`已绑定:${node.name || node.address}`)
}}
className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800 focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-200"
>
<option value=""></option>
{(nodes ?? []).map((node) => (
<option key={node.address} value={node.address}>
{node.name} ({node.address})
</option>
))}
</select>
</label>
{selectedNode ? (
<p className="text-xs text-gray-600">
<span className="font-medium text-gray-800">{selectedNode.name || selectedNode.address}</span>
</p>
) : null}
{current?.updatedAt ? (
<p className="text-xs text-gray-500">{new Date(current.updatedAt).toLocaleString()}</p>
) : null}
{error ? <p className="text-xs text-red-600">{error.message}</p> : null}
{message ? <p className="text-xs text-green-700">{message}</p> : null}
</div>
</Card>
)
}

View File

@ -1,5 +1,6 @@
'use client'
import Breadcrumbs from '@/app/panel/components/Breadcrumbs'
import MfaSetupPanel from '../account/MfaSetupPanel'
import SubscriptionPanel from '../account/SubscriptionPanel'
import UserOverview from '../components/UserOverview'
@ -11,6 +12,12 @@ export default function UserCenterAccountRoute() {
return (
<div className="space-y-6">
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/panel' },
{ label: 'Account', href: '/panel/account' },
]}
/>
<UserOverview hideMfaMainPrompt />
{!isReadOnlyRole ? <MfaSetupPanel showSummary={false} /> : null}
{!isReadOnlyRole ? <SubscriptionPanel /> : null}

View File

@ -1,10 +1,19 @@
import Breadcrumbs from '@/app/panel/components/Breadcrumbs'
import Card from '../components/Card'
export default function UserCenterApiRoute() {
return (
<Card>
<h1 className="text-2xl font-semibold text-gray-900">API Status</h1>
<p className="mt-2 text-sm text-gray-600">View backend API health and toggle feature matrices.</p>
</Card>
<div className="space-y-4">
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/panel' },
{ label: 'APIs', href: '/panel/api' },
]}
/>
<Card>
<h1 className="text-2xl font-semibold text-gray-900">API Status</h1>
<p className="mt-2 text-sm text-gray-600">View backend API health and toggle feature matrices.</p>
</Card>
</div>
)
}

View File

@ -1,5 +1,15 @@
import Breadcrumbs from '@/app/panel/components/Breadcrumbs'
import UserOverview from '../components/UserOverview'
export default function UserCenterHomeRoute() {
return <UserOverview />
return (
<div className="space-y-6">
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/panel' },
]}
/>
<UserOverview />
</div>
)
}

View File

@ -1,5 +1,6 @@
import Link from 'next/link'
import Breadcrumbs from '@/app/panel/components/Breadcrumbs'
import Card from '../components/Card'
export default function UserCenterLdpRoute() {
@ -12,22 +13,30 @@ export default function UserCenterLdpRoute() {
]
return (
<Card>
<h1 className="text-2xl font-semibold text-gray-900">LDP Management</h1>
<p className="mt-2 text-sm text-gray-600">Explore low-latency directory plane modules.</p>
<ul className="mt-4 grid gap-2 sm:grid-cols-2">
{links.map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="group flex items-center justify-between rounded-xl border border-gray-200 px-4 py-3 text-sm font-medium text-gray-700 transition hover:border-purple-400 hover:text-purple-600"
>
{link.label}
<span className="text-xs text-gray-400 transition group-hover:text-purple-400">Coming soon</span>
</Link>
</li>
))}
</ul>
</Card>
<div className="space-y-4">
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/panel' },
{ label: 'LDP', href: '/panel/ldp' },
]}
/>
<Card>
<h1 className="text-2xl font-semibold text-gray-900">LDP Management</h1>
<p className="mt-2 text-sm text-gray-600">Explore low-latency directory plane modules.</p>
<ul className="mt-4 grid gap-2 sm:grid-cols-2">
{links.map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="group flex items-center justify-between rounded-xl border border-gray-200 px-4 py-3 text-sm font-medium text-gray-700 transition hover:border-purple-400 hover:text-purple-600"
>
{link.label}
<span className="text-xs text-gray-400 transition group-hover:text-purple-400">Coming soon</span>
</Link>
</li>
))}
</ul>
</Card>
</div>
)
}

View File

@ -13,6 +13,7 @@ import UserGroupManagement, {
type ManagedUser,
type CreateManagedUserInput,
} from '../management/components/UserGroupManagement'
import SandboxNodeBindingPanel from '../management/components/SandboxNodeBindingPanel'
import { EmailBlacklist } from '../management/components/EmailBlacklist'
import Breadcrumbs from '@/app/panel/components/Breadcrumbs'
import { resolveAccess } from '@lib/accessControl'
@ -364,6 +365,7 @@ export default function UserCenterManagementRoute() {
onCreateCustomUser={handleCreateCustomUser}
onManageBlacklist={() => setIsBlacklistOpen(true)}
/>
{canCreateCustomUser ? <SandboxNodeBindingPanel /> : null}
<EmailBlacklist isOpen={isBlacklistOpen} onClose={() => setIsBlacklistOpen(false)} />
</div>
)

View File

@ -1,5 +1,6 @@
'use client'
import Breadcrumbs from '@/app/panel/components/Breadcrumbs'
import Card from '../components/Card'
import BillingOptionsPanel from '../account/BillingOptionsPanel'
import SubscriptionPanel from '../account/SubscriptionPanel'
@ -12,6 +13,12 @@ export default function UserCenterSubscriptionRoute() {
if (isReadOnlyRole) {
return (
<div className="space-y-4">
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/panel' },
{ label: 'Subscription', href: '/panel/subscription' },
]}
/>
<Card>
<h1 className="text-2xl font-semibold text-gray-900"></h1>
<p className="mt-2 text-sm text-gray-600">
@ -24,6 +31,12 @@ export default function UserCenterSubscriptionRoute() {
return (
<div className="space-y-4">
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/panel' },
{ label: 'Subscription', href: '/panel/subscription' },
]}
/>
<Card>
<h1 className="text-2xl font-semibold text-gray-900"></h1>
<p className="mt-2 text-sm text-gray-600">

View File

@ -1,8 +1,15 @@
import Breadcrumbs from '@/app/panel/components/Breadcrumbs'
import ThemePreferenceCard from '../account/ThemePreferenceCard'
export default function UserCenterThemeRoute() {
return (
<div className="space-y-6">
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/panel' },
{ label: 'Appearance', href: '/panel/appearance' },
]}
/>
<ThemePreferenceCard />
</div>
)