feat(dashboard): modularize panel via extension loader (#546)
This commit is contained in:
parent
78b5032163
commit
b5fdae1d57
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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')) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
45
dashboard/src/extensions/__tests__/loader.test.ts
Normal file
45
dashboard/src/extensions/__tests__/loader.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
5
dashboard/src/extensions/builtin/index.ts
Normal file
5
dashboard/src/extensions/builtin/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import type { DashboardExtension } from '../types'
|
||||
|
||||
import { userCenterExtension } from './user-center'
|
||||
|
||||
export const builtinExtensions: DashboardExtension[] = [userCenterExtension]
|
||||
116
dashboard/src/extensions/builtin/user-center/index.ts
Normal file
116
dashboard/src/extensions/builtin/user-center/index.ts
Normal 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 },
|
||||
},
|
||||
],
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
10
dashboard/src/extensions/builtin/user-center/routes/api.tsx
Normal file
10
dashboard/src/extensions/builtin/user-center/routes/api.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import UserOverview from '../components/UserOverview'
|
||||
|
||||
export default function UserCenterHomeRoute() {
|
||||
return <UserOverview />
|
||||
}
|
||||
33
dashboard/src/extensions/builtin/user-center/routes/ldp.tsx
Normal file
33
dashboard/src/extensions/builtin/user-center/routes/ldp.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
137
dashboard/src/extensions/loader.ts
Normal file
137
dashboard/src/extensions/loader.ts
Normal 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
|
||||
}
|
||||
84
dashboard/src/extensions/types.ts
Normal file
84
dashboard/src/extensions/types.ts
Normal 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>>
|
||||
}
|
||||
@ -23,6 +23,7 @@
|
||||
"@lib/*": ["lib/*"],
|
||||
"@types/*": ["types/*"],
|
||||
"@server/*": ["server/*"],
|
||||
"@extensions/*": ["src/extensions/*"],
|
||||
"@theme/*": ["src/theme/*"],
|
||||
"@templates/*": ["src/templates/*"],
|
||||
"@src/*": ["src/*"]
|
||||
|
||||
@ -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'),
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user