feat(license): Pro 授权机制 — 离线许可码 + 免费版功能闸门
免费版:日志查询/部署管理/采集/用户管理/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:
1 parent
c60b827262
commit
9641503007
26 files changed
+632
-23
No files matched your search
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
}
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
@@ -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: '永久',
|
||||
},
|
||||
}
|
||||
@@ -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 条目。
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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,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>
|
||||
)
|
||||
})
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
/**
|
||||
* 若该项是分组的代表入口,记录分组标识。
|
||||
* 侧栏高亮据此判断:当前路由属于该分组时整项高亮,
|
||||
|
||||
Reference in new issue
Block a user