feat: expand platform management, admin controls, and learning workflows

这个提交包含在:
Codex CLI
2026-02-15 15:41:56 +08:00
父节点 ad29a9f62d
当前提交 f209ae82da
修改 75 个文件,包含 9663 行新增794 行删除

查看文件

@@ -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>