This commit is contained in:
aaron 2026-04-13 16:28:20 +08:00
parent fa2ac8f712
commit 3d0b3ab8f6
11 changed files with 303 additions and 15 deletions

View File

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

View File

@ -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(),
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<ModuleConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadModules = async () => {
if (!activeClassId) {
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const res = await fetchAPI<ModuleConfig>(`/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 (
<div className="text-center py-12 text-gray-400">
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-gray-500 mt-1">
: {activeClassName}
</p>
<p className="text-gray-400 text-sm mt-1">
</p>
</div>
{loading ? (
<div className="animate-pulse space-y-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i}>
<CardContent className="p-4">
<div className="h-16 bg-gray-200 rounded" />
</CardContent>
</Card>
))}
</div>
) : error ? (
<ErrorState message={error} onRetry={loadModules} />
) : (
<div className="space-y-4">
{ALL_MODULES.map((module) => (
<Card key={module.key}>
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="font-medium">{module.label}</p>
<p className="text-sm text-gray-500">{module.desc}</p>
</div>
<Switch
checked={config?.enabled_modules.includes(module.key) ?? false}
onCheckedChange={(checked) => handleToggle(module.key, checked)}
/>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

View File

@ -43,6 +43,19 @@ export default function AdminPage() {
</CardContent>
</Card>
</Link>
<Link href="/admin/modules">
<Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-500 text-sm">
</p>
</CardContent>
</Card>
</Link>
</div>
</div>
);

View File

@ -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() {
</div>
<nav className="flex-1 p-4 space-y-1">
{navItems.map((item) => (
{visibleNavItems.map((item) => (
<Link
key={item.href}
href={item.href}

View File

@ -23,6 +23,8 @@ interface ActiveClassContextValue {
availableClasses: ClassInfo[];
/** Switch active class (super admin only) */
setActiveClassId: (id: number) => void;
/** Enabled modules for the active class */
enabledModules: string[] | null;
}
const ActiveClassContext = createContext<ActiveClassContextValue>({
@ -31,6 +33,7 @@ const ActiveClassContext = createContext<ActiveClassContextValue>({
canSwitchClass: false,
availableClasses: [],
setActiveClassId: () => {},
enabledModules: null,
});
export function ActiveClassProvider({ children }: { children: ReactNode }) {
@ -49,6 +52,18 @@ export function ActiveClassProvider({ children }: { children: ReactNode }) {
: null;
const activeClassName = isSuperAdmin ? superAdminClassName : userClassName;
// Derive enabled modules based on active class
const enabledModules = (() => {
if (!activeClassId) return null;
// For super admin, get from availableClasses
if (isSuperAdmin) {
const cls = availableClasses.find((c) => c.id === activeClassId);
return cls?.enabled_modules ?? null;
}
// For others, get from user (which comes from /api/auth/me)
return user?.enabled_modules ?? null;
})();
// Super admin: load all classes and auto-select first
useEffect(() => {
if (!isSuperAdmin) return;
@ -90,6 +105,7 @@ export function ActiveClassProvider({ children }: { children: ReactNode }) {
canSwitchClass: isSuperAdmin,
availableClasses,
setActiveClassId,
enabledModules,
}}
>
{children}

View File

@ -20,6 +20,7 @@ export interface AuthUser {
avatar_url: string | null;
bio: string | null;
created_at: string;
enabled_modules: string[] | null;
}
export interface LoginResponse {
@ -34,6 +35,7 @@ export interface ClassInfo {
description: string | null;
invite_code: string | null;
member_count: number;
enabled_modules: string[] | null;
created_at: string;
}