feat(license): Pro 授权机制 — 离线许可码 + 免费版功能闸门
CI / Backend (go vet + test) (push) Waiting to run
CI / Frontend (lint + build + test) (push) Waiting to run

免费版:日志查询/部署管理/采集/用户管理/LDAP/设置;Pro(¥199 个人单实例永久 / ¥499 团队多实例永久):
日志分析、日志告警、智能运维(AI)、审计日志。
- 后端:HMAC-SHA256 离线许可码(签发 CLI / license --plan --edition --seats --customer --days;
  永久=days 0),GET /system/license + POST activate;RequirePro 闸门挂 metrics/ai/audit;
  过期自动回落免费;测试覆盖签发-校验/篡改/免费拒签
- 前端:菜单 Pro 项带锁标;四个 Pro 页面免费版渲染升级引导卡(功能清单+定价+页内激活);
  设置页新增授权区(版本/席位/到期/激活入口);license 词典中英
- domains 注册提升到 main.tsx 全局(修复 license 命名空间在未引 domains 的页面显示裸键)
This commit is contained in:
opsx committed 2026-10-31 23:08:06 +08:00
1 parent c60b827262
commit 9641503007
26 files changed
+632 -23

No files matched your search

+3 -2
View File
@@ -4,6 +4,7 @@ import { Toaster } from '@/components/ui/sonner'
import { isLoggedIn, getUser } from '@/lib/auth-storage'
import { routes, findRouteMeta } from '@/router/routes'
import ErrorBoundary from '@/components/ErrorBoundary'
import { LicenseProvider } from '@/hooks/useLicense'
import { useTranslation } from 'react-i18next'
const Layout = lazy(() => import('@/components/Layout'))
@@ -58,7 +59,7 @@ function useDocumentTitle() {
const App = memo(function App() {
useDocumentTitle()
return (
<>
<LicenseProvider>
<Routes>
{routes.map((route) => (
<Route
@@ -80,7 +81,7 @@ const App = memo(function App() {
))}
</Routes>
<Toaster />
</>
</LicenseProvider>
)
})
+15
View File
@@ -0,0 +1,15 @@
import { http } from '../http'
export interface LicenseInfo {
plan: 'free' | 'pro'
edition?: string
seats?: number
instances?: number
customer?: string
expiresAt?: number
expired?: boolean
}
export const licenseApi = {
status: () => http.get<{ license: LicenseInfo; pro: boolean; features: string[] }>('/system/license'),
activate: (key: string) => http.post<LicenseInfo>('/system/license/activate', { key }),
}
+4
View File
@@ -21,6 +21,8 @@ import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { useIsMobile } from '@/hooks/use-mobile'
import { menuItems, findRouteMeta, findGroupKey } from '@/router/routes'
import { useLicense } from '@/hooks/useLicense'
import { Lock } from 'lucide-react'
import { GroupTabs } from './GroupTabs'
import { getUser, clearAuth } from '@/lib/auth-storage'
import { isThemeAuto, setThemeAuto } from '@/lib/theme-schedule'
@@ -36,6 +38,7 @@ function SidebarNav({ collapsed, onNavigate }: { collapsed: boolean; onNavigate?
const location = useLocation()
const { t } = useTranslation()
const isAdmin = getUser()?.role === 'admin'
const { pro } = useLicense()
// adminOnly 项(用户管理)仅管理员可见;后端 AdminAuth 兜底
const visibleItems = menuItems.filter((item) => !item.adminOnly || isAdmin)
return (
@@ -62,6 +65,7 @@ function SidebarNav({ collapsed, onNavigate }: { collapsed: boolean; onNavigate?
>
{item.icon && <item.icon className="w-5 h-5 flex-shrink-0" />}
{!collapsed && <span className="text-sm font-medium">{t(item.label)}</span>}
{item.proOnly && !pro && <Lock className="w-3 h-3 text-sidebar-foreground/50 flex-shrink-0" aria-label="Pro" />}
{!collapsed && isActive && (
<ChevronRight className="w-4 h-4 ml-auto flex-shrink-0" />
)}
+75
View File
@@ -0,0 +1,75 @@
import { memo, useState } from 'react'
import { useNavigate } from 'react-router'
import { useTranslation } from 'react-i18next'
import { Lock, Sparkles } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { licenseApi } from '@/api/license'
import { useLicense } from '@/hooks/useLicense'
/** Pro 功能升级引导卡:免费版进入 Pro 页面时整页替代展示 */
export const ProGate = memo(function ProGate({ featureKey }: { featureKey: string }) {
const { t } = useTranslation('license')
const { reload } = useLicense()
const navigate = useNavigate()
const [key, setKey] = useState('')
const [activating, setActivating] = useState(false)
const activate = async () => {
if (!key.trim()) return
setActivating(true)
try {
await licenseApi.activate(key.trim())
toast.success(t('activated'))
reload()
} catch (err) {
toast.error(err instanceof Error ? err.message : t('activateFailed'))
} finally {
setActivating(false)
}
}
return (
<div className="mx-auto max-w-2xl space-y-4 pt-8">
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Lock className="size-4 text-warning" />
{t('gate.title', { feature: t(`features.${featureKey}`) })}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">{t('gate.desc')}</p>
<ul className="space-y-1.5 text-sm">
{['log_analysis', 'log_alerts', 'ai_ops', 'audit_log'].map(f => (
<li key={f} className="flex items-center gap-2">
<Sparkles className="size-3.5 text-primary" />
{t(`features.${f}`)}
</li>
))}
</ul>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-lg border p-4">
<p className="text-sm font-semibold">{t('pricing.personalTitle')}</p>
<p className="mt-1 text-2xl font-bold text-primary">¥199<span className="text-sm font-normal text-muted-foreground"> {t('pricing.permanent')}</span></p>
<p className="mt-1 text-xs text-muted-foreground">{t('pricing.personalDesc')}</p>
</div>
<div className="rounded-lg border border-primary/40 p-4">
<p className="text-sm font-semibold">{t('pricing.teamTitle')}</p>
<p className="mt-1 text-2xl font-bold text-primary">¥499<span className="text-sm font-normal text-muted-foreground"> {t('pricing.permanent')}</span></p>
<p className="mt-1 text-xs text-muted-foreground">{t('pricing.teamDesc')}</p>
</div>
</div>
<div className="flex gap-2">
<Input value={key} onChange={e => setKey(e.target.value)} placeholder={t('gate.keyPlaceholder')} className="font-mono text-sm" />
<Button onClick={() => void activate()} disabled={activating || !key.trim()}>{t('gate.activate')}</Button>
<Button variant="outline" onClick={() => navigate('/settings')}>{t('gate.toSettings')}</Button>
</div>
<p className="text-xs text-muted-foreground">{t('gate.freeHint')}</p>
</CardContent>
</Card>
</div>
)
})
+28
View File
@@ -0,0 +1,28 @@
import { createContext, useContext, useEffect, useState } from 'react'
import { licenseApi, type LicenseInfo } from '@/api/license'
interface LicenseState {
pro: boolean
info: LicenseInfo | null
reload: () => void
}
const LicenseContext = createContext<LicenseState>({ pro: false, info: null, reload: () => {} })
export function LicenseProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<{ pro: boolean; info: LicenseInfo | null }>({ pro: false, info: null })
useEffect(() => {
licenseApi.status()
.then(res => setState({ pro: Boolean(res.pro), info: res.license }))
.catch(() => setState({ pro: false, info: null }))
}, [])
return (
<LicenseContext.Provider value={{ ...state, reload: () => licenseApi.status().then(res => setState({ pro: Boolean(res.pro), info: res.license })).catch(() => {}) }}>
{children}
</LicenseContext.Provider>
)
}
export function useLicense() {
return useContext(LicenseContext)
}
+4
View File
@@ -15,6 +15,8 @@ import zhAnalysis from './locales/zh/analysis'
import enAnalysis from './locales/en/analysis'
import zhLogs from './locales/zh/logs'
import enLogs from './locales/en/logs'
import zhLicense from './locales/zh/license'
import enLicense from './locales/en/license'
import zhSettings from './locales/zh/settings'
import enSettings from './locales/en/settings'
import zhAudit from './locales/zh/audit'
@@ -31,6 +33,8 @@ const DOMAIN_BUNDLES: ReadonlyArray<[string, string, object]> = [
['en', 'analysis', enAnalysis],
['zh', 'logs', zhLogs],
['en', 'logs', enLogs],
['zh', 'license', zhLicense],
['en', 'license', enLicense],
['zh', 'settings', zhSettings],
['en', 'settings', enSettings],
['zh', 'audit', zhAudit],
+39
View File
@@ -0,0 +1,39 @@
// Pro license (upgrade gate / settings section)
export default {
title: 'License',
freeBadge: 'Free',
freeDesc: 'You are on the Free plan: log query, deployment management, users and LDAP are included. Log analysis, alerts, AI ops and audit log are Pro features.',
edition: 'Edition',
expiry: 'Valid until',
customer: 'Licensed to',
seats: 'Seats',
permanent: 'perpetual',
personalTitle: 'Personal',
teamTitle: 'Team',
keyPlaceholder: 'Enter license key to activate Pro…',
activate: 'Activate',
activating: 'Activating…',
activated: 'Pro activated — all features unlocked',
activateFailed: 'Activation failed',
gate: {
title: '{{feature}} is a Pro feature',
desc: 'Upgrade to Pro to unlock the full analytics suite. The Free plan keeps log query, deployment management and user/LDAP administration.',
keyPlaceholder: 'Paste license key…',
activate: 'Activate',
toSettings: 'Open settings',
freeHint: 'Free plan includes: log query / deployment management / users / LDAP',
},
features: {
log_analysis: 'Log Analysis',
log_alerts: 'Log Alerts',
ai_ops: 'AI Ops',
audit_log: 'Audit Log',
},
pricing: {
personalTitle: 'Personal',
personalDesc: '1 instance · personal & community use · community support',
teamTitle: 'Team',
teamDesc: 'Multiple instances · commercial use · priority support',
permanent: 'one-time',
},
}
+39
View File
@@ -0,0 +1,39 @@
// Pro 授权(升级引导/设置授权区)
export default {
title: '授权',
freeBadge: '免费版',
freeDesc: '当前为免费版:日志查询、部署管理、用户与 LDAP 授权均可使用;日志分析、日志告警、智能运维与审计日志为 Pro 功能。',
edition: '版本',
expiry: '有效期',
customer: '授权对象',
seats: '席位',
permanent: '永久',
personalTitle: '个人版',
teamTitle: '团队版',
keyPlaceholder: '输入许可码激活 Pro…',
activate: '激活',
activating: '激活中…',
activated: 'Pro 已激活,全部功能已解锁',
activateFailed: '激活失败',
gate: {
title: '{{feature}} 是 Pro 功能',
desc: '升级 Pro 解锁平台全部分析能力;免费版可继续使用日志查询、部署管理与用户授权。',
keyPlaceholder: '粘贴许可码…',
activate: '激活',
toSettings: '到设置激活',
freeHint: '免费版保留:日志查询 / 部署管理 / 用户管理 / LDAP 授权',
},
features: {
log_analysis: '日志分析',
log_alerts: '日志告警',
ai_ops: '智能运维(AI)',
audit_log: '审计日志',
},
pricing: {
personalTitle: '个人版',
personalDesc: '单实例 · 个人与社区使用 · 社区支持',
teamTitle: '团队版',
teamDesc: '多实例 · 商业使用授权 · 优先支持',
permanent: '永久',
},
}
+1
View File
@@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router'
import { ThemeProvider } from 'next-themes'
import './index.css'
import '@/i18n'
import '@/i18n/domains'
// 一次性迁移历史 localStorage key → lokistack.*(保留登录态/主题/折叠/查询历史)。
// 「读旧写新」保留一个迁移版本(开发标准 08 章),之后可移除旧 key 条目。
+4
View File
@@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next'
import { LLM_PROVIDERS, matchProvider } from './llmProviders'
import { Loader2, Play, Save, Sparkles, PlugZap, History, ShieldCheck } from 'lucide-react'
import { toast } from 'sonner'
import { ProGate } from '@/components/ProGate'
import { useLicense } from '@/hooks/useLicense'
import { PageContainer } from '@/components/PageContainer'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -44,6 +46,7 @@ function fmtNum(value: number): string {
export default function AiopsPage() {
const { t } = useTranslation('aiops')
const { pro } = useLicense()
const [tab, setTab] = useState('analyze')
// ── 分析 ──
@@ -213,6 +216,7 @@ export default function AiopsPage() {
const totalPages = Math.max(1, Math.ceil(runTotal / PAGE_SIZE))
if (!pro) return <ProGate featureKey="ai_ops" />
return (
<PageContainer title={t('title')} description={t('subtitle')}>
<Tabs value={tab} onValueChange={setTab} className="space-y-4">
+4
View File
@@ -6,6 +6,8 @@ import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { NotifySection } from '@/components/NotifySection'
import { RuleFormDialog } from './components/RuleFormDialog'
import { ProGate } from '@/components/ProGate'
import { useLicense } from '@/hooks/useLicense'
import { PageContainer } from '@/components/PageContainer'
import { EmptyState } from '@/components/EmptyState'
import { ConfirmDialog } from '@/components/ConfirmDialog'
@@ -18,6 +20,7 @@ import { useTranslation } from 'react-i18next'
/** 告警规则页:规则 CRUD + 立即检查 + 发送历史 */
const AlertsPage = memo(function AlertsPage() {
const { t } = useTranslation(['alerts', 'common'])
const { pro } = useLicense()
const [rules, setRules] = useState<AlertRule[]>([])
const [sources, setSources] = useState<LogSource[]>([])
const [channels, setChannels] = useState<NotifyChannel[]>([])
@@ -100,6 +103,7 @@ const AlertsPage = memo(function AlertsPage() {
}
}
if (!pro) return <ProGate featureKey="log_alerts" />
return (
<PageContainer
title={t('title')}
+5
View File
@@ -6,6 +6,8 @@ import { BarChart3, Boxes, FileWarning, Loader2, RefreshCw, ScrollText } from 'l
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { ProGate } from '@/components/ProGate'
import { useLicense } from '@/hooks/useLicense'
import { PageContainer } from '@/components/PageContainer'
import { EmptyState } from '@/components/EmptyState'
import { ErrorState } from '@/components/ErrorState'
@@ -34,6 +36,7 @@ const ALL_NAMESPACES = '__all__'
*/
const AnalysisPage = memo(function AnalysisPage() {
const { t } = useTranslation('analysis')
const { pro } = useLicense()
const navigate = useNavigate()
const isAdmin = getUser()?.role === 'admin'
@@ -255,6 +258,8 @@ const AnalysisPage = memo(function AnalysisPage() {
const rankPageRows = rankRows.slice((rankSafePage - 1) * RANK_PAGE_SIZE, rankSafePage * RANK_PAGE_SIZE)
// 非 admin:菜单不可见,直访时给明确提示(后端 AdminAuth 403 兜底)
if (!pro) return <ProGate featureKey="log_analysis" />
if (!isAdmin) {
return (
<PageContainer title={t('title')}>
+5
View File
@@ -5,6 +5,8 @@ import '@/i18n/domains'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { ProGate } from '@/components/ProGate'
import { useLicense } from '@/hooks/useLicense'
import { PageContainer } from '@/components/PageContainer'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { EmptyState } from '@/components/EmptyState'
@@ -164,6 +166,7 @@ const DetailDialog = memo(function DetailDialog({
*/
export default function AuditPage() {
const { t } = useTranslation('audit')
const { pro } = useLicense()
const [list, setList] = useState<AuditLog[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
@@ -231,6 +234,8 @@ export default function AuditPage() {
{ label: t('stat.total'), value: stats?.totalCount ?? 0, icon: History },
]
if (!pro) return <ProGate featureKey="audit_log" />
return (
<PageContainer
title={t('title')}
@@ -0,0 +1,77 @@
import { memo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { KeyRound } from 'lucide-react'
import { licenseApi } from '@/api/license'
import { useLicense } from '@/hooks/useLicense'
import { getUser } from '@/lib/auth-storage'
/** 授权区块:当前版本状态 + 许可码激活(仅管理员可操作) */
export const LicenseSection = memo(function LicenseSection() {
const { t } = useTranslation('license')
const { pro, info, reload } = useLicense()
const isAdmin = getUser()?.role === 'admin'
const [key, setKey] = useState('')
const [activating, setActivating] = useState(false)
const activate = async () => {
if (!key.trim()) return
setActivating(true)
try {
await licenseApi.activate(key.trim())
toast.success(t('activated'))
setKey('')
reload()
} catch (err) {
toast.error(err instanceof Error ? err.message : t('activateFailed'))
} finally {
setActivating(false)
}
}
const expiry = info?.expiresAt
? info.expiresAt > 0
? new Date(info.expiresAt * 1000).toLocaleDateString()
: t('permanent')
: null
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<KeyRound className="size-4" />
{t('title')}
<Badge variant={pro ? 'default' : 'secondary'} className={pro ? 'bg-success text-success-foreground' : ''}>
{pro ? 'Pro' : t('freeBadge')}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-sm">
{pro ? (
<div className="grid gap-1 text-muted-foreground sm:grid-cols-2">
<p>{t('edition')}: {info?.edition === 'team' ? t('teamTitle') : t('personalTitle')}</p>
<p>{t('expiry')}: {expiry}</p>
{info?.customer && <p>{t('customer')}: {info.customer}</p>}
{Boolean(info?.seats) && <p>{t('seats')}: {info?.seats}</p>}
</div>
) : (
<p className="text-muted-foreground">{t('freeDesc')}</p>
)}
</div>
{isAdmin && (
<div className="flex gap-2">
<Input value={key} onChange={e => setKey(e.target.value)} placeholder={t('keyPlaceholder')} className="font-mono text-sm" />
<Button onClick={() => void activate()} disabled={activating || !key.trim()}>
{activating ? t('activating') : t('activate')}
</Button>
</div>
)}
</CardContent>
</Card>
)
})
@@ -3,11 +3,7 @@ import { useTranslation } from 'react-i18next'
import { useTheme } from 'next-themes'
import { Sun, Moon, Monitor, Clock } from 'lucide-react'
import { cn } from '@/lib/utils'
import {
DAY_START_HOUR,
DAY_END_HOUR,
setThemeAuto,
} from '@/lib/theme-schedule'
import { setThemeAuto } from '@/lib/theme-schedule'
import { useScheduledTheme } from '@/hooks/useScheduledTheme'
type ThemeMode = 'light' | 'dark' | 'system' | 'auto'
@@ -67,14 +63,6 @@ export const ThemeSection = memo(function ThemeSection() {
})}
</div>
</div>
{auto && (
<p className="text-xs text-muted-foreground">
{t('themeCard.autoHint', {
start: String(DAY_START_HOUR).padStart(2, '0'),
end: String(DAY_END_HOUR).padStart(2, '0'),
})}
</p>
)}
</div>
)
})
+2
View File
@@ -12,6 +12,7 @@ import { toast } from 'sonner'
import { PageContainer } from '@/components/PageContainer'
import { ThemeSection } from './components/ThemeSection'
import { RetentionSection } from './components/RetentionSection'
import { LicenseSection } from './components/LicenseSection'
import { LanguageSection } from '@/i18n/LanguageSection'
import { Palette } from 'lucide-react'
@@ -69,6 +70,7 @@ const SettingsPage = memo(function SettingsPage() {
return (
<PageContainer title={t('title')}>
<LicenseSection />
{/* 不再有内层 tab:LDAP / 镜像源 已拆成独立路由,
由左侧「系统设置」分组的顶层 tab 切换(单层 tab 更好理解)。 */}
<div className="space-y-6">
+6 -4
View File
@@ -71,14 +71,14 @@ export const routes: AppRoute[] = [
// ── 日志查询 / 日志分析:高频入口,保持独立菜单项(不折叠)──
{ path: '/logs', component: LogsPage, auth: true, meta: { title: 'menu.logs', icon: ScrollText, order: 5 } },
{ path: '/analysis', component: AnalysisPage, auth: true, meta: { title: 'menu.analysis', icon: BarChart3, order: 6, adminOnly: true } },
{ path: '/analysis', component: AnalysisPage, auth: true, meta: { title: 'menu.analysis', icon: BarChart3, order: 6, adminOnly: true, proOnly: true } },
// 智能运维:AI 日志分析。紧跟日志分析,便于"先看统计、再问 AI"的动线
{ path: '/aiops', component: AiopsPage, auth: true, meta: { title: 'menu.aiops', icon: Sparkles, order: 6, adminOnly: true } },
{ path: '/aiops', component: AiopsPage, auth: true, meta: { title: 'menu.aiops', icon: Sparkles, order: 6, adminOnly: true, proOnly: true } },
// ── 告警:独立入口(要盯的,不能被折叠) ──
// 告警仅管理员:普通用户按「查询授权」只看指定的日志(菜单只剩日志查询)。
// 告警规则涉及阈值/通知通道等平台级配置,不属于日志查询者的职责。
{ path: '/alerts', component: AlertsPage, auth: true, meta: { title: 'menu.alerts', icon: BellRing, order: 7, adminOnly: true } },
{ path: '/alerts', component: AlertsPage, auth: true, meta: { title: 'menu.alerts', icon: BellRing, order: 7, adminOnly: true, proOnly: true } },
// ── 系统管理(用户 / 审计 / 设置):低频管理动作 ──
{
@@ -90,7 +90,7 @@ export const routes: AppRoute[] = [
groupKey: 'system',
},
},
{ path: '/audit', component: AuditPage, auth: true, meta: { title: 'menu.audit', icon: History, order: 10, adminOnly: true, groupKey: 'system' } },
{ path: '/audit', component: AuditPage, auth: true, meta: { title: 'menu.audit', icon: History, order: 10, adminOnly: true, groupKey: 'system', proOnly: true } },
{ path: '/settings/ldap', component: LdapPage, auth: true, meta: { title: 'menu.ldap', icon: ShieldCheck, order: 11, adminOnly: true, groupKey: 'system' } },
{ path: '/settings/registry', component: RegistryPage, auth: true, meta: { title: 'menu.registry', icon: Container, order: 12, adminOnly: true, groupKey: 'system' } },
// 系统设置放回侧栏,并作为「系统设置」分组的 tab 起点(order 最小 = 排在第一个)
@@ -136,6 +136,7 @@ export const menuItems: MenuItem[] = (() => {
icon: group.icon ?? route.meta.icon,
label: group.label,
adminOnly: route.meta.adminOnly,
proOnly: route.meta.proOnly,
groupKey: group.key,
});
continue;
@@ -145,6 +146,7 @@ export const menuItems: MenuItem[] = (() => {
icon: route.meta.icon,
label: route.meta.title,
adminOnly: route.meta.adminOnly,
proOnly: route.meta.proOnly,
});
}
return items;
+6 -2
View File
@@ -13,7 +13,9 @@ export interface RouteMeta {
/** 不出现在菜单中(登录页、404 等) */
hideInMenu?: boolean;
/** 仅管理员可见/可访问(菜单过滤 + 页面内守卫,后端 AdminAuth 兜底) */
adminOnly?: boolean;
adminOnly?: boolean
/** Pro 授权功能:免费版菜单带锁标,页面渲染升级引导 */
proOnly?: boolean;
/**
* 菜单分组:同组的多个页面在侧边栏只占【一个】入口,
* 页面内用顶部 tab 切换(避免 13 个菜单把侧栏塞满)。
@@ -44,7 +46,9 @@ export interface MenuItem {
icon?: LucideIcon;
label: string;
/** 仅管理员可见(Layout 按 getUser().role 过滤) */
adminOnly?: boolean;
adminOnly?: boolean
/** Pro 功能:免费版菜单显示锁标(仍可进入看升级引导) */
proOnly?: boolean
/**
* 若该项是分组的代表入口,记录分组标识。
* 侧栏高亮据此判断:当前路由属于该分组时整项高亮,