feat: expand platform management, admin controls, and learning workflows
这个提交包含在:
@@ -1,64 +1,321 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { useUiPreferences } from "@/components/ui-preference-provider";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { clearToken, readToken } from "@/lib/auth";
|
||||
import type { ThemeId } from "@/themes/types";
|
||||
|
||||
const links = [
|
||||
["首页", "/"],
|
||||
["登录", "/auth"],
|
||||
["题库", "/problems"],
|
||||
["提交", "/submissions"],
|
||||
["错题本", "/wrong-book"],
|
||||
["比赛", "/contests"],
|
||||
["知识库", "/kb"],
|
||||
["导入任务", "/imports"],
|
||||
["在线运行", "/run"],
|
||||
["我的", "/me"],
|
||||
["排行榜", "/leaderboard"],
|
||||
["API文档", "/api-docs"],
|
||||
] as const;
|
||||
type NavLink = {
|
||||
label: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
type NavGroup = {
|
||||
key: string;
|
||||
label: string;
|
||||
links: NavLink[];
|
||||
};
|
||||
|
||||
function buildNavGroups(t: (key: string) => string, isAdmin: boolean): NavGroup[] {
|
||||
const groups: NavGroup[] = [
|
||||
{
|
||||
key: "learn",
|
||||
label: t("nav.group.learn"),
|
||||
links: [
|
||||
{ label: t("nav.link.home"), href: "/" },
|
||||
{ label: t("nav.link.problems"), href: "/problems" },
|
||||
{ label: t("nav.link.submissions"), href: "/submissions" },
|
||||
{ label: t("nav.link.wrong_book"), href: "/wrong-book" },
|
||||
{ label: t("nav.link.kb"), href: "/kb" },
|
||||
{ label: t("nav.link.run"), href: "/run" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "contest",
|
||||
label: t("nav.group.contest"),
|
||||
links: [
|
||||
{ label: t("nav.link.contests"), href: "/contests" },
|
||||
{ label: t("nav.link.leaderboard"), href: "/leaderboard" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "account",
|
||||
label: t("nav.group.account"),
|
||||
links: [
|
||||
{ label: t("nav.link.auth"), href: "/auth" },
|
||||
{ label: t("nav.link.me"), href: "/me" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
if (isAdmin) {
|
||||
groups.splice(2, 0, {
|
||||
key: "system",
|
||||
label: t("nav.group.system"),
|
||||
links: [
|
||||
{ label: t("nav.link.imports"), href: "/imports" },
|
||||
{ label: t("nav.link.backend_logs"), href: "/backend-logs" },
|
||||
{ label: t("nav.link.admin_users"), href: "/admin-users" },
|
||||
{ label: t("nav.link.admin_redeem"), href: "/admin-redeem" },
|
||||
{ label: t("nav.link.api_docs"), href: "/api-docs" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function isActivePath(pathname: string, href: string): boolean {
|
||||
if (pathname === href) return true;
|
||||
if (href === "/") return pathname === "/";
|
||||
return pathname.startsWith(`${href}/`);
|
||||
}
|
||||
|
||||
function resolveActiveGroup(pathname: string, groups: NavGroup[]): string {
|
||||
for (const group of groups) {
|
||||
for (const item of group.links) {
|
||||
if (isActivePath(pathname, item.href)) return group.key;
|
||||
}
|
||||
}
|
||||
return groups[0]?.key ?? "learn";
|
||||
}
|
||||
|
||||
function resolveActiveLink(pathname: string, group: NavGroup): string {
|
||||
for (const item of group.links) {
|
||||
if (isActivePath(pathname, item.href)) return item.href;
|
||||
}
|
||||
return group.links[0]?.href ?? "/";
|
||||
}
|
||||
|
||||
export function AppNav() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { theme, setTheme, language, setLanguage, themes, t } = useUiPreferences();
|
||||
|
||||
const [hasToken, setHasToken] = useState<boolean>(() => Boolean(readToken()));
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [desktopOpenGroup, setDesktopOpenGroup] = useState<string | null>(null);
|
||||
const desktopMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const navGroups = useMemo(() => buildNavGroups(t, isAdmin), [isAdmin, t]);
|
||||
const activeGroup = resolveActiveGroup(pathname, navGroups);
|
||||
const usePopupSecondary = theme === "default";
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => setHasToken(Boolean(readToken()));
|
||||
window.addEventListener("storage", refresh);
|
||||
window.addEventListener("focus", refresh);
|
||||
return () => {
|
||||
window.removeEventListener("storage", refresh);
|
||||
window.removeEventListener("focus", refresh);
|
||||
let canceled = false;
|
||||
const refresh = async () => {
|
||||
const token = readToken();
|
||||
if (canceled) return;
|
||||
setHasToken(Boolean(token));
|
||||
if (!token) {
|
||||
setIsAdmin(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const me = await apiFetch<{ username?: string }>("/api/v1/me", {}, token);
|
||||
if (!canceled) {
|
||||
setIsAdmin((me?.username ?? "") === "admin");
|
||||
}
|
||||
} catch {
|
||||
if (!canceled) setIsAdmin(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
const onRefresh = () => {
|
||||
void refresh();
|
||||
};
|
||||
void refresh();
|
||||
window.addEventListener("storage", onRefresh);
|
||||
window.addEventListener("focus", onRefresh);
|
||||
return () => {
|
||||
canceled = true;
|
||||
window.removeEventListener("storage", onRefresh);
|
||||
window.removeEventListener("focus", onRefresh);
|
||||
};
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!usePopupSecondary || !desktopOpenGroup) return;
|
||||
const onMouseDown = (event: MouseEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
if (!target) return;
|
||||
if (desktopMenuRef.current?.contains(target)) return;
|
||||
setDesktopOpenGroup(null);
|
||||
};
|
||||
window.addEventListener("mousedown", onMouseDown);
|
||||
return () => window.removeEventListener("mousedown", onMouseDown);
|
||||
}, [desktopOpenGroup, usePopupSecondary]);
|
||||
|
||||
const currentGroup = navGroups.find((g) => g.key === activeGroup) ?? navGroups[0];
|
||||
|
||||
return (
|
||||
<header className="border-b bg-white">
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-2 px-4 py-3">
|
||||
{links.map(([label, href]) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className="rounded-md border px-3 py-1 text-sm hover:bg-zinc-100"
|
||||
<header className="print-hidden border-b bg-[color:var(--surface)]/95 backdrop-blur supports-[backdrop-filter]:bg-[color:var(--surface)]/85">
|
||||
<div className="mx-auto max-w-6xl px-3 py-3 max-[390px]:px-2 max-[390px]:py-2 sm:px-4">
|
||||
<div className="flex items-center justify-between md:hidden">
|
||||
<span className="text-sm font-medium text-zinc-700">{t("nav.menu")}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border px-3 py-1 text-sm hover:bg-zinc-100 max-[390px]:px-2 max-[390px]:text-xs"
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls="main-nav-links"
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
{menuOpen ? t("nav.collapse") : t("nav.expand")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2 text-sm">
|
||||
<div id="main-nav-links" className={`${menuOpen ? "mt-3 block" : "hidden"} md:mt-0 md:block`}>
|
||||
<div className="space-y-3">
|
||||
{usePopupSecondary ? (
|
||||
<div ref={desktopMenuRef} className="hidden flex-wrap items-center gap-2 md:flex">
|
||||
{navGroups.map((group) => {
|
||||
const active = activeGroup === group.key;
|
||||
const opened = desktopOpenGroup === group.key;
|
||||
return (
|
||||
<div key={group.key} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md border px-3 py-1 text-sm ${
|
||||
active ? "border-zinc-900 bg-zinc-900 text-white" : "hover:bg-zinc-100"
|
||||
}`}
|
||||
aria-expanded={opened}
|
||||
onClick={() =>
|
||||
setDesktopOpenGroup((prev) => (prev === group.key ? null : group.key))
|
||||
}
|
||||
>
|
||||
{group.label}
|
||||
</button>
|
||||
{opened && (
|
||||
<div className="absolute left-0 top-full z-50 mt-2 min-w-[11rem] rounded-md border bg-[color:var(--surface)] p-1 shadow-lg">
|
||||
{group.links.map((item) => {
|
||||
const linkActive = isActivePath(pathname, item.href);
|
||||
return (
|
||||
<button
|
||||
key={item.href}
|
||||
type="button"
|
||||
className={`block w-full rounded px-3 py-1.5 text-left text-sm ${
|
||||
linkActive ? "bg-zinc-900 text-white" : "hover:bg-zinc-100"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setDesktopOpenGroup(null);
|
||||
router.push(item.href);
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden flex-wrap items-center gap-2 md:flex">
|
||||
{navGroups.map((group) => {
|
||||
const active = activeGroup === group.key;
|
||||
return (
|
||||
<button
|
||||
key={group.key}
|
||||
type="button"
|
||||
className={`rounded-md border px-3 py-1 text-sm ${
|
||||
active ? "border-zinc-900 bg-zinc-900 text-white" : "hover:bg-zinc-100"
|
||||
}`}
|
||||
onClick={() => router.push(group.links[0]?.href ?? "/")}
|
||||
>
|
||||
{group.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<span className="text-xs text-zinc-500">{t("nav.secondary_menu")}</span>
|
||||
<select
|
||||
className="min-w-[220px] rounded-md border px-3 py-1 text-sm"
|
||||
value={resolveActiveLink(pathname, currentGroup)}
|
||||
onChange={(e) => router.push(e.target.value)}
|
||||
>
|
||||
{currentGroup.links.map((item) => (
|
||||
<option key={item.href} value={item.href}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-3 md:hidden">
|
||||
{navGroups.map((group) => (
|
||||
<section key={group.key} className="rounded-lg border p-2">
|
||||
<h3 className="text-xs font-semibold text-zinc-600">{group.label}</h3>
|
||||
<select
|
||||
className="mt-2 w-full rounded-md border px-3 py-1 text-xs"
|
||||
value={resolveActiveLink(pathname, group)}
|
||||
onChange={(e) => {
|
||||
setMenuOpen(false);
|
||||
router.push(e.target.value);
|
||||
}}
|
||||
>
|
||||
{group.links.map((item) => (
|
||||
<option key={item.href} value={item.href}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center justify-end gap-2 text-xs sm:text-sm">
|
||||
<label className="inline-flex items-center gap-1">
|
||||
<span className="text-zinc-500">{t("prefs.theme")}</span>
|
||||
<select
|
||||
className="rounded-md border px-2 py-1 text-xs sm:text-sm"
|
||||
value={theme}
|
||||
onChange={(e) => {
|
||||
setDesktopOpenGroup(null);
|
||||
setTheme(e.target.value as ThemeId);
|
||||
}}
|
||||
>
|
||||
{themes.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.labels[language]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1">
|
||||
<span className="text-zinc-500">{t("prefs.language")}</span>
|
||||
<select
|
||||
className="rounded-md border px-2 py-1 text-xs sm:text-sm"
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value === "zh" ? "zh" : "en")}
|
||||
>
|
||||
<option value="en">{t("prefs.lang.en")}</option>
|
||||
<option value="zh">{t("prefs.lang.zh")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<span className={hasToken ? "text-emerald-700" : "text-zinc-500"}>
|
||||
{hasToken ? "已登录" : "未登录"}
|
||||
{hasToken ? t("nav.logged_in") : t("nav.logged_out")}
|
||||
</span>
|
||||
{hasToken && (
|
||||
<button
|
||||
onClick={() => {
|
||||
clearToken();
|
||||
setHasToken(false);
|
||||
setIsAdmin(false);
|
||||
}}
|
||||
className="rounded-md border px-3 py-1 hover:bg-zinc-100"
|
||||
>
|
||||
退出
|
||||
{t("nav.logout")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
在新工单中引用
屏蔽一个用户