diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 72547c0..f7ee257 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -76,8 +76,20 @@ async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)): @router.get("/me", response_model=UserOut) -async def get_me(user: User = Depends(get_current_user)): - return UserOut.model_validate(user) +async def get_me(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): + user_out = UserOut.model_validate(user) + + # Attach enabled_modules from user's class + if user.class_id: + from app.db.models import Class_ + from sqlalchemy import select + + result = await db.execute(select(Class_).where(Class_.id == user.class_id)) + class_ = result.scalar_one_or_none() + if class_: + user_out.enabled_modules = class_.get_enabled_modules() + + return user_out @router.put("/change-password") diff --git a/backend/app/api/classes.py b/backend/app/api/classes.py index cbdca3e..5de7746 100644 --- a/backend/app/api/classes.py +++ b/backend/app/api/classes.py @@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import require_role from app.db.database import get_db from app.db.models import User -from app.schemas.class_ import ClassCreate, ClassUpdate, ClassOut +from app.schemas.class_ import ClassCreate, ClassUpdate, ClassOut, ModuleUpdate from app.schemas.user import UserListItem from app.schemas.roster import RosterOut, RosterImportRequest from app.schemas.common import PageResponse @@ -268,3 +268,55 @@ async def regenerate_invite( if not code: raise HTTPException(status_code=404, detail="Class not found") return {"invite_code": code} + + +# --- Module management --- + + +@router.get("/{class_id}/modules") +async def get_class_modules( + class_id: int, + admin: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + if admin.role == "class_admin" and admin.class_id != class_id: + raise HTTPException(status_code=403, detail="Access denied") + + class_ = await get_class_by_id(db, class_id) + if class_ is None: + raise HTTPException(status_code=404, detail="Class not found") + + return { + "class_id": class_id, + "enabled_modules": class_.get_enabled_modules(), + "available_modules": class_.ALL_MODULES, + } + + +@router.put("/{class_id}/modules") +async def update_class_modules( + class_id: int, + data: ModuleUpdate, + admin: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + if admin.role == "class_admin" and admin.class_id != class_id: + raise HTTPException(status_code=403, detail="Access denied") + + class_ = await get_class_by_id(db, class_id) + if class_ is None: + raise HTTPException(status_code=404, detail="Class not found") + + # Validate module keys + valid_keys = set(class_.ALL_MODULES) + for m in data.enabled_modules: + if m not in valid_keys: + raise HTTPException(status_code=400, detail=f"Invalid module: {m}") + + class_.set_enabled_modules(data.enabled_modules) + await db.commit() + + return { + "class_id": class_id, + "enabled_modules": class_.get_enabled_modules(), + } diff --git a/backend/app/db/models.py b/backend/app/db/models.py index a2c794f..4c9de9c 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -15,11 +15,26 @@ class Class_(Base): cohort_year: Mapped[int] = mapped_column(Integer, nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) invite_code: Mapped[str | None] = mapped_column(String(20), unique=True, nullable=True) + enabled_modules: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array of module keys created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) updated_at: Mapped[datetime] = mapped_column( DateTime, server_default=func.now(), onupdate=func.now() ) + # All available modules + ALL_MODULES = ["announcements", "directory", "timeline", "assignments", "votes", "schedule", "resources"] + + def get_enabled_modules(self) -> list[str]: + if not self.enabled_modules: + return list(self.ALL_MODULES) + try: + return json.loads(self.enabled_modules) + except (json.JSONDecodeError, TypeError): + return list(self.ALL_MODULES) + + def set_enabled_modules(self, modules: list[str]): + self.enabled_modules = json.dumps(modules, ensure_ascii=False) if modules else None + members: Mapped[list["User"]] = relationship("User", back_populates="class_") timelines: Mapped[list["Timeline"]] = relationship( "Timeline", back_populates="class_", cascade="all, delete-orphan" diff --git a/backend/app/main.py b/backend/app/main.py index dc431d2..39875f8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -39,7 +39,7 @@ async def ensure_super_admin(): async def ensure_sample_class(): """Seed a sample class if none exists.""" - from sqlalchemy import select, func + from sqlalchemy import select, func, text from app.db.database import async_session from app.db.models import Class_ @@ -57,9 +57,23 @@ async def ensure_sample_class(): logger.info("Sample class seeded") +async def migrate_add_enabled_modules(): + """Add enabled_modules column to classes table if not exists.""" + from sqlalchemy import text + from app.db.database import engine + + async with engine.begin() as conn: + result = await conn.execute(text("PRAGMA table_info(classes)")) + columns = [row[1] for row in result.fetchall()] + if "enabled_modules" not in columns: + await conn.execute(text("ALTER TABLE classes ADD COLUMN enabled_modules TEXT")) + logger.info("Migration: added enabled_modules column to classes table") + + @asynccontextmanager async def lifespan(app: FastAPI): await create_tables() + await migrate_add_enabled_modules() await ensure_super_admin() await ensure_sample_class() yield diff --git a/backend/app/schemas/class_.py b/backend/app/schemas/class_.py index 5615b24..664af77 100644 --- a/backend/app/schemas/class_.py +++ b/backend/app/schemas/class_.py @@ -1,6 +1,7 @@ +import json from datetime import datetime -from pydantic import BaseModel +from pydantic import BaseModel, field_validator class ClassCreate(BaseModel): @@ -22,6 +23,21 @@ class ClassOut(BaseModel): description: str | None invite_code: str | None = None member_count: int = 0 + enabled_modules: list[str] | None = None created_at: datetime model_config = {"from_attributes": True} + + @field_validator("enabled_modules", mode="before") + @classmethod + def parse_enabled_modules(cls, v): + if isinstance(v, str): + try: + return json.loads(v) + except (json.JSONDecodeError, TypeError): + return None + return v + + +class ModuleUpdate(BaseModel): + enabled_modules: list[str] diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 126554a..2a1d468 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -22,6 +22,7 @@ class UserOut(BaseModel): avatar_url: str | None bio: str | None created_at: datetime + enabled_modules: list[str] | None = None model_config = {"from_attributes": True} diff --git a/frontend/src/app/(app)/admin/modules/page.tsx b/frontend/src/app/(app)/admin/modules/page.tsx new file mode 100644 index 0000000..b0c42a5 --- /dev/null +++ b/frontend/src/app/(app)/admin/modules/page.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useActiveClass } from "@/hooks/use-active-class"; +import { fetchAPI, putAPI } from "@/lib/api"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Switch } from "@/components/ui/switch"; +import { ErrorState } from "@/components/error-state"; +import { toast } from "sonner"; + +const ALL_MODULES = [ + { key: "announcements", label: "公告", desc: "发布和管理班级公告" }, + { key: "directory", label: "花名册", desc: "查看班级成员花名册" }, + { key: "timeline", label: "班级动态", desc: "分享班级动态和互动" }, + { key: "assignments", label: "作业", desc: "发布和提交课程作业" }, + { key: "votes", label: "投票", desc: "发起班级投票活动" }, + { key: "schedule", label: "排期表", desc: "查看课程和活动排期" }, + { key: "resources", label: "资源库", desc: "上传和下载学习资源" }, +]; + +interface ModuleConfig { + class_id: number; + enabled_modules: string[]; + available_modules: string[]; +} + +export default function ModulesPage() { + const { activeClassId, activeClassName } = useActiveClass(); + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadModules = async () => { + if (!activeClassId) { + setLoading(false); + return; + } + setLoading(true); + setError(null); + try { + const res = await fetchAPI(`/api/classes/${activeClassId}/modules`); + setConfig(res); + } catch (err: any) { + setError(err.message || "加载失败"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadModules(); + }, [activeClassId]); + + const handleToggle = async (moduleKey: string, enabled: boolean) => { + if (!config || !activeClassId) return; + + const updated = enabled + ? [...config.enabled_modules, moduleKey] + : config.enabled_modules.filter((m) => m !== moduleKey); + + // Optimistic update + setConfig({ ...config, enabled_modules: updated }); + + try { + await putAPI(`/api/classes/${activeClassId}/modules`, { + enabled_modules: updated, + }); + toast.success( + enabled + ? `已启用「${ALL_MODULES.find((m) => m.key === moduleKey)?.label}」` + : `已禁用「${ALL_MODULES.find((m) => m.key === moduleKey)?.label}」` + ); + } catch (err: any) { + // Rollback + setConfig(config); + toast.error(err.message || "操作失败"); + } + }; + + if (!activeClassId) { + return ( +
+ 请先选择一个班级 +
+ ); + } + + return ( +
+
+

模块管理

+

+ 班级: {activeClassName} +

+

+ 控制班级侧边栏中显示的功能模块 +

+
+ + {loading ? ( +
+ {[1, 2, 3, 4].map((i) => ( + + +
+ + + ))} +
+ ) : error ? ( + + ) : ( +
+ {ALL_MODULES.map((module) => ( + + +
+

{module.label}

+

{module.desc}

+
+ handleToggle(module.key, checked)} + /> +
+
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/app/(app)/admin/page.tsx b/frontend/src/app/(app)/admin/page.tsx index c6558d8..c6b27fa 100644 --- a/frontend/src/app/(app)/admin/page.tsx +++ b/frontend/src/app/(app)/admin/page.tsx @@ -43,6 +43,19 @@ export default function AdminPage() { + + + + + 模块管理 + + +

+ 控制班级功能模块的显示 +

+
+
+
); diff --git a/frontend/src/components/sidebar.tsx b/frontend/src/components/sidebar.tsx index a796823..be83d7e 100644 --- a/frontend/src/components/sidebar.tsx +++ b/frontend/src/components/sidebar.tsx @@ -3,35 +3,50 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { useSidebar } from "@/hooks/use-sidebar"; +import { useActiveClass } from "@/hooks/use-active-class"; import { cn } from "@/lib/utils"; import type { UserRole } from "@/lib/types"; import { useAuth } from "@/hooks/use-auth"; +// Module keys that can be toggled +const TOGGLEABLE_MODULES = ["announcements", "directory", "timeline", "assignments", "votes", "schedule", "resources"]; + const navItems = [ - { href: "/dashboard", label: "首页", icon: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" }, - { href: "/announcements", label: "公告", icon: "M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" }, - { href: "/directory", label: "花名册", icon: "M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" }, - { href: "/timeline", label: "班级动态", icon: "M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" }, - { href: "/assignments", label: "作业", icon: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" }, - { href: "/votes", label: "投票", icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" }, - { href: "/schedule", label: "排期表", icon: "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" }, - { href: "/resources", label: "资源库", icon: "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" }, - { href: "/profile", label: "个人资料", icon: "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" }, + { href: "/dashboard", label: "首页", icon: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6", moduleKey: undefined }, + { href: "/announcements", label: "公告", icon: "M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z", moduleKey: "announcements" }, + { href: "/directory", label: "花名册", icon: "M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z", moduleKey: "directory" }, + { href: "/timeline", label: "班级动态", icon: "M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z", moduleKey: "timeline" }, + { href: "/assignments", label: "作业", icon: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z", moduleKey: "assignments" }, + { href: "/votes", label: "投票", icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4", moduleKey: "votes" }, + { href: "/schedule", label: "排期表", icon: "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z", moduleKey: "schedule" }, + { href: "/resources", label: "资源库", icon: "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z", moduleKey: "resources" }, + { href: "/profile", label: "个人资料", icon: "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z", moduleKey: undefined }, ]; const adminItems = [ { href: "/admin/members", label: "成员管理", icon: "M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z", roles: ["super_admin", "class_admin"] as UserRole[] }, { href: "/admin/classes", label: "班级管理", icon: "M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4", roles: ["super_admin"] as UserRole[] }, + { href: "/admin/modules", label: "模块管理", icon: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z", roles: ["super_admin", "class_admin"] as UserRole[] }, ]; export function Sidebar() { const pathname = usePathname(); const { isOpen, close } = useSidebar(); const { user } = useAuth(); + const { enabledModules } = useActiveClass(); const visibleAdminItems = user ? adminItems.filter((item) => item.roles.includes(user.role)) : []; + // Default to all modules enabled if not loaded yet + const defaultModules = ["announcements", "directory", "timeline", "assignments", "votes", "schedule", "resources"]; + const enabledSet = new Set(enabledModules ?? defaultModules); + + // Filter navItems based on enabled modules (items without moduleKey are always visible) + const visibleNavItems = navItems.filter( + (item) => !item.moduleKey || enabledSet.has(item.moduleKey) + ); + return ( <> {/* Mobile backdrop */} @@ -56,7 +71,7 @@ export function Sidebar() {