Files
termi-blog/admin/src/components/app-shell.tsx
limitcool 497a9d713d
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Failing after 13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Has been cancelled
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Has been cancelled
feat: ship public ops features and cache docker builds
2026-04-01 13:22:19 +08:00

295 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
BarChart3,
BellRing,
BookOpenText,
Download,
ExternalLink,
Folders,
History,
Image as ImageIcon,
LayoutDashboard,
Link2,
LogOut,
MessageSquareText,
Orbit,
ScrollText,
Settings,
Sparkles,
Tags,
} from 'lucide-react'
import type { ReactNode } from 'react'
import { NavLink } from 'react-router-dom'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { buildFrontendUrl } from '@/lib/frontend-url'
import { cn } from '@/lib/utils'
const primaryNav = [
{
to: '/',
label: '概览',
description: '站点运营总览',
icon: LayoutDashboard,
},
{
to: '/analytics',
label: '数据分析',
description: '搜索词与 AI 问答洞察',
icon: BarChart3,
},
{
to: '/posts',
label: '文章',
description: 'Markdown 内容管理',
icon: ScrollText,
},
{
to: '/categories',
label: '分类',
description: '分类目录与聚合统计',
icon: Folders,
},
{
to: '/tags',
label: '标签',
description: '标签库与引用整理',
icon: Tags,
},
{
to: '/backups',
label: '备份',
description: '全站导出与恢复',
icon: Download,
},
{
to: '/revisions',
label: '版本',
description: '历史快照与一键回滚',
icon: History,
},
{
to: '/comments',
label: '评论',
description: '审核与段落回复',
icon: MessageSquareText,
},
{
to: '/friend-links',
label: '友链',
description: '友链申请与互链管理',
icon: Link2,
},
{
to: '/reviews',
label: '评测',
description: '评测内容库',
icon: BookOpenText,
},
{
to: '/media',
label: '媒体库',
description: '对象存储图片管理',
icon: ImageIcon,
},
{
to: '/subscriptions',
label: '订阅',
description: '邮件 / Webhook 推送',
icon: BellRing,
},
{
to: '/audit',
label: '审计',
description: '后台操作审计日志',
icon: Settings,
},
{
to: '/settings',
label: '设置',
description: '品牌、资料与 AI 配置',
icon: Settings,
},
]
export function AppShell({
children,
username,
email,
authSource,
authProvider,
loggingOut,
canLogout,
onLogout,
}: {
children: ReactNode
username: string | null
email: string | null
authSource: string | null
authProvider: string | null
loggingOut: boolean
canLogout: boolean
onLogout: () => Promise<void>
}) {
return (
<div className="min-h-screen bg-background text-foreground">
<div className="mx-auto flex min-h-screen w-full max-w-[1600px] gap-6 px-4 py-4 lg:px-6 lg:py-6">
<aside className="hidden w-[310px] shrink-0 lg:block">
<div className="sticky top-6 overflow-hidden rounded-[2rem] border border-border/70 bg-card/90 shadow-[0_32px_90px_rgba(15,23,42,0.14)] backdrop-blur">
<div className="space-y-5 p-6">
<div className="space-y-3">
<div className="inline-flex items-center gap-2 rounded-full border border-primary/20 bg-primary/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-primary">
<Orbit className="h-3.5 w-3.5" />
Termi
</div>
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight"></h1>
<p className="text-sm leading-6 text-muted-foreground">
React AI
</p>
</div>
</div>
<Separator />
<nav className="space-y-2">
{primaryNav.map((item) => {
const Icon = item.icon
return (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
cn(
'group flex items-start gap-3 rounded-2xl border px-4 py-3 transition-all',
isActive
? 'border-primary/30 bg-primary/10 shadow-[0_12px_30px_rgba(37,99,235,0.14)]'
: 'border-transparent bg-background/50 hover:border-border/80 hover:bg-accent/55',
)
}
>
{({ isActive }) => (
<>
<div
className={cn(
'mt-0.5 flex h-10 w-10 items-center justify-center rounded-xl border',
isActive
? 'border-primary/25 bg-primary/12 text-primary'
: 'border-border/80 bg-secondary text-muted-foreground',
)}
>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="font-medium">{item.label}</div>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
{item.description}
</p>
</div>
</>
)}
</NavLink>
)
})}
</nav>
<Separator />
<div className="rounded-[1.7rem] border border-border/70 bg-background/65 p-5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">
</p>
<p className="mt-1 text-sm text-muted-foreground">
</p>
</div>
<Badge variant="success"></Badge>
</div>
<div className="mt-4 grid gap-2">
<div className="rounded-2xl border border-border/60 bg-card/70 px-4 py-3 text-sm text-muted-foreground">
</div>
<div className="rounded-2xl border border-border/60 bg-card/70 px-4 py-3 text-sm text-muted-foreground">
</div>
</div>
</div>
</div>
</div>
</aside>
<div className="min-w-0 flex-1 space-y-6">
<header className="sticky top-4 z-20 rounded-[1.8rem] border border-border/70 bg-card/80 px-5 py-4 shadow-[0_20px_60px_rgba(15,23,42,0.1)] backdrop-blur">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-2">
<div className="inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-secondary-foreground">
<Sparkles className="h-3.5 w-3.5" />
</div>
<div>
<p className="text-sm text-muted-foreground">
{username ?? 'admin'}
</p>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{authProvider ?? 'React + shadcn/ui 基础架构'}
</p>
{email ? (
<p className="text-xs text-muted-foreground">{email}</p>
) : authSource ? (
<p className="text-xs text-muted-foreground">{authSource}</p>
) : null}
</div>
</div>
<div className="flex gap-2 overflow-x-auto lg:hidden">
{primaryNav.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
cn(
'rounded-full border px-3 py-2 text-sm whitespace-nowrap transition-colors',
isActive
? 'border-primary/30 bg-primary/10 text-primary'
: 'border-border/70 bg-background/60 text-muted-foreground',
)
}
>
{item.label}
</NavLink>
))}
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" asChild>
<a href={buildFrontendUrl('/')} target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
<Button
variant="ghost"
onClick={() => void onLogout()}
disabled={loggingOut || !canLogout}
title={canLogout ? undefined : '当前会话由前置 SSO / 代理控制'}
>
<LogOut className="h-4 w-4" />
{canLogout ? (loggingOut ? '退出中...' : '退出登录') : 'SSO 受代理保护'}
</Button>
</div>
</div>
</header>
<main>{children}</main>
</div>
</div>
</div>
)
}