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) @router.get("/me", response_model=UserOut)
async def get_me(user: User = Depends(get_current_user)): async def get_me(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
return UserOut.model_validate(user) 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") @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.core.deps import require_role
from app.db.database import get_db from app.db.database import get_db
from app.db.models import User 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.user import UserListItem
from app.schemas.roster import RosterOut, RosterImportRequest from app.schemas.roster import RosterOut, RosterImportRequest
from app.schemas.common import PageResponse from app.schemas.common import PageResponse
@ -268,3 +268,55 @@ async def regenerate_invite(
if not code: if not code:
raise HTTPException(status_code=404, detail="Class not found") raise HTTPException(status_code=404, detail="Class not found")
return {"invite_code": code} 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) cohort_year: Mapped[int] = mapped_column(Integer, nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True)
invite_code: Mapped[str | None] = mapped_column(String(20), unique=True, 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()) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now() 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_") members: Mapped[list["User"]] = relationship("User", back_populates="class_")
timelines: Mapped[list["Timeline"]] = relationship( timelines: Mapped[list["Timeline"]] = relationship(
"Timeline", back_populates="class_", cascade="all, delete-orphan" "Timeline", back_populates="class_", cascade="all, delete-orphan"

View File

@ -39,7 +39,7 @@ async def ensure_super_admin():
async def ensure_sample_class(): async def ensure_sample_class():
"""Seed a sample class if none exists.""" """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.database import async_session
from app.db.models import Class_ from app.db.models import Class_
@ -57,9 +57,23 @@ async def ensure_sample_class():
logger.info("Sample class seeded") 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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
await create_tables() await create_tables()
await migrate_add_enabled_modules()
await ensure_super_admin() await ensure_super_admin()
await ensure_sample_class() await ensure_sample_class()
yield yield

View File

@ -1,6 +1,7 @@
import json
from datetime import datetime from datetime import datetime
from pydantic import BaseModel from pydantic import BaseModel, field_validator
class ClassCreate(BaseModel): class ClassCreate(BaseModel):
@ -22,6 +23,21 @@ class ClassOut(BaseModel):
description: str | None description: str | None
invite_code: str | None = None invite_code: str | None = None
member_count: int = 0 member_count: int = 0
enabled_modules: list[str] | None = None
created_at: datetime created_at: datetime
model_config = {"from_attributes": True} 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 avatar_url: str | None
bio: str | None bio: str | None
created_at: datetime created_at: datetime
enabled_modules: list[str] | None = None
model_config = {"from_attributes": True} 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> </CardContent>
</Card> </Card>
</Link> </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>
</div> </div>
); );

View File

@ -3,35 +3,50 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useSidebar } from "@/hooks/use-sidebar"; import { useSidebar } from "@/hooks/use-sidebar";
import { useActiveClass } from "@/hooks/use-active-class";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { UserRole } from "@/lib/types"; import type { UserRole } from "@/lib/types";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
// Module keys that can be toggled
const TOGGLEABLE_MODULES = ["announcements", "directory", "timeline", "assignments", "votes", "schedule", "resources"];
const navItems = [ 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: "/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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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 = [ 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/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/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() { export function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
const { isOpen, close } = useSidebar(); const { isOpen, close } = useSidebar();
const { user } = useAuth(); const { user } = useAuth();
const { enabledModules } = useActiveClass();
const visibleAdminItems = user const visibleAdminItems = user
? adminItems.filter((item) => item.roles.includes(user.role)) ? 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 ( return (
<> <>
{/* Mobile backdrop */} {/* Mobile backdrop */}
@ -56,7 +71,7 @@ export function Sidebar() {
</div> </div>
<nav className="flex-1 p-4 space-y-1"> <nav className="flex-1 p-4 space-y-1">
{navItems.map((item) => ( {visibleNavItems.map((item) => (
<Link <Link
key={item.href} key={item.href}
href={item.href} href={item.href}

View File

@ -23,6 +23,8 @@ interface ActiveClassContextValue {
availableClasses: ClassInfo[]; availableClasses: ClassInfo[];
/** Switch active class (super admin only) */ /** Switch active class (super admin only) */
setActiveClassId: (id: number) => void; setActiveClassId: (id: number) => void;
/** Enabled modules for the active class */
enabledModules: string[] | null;
} }
const ActiveClassContext = createContext<ActiveClassContextValue>({ const ActiveClassContext = createContext<ActiveClassContextValue>({
@ -31,6 +33,7 @@ const ActiveClassContext = createContext<ActiveClassContextValue>({
canSwitchClass: false, canSwitchClass: false,
availableClasses: [], availableClasses: [],
setActiveClassId: () => {}, setActiveClassId: () => {},
enabledModules: null,
}); });
export function ActiveClassProvider({ children }: { children: ReactNode }) { export function ActiveClassProvider({ children }: { children: ReactNode }) {
@ -49,6 +52,18 @@ export function ActiveClassProvider({ children }: { children: ReactNode }) {
: null; : null;
const activeClassName = isSuperAdmin ? superAdminClassName : userClassName; 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 // Super admin: load all classes and auto-select first
useEffect(() => { useEffect(() => {
if (!isSuperAdmin) return; if (!isSuperAdmin) return;
@ -90,6 +105,7 @@ export function ActiveClassProvider({ children }: { children: ReactNode }) {
canSwitchClass: isSuperAdmin, canSwitchClass: isSuperAdmin,
availableClasses, availableClasses,
setActiveClassId, setActiveClassId,
enabledModules,
}} }}
> >
{children} {children}

View File

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