1
This commit is contained in:
parent
fa2ac8f712
commit
3d0b3ab8f6
@ -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")
|
||||||
|
|||||||
@ -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(),
|
||||||
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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}
|
||||||
|
|
||||||
|
|||||||
132
frontend/src/app/(app)/admin/modules/page.tsx
Normal file
132
frontend/src/app/(app)/admin/modules/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user