Skip to content
Merged

Web #13

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,025 changes: 2,025 additions & 0 deletions demo/uiux-spec.md

Large diffs are not rendered by default.

360 changes: 44 additions & 316 deletions frontend/src/app/(admin)/admin/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,165 +1,46 @@
"use client";

import React, { useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import Link from "next/link";
import {
LayoutDashboard,
FileText,
FolderTree,
Tags,
MessageSquare,
Mail,
Settings,
ScrollText,
ChevronRight,
LogOut,
PenSquare,
List,
MessageCircle,
Camera,
} from "lucide-react";
import {
SidebarProvider,
Sidebar,
SidebarHeader,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarGroupContent,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarMenuSub,
SidebarMenuSubItem,
SidebarMenuSubButton,
SidebarInset,
SidebarTrigger,
} from "@/components/ui/sidebar";
import {
Collapsible,
CollapsibleTrigger,
CollapsibleContent,
} from "@/components/ui/collapsible";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Separator } from "@/components/ui/separator";
import { useAuthStore, useHydrated } from "@/stores/auth";
import { CommandPalette } from "@/components/command-palette";

/* ==============================
* 侧边栏菜单配置
* ============================== */

interface NavChild {
title: string;
href: string;
icon: React.ElementType;
}

interface NavItem {
title: string;
icon: React.ElementType;
href?: string;
children?: NavChild[];
import { DashboardSidebar } from "@/components/dashboard/Sidebar";
import { DashboardHeader } from "@/components/dashboard/DashboardHeader";

/** 根据 pathname 推导页面标题(spec §5.3 标题动态) */
function titleForPath(pathname: string): string {
const map: Record<string, string> = {
"/admin": "仪表盘",
"/admin/articles": "文章管理",
"/admin/articles/editor": "编辑文章",
"/admin/categories": "分类管理",
"/admin/tags": "标签管理",
"/admin/comments": "评论管理",
"/admin/messages": "留言管理",
"/admin/talks": "说说管理",
"/admin/albums": "相册管理",
"/admin/notifications": "通知",
"/admin/users": "用户管理",
"/admin/themes": "主题商店",
"/admin/friend-links": "友情链接",
"/admin/rbac": "角色权限",
"/admin/attachments": "附件管理",
"/admin/update": "更新管理",
"/admin/settings": "站点设置",
"/admin/logs": "操作日志",
};
return map[pathname] ?? "管理后台";
}

const NAV_ITEMS: NavItem[] = [
{
title: "首页",
icon: LayoutDashboard,
href: "/admin",
},
{
title: "文章管理",
icon: FileText,
children: [
{ title: "发布文章", href: "/admin/articles/editor", icon: PenSquare },
{ title: "文章列表", href: "/admin/articles", icon: List },
{ title: "分类管理", href: "/admin/categories", icon: FolderTree },
{ title: "标签管理", href: "/admin/tags", icon: Tags },
],
},
{
title: "消息管理",
icon: MessageSquare,
children: [
{ title: "评论管理", href: "/admin/comments", icon: MessageCircle },
{ title: "留言管理", href: "/admin/messages", icon: Mail },
],
},
{
title: "说说管理",
icon: MessageCircle,
href: "/admin/talks",
},
{
title: "相册管理",
icon: Camera,
href: "/admin/albums",
},
// TODO: 用户管理、系统管理(角色/菜单)待实现,暂不显示
{
title: "日志管理",
icon: ScrollText,
href: "/admin/logs",
},
{
title: "站点配置",
icon: Settings,
href: "/admin/settings",
},
];

/* ==============================
* 面包屑:根据当前路径自动生成
* ============================== */

function buildBreadcrumb(pathname: string): { label: string; href?: string }[] {
const crumbs: { label: string; href?: string }[] = [
{ label: "首页", href: "/admin" },
];

for (const item of NAV_ITEMS) {
if (item.href && item.href === pathname) {
if (item.href !== "/admin") {
crumbs.push({ label: item.title });
}
return crumbs;
}
if (item.children) {
for (const child of item.children) {
if (child.href === pathname) {
crumbs.push({ label: item.title });
crumbs.push({ label: child.title });
return crumbs;
}
}
}
}

return crumbs;
}

/* ==============================
* Admin Layout
* ============================== */

export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
const router = useRouter();
const { token, user, logout } = useAuthStore();
const { token } = useAuthStore();
const hydrated = useHydrated();
const [sidebarOpen, setSidebarOpen] = useState(false);

const isLoginPage = pathname === "/admin/login";

Expand All @@ -169,184 +50,31 @@ export default function AdminLayout({
}
}, [hydrated, token, isLoginPage, router]);

// 登录页:不显示后台框架
if (isLoginPage) {
return <>{children}</>;
}

// 等待 hydrate / 检查登录
if (!hydrated || !token) {
return (
<div className="flex h-screen items-center justify-center text-muted-foreground">
加载中...
<div className="flex h-screen items-center justify-center bg-gray-50 text-gray-500 dark:bg-gray-900">
<div className="text-center">
<div className="mb-2 inline-block h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
<p className="text-sm">加载中...</p>
</div>
</div>
);
}

const breadcrumbs = buildBreadcrumb(pathname);

return (
<SidebarProvider>
<Sidebar collapsible="icon">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" render={<Link href="/admin" />}>
<div className="flex aspect-square size-8 items-center justify-center rounded-lg
bg-linear-to-br from-brand-500 via-brand-600 to-purple-500
text-primary-foreground shadow-sm shadow-brand-500/30">
<FileText className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">博客管理</span>
<span className="truncate text-xs text-muted-foreground">
后台管理系统
</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<div className="flex h-screen bg-gray-50 transition-colors duration-200 dark:bg-gray-900">
<DashboardSidebar open={sidebarOpen} onClose={() => setSidebarOpen(false)} />

<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>导航菜单</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{NAV_ITEMS.map((item) =>
item.children ? (
<CollapsibleNavItem
key={item.title}
item={item}
pathname={pathname}
/>
) : (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
render={<Link href={item.href!} />}
isActive={pathname === item.href}
tooltip={item.title}
>
<item.icon />
<span>{item.title}</span>
</SidebarMenuButton>
</SidebarMenuItem>
),
)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>

<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger render={<SidebarMenuButton size="lg" />}>
<Avatar className="size-8">
<AvatarImage src={user?.avatar} />
<AvatarFallback>
{user?.nickname?.charAt(0) || "A"}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{user?.nickname || "管理员"}
</span>
<span className="truncate text-xs text-muted-foreground">
{user?.email || ""}
</span>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" className="w-56">
<DropdownMenuItem
onClick={() => {
logout();
router.push("/admin/login");
}}
>
<LogOut className="mr-2 size-4" />
退出登录
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>

<SidebarInset>
{/* 顶栏 */}
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<nav className="flex items-center gap-1 text-sm text-muted-foreground">
{breadcrumbs.map((crumb, i) => (
<React.Fragment key={i}>
{i > 0 && <ChevronRight className="size-3" />}
{crumb.href ? (
<Link
href={crumb.href}
className="hover:text-foreground transition-colors"
>
{crumb.label}
</Link>
) : (
<span className="text-foreground font-medium">
{crumb.label}
</span>
)}
</React.Fragment>
))}
</nav>
</header>

{/* 主内容区 */}
<main className="flex-1 p-6">{children}</main>
</SidebarInset>
<CommandPalette />
</SidebarProvider>
);
}

/* ==============================
* 可折叠导航项
* ============================== */

function CollapsibleNavItem({
item,
pathname,
}: {
item: NavItem;
pathname: string;
}) {
const isActive = item.children?.some((c) => c.href === pathname) ?? false;
// 直接用 isActive 计算初始值,路由变化时组件会随 pathname 重新渲染
// 用户手动折叠后仍允许 toggle,因此用 isActive 作为"至少展开"的触发
const [open, setOpen] = useState(isActive);

// 路由变化导致子菜单匹配时,确保展开(避免在 effect 内同步 setState)
const effectiveOpen = open || isActive;

return (
<Collapsible open={effectiveOpen} onOpenChange={setOpen}>
<SidebarMenuItem>
<CollapsibleTrigger render={<SidebarMenuButton tooltip={item.title} />}>
<item.icon />
<span>{item.title}</span>
<ChevronRight className="ml-auto size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.children!.map((child) => (
<SidebarMenuSubItem key={child.href}>
<SidebarMenuSubButton render={<Link href={child.href} />} isActive={pathname === child.href}>
<child.icon />
<span>{child.title}</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
<div className="flex w-full flex-1 flex-col overflow-hidden md:w-auto">
<DashboardHeader title={titleForPath(pathname)} onToggleSidebar={() => setSidebarOpen((v) => !v)} />
<main className="flex-1 overflow-y-auto p-6">{children}</main>
</div>
</div>
);
}
}
Loading
Loading