feat(dashboard): modularize panel via extension loader (#546)

This commit is contained in:
shenlan 2025-10-17 15:03:26 +08:00 committed by GitHub
parent 78b5032163
commit b5fdae1d57
33 changed files with 927 additions and 463 deletions

View File

@ -1,15 +1,17 @@
export const dynamic = 'error'
import UserOverview from '../components/UserOverview'
import MfaSetupPanel from './MfaSetupPanel'
import ThemePreferenceCard from './ThemePreferenceCard'
import { redirect } from 'next/navigation'
export default function AccountPage() {
return (
<div className="space-y-6">
<UserOverview />
<ThemePreferenceCard />
<MfaSetupPanel />
</div>
)
import { resolveExtensionRouteComponent } from '@extensions/loader'
export default async function AccountPage() {
try {
const Component = await resolveExtensionRouteComponent('/panel/account')
return <Component />
} catch (error) {
if (error instanceof Error && error.message.includes('disabled')) {
redirect('/panel')
}
throw error
}
}

View File

@ -1,12 +1,17 @@
export const dynamic = 'error'
import Card from '../components/Card'
import { redirect } from 'next/navigation'
export default function AgentPage() {
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>
)
import { resolveExtensionRouteComponent } from '@extensions/loader'
export default async function AgentPage() {
try {
const Component = await resolveExtensionRouteComponent('/panel/agent')
return <Component />
} catch (error) {
if (error instanceof Error && error.message.includes('disabled')) {
redirect('/panel')
}
throw error
}
}

View File

@ -1,12 +1,17 @@
export const dynamic = 'error'
import Card from '../components/Card'
import { redirect } from 'next/navigation'
export default function APIPage() {
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>
)
import { resolveExtensionRouteComponent } from '@extensions/loader'
export default async function ApiPage() {
try {
const Component = await resolveExtensionRouteComponent('/panel/api')
return <Component />
} catch (error) {
if (error instanceof Error && error.message.includes('disabled')) {
redirect('/panel')
}
throw error
}
}

View File

@ -2,13 +2,17 @@
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Home, Server, Code, CreditCard, User, Shield, Settings, type LucideIcon } from 'lucide-react'
import { useMemo } from 'react'
import { useMemo, type ComponentType } from 'react'
import { getExtensionRegistry } from '@extensions/loader'
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
import { resolveAccess } from '@lib/accessControl'
import { useUser } from '@lib/userStore'
const registry = getExtensionRegistry()
const PlaceholderIcon: ComponentType<{ className?: string }> = () => null
export interface SidebarProps {
className?: string
onNavigate?: () => void
@ -18,8 +22,8 @@ interface NavItem {
href: string
label: string
description: string
icon: LucideIcon
disabled?: boolean
Icon: ComponentType<{ className?: string }>
disabled: boolean
}
interface NavSection {
@ -27,65 +31,6 @@ interface NavSection {
items: NavItem[]
}
const baseNavSections: NavSection[] = [
{
title: '用户中心',
items: [
{
href: '/panel',
label: 'Dashboard',
description: '专属于你的信息总览',
icon: Home,
},
],
},
{
title: '功能特性',
items: [
{
href: '/panel/agent',
label: 'Agents',
description: '管理运行时节点',
icon: Server,
disabled: true,
},
{
href: '/panel/api',
label: 'APIs',
description: '洞察后端服务',
icon: Code,
disabled: true,
},
{
href: '/panel/subscription',
label: 'Subscription',
description: '订阅方案与计费规则',
icon: CreditCard,
disabled: true,
},
],
},
{
title: '权限设置',
items: [
{
href: '/panel/account',
label: 'Accounts',
description: '目录与多因素设置',
icon: User,
disabled: true,
},
{
href: '/panel/ldp',
label: 'LDP',
description: '低时延身份平面',
icon: Shield,
disabled: true,
},
],
},
]
function isActive(pathname: string, href: string) {
if (href === '/panel') {
return pathname === '/panel'
@ -100,28 +45,47 @@ export default function Sidebar({ className = '', onNavigate }: SidebarProps) {
const { user } = useUser()
const requiresSetup = Boolean(user && (!user.mfaEnabled || user.mfaPending))
const navSections = useMemo(() => {
const sections = baseNavSections.map((section) => ({
...section,
items: [...section.items],
}))
const navSections = useMemo<NavSection[]>(() => {
return registry.sidebar
.map((section) => {
const items = section.items
.map((item) => {
const { route } = item
const guardResult = route.guard ? resolveAccess(user, route.guard) : { allowed: true }
const requiresRole = Boolean(route.guard?.roles?.length)
if (requiresRole && !guardResult.allowed) {
return null
}
if (user?.isAdmin || user?.isOperator) {
sections.push({
title: '管理页面',
items: [
{
href: '/panel/management',
label: 'Management',
description: '集中化的权限矩阵与用户编排',
icon: Settings,
},
],
const disabledByGuard = !requiresRole && !guardResult.allowed
const disabled =
item.disabled ||
disabledByGuard ||
(requiresSetup && route.path !== '/panel/account')
const Icon = route.icon ?? PlaceholderIcon
return {
href: route.path,
label: route.label,
description: route.description ?? '',
Icon,
disabled,
}
})
.filter((value): value is NavItem => Boolean(value))
if (items.length === 0) {
return null
}
return {
title: section.title,
items,
}
})
}
return sections
}, [user?.isAdmin, user?.isOperator])
.filter((value): value is NavSection => Boolean(value))
}, [requiresSetup, user])
return (
<aside
@ -159,9 +123,7 @@ export default function Sidebar({ className = '', onNavigate }: SidebarProps) {
<nav className="flex flex-1 flex-col gap-6 overflow-y-auto">
{navSections.map((section) => {
const sectionDisabled = section.items.every(
(item) => item.disabled || (requiresSetup && item.href !== '/panel/account'),
)
const sectionDisabled = section.items.every((item) => item.disabled)
return (
<div key={section.title} className="space-y-3">
@ -177,13 +139,12 @@ export default function Sidebar({ className = '', onNavigate }: SidebarProps) {
<div className={`space-y-2 ${sectionDisabled ? 'opacity-60' : ''}`}>
{section.items.map((item) => {
const active = isActive(pathname, item.href)
const Icon = item.icon
const disabled = item.disabled || (requiresSetup && item.href !== '/panel/account')
const { Icon } = item
const baseClasses = [
'group flex items-center gap-3 rounded-[var(--radius-xl)] border px-3 py-3 text-sm transition-colors',
]
if (disabled) {
if (item.disabled) {
baseClasses.push(
'cursor-not-allowed border-dashed border-[color:var(--color-surface-border)] text-[var(--color-text-subtle)] opacity-60',
)
@ -201,7 +162,7 @@ export default function Sidebar({ className = '', onNavigate }: SidebarProps) {
const iconClasses = ['flex h-8 w-8 items-center justify-center rounded-xl transition-colors']
if (active) {
iconClasses.push('bg-[var(--color-primary)] text-[var(--color-primary-foreground)]')
} else if (disabled) {
} else if (item.disabled) {
iconClasses.push('bg-[var(--color-surface-muted)] text-[var(--color-text-subtle)] opacity-60')
} else {
iconClasses.push(
@ -211,7 +172,7 @@ export default function Sidebar({ className = '', onNavigate }: SidebarProps) {
const descriptionClasses = [
'text-xs transition-colors',
disabled
item.disabled
? 'text-[var(--color-text-subtle)] opacity-60'
: 'text-[var(--color-text-subtle)] group-hover:text-[var(--color-primary)]',
]
@ -228,7 +189,7 @@ export default function Sidebar({ className = '', onNavigate }: SidebarProps) {
</div>
)
if (disabled) {
if (item.disabled) {
return (
<div key={item.href} aria-disabled={true} className="select-none">
{content}

View File

@ -1,43 +1,29 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import Header from './components/Header'
import Sidebar from './components/Sidebar'
import { getExtensionRegistry } from '@extensions/loader'
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
import { resolveAccess, type AccessRule } from '@lib/accessControl'
import { useUser } from '@lib/userStore'
const registry = getExtensionRegistry()
type RouteGuard = {
test: (pathname: string) => boolean
redirect: {
unauthenticated: string
path: string
match: 'exact' | 'startsWith'
redirect?: {
unauthenticated?: string
forbidden?: string
}
rule: AccessRule
}
const routeGuards: RouteGuard[] = [
{
test: (pathname) => pathname.startsWith('/panel/management'),
redirect: {
unauthenticated: '/login',
forbidden: '/panel',
},
rule: { requireLogin: true, roles: ['admin', 'operator'] },
},
{
test: (pathname) => pathname.startsWith('/panel'),
redirect: {
unauthenticated: '/login',
},
rule: { requireLogin: true },
},
]
export default function PanelLayout({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false)
const router = useRouter()
@ -46,28 +32,43 @@ export default function PanelLayout({ children }: { children: React.ReactNode })
const copy = translations[language].userCenter.mfa
const { user, isLoading, logout } = useUser()
const requiresSetup = Boolean(user && (!user.mfaEnabled || user.mfaPending))
const routeGuards = useMemo<RouteGuard[]>(() => {
return registry.routes
.filter((route) => route.guard)
.map((route) => ({
path: route.path,
match: route.match ?? 'exact',
redirect: route.redirect,
rule: route.guard!,
}))
.sort((a, b) => b.path.length - a.path.length)
}, [])
useEffect(() => {
if (isLoading) {
return
}
const guard = routeGuards.find((entry) => entry.test(pathname))
const guard = routeGuards.find((entry) =>
entry.match === 'startsWith' ? pathname.startsWith(entry.path) : pathname === entry.path,
)
if (!guard) {
return
}
const decision = resolveAccess(user, guard.rule)
if (!decision.allowed) {
const redirect = guard.redirect ?? {}
const destination =
decision.reason === 'unauthenticated'
? guard.redirect.unauthenticated
: guard.redirect.forbidden ?? guard.redirect.unauthenticated
? redirect.unauthenticated ?? '/login'
: redirect.forbidden ?? redirect.unauthenticated ?? '/login'
if (destination && destination !== pathname) {
router.replace(destination)
}
}
}, [isLoading, pathname, router, user])
}, [isLoading, pathname, routeGuards, router, user])
useEffect(() => {
if (!requiresSetup || pathname.startsWith('/panel/account')) {

View File

@ -1,35 +1,17 @@
import Link from 'next/link'
export const dynamic = 'error'
import Card from '../components/Card'
import { redirect } from 'next/navigation'
export default function LdpPage() {
const links = [
{ href: '/panel/ldp/users', label: 'Users' },
{ href: '/panel/ldp/services', label: 'Services' },
{ href: '/panel/ldp/config', label: 'Configuration' },
{ href: '/panel/ldp/status', label: 'Status' },
{ href: '/panel/ldp/consent', label: 'Login & Consent' },
]
import { resolveExtensionRouteComponent } from '@extensions/loader'
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>
)
export default async function LdpPage() {
try {
const Component = await resolveExtensionRouteComponent('/panel/ldp')
return <Component />
} catch (error) {
if (error instanceof Error && error.message.includes('disabled')) {
redirect('/panel')
}
throw error
}
}

View File

@ -1,282 +1,17 @@
'use client'
export const dynamic = 'error'
import { useCallback, useEffect, useMemo, useState } from 'react'
import useSWR from 'swr'
import { redirect } from 'next/navigation'
import TrendChart, { type MetricsSeries } from './components/TrendChart'
import OverviewCards, { type MetricsOverview } from './components/OverviewCards'
import PermissionMatrixEditor, {
type PermissionMatrix,
} from './components/PermissionMatrixEditor'
import UserGroupManagement, { type ManagedUser } from './components/UserGroupManagement'
import Card from '../components/Card'
import { resolveAccess } from '@lib/accessControl'
import { useUser } from '@lib/userStore'
import { resolveExtensionRouteComponent } from '@extensions/loader'
type UserMetricsResponse = {
overview: MetricsOverview
series: MetricsSeries
}
type AdminSettingsResponse = {
version: number
matrix: PermissionMatrix
}
type ApiError = {
error?: string
message?: string
matrix?: PermissionMatrix
version?: number
}
async function jsonFetcher<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
const response = await fetch(input, {
...init,
credentials: 'include',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(init?.headers instanceof Headers ? Object.fromEntries(init.headers.entries()) : init?.headers),
},
cache: 'no-store',
})
if (!response.ok) {
let payload: ApiError | undefined
try {
payload = (await response.json()) as ApiError
} catch (error) {
// Ignore JSON parse errors; fall back to status text below.
export default async function ManagementPage() {
try {
const Component = await resolveExtensionRouteComponent('/panel/management')
return <Component />
} catch (error) {
if (error instanceof Error && error.message.includes('disabled')) {
redirect('/panel')
}
const message = payload?.error ?? payload?.message ?? response.statusText
throw new Error(message || '请求失败')
throw error
}
return (await response.json()) as T
}
export default function ManagementPage() {
const { user, isLoading: isUserLoading } = useUser()
const accessDecision = useMemo(() => resolveAccess(user, { requireLogin: true, roles: ['admin', 'operator'] }), [user])
const canAccess = accessDecision.allowed
const canEditPermissions = Boolean(user?.isAdmin)
const canEditRoles = Boolean(user?.isAdmin)
const [matrixDraft, setMatrixDraft] = useState<PermissionMatrix>({})
const [matrixVersion, setMatrixVersion] = useState<number>(0)
const [matrixDirty, setMatrixDirty] = useState(false)
const [matrixSaving, setMatrixSaving] = useState(false)
const [matrixStatus, setMatrixStatus] = useState<string | undefined>()
const [matrixError, setMatrixError] = useState<string | undefined>()
const [roleUpdateMessage, setRoleUpdateMessage] = useState<string | undefined>()
const [pendingRoleUpdates, setPendingRoleUpdates] = useState<Set<string>>(new Set())
const metricsSWR = useSWR<UserMetricsResponse>(canAccess ? '/api/admin/users/metrics' : null, jsonFetcher, {
revalidateOnFocus: false,
})
const settingsSWR = useSWR<AdminSettingsResponse>(canAccess ? '/api/admin/settings' : null, jsonFetcher, {
revalidateOnFocus: false,
})
const usersSWR = useSWR<ManagedUser[]>(canAccess ? '/api/users' : null, jsonFetcher, {
revalidateOnFocus: false,
})
useEffect(() => {
if (settingsSWR.data?.matrix) {
setMatrixDraft(settingsSWR.data.matrix)
setMatrixVersion(settingsSWR.data.version)
setMatrixDirty(false)
setMatrixError(undefined)
}
}, [settingsSWR.data])
const lastUpdatedLabel = useMemo(() => {
if (!metricsSWR.data) {
return undefined
}
const now = new Date()
return `更新于 ${now.toLocaleString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})}`
}, [metricsSWR.data])
const handleTogglePermission = useCallback(
(moduleKey: string, role: string, nextValue: boolean) => {
setMatrixDraft((prev) => {
const next: PermissionMatrix = { ...prev }
const normalizedModuleKey = moduleKey.trim()
const normalizedRole = role.trim()
const currentRoleMap = next[normalizedModuleKey] ?? {}
next[normalizedModuleKey] = { ...currentRoleMap, [normalizedRole]: nextValue }
return next
})
setMatrixDirty(true)
setMatrixStatus(undefined)
setMatrixError(undefined)
},
[],
)
const handleSaveMatrix = useCallback(async () => {
if (!canEditPermissions || !matrixDirty) {
return
}
setMatrixSaving(true)
setMatrixStatus(undefined)
setMatrixError(undefined)
try {
const response = await fetch('/api/admin/settings', {
method: 'POST',
credentials: 'include',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
version: matrixVersion,
matrix: matrixDraft,
}),
})
if (response.ok) {
const payload = (await response.json()) as AdminSettingsResponse
setMatrixDraft(payload.matrix)
setMatrixVersion(payload.version)
setMatrixDirty(false)
setMatrixStatus('已保存')
settingsSWR.mutate(payload, { revalidate: false })
return
}
let payload: ApiError | undefined
try {
payload = (await response.json()) as ApiError
} catch (error) {
// ignore parsing error
}
if (response.status === 409 && payload?.matrix) {
setMatrixDraft(payload.matrix)
if (typeof payload.version === 'number') {
setMatrixVersion(payload.version)
}
setMatrixDirty(false)
setMatrixError(payload.message ?? '配置已被其他人更新,已同步最新版本')
return
}
const message = payload?.error ?? payload?.message ?? '保存失败'
throw new Error(message)
} catch (error) {
setMatrixError(error instanceof Error ? error.message : '保存失败')
} finally {
setMatrixSaving(false)
}
}, [canEditPermissions, matrixDirty, matrixDraft, matrixVersion, settingsSWR])
const markRolePending = useCallback((userId: string, pending: boolean) => {
setPendingRoleUpdates((prev) => {
const next = new Set(prev)
if (pending) {
next.add(userId)
} else {
next.delete(userId)
}
return next
})
}, [])
const handleRoleChange = useCallback(
async (userId: string, role: string) => {
if (!canEditRoles) {
return
}
setRoleUpdateMessage(undefined)
markRolePending(userId, true)
try {
await jsonFetcher(`/api/admin/users/${userId}/role`, {
method: 'POST',
body: JSON.stringify({ role }),
})
setRoleUpdateMessage('角色已更新')
usersSWR.mutate()
} catch (error) {
setRoleUpdateMessage(error instanceof Error ? error.message : '角色更新失败')
} finally {
markRolePending(userId, false)
}
},
[canEditRoles, markRolePending, usersSWR],
)
if (isUserLoading) {
return (
<div className="flex flex-col gap-4">
<Card>
<div className="h-24 animate-pulse rounded bg-gray-200/60" />
</Card>
<Card>
<div className="h-64 animate-pulse rounded bg-gray-200/60" />
</Card>
</div>
)
}
if (!canAccess) {
return (
<Card>
<div className="flex flex-col items-start gap-3 text-sm text-gray-700">
<h2 className="text-lg font-semibold text-gray-900"></h2>
<p>
{accessDecision.reason === 'unauthenticated'
? '请先登录后再访问该页面。'
: '该页面仅向管理员与运营角色开放。如果你认为这是一个错误,请联系管理员。'}
</p>
</div>
</Card>
)
}
return (
<div className="flex flex-col gap-5">
<OverviewCards
overview={metricsSWR.data?.overview}
isLoading={metricsSWR.isLoading}
lastUpdatedLabel={lastUpdatedLabel}
/>
<TrendChart series={metricsSWR.data?.series} isLoading={metricsSWR.isLoading} />
<PermissionMatrixEditor
matrix={matrixDraft}
roles={['admin', 'operator', 'user']}
canEdit={canEditPermissions}
isLoading={settingsSWR.isLoading}
isSaving={matrixSaving}
hasChanges={matrixDirty}
statusMessage={matrixStatus}
errorMessage={matrixError}
onToggle={handleTogglePermission}
onSave={handleSaveMatrix}
/>
<UserGroupManagement
users={usersSWR.data}
isLoading={usersSWR.isLoading}
canEditRoles={canEditRoles}
pendingUserIds={pendingRoleUpdates}
onRoleChange={handleRoleChange}
onInvite={() => setRoleUpdateMessage('邀请入口尚未接入,可在后台触发工单流程。')}
onImport={() => setRoleUpdateMessage('导入入口尚未接入,请联系管理员执行批量导入。')}
/>
{roleUpdateMessage ? (
<div className="rounded-xl border border-purple-100 bg-purple-50/60 px-4 py-3 text-sm text-purple-700">
{roleUpdateMessage}
</div>
) : null}
</div>
)
}

View File

@ -1,7 +1,17 @@
export const dynamic = 'error'
import UserOverview from './components/UserOverview'
import { redirect } from 'next/navigation'
export default function PanelHome() {
return <UserOverview />
import { resolveExtensionRouteComponent } from '@extensions/loader'
export default async function PanelHome() {
try {
const Component = await resolveExtensionRouteComponent('/panel')
return <Component />
} catch (error) {
if (error instanceof Error && error.message.includes('disabled')) {
redirect('/panel')
}
throw error
}
}

View File

@ -1,12 +1,17 @@
export const dynamic = 'error'
import Card from '../components/Card'
import { redirect } from 'next/navigation'
export default function SubscriptionPage() {
return (
<Card>
<h1 className="text-2xl font-semibold text-gray-900">Subscription</h1>
<p className="mt-2 text-sm text-gray-600">Manage subscriptions and invoicing rules.</p>
</Card>
)
import { resolveExtensionRouteComponent } from '@extensions/loader'
export default async function SubscriptionPage() {
try {
const Component = await resolveExtensionRouteComponent('/panel/subscription')
return <Component />
} catch (error) {
if (error instanceof Error && error.message.includes('disabled')) {
redirect('/panel')
}
throw error
}
}

View File

@ -0,0 +1,45 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
getExtensionRegistry,
resetExtensionRegistryCache,
resolveExtensionRouteComponent,
} from '@extensions/loader'
const AGENT_FLAG = 'NEXT_PUBLIC_FEATURE_AGENT_MODULE'
afterEach(() => {
delete process.env[AGENT_FLAG]
resetExtensionRegistryCache()
})
describe('extension loader', () => {
it('disables agent module when feature flag is off', async () => {
process.env[AGENT_FLAG] = '0'
resetExtensionRegistryCache()
const registry = getExtensionRegistry()
const agentRoute = registry.getRoute('/panel/agent')
expect(agentRoute?.enabled).toBe(false)
await expect(resolveExtensionRouteComponent('/panel/agent')).rejects.toThrow('disabled')
const panelRoute = registry.getRoute('/panel')
expect(panelRoute?.enabled).toBe(true)
const PanelComponent = await resolveExtensionRouteComponent('/panel')
expect(typeof PanelComponent).toBe('function')
})
it('enables agent module when feature flag is on', async () => {
process.env[AGENT_FLAG] = '1'
resetExtensionRegistryCache()
const registry = getExtensionRegistry()
const agentRoute = registry.getRoute('/panel/agent')
expect(agentRoute?.enabled).toBe(true)
const AgentComponent = await resolveExtensionRouteComponent('/panel/agent')
expect(typeof AgentComponent).toBe('function')
})
})

View File

@ -0,0 +1,5 @@
import type { DashboardExtension } from '../types'
import { userCenterExtension } from './user-center'
export const builtinExtensions: DashboardExtension[] = [userCenterExtension]

View File

@ -0,0 +1,116 @@
import { Code, CreditCard, Home, Server, Settings, Shield, User } from 'lucide-react'
import type { DashboardExtension } from '../../types'
export const userCenterExtension: DashboardExtension = {
id: 'builtin.user-center',
meta: {
title: '用户中心',
description: '核心控制台能力,包括账户、管理与观测功能。',
version: '1.0.0',
author: 'XControl',
keywords: ['dashboard', 'accounts', 'management'],
},
routes: [
{
path: '/panel',
label: 'Dashboard',
description: '专属于你的信息总览',
icon: Home,
loader: () => import('./routes/home'),
match: 'startsWith',
guard: { requireLogin: true },
redirect: { unauthenticated: '/login' },
sidebar: { section: '用户中心', order: 0 },
},
{
path: '/panel/account',
label: 'Accounts',
description: '目录与多因素设置',
icon: User,
loader: () => import('./routes/account'),
guard: { requireLogin: true },
redirect: { unauthenticated: '/login' },
sidebar: { section: '权限设置', order: 0 },
},
{
path: '/panel/agent',
label: 'Agents',
description: '管理运行时节点',
icon: Server,
loader: () => import('./routes/agent'),
guard: { requireLogin: true },
redirect: { unauthenticated: '/login' },
sidebar: { section: '功能特性', order: 0 },
featureFlag: {
id: 'user-center.agent',
title: 'Agent 管理',
description: '启用代理节点管理页面。',
envVar: 'NEXT_PUBLIC_FEATURE_AGENT_MODULE',
defaultEnabled: false,
},
},
{
path: '/panel/api',
label: 'APIs',
description: '洞察后端服务',
icon: Code,
loader: () => import('./routes/api'),
guard: { requireLogin: true },
redirect: { unauthenticated: '/login' },
sidebar: { section: '功能特性', order: 1 },
featureFlag: {
id: 'user-center.api',
title: 'API 监控',
description: '启用 API 状态与特性开关页面。',
envVar: 'NEXT_PUBLIC_FEATURE_API_MODULE',
defaultEnabled: false,
},
},
{
path: '/panel/subscription',
label: 'Subscription',
description: '订阅方案与计费规则',
icon: CreditCard,
loader: () => import('./routes/subscription'),
guard: { requireLogin: true },
redirect: { unauthenticated: '/login' },
sidebar: { section: '功能特性', order: 2 },
featureFlag: {
id: 'user-center.subscription',
title: '订阅与计费',
description: '启用订阅与计费配置页面。',
envVar: 'NEXT_PUBLIC_FEATURE_SUBSCRIPTION_MODULE',
defaultEnabled: false,
},
},
{
path: '/panel/ldp',
label: 'LDP',
description: '低时延身份平面',
icon: Shield,
loader: () => import('./routes/ldp'),
guard: { requireLogin: true },
redirect: { unauthenticated: '/login' },
sidebar: { section: '权限设置', order: 1 },
featureFlag: {
id: 'user-center.ldp',
title: 'LDP 管理',
description: '启用低时延身份平面管理模块。',
envVar: 'NEXT_PUBLIC_FEATURE_LDP_MODULE',
defaultEnabled: false,
},
},
{
path: '/panel/management',
label: 'Management',
description: '集中化的权限矩阵与用户编排',
icon: Settings,
loader: () => import('./routes/management'),
guard: { requireLogin: true, roles: ['admin', 'operator'] },
match: 'startsWith',
redirect: { unauthenticated: '/login', forbidden: '/panel' },
sidebar: { section: '管理页面', order: 0 },
},
],
}

View File

@ -0,0 +1,13 @@
import MfaSetupPanel from '../account/MfaSetupPanel'
import ThemePreferenceCard from '../account/ThemePreferenceCard'
import UserOverview from '../components/UserOverview'
export default function UserCenterAccountRoute() {
return (
<div className="space-y-6">
<UserOverview />
<ThemePreferenceCard />
<MfaSetupPanel />
</div>
)
}

View File

@ -0,0 +1,12 @@
import Card from '../components/Card'
export default function UserCenterAgentRoute() {
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>
)
}

View File

@ -0,0 +1,10 @@
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>
)
}

View File

@ -0,0 +1,5 @@
import UserOverview from '../components/UserOverview'
export default function UserCenterHomeRoute() {
return <UserOverview />
}

View File

@ -0,0 +1,33 @@
import Link from 'next/link'
import Card from '../components/Card'
export default function UserCenterLdpRoute() {
const links = [
{ href: '/panel/ldp/users', label: 'Users' },
{ href: '/panel/ldp/services', label: 'Services' },
{ href: '/panel/ldp/config', label: 'Configuration' },
{ href: '/panel/ldp/status', label: 'Status' },
{ href: '/panel/ldp/consent', label: 'Login & Consent' },
]
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>
)
}

View File

@ -0,0 +1,286 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import useSWR from 'swr'
import Card from '../components/Card'
import TrendChart, { type MetricsSeries } from '../management/components/TrendChart'
import OverviewCards, { type MetricsOverview } from '../management/components/OverviewCards'
import PermissionMatrixEditor, {
type PermissionMatrix,
} from '../management/components/PermissionMatrixEditor'
import UserGroupManagement, { type ManagedUser } from '../management/components/UserGroupManagement'
import { resolveAccess } from '@lib/accessControl'
import { useUser } from '@lib/userStore'
type UserMetricsResponse = {
overview: MetricsOverview
series: MetricsSeries
}
type AdminSettingsResponse = {
version: number
matrix: PermissionMatrix
}
type ApiError = {
error?: string
message?: string
matrix?: PermissionMatrix
version?: number
}
async function jsonFetcher<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
const response = await fetch(input, {
...init,
credentials: 'include',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(init?.headers instanceof Headers ? Object.fromEntries(init.headers.entries()) : init?.headers),
},
cache: 'no-store',
})
if (!response.ok) {
let payload: ApiError | undefined
try {
payload = (await response.json()) as ApiError
} catch (error) {
// Ignore JSON parse errors; fall back to status text below.
}
const message = payload?.error ?? payload?.message ?? response.statusText
throw new Error(message || '请求失败')
}
return (await response.json()) as T
}
export default function UserCenterManagementRoute() {
const { user, isLoading: isUserLoading } = useUser()
const accessDecision = useMemo(() => resolveAccess(user, { requireLogin: true, roles: ['admin', 'operator'] }), [user])
const canAccess = accessDecision.allowed
const canEditPermissions = Boolean(user?.isAdmin)
const canEditRoles = Boolean(user?.isAdmin)
const [matrixDraft, setMatrixDraft] = useState<PermissionMatrix>({})
const [matrixVersion, setMatrixVersion] = useState<number>(0)
const [matrixDirty, setMatrixDirty] = useState(false)
const [matrixSaving, setMatrixSaving] = useState(false)
const [matrixStatus, setMatrixStatus] = useState<string | undefined>()
const [matrixError, setMatrixError] = useState<string | undefined>()
const [roleUpdateMessage, setRoleUpdateMessage] = useState<string | undefined>()
const [pendingRoleUpdates, setPendingRoleUpdates] = useState<Set<string>>(new Set())
const metricsSWR = useSWR<UserMetricsResponse>(canAccess ? '/api/admin/users/metrics' : null, jsonFetcher, {
revalidateOnFocus: false,
})
const settingsSWR = useSWR<AdminSettingsResponse>(canAccess ? '/api/admin/settings' : null, jsonFetcher, {
revalidateOnFocus: false,
})
const usersSWR = useSWR<ManagedUser[]>(canAccess ? '/api/users' : null, jsonFetcher, {
revalidateOnFocus: false,
})
useEffect(() => {
if (settingsSWR.data?.matrix) {
setMatrixDraft(settingsSWR.data.matrix)
setMatrixVersion(settingsSWR.data.version)
setMatrixDirty(false)
setMatrixError(undefined)
}
}, [settingsSWR.data])
const lastUpdatedLabel = useMemo(() => {
if (!metricsSWR.data) {
return undefined
}
const now = new Date()
return `更新于 ${now.toLocaleString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})}`
}, [metricsSWR.data])
const handleTogglePermission = useCallback(
(moduleKey: string, role: string, nextValue: boolean) => {
setMatrixDraft((prev) => {
const next: PermissionMatrix = { ...prev }
const normalizedModuleKey = moduleKey.trim()
const normalizedRole = role.trim()
const currentRoleMap = next[normalizedModuleKey] ?? {}
next[normalizedModuleKey] = { ...currentRoleMap, [normalizedRole]: nextValue }
return next
})
setMatrixDirty(true)
setMatrixStatus(undefined)
setMatrixError(undefined)
},
[],
)
const handleSaveMatrix = useCallback(async () => {
if (!canEditPermissions || !matrixDirty) {
return
}
setMatrixSaving(true)
setMatrixStatus(undefined)
setMatrixError(undefined)
try {
const response = await fetch('/api/admin/settings', {
method: 'POST',
credentials: 'include',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
version: matrixVersion,
matrix: matrixDraft,
}),
})
if (response.ok) {
const payload = (await response.json()) as AdminSettingsResponse
setMatrixDraft(payload.matrix)
setMatrixVersion(payload.version)
setMatrixDirty(false)
setMatrixStatus('已保存')
settingsSWR.mutate(payload, { revalidate: false })
return
}
let payload: ApiError | undefined
try {
payload = (await response.json()) as ApiError
} catch (error) {
// ignore parsing error
}
if (response.status === 409 && payload?.matrix) {
setMatrixDraft(payload.matrix)
if (typeof payload.version === 'number') {
setMatrixVersion(payload.version)
}
setMatrixDirty(false)
setMatrixError(payload.message ?? '配置已被其他人更新,已同步最新版本')
return
}
const message = payload?.error ?? payload?.message ?? '保存失败'
throw new Error(message)
} catch (error) {
setMatrixError(error instanceof Error ? error.message : '保存失败')
} finally {
setMatrixSaving(false)
}
}, [canEditPermissions, matrixDirty, matrixDraft, matrixVersion, settingsSWR])
const markRolePending = useCallback((userId: string, pending: boolean) => {
setPendingRoleUpdates((prev) => {
const next = new Set(prev)
if (pending) {
next.add(userId)
} else {
next.delete(userId)
}
return next
})
}, [])
const handleRoleChange = useCallback(
async (userId: string, role: string) => {
if (!canEditRoles) {
return
}
setRoleUpdateMessage(undefined)
markRolePending(userId, true)
try {
await jsonFetcher(`/api/admin/users/${userId}/role`, {
method: 'POST',
credentials: 'include',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({ role }),
})
setRoleUpdateMessage('角色已更新')
usersSWR.mutate()
} catch (error) {
setRoleUpdateMessage(error instanceof Error ? error.message : '更新失败')
} finally {
markRolePending(userId, false)
}
},
[canEditRoles, markRolePending, usersSWR],
)
const handleRoleReset = useCallback(
async (userId: string) => {
if (!canEditRoles) {
return
}
setRoleUpdateMessage(undefined)
markRolePending(userId, true)
try {
await jsonFetcher(`/api/admin/users/${userId}/role`, {
method: 'DELETE',
credentials: 'include',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
})
setRoleUpdateMessage('角色已重置')
usersSWR.mutate()
} catch (error) {
setRoleUpdateMessage(error instanceof Error ? error.message : '更新失败')
} finally {
markRolePending(userId, false)
}
},
[canEditRoles, markRolePending, usersSWR],
)
const matrixPending = matrixSaving || isUserLoading
const metricsLoading = metricsSWR.isLoading
const settingsLoading = settingsSWR.isLoading
const usersLoading = usersSWR.isLoading
if (!canAccess) {
return (
<Card>
<h1 className="text-2xl font-semibold text-gray-900"></h1>
<p className="mt-2 text-sm text-gray-600">访</p>
</Card>
)
}
return (
<div className="space-y-6">
<OverviewCards data={metricsSWR.data?.overview} loading={metricsLoading} lastUpdatedLabel={lastUpdatedLabel} />
<TrendChart data={metricsSWR.data?.series} loading={metricsLoading} />
<PermissionMatrixEditor
matrix={matrixDraft}
loading={settingsLoading}
saving={matrixPending}
dirty={matrixDirty}
status={matrixStatus}
error={matrixError}
onTogglePermission={handleTogglePermission}
onSave={handleSaveMatrix}
canEdit={canEditPermissions}
/>
<UserGroupManagement
users={usersSWR.data}
loading={usersLoading}
onRoleChange={handleRoleChange}
onRoleReset={handleRoleReset}
canEditRoles={canEditRoles}
pendingUserIds={pendingRoleUpdates}
message={roleUpdateMessage}
/>
</div>
)
}

View File

@ -0,0 +1,10 @@
import Card from '../components/Card'
export default function UserCenterSubscriptionRoute() {
return (
<Card>
<h1 className="text-2xl font-semibold text-gray-900">Subscription</h1>
<p className="mt-2 text-sm text-gray-600">Manage subscriptions and invoicing rules.</p>
</Card>
)
}

View File

@ -0,0 +1,137 @@
import { createFeatureFlag } from '@lib/featureFlags'
import { builtinExtensions } from './builtin'
import type {
DashboardExtension,
ExtensionRegistry,
RegisteredExtension,
RegisteredRoute,
SidebarItem,
SidebarSection,
} from './types'
let registryCache: ExtensionRegistry | undefined
function instantiateExtension(definition: DashboardExtension): RegisteredExtension {
const extensionFlag = definition.featureFlag ? createFeatureFlag(definition.featureFlag) : undefined
const extensionEnabled = extensionFlag ? extensionFlag.enabled : true
const registered: RegisteredExtension = {
...definition,
featureFlag: extensionFlag,
enabled: extensionEnabled,
routes: [],
}
registered.routes = definition.routes.map((route) => {
const routeFlag = route.featureFlag ? createFeatureFlag(route.featureFlag) : undefined
const routeEnabled = extensionEnabled && (routeFlag ? routeFlag.enabled : true)
const registeredRoute: RegisteredRoute = {
...route,
extensionId: definition.id,
extension: registered,
enabled: routeEnabled,
featureFlag: routeFlag,
}
return registeredRoute
})
return registered
}
function buildSidebar(routes: RegisteredRoute[]): SidebarSection[] {
const sectionMap = new Map<string, SidebarSection>()
routes.forEach((route) => {
if (!route.sidebar || route.sidebar.hidden) {
return
}
const { section, order } = route.sidebar
const sectionId = section
let entry = sectionMap.get(sectionId)
if (!entry) {
entry = { id: sectionId, title: section, order, items: [] }
sectionMap.set(sectionId, entry)
}
const item: SidebarItem = {
route,
disabled: !route.enabled,
}
entry.items.push(item)
})
const sortedSections = Array.from(sectionMap.values()).sort((a, b) => {
const orderA = a.order ?? Number.MAX_SAFE_INTEGER
const orderB = b.order ?? Number.MAX_SAFE_INTEGER
if (orderA !== orderB) {
return orderA - orderB
}
return a.title.localeCompare(b.title, 'zh-CN')
})
sortedSections.forEach((section) => {
section.items.sort((a, b) => {
const orderA = a.route.sidebar?.order ?? Number.MAX_SAFE_INTEGER
const orderB = b.route.sidebar?.order ?? Number.MAX_SAFE_INTEGER
if (orderA !== orderB) {
return orderA - orderB
}
return a.route.label.localeCompare(b.route.label, 'zh-CN')
})
})
return sortedSections
}
function createRegistry(): ExtensionRegistry {
const extensions = builtinExtensions.map(instantiateExtension)
const routes = extensions.flatMap((extension) => extension.routes)
const routeMap = new Map<string, RegisteredRoute>()
routes.forEach((route) => {
if (!routeMap.has(route.path)) {
routeMap.set(route.path, route)
}
})
const sidebar = buildSidebar(routes)
return {
extensions,
routes,
sidebar,
getRoute: (path: string) => routeMap.get(path),
resolveComponent: async (path: string) => {
const route = routeMap.get(path)
if (!route) {
throw new Error(`No extension route registered for path: ${path}`)
}
if (!route.enabled) {
throw new Error(`Extension route is disabled: ${path}`)
}
const module = await route.loader()
return module.default
},
}
}
export function getExtensionRegistry(): ExtensionRegistry {
if (!registryCache) {
registryCache = createRegistry()
}
return registryCache
}
export async function resolveExtensionRouteComponent(path: string) {
const registry = getExtensionRegistry()
return registry.resolveComponent(path)
}
export function resetExtensionRegistryCache() {
registryCache = undefined
}

View File

@ -0,0 +1,84 @@
import type { ComponentType } from 'react'
import type { LucideIcon } from 'lucide-react'
import type { AccessRule } from '@lib/accessControl'
import type { FeatureFlag, FeatureFlagDefinition } from '@lib/featureFlags'
export type RouteMatchStrategy = 'exact' | 'startsWith'
export interface ExtensionMeta {
title: string
description?: string
version?: string
author?: string
keywords?: string[]
}
export interface ExtensionStoreRegistration {
id: string
register: () => Promise<void> | void
}
export interface ExtensionMenuItem {
section: string
order?: number
badge?: string
hidden?: boolean
}
export interface ExtensionRoute {
path: string
label: string
description?: string
icon?: LucideIcon
loader: () => Promise<{ default: ComponentType<any> }>
match?: RouteMatchStrategy
guard?: AccessRule
sidebar?: ExtensionMenuItem
featureFlag?: FeatureFlagDefinition
redirect?: {
unauthenticated?: string
forbidden?: string
}
}
export interface DashboardExtension {
id: string
meta: ExtensionMeta
routes: ExtensionRoute[]
stores?: ExtensionStoreRegistration[]
featureFlag?: FeatureFlagDefinition
}
export interface RegisteredRoute extends ExtensionRoute {
extensionId: string
extension: RegisteredExtension
enabled: boolean
featureFlag?: FeatureFlag
}
export interface RegisteredExtension extends DashboardExtension {
enabled: boolean
featureFlag?: FeatureFlag
routes: RegisteredRoute[]
}
export interface SidebarItem {
route: RegisteredRoute
disabled: boolean
}
export interface SidebarSection {
id: string
title: string
order?: number
items: SidebarItem[]
}
export interface ExtensionRegistry {
extensions: RegisteredExtension[]
routes: RegisteredRoute[]
sidebar: SidebarSection[]
getRoute: (path: string) => RegisteredRoute | undefined
resolveComponent: (path: string) => Promise<ComponentType<any>>
}

View File

@ -23,6 +23,7 @@
"@lib/*": ["lib/*"],
"@types/*": ["types/*"],
"@server/*": ["server/*"],
"@extensions/*": ["src/extensions/*"],
"@theme/*": ["src/theme/*"],
"@templates/*": ["src/templates/*"],
"@src/*": ["src/*"]

View File

@ -26,6 +26,7 @@ export default defineConfig({
'@i18n': path.resolve(__dirname, 'i18n'),
'@lib': path.resolve(__dirname, 'lib'),
'@types': path.resolve(__dirname, 'types'),
'@extensions': path.resolve(__dirname, 'src/extensions'),
'@templates': path.resolve(__dirname, 'src/templates'),
'@src': path.resolve(__dirname, 'src'),
},