91 lines
3.2 KiB
TypeScript
91 lines
3.2 KiB
TypeScript
|
|
"use client";
|
|||
|
|
|
|||
|
|
import Link from "next/link";
|
|||
|
|
import { usePathname } from "next/navigation";
|
|||
|
|
import { useEffect, useState } from "react";
|
|||
|
|
import { getMe } from "@/lib/admin";
|
|||
|
|
import { PreviewNotice, StatusCard } from "@/components/ui";
|
|||
|
|
|
|||
|
|
type Me = { email: string; role: string; plan: string; status: string };
|
|||
|
|
|
|||
|
|
const nav = [
|
|||
|
|
{ href: "/admin", label: "概览" },
|
|||
|
|
{ href: "/admin/users", label: "用户" },
|
|||
|
|
{ href: "/admin/tts", label: "TTS 与审计" },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
export function AdminGate({ children }: { children: React.ReactNode }) {
|
|||
|
|
const pathname = usePathname();
|
|||
|
|
const [me, setMe] = useState<Me | null>(null);
|
|||
|
|
const [state, setState] = useState<"loading" | "denied" | "ready">("loading");
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
getMe()
|
|||
|
|
.then((m) => {
|
|||
|
|
setMe(m);
|
|||
|
|
setState(m.role === "admin" ? "ready" : "denied");
|
|||
|
|
})
|
|||
|
|
.catch(() => setState("denied"));
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
if (state === "loading") {
|
|||
|
|
return (
|
|||
|
|
<div className="container-shell py-16 sm:py-24">
|
|||
|
|
<StatusCard title="正在校验管理权限" description="正从业务 API 读取当前管理员会话。" tone="info" />
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
if (state === "denied") {
|
|||
|
|
return (
|
|||
|
|
<div className="container-shell py-16 sm:py-24">
|
|||
|
|
<PreviewNotice>访问被拒绝:需要管理员身份。普通用户无法查看或操作管理后台。</PreviewNotice>
|
|||
|
|
<div className="mt-8">
|
|||
|
|
<StatusCard title="无权访问" description="当前会话不是管理员,或未登录。前往账户页登录后,只有 role=admin 的账户才能看到管理功能。" tone="warning" />
|
|||
|
|
<Link className="button-ghost mt-6 inline-flex" href="/account">
|
|||
|
|
前往账户
|
|||
|
|
</Link>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<>
|
|||
|
|
<div className="container-shell grid gap-6 py-10 lg:grid-cols-[220px_1fr]">
|
|||
|
|
<aside>
|
|||
|
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-subtle">Operations</p>
|
|||
|
|
<nav className="mt-4 flex gap-1 lg:flex-col" aria-label="管理后台导航">
|
|||
|
|
{nav.map((item) => {
|
|||
|
|
const active = item.href === "/admin" ? pathname === "/admin" : pathname.startsWith(item.href);
|
|||
|
|
return (
|
|||
|
|
<Link
|
|||
|
|
className={`rounded-lg px-3 py-2 text-sm font-semibold transition-colors ${active ? "bg-blue/10 text-cyan" : "text-muted hover:text-copy"}`}
|
|||
|
|
href={item.href}
|
|||
|
|
key={item.href}
|
|||
|
|
>
|
|||
|
|
{item.label}
|
|||
|
|
</Link>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</nav>
|
|||
|
|
<p className="mt-6 rounded-xl border border-line bg-panel/50 p-4 text-xs leading-6 text-subtle">
|
|||
|
|
当前管理员:{me?.email}
|
|||
|
|
</p>
|
|||
|
|
</aside>
|
|||
|
|
<div>{children}</div>
|
|||
|
|
</div>
|
|||
|
|
</>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function AdminPageShell({ eyebrow, title, description, children }: { eyebrow: string; title: string; description?: string; children: React.ReactNode }) {
|
|||
|
|
return (
|
|||
|
|
<div>
|
|||
|
|
<p className="eyebrow">{eyebrow}</p>
|
|||
|
|
<h1 className="mt-4 text-3xl font-semibold tracking-[-0.04em] sm:text-4xl">{title}</h1>
|
|||
|
|
{description ? <p className="mt-3 max-w-2xl leading-6 text-muted">{description}</p> : null}
|
|||
|
|
<div className="mt-8">{children}</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|