From 3f96c9c6502243b06efd4bb34a189e72e3a5557c Mon Sep 17 00:00:00 2001
From: yearning <10538594+wangweifeng1999@user.noreply.gitee.com>
Date: 星期一, 28 九月 2026 19:24:55 +0800
Subject: [PATCH] 复制项目

---
 frontend/src/layout/MainLayout.tsx |  106 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 106 insertions(+), 0 deletions(-)

diff --git a/frontend/src/layout/MainLayout.tsx b/frontend/src/layout/MainLayout.tsx
new file mode 100644
index 0000000..4e1fe92
--- /dev/null
+++ b/frontend/src/layout/MainLayout.tsx
@@ -0,0 +1,106 @@
+import { Layout, Menu, Button, Grid, Drawer } from "antd";
+import { Outlet, useNavigate, useLocation } from "react-router-dom";
+import { useMemo, useState } from "react";
+import { MenuOutlined } from "@ant-design/icons";
+import type { UserMe } from "../App";
+import { clearToken } from "../api";
+import SystemBrand from "../components/SystemBrand";
+import ChangePasswordModal from "../components/ChangePasswordModal";
+import AppBreadcrumb from "./AppBreadcrumb";
+import "./MainLayout.css";
+
+const { Header, Sider, Content } = Layout;
+const { useBreakpoint } = Grid;
+
+export default function MainLayout({
+  user,
+  onLogout,
+}: {
+  user: UserMe;
+  onLogout: () => void;
+}) {
+  const nav = useNavigate();
+  const loc = useLocation();
+  const screens = useBreakpoint();
+  const [drawerOpen, setDrawerOpen] = useState(false);
+  const [pwdOpen, setPwdOpen] = useState(false);
+
+  const items = useMemo(() => {
+    const m = [{ key: "/scores", label: "鎴愮哗鏌ヨ" }];
+    if (user.role === "admin") {
+      m.push({ key: "/admin/import", label: "鏁版嵁瀵煎叆" });
+      m.push({ key: "/admin/graders", label: "鑰冭瘎鍛樼鐞�" });
+      m.push({ key: "/admin/settings", label: "绯荤粺璁剧疆" });
+    }
+    return m;
+  }, [user.role]);
+
+  const menu = (
+    <Menu
+      className="app-sider-menu"
+      mode="inline"
+      selectedKeys={[loc.pathname.startsWith("/attempts") ? "/scores" : loc.pathname]}
+      items={items}
+      onClick={({ key }) => {
+        nav(key);
+        setDrawerOpen(false);
+      }}
+    />
+  );
+
+  const isMobile = !screens.md;
+
+  return (
+    <Layout style={{ minHeight: "100vh", display: "flex", flexDirection: "column" }}>
+      <Header className="app-header">
+        {isMobile && (
+          <Button type="text" icon={<MenuOutlined />} onClick={() => setDrawerOpen(true)} style={{ marginRight: 8 }} />
+        )}
+        <div style={{ flex: 1, minWidth: 0 }}>
+          <SystemBrand size="sm" />
+        </div>
+        <span style={{ marginRight: 12 }}>{user.display_name || user.username}</span>
+        <Button type="link" onClick={() => setPwdOpen(true)}>淇敼瀵嗙爜</Button>
+        <Button
+          type="link"
+          onClick={() => {
+            clearToken();
+            onLogout();
+            nav("/login");
+          }}
+        >
+          閫�鍑�
+        </Button>
+      </Header>
+      <ChangePasswordModal open={pwdOpen} onClose={() => setPwdOpen(false)} />
+      <Layout className="app-shell-body" style={{ flex: 1, minHeight: 0 }}>
+        {!isMobile && (
+          <Sider width={200} theme="light" className="app-sider">
+            {menu}
+          </Sider>
+        )}
+        <Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} placement="left" width={220}>
+          {menu}
+        </Drawer>
+        <Content
+          className={[
+            "main-layout__content",
+            loc.pathname.startsWith("/attempts/") ? "main-layout__content--attempt" : "",
+            loc.pathname === "/scores" ? "main-layout__content--scores" : "",
+          ]
+            .filter(Boolean)
+            .join(" ")}
+          style={{
+            padding: 16,
+            background: loc.pathname.startsWith("/attempts/") ? "#fff" : "#f5f5f5",
+          }}
+        >
+          <AppBreadcrumb />
+          <div className="main-layout__content-inner">
+            <Outlet />
+          </div>
+        </Content>
+      </Layout>
+    </Layout>
+  );
+}

--
Gitblit v1.8.0