feat: add breadcrumbs to management/agent pages and implement regional node grouping

This commit is contained in:
Haitao Pan 2026-02-05 14:06:04 +08:00
parent b5109778b5
commit 860abc2870
4 changed files with 203 additions and 8 deletions

2
next-env.d.ts vendored
View File

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -0,0 +1,35 @@
'use client'
import Link from 'next/link'
import { ChevronRight } from 'lucide-react'
export interface Crumb {
label: string
href: string
}
export default function Breadcrumbs({ items }: { items: Crumb[] }) {
return (
<nav className="flex mb-4" aria-label="Breadcrumb">
<ol className="inline-flex items-center space-x-1 md:space-x-3">
{items.map((item, index) => (
<li key={item.href} className="inline-flex items-center">
{index > 0 && <ChevronRight className="w-4 h-4 text-[var(--color-text-subtle)] mx-1" />}
{index === items.length - 1 ? (
<span className="text-sm font-medium text-[var(--color-text-subtle)] cursor-default">
{item.label}
</span>
) : (
<Link
href={item.href}
className="inline-flex items-center text-sm font-medium text-[var(--color-primary)] hover:text-[var(--color-primary-hover)] transition-colors"
>
{item.label}
</Link>
)}
</li>
))}
</ol>
</nav>
)
}

View File

@ -1,12 +1,161 @@
import Card from '../components/Card'
'use client'
import { useMemo } from 'react'
import useSWR from 'swr'
import { Server, MapPin, Plus, ExternalLink, RefreshCw } from 'lucide-react'
import Breadcrumbs from '@/app/panel/components/Breadcrumbs'
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
interface VlessNode {
name: string
address: string
port?: number
transport?: string
path?: string
mode?: string
security?: string
uri_scheme_xhttp?: string
uri_scheme_tcp?: string
}
async function fetcher(url: string) {
const res = await fetch(url, { credentials: 'include' })
if (!res.ok) throw new Error('Failed to fetch')
return res.json()
}
export default function UserCenterAgentRoute() {
const { language } = useLanguage()
const t = translations[language].userCenter
const { data: nodes, error, isLoading, mutate } = useSWR<VlessNode[]>('/api/agent/nodes', fetcher)
const groupedNodes = useMemo(() => {
const groups: Record<string, VlessNode[]> = {
HK: [],
JP: [],
US: [],
Other: [],
}
if (!nodes) return groups
nodes.forEach((node) => {
const name = node.name.toLowerCase()
if (name.includes('hk')) groups.HK.push(node)
else if (name.includes('jp')) groups.JP.push(node)
else if (name.includes('us')) groups.US.push(node)
else groups.Other.push(node)
})
return groups
}, [nodes])
return (
<Card>
<h1 className="text-2xl font-semibold text-gray-900">Agent Management</h1>
<p className="mt-2 text-sm text-gray-600">
Manage node agents and rollout updates from a unified workspace.
</p>
</Card>
<div className="space-y-6">
<Breadcrumbs
items={[
{ label: t.items.dashboard, href: '/panel' },
{ label: t.sections.productivity, href: '/panel/agent' },
{ label: t.items.agents, href: '/panel/agent' },
]}
/>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--color-heading)]">{t.items.agents}</h1>
<p className="text-sm text-[var(--color-text-subtle)]">
{language === 'zh' ? '查看并管理您在全球分布的运行节点。' : 'View and manage your globally distributed running nodes.'}
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => mutate()}
className="inline-flex items-center gap-2 rounded-xl border border-[color:var(--color-surface-border)] bg-[var(--color-surface)] px-4 py-2 text-sm font-medium text-[var(--color-text)] transition-colors hover:bg-[var(--color-surface-hover)]"
>
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
{language === 'zh' ? '刷新' : 'Refresh'}
</button>
<button className="inline-flex items-center gap-2 rounded-xl bg-[var(--color-primary)] px-4 py-2 text-sm font-medium text-[var(--color-primary-foreground)] shadow-sm transition-opacity hover:opacity-90">
<Plus className="h-4 w-4" />
{language === 'zh' ? '添加节点' : 'Add Node'}
</button>
</div>
</div>
<div className="grid gap-6">
{Object.entries(groupedNodes).map(([region, regionNodes]) => (
regionNodes.length > 0 && (
<section key={region} className="space-y-4">
<div className="flex items-center gap-2">
<MapPin className="h-5 w-5 text-[var(--color-primary)]" />
<h2 className="text-lg font-semibold text-[var(--color-heading)]">
{region === 'Other' ? (language === 'zh' ? '其他地区' : 'Other Regions') : `${region} Region`}
</h2>
<span className="rounded-full bg-[var(--color-surface-muted)] px-2.5 py-0.5 text-xs font-medium text-[var(--color-text-subtle)]">
{regionNodes.length}
</span>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{regionNodes.map((node) => (
<div
key={node.address}
className="group relative rounded-2xl border border-[color:var(--color-surface-border)] bg-[var(--color-surface)] p-5 transition-all hover:border-[color:var(--color-primary-border)] hover:shadow-md"
>
<div className="flex items-start justify-between">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--color-primary-muted)] text-[var(--color-primary)]">
<Server className="h-5 w-5" />
</div>
<span className="flex items-center gap-1.5 rounded-full bg-green-500/10 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-green-600 dark:bg-green-500/20 dark:text-green-400">
<span className="h-1.5 w-1.5 rounded-full bg-green-500 animate-pulse" />
Online
</span>
</div>
<div className="mt-4">
<h3 className="font-bold text-[var(--color-heading)] truncate">{node.name}</h3>
<p className="mt-1 text-xs text-[var(--color-text-subtle)] truncate">{node.address}</p>
</div>
<div className="mt-6 flex items-center justify-between border-t border-[color:var(--color-surface-border)] pt-4">
<div className="text-[10px] text-[var(--color-text-subtle)]">
Port: <span className="font-medium text-[var(--color-text)]">{node.port || 443}</span>
</div>
<button className="text-[var(--color-primary)] transition-transform hover:scale-110">
<ExternalLink className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
</section>
)
))}
{!isLoading && nodes?.length === 0 && (
<div className="flex flex-col items-center justify-center rounded-3xl border border-dashed border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)]/20 py-20 text-center">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-[var(--color-surface-muted)] text-[var(--color-text-subtle)]">
<Server className="h-8 w-8" />
</div>
<h3 className="text-lg font-medium text-[var(--color-heading)]">
{language === 'zh' ? '暂无运行节点' : 'No Running Nodes'}
</h3>
<p className="mt-2 text-sm text-[var(--color-text-subtle)]">
{language === 'zh' ? '您可以点击上方按钮添加您的第一个节点。' : 'You can click the button above to add your first node.'}
</p>
</div>
)}
{isLoading && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<div key={i} className="animate-pulse rounded-2xl border border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)]/30 p-5 h-44" />
))}
</div>
)}
</div>
</div>
)
}

View File

@ -14,8 +14,11 @@ import UserGroupManagement, {
type CreateManagedUserInput,
} from '../management/components/UserGroupManagement'
import { EmailBlacklist } from '../management/components/EmailBlacklist'
import Breadcrumbs from '@/app/panel/components/Breadcrumbs'
import { resolveAccess } from '@lib/accessControl'
import { useUserStore } from '@lib/userStore'
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
type UserMetricsResponse = {
overview: MetricsOverview
@ -61,6 +64,8 @@ async function jsonFetcher<T>(input: RequestInfo, init?: RequestInit): Promise<T
}
export default function UserCenterManagementRoute() {
const { language } = useLanguage()
const t = translations[language].userCenter
const user = useUserStore((state) => state.user)
const isUserLoading = useUserStore((state) => state.isLoading)
const accessDecision = useMemo(() => resolveAccess(user, { requireLogin: true, roles: ['admin', 'operator'] }), [user])
@ -325,6 +330,12 @@ export default function UserCenterManagementRoute() {
return (
<div className="space-y-6">
<Breadcrumbs
items={[
{ label: t.items.dashboard, href: '/panel' },
{ label: translations[language].nav.account.management, href: '/panel/management' },
]}
/>
<OverviewCards overview={metricsSWR.data?.overview} isLoading={metricsLoading} lastUpdatedLabel={lastUpdatedLabel} />
<TrendChart series={metricsSWR.data?.series} isLoading={metricsLoading} />
<PermissionMatrixEditor