diff --git a/backend/Dockerfile b/backend/Dockerfile index 613e19f..3023269 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -14,4 +14,4 @@ RUN mkdir -p /app/data EXPOSE 8000 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"] diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 21a77bf..619aa60 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import async_engine_from_config from app.config import settings from app.db.base import Base -from app.db.models import Class_, User, Timeline, Schedule, Announcement, Resource, Notification # noqa: ensure models registered +from app.db import models as _models # noqa: F401 ensure all models registered config = context.config config.set_main_option("sqlalchemy.url", settings.database_url) diff --git a/backend/alembic/versions/20260426_remove_legacy_roster_and_user_columns.py b/backend/alembic/versions/20260426_remove_legacy_roster_and_user_columns.py new file mode 100644 index 0000000..2db9249 --- /dev/null +++ b/backend/alembic/versions/20260426_remove_legacy_roster_and_user_columns.py @@ -0,0 +1,66 @@ +"""remove legacy roster and user columns + +Revision ID: 20260426_remove_legacy +Revises: +Create Date: 2026-04-26 23:40:00 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260426_remove_legacy" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + tables = set(inspector.get_table_names()) + if "student_rosters" in tables: + op.drop_table("student_rosters") + + user_columns = {column["name"] for column in inspector.get_columns("users")} + legacy_columns = {"class_id", "committee_role", "class_permissions"} + if legacy_columns.intersection(user_columns): + with op.batch_alter_table("users") as batch_op: + if "class_id" in user_columns: + batch_op.drop_column("class_id") + if "committee_role" in user_columns: + batch_op.drop_column("committee_role") + if "class_permissions" in user_columns: + batch_op.drop_column("class_permissions") + + +def downgrade() -> None: + with op.batch_alter_table("users") as batch_op: + batch_op.add_column(sa.Column("class_id", sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column("committee_role", sa.String(length=50), nullable=True)) + batch_op.add_column(sa.Column("class_permissions", sa.Text(), nullable=True)) + + op.create_foreign_key( + "fk_users_class_id_classes", + "users", + "classes", + ["class_id"], + ["id"], + ) + op.create_table( + "student_rosters", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("class_id", sa.Integer(), nullable=False), + sa.Column("student_id", sa.String(length=50), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False, server_default="inactive"), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["class_id"], ["classes.id"]), + sa.ForeignKeyConstraint(["user_id"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_student_rosters_class_id", "student_rosters", ["class_id"]) diff --git a/backend/app/api/announcements.py b/backend/app/api/announcements.py index e8a2671..c9343ec 100644 --- a/backend/app/api/announcements.py +++ b/backend/app/api/announcements.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import require_role +from app.core.deps import ensure_class_permission, require_role, resolve_class_id_for_user from app.db.database import get_db from app.db.models import User from app.schemas.announcement import AnnouncementCreate, AnnouncementUpdate, AnnouncementOut @@ -22,12 +22,13 @@ async def get_announcements( page: int = 1, page_size: int = 20, class_id: int | None = None, - user: User = Depends(require_role("super_admin", "class_admin", "student")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): - effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + effective_class_id = resolve_class_id_for_user(user, class_id) if effective_class_id is None: return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) + ensure_class_permission(user, "class_view", effective_class_id) announcements, total = await list_announcements(db, effective_class_id, page, page_size) total_pages = (total + page_size - 1) // page_size @@ -57,12 +58,13 @@ async def get_announcements( async def create_new_announcement( data: AnnouncementCreate, class_id: int | None = None, - user: User = Depends(require_role("super_admin", "class_admin")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): - effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + effective_class_id = resolve_class_id_for_user(user, class_id) if effective_class_id is None: raise HTTPException(status_code=400, detail="You are not assigned to a class") + ensure_class_permission(user, "announcement_manage", effective_class_id) announcement = await create_announcement(db, effective_class_id, user.id, data) return AnnouncementOut( @@ -82,14 +84,13 @@ async def create_new_announcement( async def update_existing_announcement( announcement_id: int, data: AnnouncementUpdate, - user: User = Depends(require_role("super_admin", "class_admin")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): announcement = await get_announcement_by_id(db, announcement_id) if announcement is None: raise HTTPException(status_code=404, detail="Announcement not found") - if user.role != "super_admin" and announcement.class_id != user.class_id: - raise HTTPException(status_code=403, detail="Access denied") + ensure_class_permission(user, "announcement_manage", announcement.class_id) updated = await update_announcement(db, announcement, data) return AnnouncementOut( @@ -108,14 +109,13 @@ async def update_existing_announcement( @router.delete("/{announcement_id}") async def delete_existing_announcement( announcement_id: int, - user: User = Depends(require_role("super_admin", "class_admin")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): announcement = await get_announcement_by_id(db, announcement_id) if announcement is None: raise HTTPException(status_code=404, detail="Announcement not found") - if user.role != "super_admin" and announcement.class_id != user.class_id: - raise HTTPException(status_code=403, detail="Access denied") + ensure_class_permission(user, "announcement_manage", announcement.class_id) await delete_announcement(db, announcement) return {"message": "Announcement deleted"} diff --git a/backend/app/api/assignments.py b/backend/app/api/assignments.py index a7ec865..8a86c0a 100644 --- a/backend/app/api/assignments.py +++ b/backend/app/api/assignments.py @@ -3,7 +3,12 @@ from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.core.deps import require_role +from app.core.deps import ( + ensure_class_permission, + get_effective_class_permissions, + require_role, + resolve_class_id_for_user, +) from app.db.database import get_db from app.db.models import User, Class_ from app.schemas.assignment import ( @@ -28,10 +33,11 @@ from app.services.cos_service import upload_file router = APIRouter(prefix="/api/assignments", tags=["assignments"]) -async def _get_roster_count(db: AsyncSession, class_id: int) -> int: - from app.db.models import StudentRoster +async def _get_member_count(db: AsyncSession, class_id: int) -> int: result = await db.execute( - select(func.count(StudentRoster.id)).where(StudentRoster.class_id == class_id) + select(func.count(User.id)) + .join(Class_.memberships) + .where(Class_.id == class_id) ) return result.scalar() or 0 @@ -81,17 +87,18 @@ async def get_assignments( page: int = 1, page_size: int = 20, class_id: int | None = None, - user: User = Depends(require_role("super_admin", "class_admin", "student")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): - effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + effective_class_id = resolve_class_id_for_user(user, class_id) if effective_class_id is None: return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) + ensure_class_permission(user, "class_view", effective_class_id) assignments, total = await list_assignments(db, effective_class_id, page, page_size) total_pages = (total + page_size - 1) // page_size - roster_count = await _get_roster_count(db, effective_class_id) - items = [_build_assignment_out(a, user.id, roster_count) for a in assignments] + member_count = await _get_member_count(db, effective_class_id) + items = [_build_assignment_out(a, user.id, member_count) for a in assignments] return PageResponse(items=items, total=total, page=page, page_size=page_size, total_pages=total_pages) @@ -99,30 +106,30 @@ async def get_assignments( async def create_new_assignment( data: AssignmentCreate, class_id: int | None = None, - user: User = Depends(require_role("super_admin", "class_admin")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): - effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + effective_class_id = resolve_class_id_for_user(user, class_id) if effective_class_id is None: raise HTTPException(status_code=400, detail="You are not assigned to a class") + ensure_class_permission(user, "assignment_manage", effective_class_id) assignment = await create_assignment(db, effective_class_id, user.id, data) - roster_count = await _get_roster_count(db, effective_class_id) - return _build_assignment_out(assignment, user.id, roster_count) + member_count = await _get_member_count(db, effective_class_id) + return _build_assignment_out(assignment, user.id, member_count) @router.post("/{assignment_id}/attachments") async def upload_assignment_attachments( assignment_id: int, files: list[UploadFile] = File(...), - user: User = Depends(require_role("super_admin", "class_admin")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): assignment = await get_assignment_by_id(db, assignment_id) if assignment is None: raise HTTPException(status_code=404, detail="Assignment not found") - if user.role != "super_admin" and assignment.class_id != user.class_id: - raise HTTPException(status_code=403, detail="Access denied") + ensure_class_permission(user, "assignment_manage", assignment.class_id) urls = [] for f in files: @@ -142,19 +149,18 @@ async def upload_assignment_attachments( @router.get("/{assignment_id}", response_model=AssignmentDetailOut) async def get_assignment_detail( assignment_id: int, - user: User = Depends(require_role("super_admin", "class_admin", "student")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): assignment = await get_assignment_by_id(db, assignment_id) if assignment is None: raise HTTPException(status_code=404, detail="Assignment not found") - if user.role != "super_admin" and assignment.class_id != user.class_id: - raise HTTPException(status_code=403, detail="Access denied") + ensure_class_permission(user, "class_view", assignment.class_id) - base = _build_assignment_out(assignment, user.id, await _get_roster_count(db, assignment.class_id)) + base = _build_assignment_out(assignment, user.id, await _get_member_count(db, assignment.class_id)) # Student only sees their own submission - if user.role == "student": + if "assignment_manage" not in get_effective_class_permissions(user, assignment.class_id) and user.role == "student": my_submission = None for s in (assignment.submissions or []): if s.student_id == user.id: @@ -171,30 +177,28 @@ async def get_assignment_detail( async def update_existing_assignment( assignment_id: int, data: AssignmentUpdate, - user: User = Depends(require_role("super_admin", "class_admin")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): assignment = await get_assignment_by_id(db, assignment_id) if assignment is None: raise HTTPException(status_code=404, detail="Assignment not found") - if user.role != "super_admin" and assignment.class_id != user.class_id: - raise HTTPException(status_code=403, detail="Access denied") + ensure_class_permission(user, "assignment_manage", assignment.class_id) updated = await update_assignment(db, assignment, data) - return _build_assignment_out(updated, user.id, await _get_roster_count(db, updated.class_id)) + return _build_assignment_out(updated, user.id, await _get_member_count(db, updated.class_id)) @router.delete("/{assignment_id}") async def delete_existing_assignment( assignment_id: int, - user: User = Depends(require_role("super_admin", "class_admin")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): assignment = await get_assignment_by_id(db, assignment_id) if assignment is None: raise HTTPException(status_code=404, detail="Assignment not found") - if user.role != "super_admin" and assignment.class_id != user.class_id: - raise HTTPException(status_code=403, detail="Access denied") + ensure_class_permission(user, "assignment_manage", assignment.class_id) await delete_assignment(db, assignment) return {"message": "Assignment deleted"} @@ -205,14 +209,13 @@ async def submit_assignment( assignment_id: int, notes: str = "", file: UploadFile = File(...), - user: User = Depends(require_role("super_admin", "class_admin", "student")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): assignment = await get_assignment_by_id(db, assignment_id) if assignment is None: raise HTTPException(status_code=404, detail="Assignment not found") - if user.role != "super_admin" and assignment.class_id != user.class_id: - raise HTTPException(status_code=403, detail="Access denied") + ensure_class_permission(user, "class_view", assignment.class_id) # Upload file file_url = None @@ -250,7 +253,7 @@ async def submit_assignment( async def grade_assignment_submission( submission_id: int, data: SubmissionGrade, - user: User = Depends(require_role("super_admin", "class_admin")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): from sqlalchemy import select @@ -262,6 +265,10 @@ async def grade_assignment_submission( submission = result.scalar_one_or_none() if submission is None: raise HTTPException(status_code=404, detail="Submission not found") + assignment = await get_assignment_by_id(db, submission.assignment_id) + if assignment is None: + raise HTTPException(status_code=404, detail="Assignment not found") + ensure_class_permission(user, "assignment_manage", assignment.class_id) graded = await grade_submission(db, submission, data) diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index f7ee257..6a787dd 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -1,90 +1,98 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from app.core.auth import hash_password, verify_password, create_access_token from app.core.deps import get_current_user from app.db.database import get_db -from app.db.models import User +from app.db.models import ClassMembership, User from app.schemas.auth import LoginRequest, RegisterRequest, ChangePasswordRequest -from app.schemas.user import TokenResponse, UserOut -from app.services.roster_service import validate_registration +from app.schemas.user import TokenResponse, UserOut, build_user_out +from app.services.member_activation_service import validate_registration router = APIRouter(prefix="/api/auth", tags=["auth"]) -@router.post("/register") -async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)): - # 1. Check if email is already registered +@router.post("/activate") +async def activate_account(req: RegisterRequest, db: AsyncSession = Depends(get_db)): + # 1. Check if email is already in use existing = await db.execute(select(User).where(User.email == req.email)) if existing.scalar_one_or_none(): raise HTTPException(status_code=400, detail="该邮箱已注册") - # 2. Validate invite_code + student_id against roster - roster_entry = await validate_registration(db, req.invite_code, req.student_id) - if roster_entry is None: - raise HTTPException( - status_code=400, detail="邀请码或学号无效,或该学号已注册" - ) + # 2. Validate invite_code + student_id against inactive class member + activation_target = await validate_registration(db, req.invite_code, req.student_id) + if activation_target is None: + raise HTTPException(status_code=400, detail="邀请码或学号无效,或账号已激活") - # 3. Create user with approved status directly - user = User( - email=req.email, - password_hash=hash_password(req.password), - name=req.name, - student_id=req.student_id, - role="student", - status="approved", - class_id=roster_entry.class_id, - ) - db.add(user) - await db.flush() - - # 4. Mark roster entry as registered - roster_entry.status = "registered" - roster_entry.user_id = user.id + user, class_id = activation_target + user.email = req.email + user.password_hash = hash_password(req.password) + user.status = "approved" await db.commit() + result = await db.execute( + select(User) + .options( + selectinload(User.memberships), + selectinload(User.memberships).selectinload(ClassMembership.class_), + ) + .where(User.id == user.id) + ) + user = result.scalar_one() + user.set_active_membership(class_id) - # 5. Issue token — register and login in one step + # 3. Issue token — activation and login in one step token = create_access_token({"sub": str(user.id), "role": user.role}) return { - "message": "注册成功", + "message": "账号激活成功", "token": token, - "user": UserOut.model_validate(user), + "user": build_user_out(user, class_id), } @router.post("/login", response_model=TokenResponse) async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)): - result = await db.execute(select(User).where(User.email == req.email)) + result = await db.execute( + select(User) + .options( + selectinload(User.memberships), + selectinload(User.memberships).selectinload(ClassMembership.class_), + ) + .where(User.email == req.email) + ) user = result.scalar_one_or_none() if user is None: raise HTTPException(status_code=401, detail="邮箱或密码错误") + if user.status == "inactive": + raise HTTPException(status_code=401, detail="账号尚未激活") + if user.status == "disabled": raise HTTPException(status_code=401, detail="账号已被禁用") - if not verify_password(req.password, user.password_hash): + if not user.password_hash or not verify_password(req.password, user.password_hash): raise HTTPException(status_code=401, detail="邮箱或密码错误") token = create_access_token({"sub": str(user.id), "role": user.role}) return TokenResponse( token=token, - user=UserOut.model_validate(user), + user=build_user_out(user), ) @router.get("/me", response_model=UserOut) async def get_me(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): - user_out = UserOut.model_validate(user) + default_membership = user.get_default_membership() + user_out = build_user_out(user, default_membership.class_id if default_membership else None) - # Attach enabled_modules from user's class - if user.class_id: + # Attach enabled_modules from active class + if default_membership: from app.db.models import Class_ from sqlalchemy import select - result = await db.execute(select(Class_).where(Class_.id == user.class_id)) + result = await db.execute(select(Class_).where(Class_.id == default_membership.class_id)) class_ = result.scalar_one_or_none() if class_: user_out.enabled_modules = class_.get_enabled_modules() @@ -98,7 +106,7 @@ async def change_password( user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - if not verify_password(req.old_password, user.password_hash): + if not user.password_hash or not verify_password(req.old_password, user.password_hash): raise HTTPException(status_code=400, detail="Old password is incorrect") user.password_hash = hash_password(req.new_password) await db.commit() diff --git a/backend/app/api/classes.py b/backend/app/api/classes.py index 5de7746..803cfdb 100644 --- a/backend/app/api/classes.py +++ b/backend/app/api/classes.py @@ -4,12 +4,20 @@ import io from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import require_role +from app.core.deps import ( + ensure_class_permission, + get_current_user, + require_role, +) from app.db.database import get_db from app.db.models import User 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.user import UserListItem, build_user_list_item +from app.schemas.inactive_member import ( + InactiveMemberOut, + MemberImportRequest, + build_inactive_member_out, +) from app.schemas.common import PageResponse from app.services.class_service import ( create_class, @@ -20,13 +28,13 @@ from app.services.class_service import ( get_member_count, get_class_members, ) -from app.services.roster_service import ( +from app.services.member_activation_service import ( ensure_invite_code, regenerate_invite_code, - import_roster, - get_roster, - delete_roster_entry, - clear_unregistered_roster, + import_members, + get_inactive_members, + delete_inactive_member, + clear_inactive_members, ) router = APIRouter(prefix="/api/classes", tags=["classes"]) @@ -36,8 +44,21 @@ router = APIRouter(prefix="/api/classes", tags=["classes"]) async def get_classes( page: int = 1, page_size: int = 50, + user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): + if user.role not in {"super_admin", "teacher"}: + membership = user.get_default_membership() + if membership is None: + return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) + class_ = await get_class_by_id(db, membership.class_id) + if class_ is None: + return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) + count = await get_member_count(db, class_.id) + out = ClassOut.model_validate(class_) + out.member_count = count + return PageResponse(items=[out], total=1, page=1, page_size=page_size, total_pages=1) + classes, total = await list_classes(db, page, page_size) total_pages = (total + page_size - 1) // page_size result = [] @@ -98,16 +119,15 @@ async def get_members( status: str | None = None, page: int = 1, page_size: int = 50, - admin: User = Depends(require_role("super_admin", "class_admin")), + admin: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): - if admin.role == "class_admin" and admin.class_id != class_id: - raise HTTPException(status_code=403, detail="Access denied for this class") + ensure_class_permission(admin, "member_view", class_id) members, total = await get_class_members(db, class_id, status, page, page_size) total_pages = (total + page_size - 1) // page_size return PageResponse( - items=[UserListItem.model_validate(m) for m in members], + items=[build_user_list_item(m, class_id) for m in members], total=total, page=page, page_size=page_size, @@ -115,23 +135,22 @@ async def get_members( ) -# --- Roster management --- +# --- Inactive member management --- -@router.get("/{class_id}/roster", response_model=PageResponse[RosterOut]) -async def get_class_roster( +@router.get("/{class_id}/inactive-members", response_model=PageResponse[InactiveMemberOut]) +async def get_class_inactive_members( class_id: int, page: int = 1, page_size: int = 50, - admin: User = Depends(require_role("super_admin", "class_admin")), + admin: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): - if admin.role == "class_admin" and admin.class_id != class_id: - raise HTTPException(status_code=403, detail="Access denied") - entries, total = await get_roster(db, class_id, page, page_size) + ensure_class_permission(admin, "member_manage", class_id) + entries, total = await get_inactive_members(db, class_id, page, page_size) total_pages = (total + page_size - 1) // page_size return PageResponse( - items=[RosterOut.model_validate(e) for e in entries], + items=[build_inactive_member_out(entry) for entry in entries], total=total, page=page, page_size=page_size, @@ -139,28 +158,26 @@ async def get_class_roster( ) -@router.post("/{class_id}/roster/import") -async def import_class_roster( +@router.post("/{class_id}/inactive-members/import") +async def import_class_members( class_id: int, - data: RosterImportRequest, - admin: User = Depends(require_role("super_admin", "class_admin")), + data: MemberImportRequest, + admin: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): - if admin.role == "class_admin" and admin.class_id != class_id: - raise HTTPException(status_code=403, detail="Access denied") - count = await import_roster(db, class_id, data.entries) - return {"message": f"成功导入 {count} 条记录"} + ensure_class_permission(admin, "member_manage", class_id) + count = await import_members(db, class_id, data.entries) + return {"message": f"成功导入 {count} 位成员"} -@router.post("/{class_id}/roster/upload") -async def upload_roster_file( +@router.post("/{class_id}/inactive-members/upload") +async def upload_member_file( class_id: int, file: UploadFile = File(...), - admin: User = Depends(require_role("super_admin", "class_admin")), + admin: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): - if admin.role == "class_admin" and admin.class_id != class_id: - raise HTTPException(status_code=403, detail="Access denied") + ensure_class_permission(admin, "member_manage", class_id) contents = await file.read() filename = file.filename or "" @@ -214,33 +231,33 @@ async def upload_roster_file( if not entries: raise HTTPException(status_code=400, detail="未找到有效数据") - count = await import_roster(db, class_id, entries) - return {"message": f"成功导入 {count} 条记录"} + count = await import_members(db, class_id, entries) + return {"message": f"成功导入 {count} 位成员"} -@router.delete("/{class_id}/roster/{roster_id}") -async def delete_roster_item( +@router.delete("/{class_id}/inactive-members/{user_id}") +async def delete_inactive_member_item( class_id: int, - roster_id: int, - admin: User = Depends(require_role("super_admin", "class_admin")), + user_id: int, + admin: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): - success = await delete_roster_entry(db, roster_id) + ensure_class_permission(admin, "member_manage", class_id) + success = await delete_inactive_member(db, class_id, user_id) if not success: - raise HTTPException(status_code=400, detail="无法删除(已注册或不存在)") + raise HTTPException(status_code=400, detail="无法删除(已激活、已加入其他班级或不存在)") return {"message": "已删除"} -@router.post("/{class_id}/roster/clear") -async def clear_roster( +@router.post("/{class_id}/inactive-members/clear") +async def clear_class_inactive_members( class_id: int, - admin: User = Depends(require_role("super_admin", "class_admin")), + admin: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): - if admin.role == "class_admin" and admin.class_id != class_id: - raise HTTPException(status_code=403, detail="Access denied") - count = await clear_unregistered_roster(db, class_id) - return {"message": f"已清除 {count} 条未注册记录"} + ensure_class_permission(admin, "member_manage", class_id) + count = await clear_inactive_members(db, class_id) + return {"message": f"已清除 {count} 位未激活成员"} # --- Invite code management --- @@ -249,9 +266,10 @@ async def clear_roster( @router.get("/{class_id}/invite-code") async def get_invite_code( class_id: int, - admin: User = Depends(require_role("super_admin", "class_admin")), + admin: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): + ensure_class_permission(admin, "member_manage", class_id) code = await ensure_invite_code(db, class_id) if not code: raise HTTPException(status_code=404, detail="Class not found") @@ -261,9 +279,10 @@ async def get_invite_code( @router.post("/{class_id}/invite-code/regenerate") async def regenerate_invite( class_id: int, - admin: User = Depends(require_role("super_admin", "class_admin")), + admin: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): + ensure_class_permission(admin, "member_manage", class_id) code = await regenerate_invite_code(db, class_id) if not code: raise HTTPException(status_code=404, detail="Class not found") @@ -276,11 +295,10 @@ async def regenerate_invite( @router.get("/{class_id}/modules") async def get_class_modules( class_id: int, - admin: User = Depends(require_role("super_admin", "class_admin")), + admin: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): - if admin.role == "class_admin" and admin.class_id != class_id: - raise HTTPException(status_code=403, detail="Access denied") + ensure_class_permission(admin, "module_manage", class_id) class_ = await get_class_by_id(db, class_id) if class_ is None: @@ -297,11 +315,10 @@ async def get_class_modules( async def update_class_modules( class_id: int, data: ModuleUpdate, - admin: User = Depends(require_role("super_admin", "class_admin")), + admin: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): - if admin.role == "class_admin" and admin.class_id != class_id: - raise HTTPException(status_code=403, detail="Access denied") + ensure_class_permission(admin, "module_manage", class_id) class_ = await get_class_by_id(db, class_id) if class_ is None: diff --git a/backend/app/api/directory.py b/backend/app/api/directory.py index 4c604a3..f84862f 100644 --- a/backend/app/api/directory.py +++ b/backend/app/api/directory.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import get_current_user +from app.core.deps import ensure_class_permission, get_current_user, resolve_class_id_for_user from app.db.database import get_db from app.db.models import User from app.schemas.user import UserPublic @@ -23,18 +23,21 @@ async def search_members( user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - # Determine effective class_id: super_admin can specify one, others use their own - effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + effective_class_id = resolve_class_id_for_user(user, class_id) if effective_class_id is None: return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) + ensure_class_permission(user, "class_view", effective_class_id) users, total = await search_directory( db, effective_class_id, search, industry, company, page, page_size ) total_pages = (total + page_size - 1) // page_size - include_contact = True # Same class, approved users can see contact + include_contact = True # Same class, active members can see contact return PageResponse( - items=[user_to_public(u, include_contact=include_contact) for u in users], + items=[ + user_to_public(u, effective_class_id, include_contact=include_contact) + for u in users + ], total=total, page=page, page_size=page_size, @@ -53,5 +56,11 @@ async def get_member_detail( raise HTTPException(status_code=404, detail="User not found") # Privacy: only show contact info to same-class members - include_contact = user.class_id == target.class_id - return user_to_public(target, include_contact=include_contact) + shared_class_ids = { + membership.class_id for membership in user.memberships + } & { + membership.class_id for membership in target.memberships + } + include_contact = bool(shared_class_ids) + scoped_class_id = next(iter(shared_class_ids), None) + return user_to_public(target, scoped_class_id, include_contact=include_contact) diff --git a/backend/app/api/fund.py b/backend/app/api/fund.py index 701798c..285371e 100644 --- a/backend/app/api/fund.py +++ b/backend/app/api/fund.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import require_role +from app.core.deps import ensure_class_permission, require_role, resolve_class_id_for_user from app.db.database import get_db from app.db.models import FundRecord, User from app.schemas.fund import FundRecordCreate, FundRecordUpdate, FundRecordOut, FundStatistics @@ -33,15 +33,16 @@ def record_to_out(record: FundRecord) -> FundRecordOut: @router.get("/statistics", response_model=FundStatistics) async def get_statistics( class_id: int | None = None, - user: User = Depends(require_role("super_admin", "class_admin", "student")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): - effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + effective_class_id = resolve_class_id_for_user(user, class_id) if effective_class_id is None: return FundStatistics( total_income=0, total_expense=0, balance=0, income_by_category=[], expense_by_category=[] ) + ensure_class_permission(user, "class_view", effective_class_id) return await get_fund_statistics(db, effective_class_id) @@ -52,12 +53,13 @@ async def get_fund_records( type: str | None = None, category: str | None = None, class_id: int | None = None, - user: User = Depends(require_role("super_admin", "class_admin", "student")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): - effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + effective_class_id = resolve_class_id_for_user(user, class_id) if effective_class_id is None: return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) + ensure_class_permission(user, "class_view", effective_class_id) records, total = await list_fund_records(db, effective_class_id, page, page_size, type, category) total_pages = (total + page_size - 1) // page_size @@ -69,12 +71,13 @@ async def get_fund_records( async def create_new_record( data: FundRecordCreate, class_id: int | None = None, - user: User = Depends(require_role("super_admin", "class_admin")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): - effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + effective_class_id = resolve_class_id_for_user(user, class_id) if effective_class_id is None: raise HTTPException(status_code=400, detail="No class specified") + ensure_class_permission(user, "fund_manage", effective_class_id) if data.type not in ("income", "expense"): raise HTTPException(status_code=400, detail="Type must be 'income' or 'expense'") @@ -91,14 +94,13 @@ async def create_new_record( async def update_existing_record( record_id: int, data: FundRecordUpdate, - user: User = Depends(require_role("super_admin", "class_admin")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): record = await get_fund_record_by_id(db, record_id) if record is None: raise HTTPException(status_code=404, detail="Record not found") - if user.role != "super_admin" and record.class_id != user.class_id: - raise HTTPException(status_code=403, detail="Access denied") + ensure_class_permission(user, "fund_manage", record.class_id) if data.type is not None and data.type not in ("income", "expense"): raise HTTPException(status_code=400, detail="Type must be 'income' or 'expense'") @@ -114,14 +116,13 @@ async def update_existing_record( @router.delete("/{record_id}") async def delete_existing_record( record_id: int, - user: User = Depends(require_role("super_admin", "class_admin")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): record = await get_fund_record_by_id(db, record_id) if record is None: raise HTTPException(status_code=404, detail="Record not found") - if user.role != "super_admin" and record.class_id != user.class_id: - raise HTTPException(status_code=403, detail="Access denied") + ensure_class_permission(user, "fund_manage", record.class_id) await delete_fund_record(db, record) - return {"message": "Record deleted"} \ No newline at end of file + return {"message": "Record deleted"} diff --git a/backend/app/api/resources.py b/backend/app/api/resources.py index 6d8f83d..f358fa0 100644 --- a/backend/app/api/resources.py +++ b/backend/app/api/resources.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import require_role +from app.core.deps import ensure_class_permission, require_role, resolve_class_id_for_user from app.db.database import get_db from app.db.models import User from app.schemas.resource import ResourceCreate, ResourceOut @@ -40,12 +40,13 @@ async def get_resources( page_size: int = 20, category: str | None = None, class_id: int | None = None, - user: User = Depends(require_role("super_admin", "class_admin", "student")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): - effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + effective_class_id = resolve_class_id_for_user(user, class_id) if effective_class_id is None: return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) + ensure_class_permission(user, "class_view", effective_class_id) resources, total = await list_resources(db, effective_class_id, category, page, page_size) total_pages = (total + page_size - 1) // page_size @@ -81,12 +82,13 @@ async def upload_new_resource( category: str = Form(...), description: str | None = Form(None), class_id: int | None = Form(None), - user: User = Depends(require_role("super_admin", "class_admin")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): - effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + effective_class_id = resolve_class_id_for_user(user, class_id) if effective_class_id is None: raise HTTPException(status_code=400, detail="You are not assigned to a class") + ensure_class_permission(user, "resource_manage", effective_class_id) contents = await file.read() if len(contents) > 50 * 1024 * 1024: # 50MB limit @@ -126,12 +128,13 @@ async def upload_new_resource( @router.post("/{resource_id}/download") async def download_resource( resource_id: int, - user: User = Depends(require_role("super_admin", "class_admin", "student")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): resource = await get_resource_by_id(db, resource_id) if resource is None: raise HTTPException(status_code=404, detail="Resource not found") + ensure_class_permission(user, "class_view", resource.class_id) await increment_download_count(db, resource) return {"file_url": resource.file_url} @@ -140,14 +143,13 @@ async def download_resource( @router.delete("/{resource_id}") async def delete_existing_resource( resource_id: int, - user: User = Depends(require_role("super_admin", "class_admin")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): resource = await get_resource_by_id(db, resource_id) if resource is None: raise HTTPException(status_code=404, detail="Resource not found") - if user.role != "super_admin" and resource.class_id != user.class_id: - raise HTTPException(status_code=403, detail="Access denied") + ensure_class_permission(user, "resource_manage", resource.class_id) await delete_resource(db, resource) return {"message": "Resource deleted"} diff --git a/backend/app/api/schedule.py b/backend/app/api/schedule.py index 5174539..cfd3057 100644 --- a/backend/app/api/schedule.py +++ b/backend/app/api/schedule.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import require_role +from app.core.deps import ensure_class_permission, require_role, resolve_class_id_for_user from app.db.database import get_db from app.db.models import User from app.schemas.schedule import ScheduleCreate, ScheduleUpdate, ScheduleOut @@ -22,12 +22,13 @@ router = APIRouter(prefix="/api/schedule", tags=["schedule"]) async def get_upcoming( limit: int = 10, class_id: int | None = None, - user: User = Depends(require_role("super_admin", "class_admin", "student")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): - effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + effective_class_id = resolve_class_id_for_user(user, class_id) if effective_class_id is None: return [] + ensure_class_permission(user, "class_view", effective_class_id) items = await get_upcoming_schedules(db, effective_class_id, limit) return [ScheduleOut.model_validate(i) for i in items] @@ -38,12 +39,13 @@ async def get_schedules( page: int = 1, page_size: int = 50, class_id: int | None = None, - user: User = Depends(require_role("super_admin", "class_admin", "student")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): - effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + effective_class_id = resolve_class_id_for_user(user, class_id) if effective_class_id is None: return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) + ensure_class_permission(user, "class_view", effective_class_id) items, total = await list_schedules(db, effective_class_id, type, page, page_size) total_pages = (total + page_size - 1) // page_size @@ -60,12 +62,13 @@ async def get_schedules( async def create_new_schedule( data: ScheduleCreate, class_id: int | None = None, - user: User = Depends(require_role("super_admin", "class_admin")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): - effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + effective_class_id = resolve_class_id_for_user(user, class_id) if effective_class_id is None: raise HTTPException(status_code=400, detail="You are not assigned to a class") + ensure_class_permission(user, "schedule_manage", effective_class_id) item = await create_schedule(db, effective_class_id, data) return ScheduleOut.model_validate(item) @@ -75,14 +78,13 @@ async def create_new_schedule( async def update_existing_schedule( schedule_id: int, data: ScheduleUpdate, - user: User = Depends(require_role("super_admin", "class_admin")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): item = await get_schedule_by_id(db, schedule_id) if item is None: raise HTTPException(status_code=404, detail="Schedule not found") - if user.role != "super_admin" and item.class_id != user.class_id: - raise HTTPException(status_code=403, detail="Access denied") + ensure_class_permission(user, "schedule_manage", item.class_id) updated = await update_schedule(db, item, data) return ScheduleOut.model_validate(updated) @@ -91,14 +93,13 @@ async def update_existing_schedule( @router.delete("/{schedule_id}") async def delete_existing_schedule( schedule_id: int, - user: User = Depends(require_role("super_admin", "class_admin")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): item = await get_schedule_by_id(db, schedule_id) if item is None: raise HTTPException(status_code=404, detail="Schedule not found") - if user.role != "super_admin" and item.class_id != user.class_id: - raise HTTPException(status_code=403, detail="Access denied") + ensure_class_permission(user, "schedule_manage", item.class_id) await delete_schedule(db, item) return {"message": "Schedule deleted"} diff --git a/backend/app/api/timeline.py b/backend/app/api/timeline.py index a03f653..58a71c7 100644 --- a/backend/app/api/timeline.py +++ b/backend/app/api/timeline.py @@ -2,7 +2,12 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form from sqlalchemy.ext.asyncio import AsyncSession import asyncio -from app.core.deps import require_role +from app.core.deps import ( + ensure_class_permission, + get_effective_class_permissions, + require_role, + resolve_class_id_for_user, +) from app.db.database import get_db from app.db.models import User from app.schemas.timeline import ( @@ -68,12 +73,13 @@ async def get_timelines( page: int = 1, page_size: int = 20, class_id: int | None = None, - user: User = Depends(require_role("super_admin", "class_admin", "student")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): - effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + effective_class_id = resolve_class_id_for_user(user, class_id) if effective_class_id is None: return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) + ensure_class_permission(user, "class_view", effective_class_id) posts, total = await list_timelines(db, effective_class_id, page, page_size) total_pages = (total + page_size - 1) // page_size @@ -91,12 +97,13 @@ async def create_new_timeline( content: str | None = Form(None), class_id: int | None = Form(None), files: list[UploadFile] = File(default=[]), - user: User = Depends(require_role("super_admin", "class_admin", "student")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): - effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + effective_class_id = resolve_class_id_for_user(user, class_id) if effective_class_id is None: raise HTTPException(status_code=400, detail="You are not assigned to a class") + ensure_class_permission(user, "class_view", effective_class_id) data = TimelineCreate(title=title, content=content) post = await create_timeline(db, effective_class_id, user.id, data) @@ -135,7 +142,7 @@ async def create_new_timeline( async def upload_timeline_images( post_id: int, files: list[UploadFile] = File(...), - user: User = Depends(require_role("super_admin", "class_admin", "student")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): post = await get_timeline_by_id(db, post_id) @@ -143,10 +150,10 @@ async def upload_timeline_images( raise HTTPException(status_code=404, detail="Timeline post not found") # Student can only upload to own post; admin can upload to any in their class - if user.role == "student" and post.author_id != user.id: - raise HTTPException(status_code=403, detail="Access denied") - if user.role != "super_admin" and post.class_id != user.class_id: + can_manage = "timeline_manage" in get_effective_class_permissions(user, post.class_id) + if not can_manage and post.author_id != user.id: raise HTTPException(status_code=403, detail="Access denied") + ensure_class_permission(user, "class_view", post.class_id) urls = [] for f in files: @@ -166,16 +173,16 @@ async def upload_timeline_images( async def update_existing_timeline( post_id: int, data: TimelineUpdate, - user: User = Depends(require_role("super_admin", "class_admin", "student")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): post = await get_timeline_by_id(db, post_id) if post is None: raise HTTPException(status_code=404, detail="Timeline post not found") - if user.role == "student" and post.author_id != user.id: - raise HTTPException(status_code=403, detail="Access denied") - if user.role != "super_admin" and post.class_id != user.class_id: + can_manage = "timeline_manage" in get_effective_class_permissions(user, post.class_id) + if not can_manage and post.author_id != user.id: raise HTTPException(status_code=403, detail="Access denied") + ensure_class_permission(user, "class_view", post.class_id) updated = await update_timeline(db, post, data) return _build_timeline_out(updated, user.id) @@ -184,16 +191,16 @@ async def update_existing_timeline( @router.delete("/{post_id}") async def delete_existing_timeline( post_id: int, - user: User = Depends(require_role("super_admin", "class_admin", "student")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): post = await get_timeline_by_id(db, post_id) if post is None: raise HTTPException(status_code=404, detail="Timeline post not found") - if user.role == "student" and post.author_id != user.id: - raise HTTPException(status_code=403, detail="Access denied") - if user.role != "super_admin" and post.class_id != user.class_id: + can_manage = "timeline_manage" in get_effective_class_permissions(user, post.class_id) + if not can_manage and post.author_id != user.id: raise HTTPException(status_code=403, detail="Access denied") + ensure_class_permission(user, "class_view", post.class_id) await delete_timeline(db, post) return {"message": "Timeline post deleted"} @@ -204,14 +211,13 @@ async def delete_existing_timeline( @router.post("/{post_id}/like") async def like_timeline_post( post_id: int, - user: User = Depends(require_role("super_admin", "class_admin", "student")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): post = await get_timeline_by_id(db, post_id) if post is None: raise HTTPException(status_code=404, detail="Timeline post not found") - if user.role != "super_admin" and post.class_id != user.class_id: - raise HTTPException(status_code=403, detail="Access denied") + ensure_class_permission(user, "class_view", post.class_id) return await toggle_like(db, post_id, user.id) @@ -220,9 +226,13 @@ async def get_post_comments( post_id: int, page: int = 1, page_size: int = 50, - user: User = Depends(require_role("super_admin", "class_admin", "student")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): + post = await get_timeline_by_id(db, post_id) + if post is None: + raise HTTPException(status_code=404, detail="Timeline post not found") + ensure_class_permission(user, "class_view", post.class_id) comments, total = await list_comments(db, post_id, page, page_size) total_pages = (total + page_size - 1) // page_size items = [ @@ -244,14 +254,13 @@ async def get_post_comments( async def add_post_comment( post_id: int, data: TimelineCommentCreate, - user: User = Depends(require_role("super_admin", "class_admin", "student")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): post = await get_timeline_by_id(db, post_id) if post is None: raise HTTPException(status_code=404, detail="Timeline post not found") - if user.role != "super_admin" and post.class_id != user.class_id: - raise HTTPException(status_code=403, detail="Access denied") + ensure_class_permission(user, "class_view", post.class_id) comment = await create_comment(db, post_id, user.id, data) return TimelineCommentOut( @@ -268,14 +277,19 @@ async def add_post_comment( @router.delete("/comments/{comment_id}") async def delete_timeline_comment( comment_id: int, - user: User = Depends(require_role("super_admin", "class_admin", "student")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): comment = await get_comment_by_id(db, comment_id) if comment is None: raise HTTPException(status_code=404, detail="Comment not found") - if user.role == "student" and comment.author_id != user.id: + can_manage = "timeline_manage" in get_effective_class_permissions(user, post.class_id) + if not can_manage and comment.author_id != user.id: raise HTTPException(status_code=403, detail="Access denied") + post = await get_timeline_by_id(db, comment.post_id) + if post is None: + raise HTTPException(status_code=404, detail="Timeline post not found") + ensure_class_permission(user, "class_view", post.class_id) await delete_comment(db, comment) return {"message": "Comment deleted"} diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py index bb9e687..05bad15 100644 --- a/backend/app/api/upload.py +++ b/backend/app/api/upload.py @@ -10,7 +10,7 @@ router = APIRouter(prefix="/api/upload", tags=["upload"]) @router.post("/image") async def upload_image_api( file: UploadFile = File(...), - user: User = Depends(require_role("super_admin", "class_admin")), + user: User = Depends(require_role("super_admin", "teacher", "student")), ): """Upload an image to Tencent COS.""" contents = await file.read() diff --git a/backend/app/api/users.py b/backend/app/api/users.py index 2dbc612..91dfbdf 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -1,29 +1,50 @@ -import json - -from pydantic import BaseModel -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import get_current_user, require_role +from app.core.deps import ( + CLASS_PERMISSIONS, + ensure_class_access, + ensure_class_permission, + get_current_user, + require_role, +) from app.db.database import get_db from app.db.models import User -from app.schemas.user import UserOut, UserUpdate, UserListItem, UserStatusUpdate +from app.schemas.user import ( + TeacherAssignRequest, + TeacherAssignResponse, + TeacherCreateRequest, + TeacherCreateResponse, + UserOut, + UserUpdate, + UserListItem, + UserStatusUpdate, + UserRoleUpdate, + CommitteeRoleUpdate, + ClassPermissionsUpdate, + build_user_list_item, + build_user_out, +) from app.schemas.common import PageResponse from app.services.user_service import ( update_profile, update_user_status, + update_user_role, + update_user_committee_role, + update_user_class_permissions, + assign_existing_teacher_to_class, + create_or_assign_teacher, list_users, get_user_by_id, ) from app.services.cos_service import upload_image -from app.services.email_service import send_approval_notification router = APIRouter(prefix="/api/users", tags=["users"]) @router.get("/me", response_model=UserOut) async def get_my_profile(user: User = Depends(get_current_user)): - return UserOut.model_validate(user) + return build_user_out(user) @router.put("/me", response_model=UserOut) @@ -36,7 +57,7 @@ async def update_my_profile( updated = await update_profile(db, user, data) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) - return UserOut.model_validate(updated) + return build_user_out(updated) @router.post("/me/avatar") @@ -65,13 +86,18 @@ async def list_all_users( class_id: int | None = None, status: str | None = None, role: str | None = None, - admin: User = Depends(require_role("super_admin")), + admin: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): + if class_id is not None: + ensure_class_permission(admin, "member_view", class_id) + elif admin.role != "super_admin": + raise HTTPException(status_code=400, detail="class_id is required") + users, total = await list_users(db, page, page_size, class_id, status, role) total_pages = (total + page_size - 1) // page_size return PageResponse( - items=[UserListItem.model_validate(u) for u in users], + items=[build_user_list_item(u, class_id) for u in users], total=total, page=page, page_size=page_size, @@ -83,18 +109,15 @@ async def list_all_users( async def change_user_status( user_id: int, data: UserStatusUpdate, - admin: User = Depends(require_role("super_admin", "class_admin")), + admin: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): target = await get_user_by_id(db, user_id) if target is None: raise HTTPException(status_code=404, detail="User not found") - # Class admin can only manage users in their own class - if admin.role == "class_admin" and target.class_id != admin.class_id: - raise HTTPException( - status_code=403, detail="Cannot manage users outside your class" - ) + target_class_id = target.get_default_membership().class_id if target.get_default_membership() else None + ensure_class_permission(admin, "member_manage", target_class_id) # Only super_admin can change roles if data.role and admin.role != "super_admin": @@ -103,53 +126,107 @@ async def change_user_status( ) updated = await update_user_status(db, user_id, data.status, data.role) - - # Send email notification - if data.status in ("approved", "rejected"): - await send_approval_notification(target.email, data.status == "approved") - - return {"message": f"User status updated to {data.status}"} + return {"message": f"用户状态已更新为 {data.status}"} @router.put("/{user_id}/role") async def change_user_role( user_id: int, - role: str, + data: UserRoleUpdate, admin: User = Depends(require_role("super_admin")), db: AsyncSession = Depends(get_db), ): - if role not in ("super_admin", "class_admin", "student"): + if data.role not in ("super_admin", "teacher", "student"): raise HTTPException(status_code=400, detail="Invalid role") target = await get_user_by_id(db, user_id) if target is None: raise HTTPException(status_code=404, detail="User not found") - target.role = role - await db.commit() - return {"message": f"User role updated to {role}"} - - -class CommitteeRoleUpdate(BaseModel): - committee_role: str | None = None + await update_user_role(db, target, data.role) + return {"message": f"User role updated to {data.role}"} @router.put("/{user_id}/committee-role") async def change_committee_role( user_id: int, data: CommitteeRoleUpdate, - admin: User = Depends(require_role("super_admin", "class_admin")), + admin: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): target = await get_user_by_id(db, user_id) if target is None: raise HTTPException(status_code=404, detail="User not found") - if admin.role == "class_admin" and target.class_id != admin.class_id: - raise HTTPException( - status_code=403, detail="Cannot manage users outside your class" - ) + ensure_class_permission(admin, "committee_manage", data.class_id) - target.committee_role = data.committee_role - await db.commit() + try: + await update_user_committee_role(db, target, data.class_id, data.committee_role) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) return {"message": "Committee role updated"} + + +@router.put("/{user_id}/class-permissions") +async def change_class_permissions( + user_id: int, + data: ClassPermissionsUpdate, + admin: User = Depends(require_role("super_admin", "teacher", "student")), + db: AsyncSession = Depends(get_db), +): + target = await get_user_by_id(db, user_id) + if target is None: + raise HTTPException(status_code=404, detail="User not found") + + ensure_class_permission(admin, "committee_manage", data.class_id) + + invalid = [permission for permission in data.class_permissions if permission not in CLASS_PERMISSIONS] + if invalid: + raise HTTPException(status_code=400, detail=f"Invalid permissions: {', '.join(invalid)}") + + try: + await update_user_class_permissions(db, target, data.class_id, data.class_permissions) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + return {"message": "Class permissions updated"} + + +@router.post("/teachers", response_model=TeacherCreateResponse) +async def create_teacher_user( + data: TeacherCreateRequest, + admin: User = Depends(require_role("super_admin")), + db: AsyncSession = Depends(get_db), +): + try: + user, created, assigned = await create_or_assign_teacher(db, data) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + if created: + message = "老师账号已创建并加入当前班级" + elif assigned: + message = "老师账号已加入当前班级" + else: + message = "该老师已存在于当前班级" + + return TeacherCreateResponse( + message=message, + user=build_user_out(user, data.class_id), + ) + + +@router.post("/teachers/assign", response_model=TeacherAssignResponse) +async def assign_teacher_to_class( + data: TeacherAssignRequest, + admin: User = Depends(require_role("super_admin")), + db: AsyncSession = Depends(get_db), +): + try: + user, assigned = await assign_existing_teacher_to_class(db, data) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + return TeacherAssignResponse( + message="老师已加入当前班级" if assigned else "该老师已在当前班级", + user=build_user_out(user, data.class_id), + ) diff --git a/backend/app/api/votes.py b/backend/app/api/votes.py index 59137ab..d4ea8b8 100644 --- a/backend/app/api/votes.py +++ b/backend/app/api/votes.py @@ -1,7 +1,12 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import require_role +from app.core.deps import ( + ensure_class_permission, + get_effective_class_permissions, + require_role, + resolve_class_id_for_user, +) from app.db.database import get_db from app.db.models import User from app.schemas.vote import VoteCreate, VoteUpdate, VoteSubmit, VoteOptionOut, VoteOut @@ -70,12 +75,13 @@ async def get_votes( page: int = 1, page_size: int = 20, class_id: int | None = None, - user: User = Depends(require_role("super_admin", "class_admin", "student")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): - effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + effective_class_id = resolve_class_id_for_user(user, class_id) if effective_class_id is None: return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) + ensure_class_permission(user, "class_view", effective_class_id) votes, total = await list_votes(db, effective_class_id, page, page_size) total_pages = (total + page_size - 1) // page_size @@ -87,7 +93,7 @@ async def get_votes( async def create_new_vote( data: VoteCreate, class_id: int | None = None, - user: User = Depends(require_role("super_admin", "class_admin", "student")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): if len(data.options) < 2: @@ -95,9 +101,10 @@ async def create_new_vote( if data.vote_type == "multiple" and data.max_choices < 2: raise HTTPException(status_code=400, detail="多选投票最多可选数不能小于 2") - effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + effective_class_id = resolve_class_id_for_user(user, class_id) if effective_class_id is None: raise HTTPException(status_code=400, detail="You are not assigned to a class") + ensure_class_permission(user, "class_view", effective_class_id) vote = await create_vote(db, effective_class_id, user.id, data) # Reload with relationships @@ -108,14 +115,13 @@ async def create_new_vote( @router.get("/{vote_id}", response_model=VoteOut) async def get_vote_detail( vote_id: int, - user: User = Depends(require_role("super_admin", "class_admin", "student")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): vote = await get_vote_by_id(db, vote_id) if vote is None: raise HTTPException(status_code=404, detail="Vote not found") - if user.role != "super_admin" and vote.class_id != user.class_id: - raise HTTPException(status_code=403, detail="Access denied") + ensure_class_permission(user, "class_view", vote.class_id) return _build_vote_out(vote, user.id) @@ -123,14 +129,13 @@ async def get_vote_detail( async def submit_vote_response( vote_id: int, data: VoteSubmit, - user: User = Depends(require_role("super_admin", "class_admin", "student")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): vote = await get_vote_by_id(db, vote_id) if vote is None: raise HTTPException(status_code=404, detail="Vote not found") - if user.role != "super_admin" and vote.class_id != user.class_id: - raise HTTPException(status_code=403, detail="Access denied") + ensure_class_permission(user, "class_view", vote.class_id) try: await submit_vote(db, vote_id, user.id, data.option_ids) @@ -143,17 +148,17 @@ async def submit_vote_response( @router.put("/{vote_id}/close") async def close_vote_endpoint( vote_id: int, - user: User = Depends(require_role("super_admin", "class_admin", "student")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): vote = await get_vote_by_id(db, vote_id) if vote is None: raise HTTPException(status_code=404, detail="Vote not found") # Only creator or admin can close - if user.role == "student" and vote.creator_id != user.id: + can_manage = "vote_manage" in get_effective_class_permissions(user, vote.class_id) + if not can_manage and user.role == "student" and vote.creator_id != user.id: raise HTTPException(status_code=403, detail="只有创建者或管理员可以关闭投票") - if user.role != "super_admin" and vote.class_id != user.class_id: - raise HTTPException(status_code=403, detail="Access denied") + ensure_class_permission(user, "class_view", vote.class_id) await close_vote(db, vote) return {"message": "投票已关闭"} @@ -162,16 +167,16 @@ async def close_vote_endpoint( @router.delete("/{vote_id}") async def delete_vote_endpoint( vote_id: int, - user: User = Depends(require_role("super_admin", "class_admin", "student")), + user: User = Depends(require_role("super_admin", "teacher", "student")), db: AsyncSession = Depends(get_db), ): vote = await get_vote_by_id(db, vote_id) if vote is None: raise HTTPException(status_code=404, detail="Vote not found") - if user.role == "student" and vote.creator_id != user.id: + can_manage = "vote_manage" in get_effective_class_permissions(user, vote.class_id) + if not can_manage and user.role == "student" and vote.creator_id != user.id: raise HTTPException(status_code=403, detail="只有创建者或管理员可以删除投票") - if user.role != "super_admin" and vote.class_id != user.class_id: - raise HTTPException(status_code=403, detail="Access denied") + ensure_class_permission(user, "class_view", vote.class_id) await delete_vote(db, vote) return {"message": "投票已删除"} diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py index e1a8bba..ca2b25b 100644 --- a/backend/app/core/deps.py +++ b/backend/app/core/deps.py @@ -1,14 +1,44 @@ from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy import select +from sqlalchemy.orm import selectinload from sqlalchemy.ext.asyncio import AsyncSession from app.core.auth import decode_access_token from app.db.database import get_db -from app.db.models import User +from app.db.models import ClassMembership, User security = HTTPBearer() +CLASS_PERMISSIONS = { + "class_view", + "member_view", + "member_manage", + "committee_manage", + "announcement_manage", + "timeline_manage", + "vote_manage", + "schedule_manage", + "resource_manage", + "assignment_manage", + "fund_manage", + "module_manage", +} + +TEACHER_DEFAULT_PERMISSIONS = { + "class_view", + "member_view", + "member_manage", + "committee_manage", + "announcement_manage", + "timeline_manage", + "vote_manage", + "schedule_manage", + "resource_manage", + "assignment_manage", + "module_manage", +} + async def get_current_user( credentials: HTTPAuthorizationCredentials = Depends(security), @@ -28,13 +58,24 @@ async def get_current_user( detail="Invalid token format", ) - result = await db.execute(select(User).where(User.id == int(user_id))) + result = await db.execute( + select(User) + .options( + selectinload(User.memberships), + selectinload(User.memberships).selectinload(ClassMembership.class_), + ) + .where(User.id == int(user_id)) + ) user = result.scalar_one_or_none() if user is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found" ) + if user.status == "inactive": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Account inactive" + ) if user.status == "disabled": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Account disabled" @@ -55,3 +96,68 @@ def require_role(*roles: str): return user return _check + + +def get_membership_for_class(user: User, class_id: int | None) -> ClassMembership | None: + return user.get_membership(class_id) + + +def get_active_membership( + user: User, class_id: int | None = None +) -> ClassMembership | None: + membership = get_membership_for_class(user, class_id) + if membership is not None: + return membership + return user.get_default_membership() + + +def get_effective_class_permissions(user: User, class_id: int | None = None) -> set[str]: + if user.role == "super_admin": + return set(CLASS_PERMISSIONS) + membership = get_active_membership(user, class_id) + scoped_permissions = membership.get_class_permissions() if membership else [] + if user.role == "teacher": + return set(TEACHER_DEFAULT_PERMISSIONS) | set(scoped_permissions) + return set(scoped_permissions) + + +def can_access_class(user: User, class_id: int | None) -> bool: + if class_id is None: + return False + if user.role in {"super_admin", "teacher"}: + return True + return get_membership_for_class(user, class_id) is not None + + +def resolve_class_id_for_user(user: User, requested_class_id: int | None) -> int | None: + if user.role in {"super_admin", "teacher"}: + return requested_class_id + if requested_class_id is not None and can_access_class(user, requested_class_id): + return requested_class_id + membership = user.get_default_membership() + return membership.class_id if membership else None + + +def ensure_class_access(user: User, class_id: int | None): + if not can_access_class(user, class_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied for this class", + ) + + +def ensure_class_permission(user: User, permission: str, class_id: int | None = None): + if permission not in CLASS_PERMISSIONS: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Unknown permission: {permission}", + ) + if class_id is not None: + ensure_class_access(user, class_id) + if user.role == "super_admin": + return + if permission not in get_effective_class_permissions(user, class_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Insufficient permissions", + ) diff --git a/backend/app/db/database.py b/backend/app/db/database.py index 3982c31..bfe5cc5 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -9,9 +9,3 @@ async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit async def get_db(): async with async_session() as session: yield session - - -async def create_tables(): - from app.db.base import Base - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 572a89b..1addef3 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -35,7 +35,9 @@ class Class_(Base): 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_") + memberships: Mapped[list["ClassMembership"]] = relationship( + "ClassMembership", back_populates="class_", cascade="all, delete-orphan" + ) timelines: Mapped[list["Timeline"]] = relationship( "Timeline", back_populates="class_", cascade="all, delete-orphan" ) @@ -48,9 +50,6 @@ class Class_(Base): resources: Mapped[list["Resource"]] = relationship( "Resource", back_populates="class_", cascade="all, delete-orphan" ) - roster: Mapped[list["StudentRoster"]] = relationship( - "StudentRoster", back_populates="class_", cascade="all, delete-orphan" - ) assignments: Mapped[list["Assignment"]] = relationship( "Assignment", back_populates="class_", cascade="all, delete-orphan" ) @@ -67,25 +66,19 @@ class User(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) - password_hash: Mapped[str] = mapped_column(Text, nullable=False) + password_hash: Mapped[str | None] = mapped_column(Text, nullable=True) name: Mapped[str] = mapped_column(String(100), nullable=False) student_id: Mapped[str | None] = mapped_column(String(50), nullable=True, unique=True) - # role: super_admin | class_admin | student + # role: super_admin | teacher | student role: Mapped[str] = mapped_column(String(20), default="student", nullable=False) - # status: pending | approved | rejected | disabled - status: Mapped[str] = mapped_column(String(20), default="pending", nullable=False) - - class_id: Mapped[int | None] = mapped_column( - Integer, ForeignKey("classes.id"), nullable=True - ) - class_: Mapped["Class_ | None"] = relationship("Class_", back_populates="members") + # status: inactive | approved | disabled + status: Mapped[str] = mapped_column(String(20), default="inactive", nullable=False) # Profile industry: Mapped[str | None] = mapped_column(String(100), nullable=True) company: Mapped[str | None] = mapped_column(String(100), nullable=True) position: Mapped[str | None] = mapped_column(String(100), nullable=True) - committee_role: Mapped[str | None] = mapped_column(String(50), nullable=True) skills_tags: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array wechat_id: Mapped[str | None] = mapped_column(String(100), nullable=True) phone: Mapped[str | None] = mapped_column(String(20), nullable=True) @@ -97,6 +90,9 @@ class User(Base): DateTime, server_default=func.now(), onupdate=func.now() ) + memberships: Mapped[list["ClassMembership"]] = relationship( + "ClassMembership", back_populates="user", cascade="all, delete-orphan" + ) timeline_posts: Mapped[list["Timeline"]] = relationship( "Timeline", back_populates="author" ) @@ -121,6 +117,65 @@ class User(Base): def set_skills_list(self, tags: list[str]): self.skills_tags = json.dumps(tags, ensure_ascii=False) if tags else None + def get_membership(self, class_id: int | None) -> "ClassMembership | None": + if class_id is None: + return None + return next((m for m in self.memberships if m.class_id == class_id), None) + + def get_default_membership(self) -> "ClassMembership | None": + active_membership = getattr(self, "_active_membership", None) + if active_membership is not None: + return active_membership + if len(self.memberships) == 1: + return self.memberships[0] + return self.memberships[0] if self.memberships else None + + def set_active_membership(self, class_id: int | None = None): + setattr(self, "_active_membership", self.get_membership(class_id)) + + +class ClassMembership(Base): + __tablename__ = "class_memberships" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False, index=True + ) + class_id: Mapped[int] = mapped_column( + Integer, ForeignKey("classes.id"), nullable=False, index=True + ) + membership_role: Mapped[str] = mapped_column( + String(20), default="student", nullable=False + ) + committee_role: Mapped[str | None] = mapped_column(String(50), nullable=True) + class_permissions: Mapped[str | None] = mapped_column(Text, nullable=True) + 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() + ) + + user: Mapped["User"] = relationship("User", back_populates="memberships") + class_: Mapped["Class_"] = relationship("Class_", back_populates="memberships") + + __table_args__ = ( + UniqueConstraint("user_id", "class_id", name="uq_class_membership_user_class"), + ) + + def get_class_permissions(self) -> list[str]: + if not self.class_permissions: + return [] + try: + return json.loads(self.class_permissions) + except (json.JSONDecodeError, TypeError): + return [] + + def set_class_permissions(self, permissions: list[str]): + self.class_permissions = ( + json.dumps(sorted(set(permissions)), ensure_ascii=False) + if permissions + else None + ) + class Timeline(Base): __tablename__ = "timelines" @@ -245,27 +300,6 @@ class Notification(Base): user: Mapped["User"] = relationship("User") -class StudentRoster(Base): - __tablename__ = "student_rosters" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - class_id: Mapped[int] = mapped_column( - Integer, ForeignKey("classes.id"), nullable=False, index=True - ) - student_id: Mapped[str] = mapped_column(String(50), nullable=False) - name: Mapped[str] = mapped_column(String(100), nullable=False) - status: Mapped[str] = mapped_column( - String(20), default="unregistered", nullable=False - ) - user_id: Mapped[int | None] = mapped_column( - Integer, ForeignKey("users.id"), nullable=True - ) - created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) - - class_: Mapped["Class_"] = relationship("Class_", back_populates="roster") - user: Mapped["User | None"] = relationship("User") - - class TimelineLike(Base): __tablename__ = "timeline_likes" diff --git a/backend/app/main.py b/backend/app/main.py index 96651d6..0a3a9b7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,7 +5,6 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.config import settings -from app.db.database import create_tables from app.api import auth, users, classes, directory, timeline, schedule, upload, announcements, resources, notifications, votes, assignments, fund logging.basicConfig( @@ -37,45 +36,9 @@ async def ensure_super_admin(): logger.info("Super admin seeded: %s", settings.super_admin_email) -async def ensure_sample_class(): - """Seed a sample class if none exists.""" - from sqlalchemy import select, func, text - from app.db.database import async_session - from app.db.models import Class_ - - async with async_session() as db: - result = await db.execute(select(func.count(Class_.id))) - count = result.scalar() - if count == 0: - sample = Class_( - name="HKU ICB Sample Class", - cohort_year=2025, - description="Sample class for testing", - ) - db.add(sample) - await db.commit() - 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/auth.py b/backend/app/schemas/auth.py index 851f3f7..04d4c5f 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -9,7 +9,6 @@ class LoginRequest(BaseModel): class RegisterRequest(BaseModel): invite_code: str student_id: str - name: str email: EmailStr password: str diff --git a/backend/app/schemas/inactive_member.py b/backend/app/schemas/inactive_member.py new file mode 100644 index 0000000..2670ed2 --- /dev/null +++ b/backend/app/schemas/inactive_member.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel + +from app.db.models import User + + +class InactiveMemberOut(BaseModel): + id: int + student_id: str + name: str + status: str # "inactive" + user_id: int | None + + +class MemberImportRequest(BaseModel): + entries: list[dict] # [{"student_id": "...", "name": "..."}, ...] + + +def build_inactive_member_out(user: User) -> InactiveMemberOut: + return InactiveMemberOut( + id=user.id, + student_id=user.student_id or "", + name=user.name, + status="inactive", + user_id=user.id, + ) diff --git a/backend/app/schemas/roster.py b/backend/app/schemas/roster.py deleted file mode 100644 index 75640e7..0000000 --- a/backend/app/schemas/roster.py +++ /dev/null @@ -1,15 +0,0 @@ -from pydantic import BaseModel - - -class RosterOut(BaseModel): - id: int - student_id: str - name: str - status: str # "unregistered" | "registered" - user_id: int | None - - model_config = {"from_attributes": True} - - -class RosterImportRequest(BaseModel): - entries: list[dict] # [{"student_id": "...", "name": "..."}, ...] diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 2a1d468..74b0cf9 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -1,7 +1,17 @@ -import json from datetime import datetime -from pydantic import BaseModel, EmailStr, field_validator +from pydantic import BaseModel, EmailStr, Field + +from app.db.models import ClassMembership, User + + +class MembershipOut(BaseModel): + id: int + class_id: int + class_name: str | None + membership_role: str + committee_role: str | None = None + class_permissions: list[str] class UserOut(BaseModel): @@ -11,34 +21,21 @@ class UserOut(BaseModel): student_id: str | None role: str status: str - class_id: int | None industry: str | None company: str | None position: str | None - committee_role: str | None skills_tags: list[str] | None wechat_id: str | None phone: str | None avatar_url: str | None bio: str | None created_at: datetime + memberships: list[MembershipOut] + active_membership: MembershipOut | None = None enabled_modules: list[str] | None = None - model_config = {"from_attributes": True} - - @field_validator("skills_tags", mode="before") - @classmethod - def parse_skills_tags(cls, v): - if isinstance(v, str): - try: - return json.loads(v) - except (json.JSONDecodeError, TypeError): - return [] - return v - class UserPublic(BaseModel): - """Shown to same-class approved members (includes contact info).""" id: int name: str student_id: str | None @@ -53,20 +50,19 @@ class UserPublic(BaseModel): class UserListItem(BaseModel): - """For admin user management list.""" id: int email: str name: str student_id: str | None role: str status: str - class_id: int | None industry: str | None company: str | None committee_role: str | None = None + class_permissions: list[str] created_at: datetime - - model_config = {"from_attributes": True} + memberships: list[MembershipOut] + active_membership: MembershipOut | None = None class UserUpdate(BaseModel): @@ -81,10 +77,101 @@ class UserUpdate(BaseModel): class UserStatusUpdate(BaseModel): - status: str # approved | rejected | disabled + status: str role: str | None = None +class UserRoleUpdate(BaseModel): + role: str + + +class TeacherCreateRequest(BaseModel): + class_id: int + name: str = Field(min_length=1, max_length=100) + email: EmailStr + password: str = Field(min_length=8, max_length=128) + + +class TeacherCreateResponse(BaseModel): + message: str + user: UserOut + + +class TeacherAssignRequest(BaseModel): + class_id: int + email: EmailStr + + +class TeacherAssignResponse(BaseModel): + message: str + user: UserOut + + +class CommitteeRoleUpdate(BaseModel): + class_id: int + committee_role: str | None = None + + +class ClassPermissionsUpdate(BaseModel): + class_id: int + class_permissions: list[str] + + class TokenResponse(BaseModel): token: str user: UserOut + + +def build_membership_out(membership: ClassMembership) -> MembershipOut: + return MembershipOut( + id=membership.id, + class_id=membership.class_id, + class_name=membership.class_.name if membership.class_ else None, + membership_role=membership.membership_role, + committee_role=membership.committee_role, + class_permissions=membership.get_class_permissions(), + ) + + +def build_user_out(user: User, class_id: int | None = None) -> UserOut: + active_membership = user.get_membership(class_id) if class_id is not None else user.get_default_membership() + memberships = [build_membership_out(membership) for membership in user.memberships] + return UserOut( + id=user.id, + email=user.email, + name=user.name, + student_id=user.student_id, + role=user.role, + status=user.status, + industry=user.industry, + company=user.company, + position=user.position, + skills_tags=user.get_skills_list(), + wechat_id=user.wechat_id, + phone=user.phone, + avatar_url=user.avatar_url, + bio=user.bio, + created_at=user.created_at, + memberships=memberships, + active_membership=build_membership_out(active_membership) if active_membership else None, + ) + + +def build_user_list_item(user: User, class_id: int | None = None) -> UserListItem: + active_membership = user.get_membership(class_id) if class_id is not None else user.get_default_membership() + memberships = [build_membership_out(membership) for membership in user.memberships] + return UserListItem( + id=user.id, + email=user.email, + name=user.name, + student_id=user.student_id, + role=user.role, + status=user.status, + industry=user.industry, + company=user.company, + committee_role=active_membership.committee_role if active_membership else None, + class_permissions=active_membership.get_class_permissions() if active_membership else [], + created_at=user.created_at, + memberships=memberships, + active_membership=build_membership_out(active_membership) if active_membership else None, + ) diff --git a/backend/app/services/class_service.py b/backend/app/services/class_service.py index 003efd1..81c65c7 100644 --- a/backend/app/services/class_service.py +++ b/backend/app/services/class_service.py @@ -1,7 +1,8 @@ from sqlalchemy import select, func +from sqlalchemy.orm import selectinload from sqlalchemy.ext.asyncio import AsyncSession -from app.db.models import Class_, User +from app.db.models import Class_, ClassMembership, User from app.schemas.class_ import ClassCreate, ClassUpdate @@ -49,8 +50,11 @@ async def list_classes( async def get_member_count(db: AsyncSession, class_id: int) -> int: result = await db.execute( - select(func.count(User.id)).where( - User.class_id == class_id, User.status == "approved" + select(func.count(ClassMembership.id)) + .join(User, User.id == ClassMembership.user_id) + .where( + ClassMembership.class_id == class_id, + User.status == "approved", ) ) return result.scalar() or 0 @@ -63,8 +67,18 @@ async def get_class_members( page: int = 1, page_size: int = 50, ) -> tuple[list[User], int]: - query = select(User).where(User.class_id == class_id) - count_query = select(func.count(User.id)).where(User.class_id == class_id) + query = ( + select(User) + .options( + selectinload(User.memberships), + selectinload(User.memberships).selectinload(ClassMembership.class_), + ) + .join(ClassMembership) + .where(ClassMembership.class_id == class_id) + ) + count_query = select(func.count(User.id)).join(ClassMembership).where( + ClassMembership.class_id == class_id + ) if status: query = query.where(User.status == status) @@ -76,4 +90,4 @@ async def get_class_members( result = await db.execute( query.order_by(User.name).offset((page - 1) * page_size).limit(page_size) ) - return list(result.scalars().all()), total + return list(result.scalars().unique().all()), total diff --git a/backend/app/services/directory_service.py b/backend/app/services/directory_service.py index fd9c605..b805b62 100644 --- a/backend/app/services/directory_service.py +++ b/backend/app/services/directory_service.py @@ -1,8 +1,9 @@ import json from sqlalchemy import select, or_, func, case +from sqlalchemy.orm import selectinload from sqlalchemy.ext.asyncio import AsyncSession -from app.db.models import User +from app.db.models import ClassMembership, User from app.schemas.user import UserPublic @@ -15,12 +16,20 @@ async def search_directory( page: int = 1, page_size: int = 20, ) -> tuple[list[User], int]: - """Search approved members in a class.""" - query = select(User).where( - User.class_id == class_id, User.status == "approved" + """Search active members in a class.""" + query = ( + select(User) + .options( + selectinload(User.memberships), + selectinload(User.memberships).selectinload(ClassMembership.class_), + ) + .join(ClassMembership) + .where(ClassMembership.class_id == class_id, User.status == "approved") ) - count_query = select(func.count(User.id)).where( - User.class_id == class_id, User.status == "approved" + count_query = ( + select(func.count(User.id)) + .join(ClassMembership) + .where(ClassMembership.class_id == class_id, User.status == "approved") ) if search: @@ -54,23 +63,26 @@ async def search_directory( # Committee role priority: 班长(1) > 副班长(2) > other roles(3) > no role(4) committee_order = case( - (User.committee_role == None, 4), - (User.committee_role == "班长", 1), - (User.committee_role == "副班长", 2), + (ClassMembership.committee_role == None, 4), + (ClassMembership.committee_role == "班长", 1), + (ClassMembership.committee_role == "副班长", 2), else_=3, ) result = await db.execute( - query.order_by(committee_order, User.committee_role, User.name) + query.order_by(committee_order, ClassMembership.committee_role, User.name) .offset((page - 1) * page_size) .limit(page_size) ) - users = list(result.scalars().all()) + users = list(result.scalars().unique().all()) return users, total -def user_to_public(user: User, include_contact: bool = True) -> UserPublic: +def user_to_public( + user: User, class_id: int | None = None, include_contact: bool = True +) -> UserPublic: """Convert User model to public profile, optionally hiding contact info.""" + membership = user.get_membership(class_id) if class_id is not None else user.get_default_membership() return UserPublic( id=user.id, name=user.name, @@ -78,7 +90,7 @@ def user_to_public(user: User, include_contact: bool = True) -> UserPublic: industry=user.industry, company=user.company, position=user.position, - committee_role=user.committee_role, + committee_role=membership.committee_role if membership else None, wechat_id=user.wechat_id if include_contact else None, phone=user.phone if include_contact else None, avatar_url=user.avatar_url, diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 28f0b20..8d532d8 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -36,27 +36,13 @@ async def send_email(to: str, subject: str, html_body: str) -> bool: return False -async def send_registration_notification( - admin_email: str, student_name: str, class_name: str -): - html = f""" -

New Registration Pending Approval

-

{student_name} has registered for {class_name}.

-

Please log in to HKU ICB to review and approve.

+async def send_account_activated_email(member_email: str): + html = """ +

Account Activated

+

Your HKU ICB account has been activated successfully.

+

You can now log in to the platform.

""" - await send_email(admin_email, "HKU ICB: New Registration", html) - - -async def send_approval_notification(student_email: str, approved: bool): - status_text = "approved" if approved else "rejected" - html = f""" -

Registration {status_text.capitalize()}

-

Your registration has been {status_text}.

- {"

You can now log in to HKU ICB.

" if approved else ""} - """ - await send_email( - student_email, f"HKU ICB: Registration {status_text.capitalize()}", html - ) + await send_email(member_email, "HKU ICB: Account Activated", html) async def send_class_notification_email( diff --git a/backend/app/services/member_activation_service.py b/backend/app/services/member_activation_service.py new file mode 100644 index 0000000..d0b852f --- /dev/null +++ b/backend/app/services/member_activation_service.py @@ -0,0 +1,208 @@ +import secrets + +from sqlalchemy import select, func +from sqlalchemy.orm import selectinload +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import ClassMembership, Class_, User + + +def generate_invite_code(length: int = 8) -> str: + chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + return "".join(secrets.choice(chars) for _ in range(length)) + + +async def ensure_invite_code(db: AsyncSession, class_id: int) -> str: + result = await db.execute(select(Class_).where(Class_.id == class_id)) + class_ = result.scalar_one_or_none() + if class_ is None: + return "" + if not class_.invite_code: + class_.invite_code = generate_invite_code() + await db.commit() + await db.refresh(class_) + return class_.invite_code + + +async def regenerate_invite_code(db: AsyncSession, class_id: int) -> str: + result = await db.execute(select(Class_).where(Class_.id == class_id)) + class_ = result.scalar_one_or_none() + if class_ is None: + return "" + class_.invite_code = generate_invite_code() + await db.commit() + await db.refresh(class_) + return class_.invite_code + + +async def import_members( + db: AsyncSession, class_id: int, entries: list[dict] +) -> int: + incoming_entries: list[tuple[str, str]] = [] + for entry in entries: + sid = entry.get("student_id", "").strip() + name = entry.get("name", "").strip() + if sid and name: + incoming_entries.append((sid, name)) + + if not incoming_entries: + return 0 + + student_ids = {sid for sid, _ in incoming_entries} + result = await db.execute( + select(User) + .options( + selectinload(User.memberships), + selectinload(User.memberships).selectinload(ClassMembership.class_), + ) + .where(User.student_id.in_(student_ids)) + ) + existing_users = {user.student_id: user for user in result.scalars().unique().all() if user.student_id} + + count = 0 + seen_in_batch: set[str] = set() + for sid, name in incoming_entries: + if sid in seen_in_batch: + continue + seen_in_batch.add(sid) + + user = existing_users.get(sid) + if user is not None: + if user.get_membership(class_id) is not None: + continue + if user.status == "inactive": + user.name = name + db.add( + ClassMembership( + user_id=user.id, + class_id=class_id, + membership_role="student", + ) + ) + count += 1 + continue + + placeholder_email = f"inactive+{class_id}.{sid}@member.local" + new_user = User( + email=placeholder_email, + password_hash=None, + name=name, + student_id=sid, + role="student", + status="inactive", + ) + db.add(new_user) + await db.flush() + db.add( + ClassMembership( + user_id=new_user.id, + class_id=class_id, + membership_role="student", + ) + ) + existing_users[sid] = new_user + count += 1 + + await db.commit() + return count + + +async def get_inactive_members( + db: AsyncSession, class_id: int, page: int = 1, page_size: int = 50 +) -> tuple[list[User], int]: + query = ( + select(User) + .options( + selectinload(User.memberships), + selectinload(User.memberships).selectinload(ClassMembership.class_), + ) + .join(ClassMembership) + .where( + ClassMembership.class_id == class_id, + User.status == "inactive", + ) + ) + count_query = ( + select(func.count(User.id)) + .join(ClassMembership) + .where( + ClassMembership.class_id == class_id, + User.status == "inactive", + ) + ) + + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + result = await db.execute( + query.order_by(User.student_id) + .offset((page - 1) * page_size) + .limit(page_size) + ) + return list(result.scalars().unique().all()), total + + +async def validate_registration( + db: AsyncSession, invite_code: str, student_id: str +) -> tuple[User, int] | None: + class_result = await db.execute( + select(Class_).where(Class_.invite_code == invite_code) + ) + class_ = class_result.scalar_one_or_none() + if class_ is None: + return None + user_result = await db.execute( + select(User) + .options( + selectinload(User.memberships), + selectinload(User.memberships).selectinload(ClassMembership.class_), + ) + .join(ClassMembership) + .where( + ClassMembership.class_id == class_.id, + User.student_id == student_id, + User.status == "inactive", + ) + ) + user = user_result.scalar_one_or_none() + if user is None: + return None + user.set_active_membership(class_.id) + return user, class_.id + + +async def delete_inactive_member(db: AsyncSession, class_id: int, user_id: int) -> bool: + result = await db.execute( + select(User) + .options(selectinload(User.memberships)) + .where(User.id == user_id) + ) + user = result.scalar_one_or_none() + if user is None or user.status != "inactive": + return False + membership = user.get_membership(class_id) + if membership is None: + return False + other_memberships = [item for item in user.memberships if item.class_id != class_id] + await db.delete(membership) + await db.flush() + if not other_memberships: + await db.delete(user) + await db.commit() + return True + + +async def clear_inactive_members(db: AsyncSession, class_id: int) -> int: + result = await db.execute( + select(User.id) + .join(ClassMembership) + .where( + ClassMembership.class_id == class_id, + User.status == "inactive", + ) + ) + user_ids = list(result.scalars().all()) + removed = 0 + for user_id in user_ids: + removed += 1 if await delete_inactive_member(db, class_id, user_id) else 0 + return removed diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index f379b8a..b54d29c 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -4,7 +4,7 @@ import logging from sqlalchemy import select, func, update from sqlalchemy.ext.asyncio import AsyncSession -from app.db.models import Notification, User +from app.db.models import ClassMembership, Notification, User from app.services.email_service import send_class_notification_email logger = logging.getLogger(__name__) @@ -43,10 +43,12 @@ async def create_notifications_for_class( email_body: str | None = None, email_action_path: str | None = None, ): - """Create in-app notifications + send email for all approved users in a class.""" + """Create in-app notifications + send email for all active users in a class.""" result = await db.execute( - select(User.id, User.email).where( - User.class_id == class_id, + select(User.id, User.email) + .join(ClassMembership, ClassMembership.user_id == User.id) + .where( + ClassMembership.class_id == class_id, User.status == "approved", ) ) diff --git a/backend/app/services/roster_service.py b/backend/app/services/roster_service.py deleted file mode 100644 index b0b2fc0..0000000 --- a/backend/app/services/roster_service.py +++ /dev/null @@ -1,125 +0,0 @@ -import secrets - -from sqlalchemy import select, func -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.models import StudentRoster, Class_ - - -def generate_invite_code(length: int = 8) -> str: - chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" - return "".join(secrets.choice(chars) for _ in range(length)) - - -async def ensure_invite_code(db: AsyncSession, class_id: int) -> str: - result = await db.execute(select(Class_).where(Class_.id == class_id)) - class_ = result.scalar_one_or_none() - if class_ is None: - return "" - if not class_.invite_code: - class_.invite_code = generate_invite_code() - await db.commit() - await db.refresh(class_) - return class_.invite_code - - -async def regenerate_invite_code(db: AsyncSession, class_id: int) -> str: - result = await db.execute(select(Class_).where(Class_.id == class_id)) - class_ = result.scalar_one_or_none() - if class_ is None: - return "" - class_.invite_code = generate_invite_code() - await db.commit() - await db.refresh(class_) - return class_.invite_code - - -async def import_roster( - db: AsyncSession, class_id: int, entries: list[dict] -) -> int: - existing_ids: set[str] = set() - result = await db.execute( - select(StudentRoster.student_id).where(StudentRoster.class_id == class_id) - ) - for row in result.all(): - existing_ids.add(row[0]) - - count = 0 - for entry in entries: - sid = entry.get("student_id", "").strip() - name = entry.get("name", "").strip() - if not sid or not name or sid in existing_ids: - continue - roster = StudentRoster(class_id=class_id, student_id=sid, name=name) - db.add(roster) - existing_ids.add(sid) - count += 1 - await db.commit() - return count - - -async def get_roster( - db: AsyncSession, class_id: int, page: int = 1, page_size: int = 50 -) -> tuple[list[StudentRoster], int]: - query = select(StudentRoster).where(StudentRoster.class_id == class_id) - count_query = select(func.count(StudentRoster.id)).where( - StudentRoster.class_id == class_id - ) - - total_result = await db.execute(count_query) - total = total_result.scalar() or 0 - - result = await db.execute( - query.order_by(StudentRoster.student_id) - .offset((page - 1) * page_size) - .limit(page_size) - ) - return list(result.scalars().all()), total - - -async def validate_registration( - db: AsyncSession, invite_code: str, student_id: str -) -> StudentRoster | None: - class_result = await db.execute( - select(Class_).where(Class_.invite_code == invite_code) - ) - class_ = class_result.scalar_one_or_none() - if class_ is None: - return None - - roster_result = await db.execute( - select(StudentRoster).where( - StudentRoster.class_id == class_.id, - StudentRoster.student_id == student_id, - StudentRoster.status == "unregistered", - ) - ) - return roster_result.scalar_one_or_none() - - -async def delete_roster_entry(db: AsyncSession, roster_id: int) -> bool: - result = await db.execute( - select(StudentRoster).where(StudentRoster.id == roster_id) - ) - entry = result.scalar_one_or_none() - if entry is None: - return False - if entry.status == "registered": - return False - await db.delete(entry) - await db.commit() - return True - - -async def clear_unregistered_roster(db: AsyncSession, class_id: int) -> int: - result = await db.execute( - select(StudentRoster).where( - StudentRoster.class_id == class_id, - StudentRoster.status == "unregistered", - ) - ) - entries = list(result.scalars().all()) - for entry in entries: - await db.delete(entry) - await db.commit() - return len(entries) diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index a951866..10a8d96 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -1,17 +1,33 @@ from sqlalchemy import select, func +from sqlalchemy.orm import selectinload from sqlalchemy.ext.asyncio import AsyncSession -from app.db.models import User -from app.schemas.user import UserOut, UserUpdate +from app.db.models import ClassMembership, User +from app.core.auth import hash_password +from app.schemas.user import TeacherCreateRequest, TeacherAssignRequest, UserUpdate async def get_user_by_email(db: AsyncSession, email: str) -> User | None: - result = await db.execute(select(User).where(User.email == email)) + result = await db.execute( + select(User) + .options( + selectinload(User.memberships), + selectinload(User.memberships).selectinload(ClassMembership.class_), + ) + .where(User.email == email) + ) return result.scalar_one_or_none() async def get_user_by_id(db: AsyncSession, user_id: int) -> User | None: - result = await db.execute(select(User).where(User.id == user_id)) + result = await db.execute( + select(User) + .options( + selectinload(User.memberships), + selectinload(User.memberships).selectinload(ClassMembership.class_), + ) + .where(User.id == user_id) + ) return result.scalar_one_or_none() @@ -48,6 +64,39 @@ async def update_user_status( return user +async def update_user_role( + db: AsyncSession, user: User, role: str +) -> User: + user.role = role + await db.commit() + await db.refresh(user) + return user + + +async def update_user_committee_role( + db: AsyncSession, user: User, class_id: int, committee_role: str | None +) -> User: + membership = user.get_membership(class_id) + if membership is None: + raise ValueError("User is not a member of the class") + membership.committee_role = committee_role + await db.commit() + await db.refresh(user) + return user + + +async def update_user_class_permissions( + db: AsyncSession, user: User, class_id: int, class_permissions: list[str] +) -> User: + membership = user.get_membership(class_id) + if membership is None: + raise ValueError("User is not a member of the class") + membership.set_class_permissions(class_permissions) + await db.commit() + await db.refresh(user) + return user + + async def list_users( db: AsyncSession, page: int = 1, @@ -56,12 +105,15 @@ async def list_users( status: str | None = None, role: str | None = None, ) -> tuple[list[User], int]: - query = select(User) + query = select(User).options( + selectinload(User.memberships), + selectinload(User.memberships).selectinload(ClassMembership.class_), + ) count_query = select(func.count(User.id)) if class_id is not None: - query = query.where(User.class_id == class_id) - count_query = count_query.where(User.class_id == class_id) + query = query.join(ClassMembership).where(ClassMembership.class_id == class_id) + count_query = count_query.join(ClassMembership).where(ClassMembership.class_id == class_id) if status is not None: query = query.where(User.status == status) count_query = count_query.where(User.status == status) @@ -75,6 +127,96 @@ async def list_users( query = query.order_by(User.created_at.desc()) query = query.offset((page - 1) * page_size).limit(page_size) result = await db.execute(query) - users = list(result.scalars().all()) + users = list(result.scalars().unique().all()) return users, total + + +async def create_or_assign_teacher( + db: AsyncSession, + data: TeacherCreateRequest, +) -> tuple[User, bool, bool]: + existing_user = await get_user_by_email(db, data.email) + created = False + assigned = False + + if existing_user is not None: + if existing_user.role != "teacher": + raise ValueError("该邮箱已存在且不是老师账号") + + membership = existing_user.get_membership(data.class_id) + if membership is None: + db.add( + ClassMembership( + user_id=existing_user.id, + class_id=data.class_id, + membership_role="teacher", + ) + ) + assigned = True + await db.commit() + refreshed = await get_user_by_id(db, existing_user.id) + if refreshed is None: + raise ValueError("老师账号创建失败") + refreshed.set_active_membership(data.class_id) + return refreshed, created, assigned + + existing_user.set_active_membership(data.class_id) + return existing_user, created, assigned + + user = User( + email=data.email, + password_hash=hash_password(data.password), + name=data.name.strip(), + student_id=None, + role="teacher", + status="approved", + ) + db.add(user) + await db.flush() + db.add( + ClassMembership( + user_id=user.id, + class_id=data.class_id, + membership_role="teacher", + ) + ) + await db.commit() + created = True + assigned = True + + refreshed = await get_user_by_id(db, user.id) + if refreshed is None: + raise ValueError("老师账号创建失败") + refreshed.set_active_membership(data.class_id) + return refreshed, created, assigned + + +async def assign_existing_teacher_to_class( + db: AsyncSession, + data: TeacherAssignRequest, +) -> tuple[User, bool]: + user = await get_user_by_email(db, data.email) + if user is None: + raise ValueError("未找到该邮箱对应的老师账号") + if user.role != "teacher": + raise ValueError("该邮箱对应的账号不是老师") + + membership = user.get_membership(data.class_id) + if membership is not None: + user.set_active_membership(data.class_id) + return user, False + + db.add( + ClassMembership( + user_id=user.id, + class_id=data.class_id, + membership_role="teacher", + ) + ) + await db.commit() + refreshed = await get_user_by_id(db, user.id) + if refreshed is None: + raise ValueError("老师分配失败") + refreshed.set_active_membership(data.class_id) + return refreshed, True diff --git a/backend/seed_demo.py b/backend/seed_demo.py index 9d70c43..6cd68a3 100644 --- a/backend/seed_demo.py +++ b/backend/seed_demo.py @@ -18,7 +18,7 @@ from app.db.base import Base from app.db.models import ( Class_, User, Timeline, TimelineLike, TimelineComment, Schedule, Announcement, Resource, Notification, - StudentRoster, Vote, VoteOption, VoteResponse, + ClassMembership, Vote, VoteOption, VoteResponse, Assignment, AssignmentSubmission, ) from app.core.auth import hash_password @@ -231,23 +231,29 @@ async def seed(): await db.flush() print(f"[+] Class: {cls.name} (invite code: {cls.invite_code})") - # ── 2. Create class admin ──────────────────────────────────────── - admin = User( + # ── 2. Create teacher ──────────────────────────────────────────── + teacher = User( email=CLASS_ADMIN["email"], password_hash=pwd_hash, name=CLASS_ADMIN["name"], - role="class_admin", + role="teacher", status="approved", - class_id=cls.id, industry="教育", company="香港大学", position="教授", bio="香港大学中国商业学院教授,专注于战略管理和企业转型研究。", wechat_id="lin_prof_hku", ) - db.add(admin) + db.add(teacher) await db.flush() - print(f"[+] Class Admin: {admin.name} ({admin.email})") + db.add( + ClassMembership( + user_id=teacher.id, + class_id=cls.id, + membership_role="teacher", + ) + ) + print(f"[+] Teacher: {teacher.name} ({teacher.email})") # ── 3. Create students ─────────────────────────────────────────── COMMITTEE_MAP = {0: "班长", 1: "副班长", 3: "学习委员", 5: "组织委员", 7: "宣传委员", 9: "文体委员"} @@ -261,11 +267,9 @@ async def seed(): student_id=s["student_id"], role="student", status="approved", - class_id=cls.id, industry=INDUSTRIES[i % len(INDUSTRIES)], company=COMPANIES[i % len(COMPANIES)], position=POSITIONS[i % len(POSITIONS)], - committee_role=COMMITTEE_MAP.get(i), skills_tags='["' + '", "'.join(skills) + '"]', bio=f"在{COMPANIES[i % len(COMPANIES)]}担任{POSITIONS[i % len(POSITIONS)]},拥有丰富的{INDUSTRIES[i % len(INDUSTRIES)]}行业经验。", wechat_id=f"wx_{s['student_id']}", @@ -275,23 +279,19 @@ async def seed(): students.append(user) await db.flush() + for user in students: + db.add( + ClassMembership( + user_id=user.id, + class_id=cls.id, + membership_role="student", + committee_role=COMMITTEE_MAP.get(i), + ) + ) print(f"[+] {len(students)} students created (password: demo123)") - # ── 4. Student roster ──────────────────────────────────────────── - for s in students: - roster = StudentRoster( - class_id=cls.id, - student_id=s.student_id, - name=s.name, - status="registered", - user_id=s.id, - ) - db.add(roster) - await db.flush() - print(f"[+] {len(students)} roster entries created") - - # ── 5. Timelines with likes and comments ───────────────────────── - all_users = [admin] + students + # ── 4. Timelines with likes and comments ───────────────────────── + all_users = [teacher] + students for i, post_data in enumerate(TIMELINE_POSTS): author = random.choice(all_users) post = Timeline( diff --git a/frontend/next.config.ts b/frontend/next.config.ts index bf85763..2012776 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -3,6 +3,18 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "standalone", allowedDevOrigins: ["192.168.31.172"], + images: { + remotePatterns: [ + { + protocol: "http", + hostname: "**", + }, + { + protocol: "https", + hostname: "**", + }, + ], + }, }; export default nextConfig; diff --git a/frontend/src/app/(app)/admin/classes/page.tsx b/frontend/src/app/(app)/admin/classes/page.tsx index df728cc..df70224 100644 --- a/frontend/src/app/(app)/admin/classes/page.tsx +++ b/frontend/src/app/(app)/admin/classes/page.tsx @@ -1,8 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { useAuth } from "@/hooks/use-auth"; -import { fetchAPI, postAPI, putAPI, deleteAPI } from "@/lib/api"; +import { fetchAPI, postAPI, putAPI, deleteAPI, getErrorMessage } from "@/lib/api"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -21,7 +20,6 @@ import { } from "@/components/ui/dialog"; export default function ClassesPage() { - const { user } = useAuth(); const [classes, setClasses] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -35,10 +33,10 @@ export default function ClassesPage() { const loadClasses = async () => { setError(null); try { - const res = await fetchAPI("/api/classes/"); + const res = await fetchAPI<{ items: ClassInfo[] }>("/api/classes/"); setClasses(res.items || []); - } catch (err: any) { - setError(err.message || "加载失败"); + } catch (err: unknown) { + setError(getErrorMessage(err, "加载失败")); } finally { setLoading(false); } @@ -90,8 +88,8 @@ export default function ClassesPage() { setDialogOpen(false); resetForm(); loadClasses(); - } catch (err: any) { - toast.error(err.message || "操作失败"); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "操作失败")); } finally { setSubmitting(false); } @@ -105,8 +103,8 @@ export default function ClassesPage() { toast.success("已删除"); setDeleteTarget(null); loadClasses(); - } catch (err: any) { - toast.error(err.message || "删除失败"); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "删除失败")); } }; @@ -114,8 +112,9 @@ export default function ClassesPage() {
-

班级管理

-

创建和管理班级

+
Classes
+

班级管理

+

创建、调整与维护研究生班级信息

{ setDialogOpen(open); if (!open) resetForm(); }}> @@ -163,7 +162,7 @@ export default function ClassesPage() { {loading ? (
{[1, 2].map((i) => ( -
+
))}
) : error ? ( @@ -171,11 +170,11 @@ export default function ClassesPage() { ) : (
{classes.map((cls) => ( - +
-

{cls.name}

-

+

{cls.name}

+

{cls.cohort_year}届 · {cls.member_count} 名成员

diff --git a/frontend/src/app/(app)/admin/members/page.tsx b/frontend/src/app/(app)/admin/members/page.tsx index 9f55c6f..cb5ea01 100644 --- a/frontend/src/app/(app)/admin/members/page.tsx +++ b/frontend/src/app/(app)/admin/members/page.tsx @@ -1,9 +1,16 @@ "use client"; -import { useEffect, useState, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useAuth } from "@/hooks/use-auth"; import { useActiveClass } from "@/hooks/use-active-class"; -import { fetchAPI, putAPI, postAPI, deleteAPI, uploadAPI } from "@/lib/api"; +import { + deleteAPI, + fetchAPI, + getErrorMessage, + postAPI, + putAPI, + uploadAPI, +} from "@/lib/api"; import { Card, CardContent } from "@/components/ui/card"; import { ErrorState } from "@/components/error-state"; import { Pagination } from "@/components/pagination"; @@ -12,6 +19,7 @@ import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Select, SelectContent, @@ -28,15 +36,161 @@ import { } from "@/components/ui/dialog"; import { ConfirmDialog } from "@/components/confirm-dialog"; import { toast } from "sonner"; -import type { UserListItem, RosterEntry } from "@/lib/types"; -import { ROLES } from "@/lib/constants"; +import type { + ClassPermission, + InactiveMemberEntry, + PageResponse, + UserListItem, +} from "@/lib/types"; +import { CLASS_PERMISSIONS, ROLES } from "@/lib/constants"; +import { hasClassPermission } from "@/lib/permissions"; + +type InviteCodeResponse = { + invite_code: string; +}; + +type MessageResponse = { + message: string; +}; + +type TeacherCreateRequest = { + class_id: number; + name: string; + email: string; + password: string; +}; + +type TeacherCreateResponse = { + message: string; + user: UserListItem; +}; + +type TeacherAssignRequest = { + class_id: number; + email: string; +}; + +type TeacherAssignResponse = { + message: string; + user: UserListItem; +}; + +const PERMISSION_GROUPS: Array<{ + title: string; + description: string; + permissions: ClassPermission[]; +}> = [ + { + title: "成员与组织", + description: "管理班级成员、班委和基础可见范围", + permissions: ["class_view", "member_view", "member_manage", "committee_manage"], + }, + { + title: "内容与活动", + description: "管理公告、动态、投票、排期和资源库", + permissions: [ + "announcement_manage", + "timeline_manage", + "vote_manage", + "schedule_manage", + "resource_manage", + ], + }, + { + title: "教学与财务", + description: "管理作业、班费和模块开关", + permissions: ["assignment_manage", "fund_manage", "module_manage"], + }, +]; + +const COMMITTEE_PRESETS: Array<{ + role: string; + description: string; + permissions: ClassPermission[]; +}> = [ + { + role: "班长", + description: "统筹班级日常管理与主要内容发布", + permissions: [ + "class_view", + "member_view", + "member_manage", + "committee_manage", + "announcement_manage", + "timeline_manage", + "vote_manage", + "schedule_manage", + "resource_manage", + ], + }, + { + role: "副班长", + description: "协助班长处理班级组织和内容管理", + permissions: [ + "class_view", + "member_view", + "member_manage", + "announcement_manage", + "timeline_manage", + "vote_manage", + "schedule_manage", + "resource_manage", + ], + }, + { + role: "学习委员", + description: "负责学习通知、作业协同与课程安排", + permissions: [ + "class_view", + "member_view", + "announcement_manage", + "assignment_manage", + "schedule_manage", + "resource_manage", + ], + }, + { + role: "组织委员", + description: "负责活动组织、投票协同和排期推进", + permissions: [ + "class_view", + "member_view", + "timeline_manage", + "vote_manage", + "schedule_manage", + "resource_manage", + ], + }, + { + role: "生活委员", + description: "负责生活事务通知与班费相关管理", + permissions: [ + "class_view", + "member_view", + "announcement_manage", + "fund_manage", + "resource_manage", + ], + }, + { + role: "文体委员", + description: "负责文体活动、动态发布与投票互动", + permissions: [ + "class_view", + "member_view", + "timeline_manage", + "vote_manage", + "schedule_manage", + "resource_manage", + ], + }, +]; export default function MembersPage() { const { user } = useAuth(); const { activeClassId } = useActiveClass(); - // Tab state - const [activeTab, setActiveTab] = useState<"members" | "roster">("roster"); + const [activeTab, setActiveTab] = useState<"members" | "inactive">("inactive"); // Members state const [members, setMembers] = useState([]); @@ -44,104 +198,130 @@ export default function MembersPage() { const [membersError, setMembersError] = useState(null); const [membersPage, setMembersPage] = useState(1); const [membersTotalPages, setMembersTotalPages] = useState(1); - // Roster state - const [roster, setRoster] = useState([]); - const [rosterLoading, setRosterLoading] = useState(true); - const [rosterError, setRosterError] = useState(null); - const [rosterPage, setRosterPage] = useState(1); - const [rosterTotalPages, setRosterTotalPages] = useState(1); + // Inactive member state + const [inactiveMembers, setInactiveMembers] = useState([]); + const [inactiveLoading, setInactiveLoading] = useState(true); + const [inactiveError, setInactiveError] = useState(null); + const [inactivePage, setInactivePage] = useState(1); + const [inactiveTotalPages, setInactiveTotalPages] = useState(1); const [inviteCode, setInviteCode] = useState(""); const [importOpen, setImportOpen] = useState(false); const [importText, setImportText] = useState(""); const [importing, setImporting] = useState(false); const [clearTarget, setClearTarget] = useState(false); + const [teacherCreateOpen, setTeacherCreateOpen] = useState(false); + const [teacherName, setTeacherName] = useState(""); + const [teacherEmail, setTeacherEmail] = useState(""); + const [teacherPassword, setTeacherPassword] = useState(""); + const [teacherSubmitting, setTeacherSubmitting] = useState(false); + const [teacherAssignOpen, setTeacherAssignOpen] = useState(false); + const [teacherAssignEmail, setTeacherAssignEmail] = useState(""); + const [teacherAssignSubmitting, setTeacherAssignSubmitting] = useState(false); + const [memberManagerOpen, setMemberManagerOpen] = useState(false); + const [managingMember, setManagingMember] = useState(null); + const [memberRoleValue, setMemberRoleValue] = useState("student"); + const [memberCommitteeValue, setMemberCommitteeValue] = useState(""); + const [memberPermissionsValue, setMemberPermissionsValue] = useState([]); + const [memberSubmitting, setMemberSubmitting] = useState(false); const fileInputRef = useRef(null); const isSuperAdmin = user?.role === "super_admin"; + const canManageCommittee = hasClassPermission(user, "committee_manage", activeClassId); + const canOpenMemberManager = isSuperAdmin || canManageCommittee; // Load members - const loadMembers = async () => { + const loadMembers = useCallback(async () => { if (!activeClassId) { + setMembers([]); setMembersLoading(false); return; } setMembersLoading(true); setMembersError(null); try { - const res = await fetchAPI(`/api/classes/${activeClassId}/members`, { - page: String(membersPage), - page_size: "20", - }); - setMembers(res.items || []); - setMembersTotalPages(res.total_pages || 1); - } catch (err: any) { - setMembersError(err.message || "加载失败"); + const res = await fetchAPI>( + `/api/classes/${activeClassId}/members`, + { + status: "approved", + page: String(membersPage), + page_size: "20", + } + ); + setMembers(res.items ?? []); + setMembersTotalPages(res.total_pages ?? 1); + } catch (err: unknown) { + setMembersError(getErrorMessage(err, "加载失败")); } finally { setMembersLoading(false); } - }; + }, [activeClassId, membersPage]); - // Load roster - const loadRoster = async () => { + // Load inactive members + const loadInactiveMembers = useCallback(async () => { if (!activeClassId) { - setRosterLoading(false); + setInactiveMembers([]); + setInviteCode(""); + setInactiveLoading(false); return; } - setRosterLoading(true); - setRosterError(null); + setInactiveLoading(true); + setInactiveError(null); try { - const [rosterRes, codeRes] = await Promise.all([ - fetchAPI(`/api/classes/${activeClassId}/roster`, { - page: String(rosterPage), + const [inactiveRes, codeRes] = await Promise.all([ + fetchAPI>(`/api/classes/${activeClassId}/inactive-members`, { + page: String(inactivePage), page_size: "50", }), - fetchAPI(`/api/classes/${activeClassId}/invite-code`), + fetchAPI(`/api/classes/${activeClassId}/invite-code`), ]); - setRoster(rosterRes.items || []); - setRosterTotalPages(rosterRes.total_pages || 1); - setInviteCode(codeRes.invite_code || ""); - } catch (err: any) { - setRosterError(err.message || "加载失败"); + setInactiveMembers(inactiveRes.items ?? []); + setInactiveTotalPages(inactiveRes.total_pages ?? 1); + setInviteCode(codeRes.invite_code ?? ""); + } catch (err: unknown) { + setInactiveError(getErrorMessage(err, "加载失败")); } finally { - setRosterLoading(false); + setInactiveLoading(false); } - }; + }, [activeClassId, inactivePage]); useEffect(() => { if (!activeClassId) return; - if (activeTab === "members") loadMembers(); - else loadRoster(); - }, [activeClassId, activeTab, membersPage, rosterPage]); + if (activeTab === "members") { + void loadMembers(); + return; + } + void loadInactiveMembers(); + }, [activeClassId, activeTab, loadInactiveMembers, loadMembers]); // Member actions const handleRoleChange = async (userId: number, newRole: string) => { try { - await putAPI(`/api/users/${userId}/status`, { - status: "approved", + await putAPI(`/api/users/${userId}/role`, { role: newRole, }); toast.success( `角色已更新为 ${ROLES[newRole as keyof typeof ROLES] || newRole}` ); - loadMembers(); - } catch (err: any) { - toast.error(err.message || "操作失败"); + await loadMembers(); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "操作失败")); } }; const handleCommitteeRoleChange = async (userId: number, newRole: string) => { try { - await putAPI(`/api/users/${userId}/committee-role`, { + await putAPI(`/api/users/${userId}/committee-role`, { + class_id: activeClassId, committee_role: newRole === "__none__" ? null : newRole, }); toast.success(newRole === "__none__" ? "已清除班委标签" : `已设置班委标签: ${newRole}`); - loadMembers(); - } catch (err: any) { - toast.error(err.message || "操作失败"); + await loadMembers(); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "操作失败")); } }; - // Roster actions + // Inactive member actions const handleCopyCode = () => { navigator.clipboard.writeText(inviteCode); toast.success("邀请码已复制"); @@ -149,11 +329,13 @@ export default function MembersPage() { const handleRegenerateCode = async () => { try { - const res = await postAPI(`/api/classes/${activeClassId}/invite-code/regenerate`); + const res = await postAPI( + `/api/classes/${activeClassId}/invite-code/regenerate` + ); setInviteCode(res.invite_code); toast.success("邀请码已重新生成"); - } catch (err: any) { - toast.error(err.message || "操作失败"); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "操作失败")); } }; @@ -174,15 +356,18 @@ export default function MembersPage() { toast.error("未解析到有效数据,每行格式:学号 姓名"); return; } - const res = await postAPI(`/api/classes/${activeClassId}/roster/import`, { - entries, - }); + const res = await postAPI( + `/api/classes/${activeClassId}/inactive-members/import`, + { + entries, + } + ); toast.success(res.message); setImportOpen(false); setImportText(""); - loadRoster(); - } catch (err: any) { - toast.error(err.message || "导入失败"); + await loadInactiveMembers(); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "导入失败")); } finally { setImporting(false); } @@ -195,63 +380,232 @@ export default function MembersPage() { try { const formData = new FormData(); formData.append("file", fileInput.files[0]); - const res = await uploadAPI(`/api/classes/${activeClassId}/roster/upload`, formData); - toast.success((res as any).message); + const res = await uploadAPI( + `/api/classes/${activeClassId}/inactive-members/upload`, + formData + ); + toast.success(res.message); setImportOpen(false); if (fileInput) fileInput.value = ""; - loadRoster(); - } catch (err: any) { - toast.error(err.message || "上传失败"); + await loadInactiveMembers(); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "上传失败")); } finally { setImporting(false); } }; - const handleDeleteRoster = async (rosterId: number) => { + const handleDeleteInactiveMember = async (userId: number) => { try { - await deleteAPI(`/api/classes/${activeClassId}/roster/${rosterId}`); + await deleteAPI(`/api/classes/${activeClassId}/inactive-members/${userId}`); toast.success("已删除"); - loadRoster(); - } catch (err: any) { - toast.error(err.message || "删除失败"); + await loadInactiveMembers(); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "删除失败")); } }; const handleClearRoster = async () => { try { - const res = await postAPI(`/api/classes/${activeClassId}/roster/clear`); + const res = await postAPI(`/api/classes/${activeClassId}/inactive-members/clear`); toast.success(res.message); setClearTarget(false); - loadRoster(); - } catch (err: any) { - toast.error(err.message || "操作失败"); + await loadInactiveMembers(); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "操作失败")); } }; - const [editingCommitteeId, setEditingCommitteeId] = useState(null); - const [editingCommitteeValue, setEditingCommitteeValue] = useState(""); + const togglePermission = (permission: ClassPermission) => { + setMemberPermissionsValue((prev) => + prev.includes(permission) + ? prev.filter((item) => item !== permission) + : [...prev, permission] + ); + }; + + const applyPermissionPreset = (permissions: ClassPermission[]) => { + setMemberPermissionsValue(Array.from(new Set(permissions))); + }; + + const applyCommitteePreset = (presetRole: string) => { + const preset = COMMITTEE_PRESETS.find((item) => item.role === presetRole); + if (!preset) { + return; + } + setMemberCommitteeValue(preset.role); + applyPermissionPreset(preset.permissions); + }; + + const handlePermissionsSave = async (userId: number, permissions: ClassPermission[]) => { + try { + await putAPI(`/api/users/${userId}/class-permissions`, { + class_id: activeClassId, + class_permissions: permissions, + }); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "操作失败")); + throw err; + } + }; + + const resetTeacherForm = () => { + setTeacherName(""); + setTeacherEmail(""); + setTeacherPassword(""); + }; + + const resetTeacherAssignForm = () => { + setTeacherAssignEmail(""); + }; + + const handleCreateTeacher = async () => { + if (!activeClassId) return; + if (!teacherName.trim()) { + toast.error("请输入老师姓名"); + return; + } + if (!teacherEmail.trim()) { + toast.error("请输入老师邮箱"); + return; + } + if (teacherPassword.length < 8) { + toast.error("初始密码至少 8 位"); + return; + } + + setTeacherSubmitting(true); + try { + const payload: TeacherCreateRequest = { + class_id: activeClassId, + name: teacherName.trim(), + email: teacherEmail.trim(), + password: teacherPassword, + }; + const res = await postAPI("/api/users/teachers", payload); + toast.success(res.message); + setTeacherCreateOpen(false); + resetTeacherForm(); + if (activeTab === "members") { + await loadMembers(); + } + } catch (err: unknown) { + toast.error(getErrorMessage(err, "创建老师失败")); + } finally { + setTeacherSubmitting(false); + } + }; + + const handleAssignTeacher = async () => { + if (!activeClassId) return; + if (!teacherAssignEmail.trim()) { + toast.error("请输入老师邮箱"); + return; + } + + setTeacherAssignSubmitting(true); + try { + const payload: TeacherAssignRequest = { + class_id: activeClassId, + email: teacherAssignEmail.trim(), + }; + const res = await postAPI("/api/users/teachers/assign", payload); + toast.success(res.message); + setTeacherAssignOpen(false); + resetTeacherAssignForm(); + if (activeTab === "members") { + await loadMembers(); + } + } catch (err: unknown) { + toast.error(getErrorMessage(err, "分配老师失败")); + } finally { + setTeacherAssignSubmitting(false); + } + }; + + const openMemberManager = (member: UserListItem) => { + setManagingMember(member); + setMemberRoleValue(member.role); + setMemberCommitteeValue(member.committee_role ?? ""); + setMemberPermissionsValue(member.class_permissions ?? []); + setMemberManagerOpen(true); + }; + + const resetMemberManager = () => { + setManagingMember(null); + setMemberRoleValue("student"); + setMemberCommitteeValue(""); + setMemberPermissionsValue([]); + setMemberSubmitting(false); + }; + + const handleMemberManagerSave = async () => { + if (!managingMember || !activeClassId) return; + + const originalRole = managingMember.role; + const originalCommittee = managingMember.committee_role ?? ""; + const originalPermissions = managingMember.class_permissions ?? []; + const normalizedCommittee = memberCommitteeValue.trim(); + const permissionsChanged = + originalPermissions.length !== memberPermissionsValue.length || + originalPermissions.some((permission) => !memberPermissionsValue.includes(permission)); + + setMemberSubmitting(true); + try { + if (isSuperAdmin && memberRoleValue !== originalRole) { + await handleRoleChange(managingMember.id, memberRoleValue); + } + + if (memberRoleValue !== "super_admin" && canManageCommittee) { + if (normalizedCommittee !== originalCommittee) { + await handleCommitteeRoleChange( + managingMember.id, + normalizedCommittee ? normalizedCommittee : "__none__" + ); + } + + if (permissionsChanged) { + await handlePermissionsSave(managingMember.id, memberPermissionsValue); + toast.success("成员设置已更新"); + } else if (normalizedCommittee !== originalCommittee || memberRoleValue !== originalRole) { + toast.success("成员设置已更新"); + } + } else if (memberRoleValue !== originalRole) { + toast.success("成员设置已更新"); + } + + setMemberManagerOpen(false); + resetMemberManager(); + await loadMembers(); + } catch { + // Errors are surfaced by the called actions. + } finally { + setMemberSubmitting(false); + } + }; return (
-

成员与花名册

-

管理班级花名册、邀请码和已注册成员

+
Members
+

成员管理

+

管理成员导入、激活码和班级成员权限

{!activeClassId ? ( -
+
{isSuperAdmin ? "请在顶部选择一个班级" : "您尚未分配班级"}
) : ( <> {/* Tab switcher */} -
+
- {activeTab === "roster" && ( + {activeTab === "inactive" && (
{/* Invite code section */} - +
-

班级邀请码

-

+

成员激活码

+

{inviteCode || "—"}

@@ -283,8 +637,8 @@ export default function MembersPage() {
-

- 将此邀请码分享给学生,学生注册时输入邀请码+学号即可加入班级 +

+ 将此激活码分享给成员,成员输入激活码和学号后即可激活账号

@@ -293,11 +647,11 @@ export default function MembersPage() {
{ setImportOpen(open); if (!open) setImportText(""); }}> - + - 导入花名册 + 导入成员
@@ -351,52 +705,48 @@ export default function MembersPage() {
- {/* Roster list */} - {rosterLoading ? ( + {/* Inactive member list */} + {inactiveLoading ? (
{[1, 2, 3].map((i) => ( - +
))}
- ) : rosterError ? ( - - ) : roster.length === 0 ? ( -
- 暂无花名册数据,点击「导入花名册」添加 + ) : inactiveError ? ( + + ) : inactiveMembers.length === 0 ? ( +
+ 暂无未激活成员,点击「导入成员」添加
) : (
- {roster.map((r) => ( - + {inactiveMembers.map((r) => ( +
-

{r.name}

-

{r.student_id}

+

{r.name}

+

{r.student_id}

- - {r.status === "registered" ? "已注册" : "未注册"} - - {r.status === "unregistered" && ( - - )} + 未激活 +
@@ -405,21 +755,127 @@ export default function MembersPage() { )}
)} {activeTab === "members" && (
-

已注册成员

+
+

已激活成员

+ {isSuperAdmin && ( +
+ { + setTeacherAssignOpen(open); + if (!open) { + resetTeacherAssignForm(); + } + }} + > + + + + + + 分配已有老师到当前班级 + +
+
+ + setTeacherAssignEmail(e.target.value)} + placeholder="teacher@example.com" + /> +
+

+ 适合把已经存在的老师账号继续加入当前班级,不会重复创建账号。 +

+ +
+
+
+ + { + setTeacherCreateOpen(open); + if (!open) { + resetTeacherForm(); + } + }} + > + + + + + + 创建老师账号 + +
+
+ + setTeacherName(e.target.value)} + placeholder="请输入老师姓名" + /> +
+
+ + setTeacherEmail(e.target.value)} + placeholder="teacher@example.com" + /> +
+
+ + setTeacherPassword(e.target.value)} + placeholder="至少 8 位" + /> +
+

+ 创建后该老师会直接加入当前选中的班级,可继续加入其他班级。 +

+ +
+
+
+
+ )} +
{membersLoading ? (
{[1, 2, 3].map((i) => ( - +
@@ -429,89 +885,63 @@ export default function MembersPage() { ) : membersError ? ( ) : members.length === 0 ? ( -
暂无已注册成员
+
暂无已激活成员
) : (
{members.map((m) => ( - - -
+ + +
-

+

{m.name} {m.committee_role && ( - + {m.committee_role} )}

-

+

{m.email} {m.student_id ? ` · ${m.student_id}` : ""} {m.company ? ` · ${m.company}` : ""}

+ +
+ {m.class_permissions && m.class_permissions.length > 0 ? ( + m.class_permissions.map((permission) => ( + + {CLASS_PERMISSIONS[permission]} + + )) + ) : ( + + 普通成员 + + )} +
-
- {m.role !== "super_admin" && editingCommitteeId === m.id ? ( - setEditingCommitteeValue(e.target.value)} - onBlur={() => { - if (editingCommitteeValue.trim()) { - handleCommitteeRoleChange(m.id, editingCommitteeValue.trim()); - } else if (m.committee_role) { - handleCommitteeRoleChange(m.id, "__none__"); - } - setEditingCommitteeId(null); - setEditingCommitteeValue(""); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - (e.target as HTMLInputElement).blur(); - } - if (e.key === "Escape") { - setEditingCommitteeId(null); - setEditingCommitteeValue(""); - } - }} - autoFocus - /> - ) : m.role !== "super_admin" ? ( +
+ + {ROLES[m.role as keyof typeof ROLES] || m.role} + + {canOpenMemberManager && ( - ) : null} - {isSuperAdmin ? ( - - ) : ( - - {ROLES[m.role as keyof typeof ROLES] || m.role} - )}
@@ -533,12 +963,252 @@ export default function MembersPage() { { if (!open) setClearTarget(false); }} - title="清除未注册花名册" - description="确定清除所有未注册的花名册条目?此操作不可恢复。" + title="清除未激活成员" + description="确定清除当前班级所有未激活成员?此操作不可恢复。" confirmText="清除" variant="destructive" onConfirm={handleClearRoster} /> + + { + setMemberManagerOpen(open); + if (!open) { + resetMemberManager(); + } + }} + > + + +
+ 成员设置 +
+
+ {managingMember && ( +
+
+
+
+

{managingMember.name}

+ + {ROLES[managingMember.role as keyof typeof ROLES] || managingMember.role} + + {managingMember.committee_role && ( + + {managingMember.committee_role} + + )} +
+

+ {managingMember.email} + {managingMember.student_id ? ` · ${managingMember.student_id}` : ""} +

+
+
+ +
+ + + + 身份设置 + + + 权限设置 + + + + + {isSuperAdmin && managingMember.role !== "super_admin" ? ( +
+
+

系统角色

+

+ 角色决定是否具备跨班管理能力。老师和同学都可以在当前班级里继续配置班委身份。 +

+
+ +
+ ) : ( +
+

系统角色

+

+ 当前成员的系统角色为 + + {ROLES[memberRoleValue as keyof typeof ROLES] || memberRoleValue} + + 。 +

+
+ )} + + {managingMember.role !== "super_admin" && canManageCommittee && ( +
+
+

班级身份

+

+ 在这里设置当前班级中的班委身份。预设会自动带出推荐权限。 +

+
+ +
+ + setMemberCommitteeValue(e.target.value)} + placeholder="例如:班长、学习委员,留空表示不是班委" + /> +
+ {COMMITTEE_PRESETS.map((preset) => ( + + ))} +
+
+ {COMMITTEE_PRESETS.find((preset) => preset.role === memberCommitteeValue)?.description ?? + "可先选择班委预设,再去权限页微调具体管理能力。"} +
+
+
+ )} +
+ + + {managingMember.role !== "super_admin" && canManageCommittee ? ( +
+
+
+ +
+ + +
+
+ + {memberCommitteeValue && + COMMITTEE_PRESETS.find((preset) => preset.role === memberCommitteeValue) && ( +
+ {COMMITTEE_PRESETS.find((preset) => preset.role === memberCommitteeValue)!.permissions.map( + (permission) => ( + + {CLASS_PERMISSIONS[permission]} + + ) + )} +
+ )} +
+ +
+ {PERMISSION_GROUPS.map((group) => ( +
+
+

{group.title}

+

{group.description}

+
+
+ {group.permissions.map((permission) => ( + + ))} +
+
+ ))} +
+
+ ) : ( +
+

当前成员暂无可配置的班级权限项。

+
+ )} +
+
+
+ +
+ + +
+
+ )} +
+
); } diff --git a/frontend/src/app/(app)/admin/modules/page.tsx b/frontend/src/app/(app)/admin/modules/page.tsx index 019fad4..b61b904 100644 --- a/frontend/src/app/(app)/admin/modules/page.tsx +++ b/frontend/src/app/(app)/admin/modules/page.tsx @@ -1,17 +1,17 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useActiveClass } from "@/hooks/use-active-class"; import { useAuth } from "@/hooks/use-auth"; -import { fetchAPI, putAPI } from "@/lib/api"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { fetchAPI, getErrorMessage, putAPI } from "@/lib/api"; +import { Card, CardContent } 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: "directory", label: "成员名录", desc: "查看班级成员名录" }, { key: "timeline", label: "班级动态", desc: "分享班级动态和互动" }, { key: "assignments", label: "作业", desc: "发布和提交课程作业" }, { key: "votes", label: "投票", desc: "发起班级投票活动" }, @@ -33,7 +33,7 @@ export default function ModulesPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const loadModules = async () => { + const loadModules = useCallback(async () => { if (!activeClassId) { setLoading(false); return; @@ -43,16 +43,16 @@ export default function ModulesPage() { try { const res = await fetchAPI(`/api/classes/${activeClassId}/modules`); setConfig(res); - } catch (err: any) { - setError(err.message || "加载失败"); + } catch (err: unknown) { + setError(getErrorMessage(err, "加载失败")); } finally { setLoading(false); } - }; + }, [activeClassId]); useEffect(() => { - loadModules(); - }, [activeClassId]); + void loadModules(); + }, [loadModules]); const handleToggle = async (moduleKey: string, enabled: boolean) => { if (!config || !activeClassId) return; @@ -76,16 +76,16 @@ export default function ModulesPage() { ? `已启用「${ALL_MODULES.find((m) => m.key === moduleKey)?.label}」` : `已禁用「${ALL_MODULES.find((m) => m.key === moduleKey)?.label}」` ); - } catch (err: any) { + } catch (err: unknown) { // Rollback setConfig(config); - toast.error(err.message || "操作失败"); + toast.error(getErrorMessage(err, "操作失败")); } }; if (!activeClassId) { return ( -
+
请先选择一个班级
); @@ -94,11 +94,12 @@ export default function ModulesPage() { return (
-

模块管理

-

+

Modules
+

模块管理

+

班级: {activeClassName}

-

+

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

@@ -106,7 +107,7 @@ export default function ModulesPage() { {loading ? (
{[1, 2, 3, 4].map((i) => ( - +
@@ -118,11 +119,11 @@ export default function ModulesPage() { ) : (
{ALL_MODULES.map((module) => ( - +
-

{module.label}

-

{module.desc}

+

{module.label}

+

{module.desc}

-

管理后台

-

+

Administration
+

班级管理台

+

当前角色: {user?.role ? ROLES[user.role] : "-"}

- + - 成员管理 + 成员管理 -

- 审核注册申请、管理成员状态 +

+ 导入成员、管理未激活成员和班级权限

- + - 班级管理 + 班级管理 -

+

创建和管理班级信息

@@ -45,12 +46,12 @@ export default function AdminPage() { - + - 模块管理 + 模块管理 -

+

控制班级功能模块的显示

diff --git a/frontend/src/app/(app)/announcements/page.tsx b/frontend/src/app/(app)/announcements/page.tsx index b727e10..35361b6 100644 --- a/frontend/src/app/(app)/announcements/page.tsx +++ b/frontend/src/app/(app)/announcements/page.tsx @@ -1,8 +1,8 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useActiveClass } from "@/hooks/use-active-class"; -import { fetchAPI, postAPI, putAPI, deleteAPI } from "@/lib/api"; +import { fetchAPI, postAPI, putAPI, deleteAPI, getErrorMessage } from "@/lib/api"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -20,7 +20,7 @@ import { ConfirmDialog } from "@/components/confirm-dialog"; import { ErrorState } from "@/components/error-state"; import { Pagination } from "@/components/pagination"; import { toast } from "sonner"; -import type { Announcement } from "@/lib/types"; +import type { Announcement, PageResponse } from "@/lib/types"; export default function AnnouncementsPage() { const { activeClassId } = useActiveClass(); @@ -41,27 +41,33 @@ export default function AnnouncementsPage() { // Delete state const [deleteTarget, setDeleteTarget] = useState(null); - const loadAnnouncements = async () => { + const loadAnnouncements = useCallback(async () => { + if (!activeClassId) { + setAnnouncements([]); + setError(null); + setLoading(false); + return; + } + setLoading(true); setError(null); try { - const res = await fetchAPI("/api/announcements/", { + const res = await fetchAPI>("/api/announcements/", { page_size: "10", page: String(page), class_id: String(activeClassId), }); - setAnnouncements(res.items || []); - setTotalPages(res.total_pages || 1); - } catch (err: any) { - setError(err.message || "加载失败"); + setAnnouncements(res.items ?? []); + setTotalPages(res.total_pages ?? 1); + } catch (err: unknown) { + setError(getErrorMessage(err, "加载失败")); } finally { setLoading(false); } - }; + }, [activeClassId, page]); useEffect(() => { - if (!activeClassId) return; - loadAnnouncements(); - }, [activeClassId, page]); + void loadAnnouncements(); + }, [loadAnnouncements]); const resetForm = () => { setEditingId(null); @@ -90,19 +96,18 @@ export default function AnnouncementsPage() { }); toast.success("公告已更新"); } else { - await postAPI("/api/announcements/", { + await postAPI(`/api/announcements/?class_id=${activeClassId}`, { title: newTitle, content: newContent || null, is_pinned: newIsPinned, - class_id: activeClassId, }); toast.success("公告已发布"); } setDialogOpen(false); resetForm(); - loadAnnouncements(); - } catch (err: any) { - toast.error(err.message || (editingId ? "更新失败" : "发布失败")); + await loadAnnouncements(); + } catch (err: unknown) { + toast.error(getErrorMessage(err, editingId ? "更新失败" : "发布失败")); } finally { setSubmitting(false); } @@ -113,9 +118,9 @@ export default function AnnouncementsPage() { await deleteAPI(`/api/announcements/${id}`); toast.success("已删除"); setDeleteTarget(null); - loadAnnouncements(); - } catch (err: any) { - toast.error(err.message || "删除失败"); + await loadAnnouncements(); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "删除失败")); } }; @@ -123,10 +128,11 @@ export default function AnnouncementsPage() {
-

公告

-

班级重要通知与公告

+
Announcements
+

班级公告

+

发布课程通知、班级说明与重要提醒

- + { setDialogOpen(open); if (!open) resetForm(); @@ -173,7 +179,7 @@ export default function AnnouncementsPage() { {loading ? (
{[1, 2, 3].map((i) => ( - +
@@ -183,20 +189,23 @@ export default function AnnouncementsPage() { ) : error ? ( ) : announcements.length === 0 ? ( -
暂无公告
+
暂无公告
) : (
{announcements.map((item) => ( - +
{item.is_pinned && ( - 置顶 + 置顶 )} -

{item.title}

+

{item.title}

- +
{item.content && ( -

{item.content}

+

{item.content}

)} -

+

{item.author_name} ·{" "} {new Date(item.created_at).toLocaleDateString("zh-CN", { year: "numeric", diff --git a/frontend/src/app/(app)/assignments/[id]/page.tsx b/frontend/src/app/(app)/assignments/[id]/page.tsx index c9ce2a5..f2d8ccf 100644 --- a/frontend/src/app/(app)/assignments/[id]/page.tsx +++ b/frontend/src/app/(app)/assignments/[id]/page.tsx @@ -1,9 +1,9 @@ "use client"; -import { useEffect, useState, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useParams } from "next/navigation"; import { useAuth } from "@/hooks/use-auth"; -import { fetchAPI, postAPI, putAPI, uploadAPI } from "@/lib/api"; +import { fetchAPI, putAPI, uploadAPI, getErrorMessage } from "@/lib/api"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; @@ -75,24 +75,22 @@ export default function AssignmentDetailPage() { return `${days} 天后截止`; }; - const isAdmin = - user?.role === "class_admin" || user?.role === "super_admin"; - - const loadAssignment = async () => { + const loadAssignment = useCallback(async () => { + setLoading(true); setError(null); try { const res = await fetchAPI(`/api/assignments/${id}`); setAssignment(res); - } catch (err: any) { - setError(err.message || "加载失败"); + } catch (err: unknown) { + setError(getErrorMessage(err, "加载失败")); } finally { setLoading(false); } - }; + }, [id]); useEffect(() => { - loadAssignment(); - }, [id]); + void loadAssignment(); + }, [loadAssignment]); const handleSubmit = async () => { if (!selectedFile) { @@ -110,9 +108,9 @@ export default function AssignmentDetailPage() { setSelectedFile(null); setNotes(""); if (fileInputRef.current) fileInputRef.current.value = ""; - loadAssignment(); - } catch (err: any) { - toast.error(err.message || "提交失败"); + await loadAssignment(); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "提交失败")); } finally { setSubmitting(false); } @@ -123,9 +121,9 @@ export default function AssignmentDetailPage() { try { await putAPI(`/api/assignments/${id}`, { status: "closed" }); toast.success("作业已关闭"); - loadAssignment(); - } catch (err: any) { - toast.error(err.message || "操作失败"); + await loadAssignment(); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "操作失败")); } finally { setClosing(false); } @@ -145,9 +143,9 @@ export default function AssignmentDetailPage() { }); toast.success("评分已保存"); setActiveGradeId(null); - loadAssignment(); - } catch (err: any) { - toast.error(err.message || "评分失败"); + await loadAssignment(); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "评分失败")); } finally { setGradingSubmitting(false); } @@ -193,12 +191,8 @@ export default function AssignmentDetailPage() { (s) => s.student_id === user?.id ) ?? null; - // Compute unsubmitted students for admin view - const submittedStudentIds = new Set( - (assignment.submissions || []).map((s) => s.student_id) - ); // We only know student names from submissions; for unsubmitted we need the - // class roster. Since the API doesn't return the full roster in this endpoint, + // full class member list. Since the API doesn't return all members here, // we display submission info from what's available. return ( @@ -336,7 +330,7 @@ export default function AssignmentDetailPage() {

{/* Admin actions */} - +
{assignment.status === "open" && ( + + + + +
+
+ + +
+ + +

Pinned Notes

+

{announcements.length}

+

当前可见公告

+
+
+ + +

Upcoming

+

{upcoming.length}

+

近期待办排期

+
+
+ + +

Class Feed

+

{recentTimeline.length}

+

最近班级动态

+
+
{error ? ( - + window.location.reload()} /> ) : ( <> {/* Latest announcements */} {announcements.length > 0 && ( - + - 最新公告 - + 最新公告 + 查看全部 @@ -80,17 +143,19 @@ export default function DashboardPage() { {announcements.map((a) => (
setSelectedAnnouncement(a)} > - {a.is_pinned && ( - 置顶 - )} -
-

{a.title}

-

+

+ {a.is_pinned && ( + 置顶 + )} +
+

{a.title}

+

{a.author_name} · {new Date(a.created_at).toLocaleDateString("zh-CN")}

+
))} @@ -101,19 +166,19 @@ export default function DashboardPage() {
{/* Upcoming schedules */} - + - 即将到来 + 即将到来 {upcoming.length === 0 ? ( -

暂无排期

+

暂无排期

) : (
{upcoming.map((item) => (
setSelectedSchedule(item)} >
@@ -123,14 +188,14 @@ export default function DashboardPage() { }`} />
-

{item.title}

-

+

{item.title}

+

{new Date(item.start_time).toLocaleDateString("zh-CN")}

- - {getCountdown(item.start_time)} + + {countdownByScheduleId[item.id] ?? "即将开始"}
))} @@ -140,19 +205,19 @@ export default function DashboardPage() { {/* Recent timeline */} - + - 最近动态 + 最近动态 {recentTimeline.length === 0 ? ( -

暂无动态

+

暂无动态

) : (
{recentTimeline.map((post) => ( -
-

{post.title}

-

+

+

{post.title}

+

{post.author_name} ·{" "} {new Date(post.created_at).toLocaleDateString("zh-CN")}

@@ -186,9 +251,9 @@ export default function DashboardPage() { })}
{selectedAnnouncement.content ? ( -

{selectedAnnouncement.content}

+

{selectedAnnouncement.content}

) : ( -

暂无详细内容

+

暂无详细内容

)}
@@ -211,10 +276,10 @@ export default function DashboardPage() { {SCHEDULE_TYPES[selectedSchedule.type]?.label || selectedSchedule.type} - {getCountdown(selectedSchedule.start_time)} + {countdownByScheduleId[selectedSchedule.id] ?? "即将开始"}
-
+

开始:{new Date(selectedSchedule.start_time).toLocaleString("zh-CN", { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" })}

{selectedSchedule.end_time && (

结束:{new Date(selectedSchedule.end_time).toLocaleString("zh-CN", { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" })}

@@ -224,9 +289,9 @@ export default function DashboardPage() { )}
{selectedSchedule.description ? ( -

{selectedSchedule.description}

+

{selectedSchedule.description}

) : ( -

暂无详细说明

+

暂无详细说明

)}
diff --git a/frontend/src/app/(app)/directory/[id]/page.tsx b/frontend/src/app/(app)/directory/[id]/page.tsx index 5640465..7fe5049 100644 --- a/frontend/src/app/(app)/directory/[id]/page.tsx +++ b/frontend/src/app/(app)/directory/[id]/page.tsx @@ -2,8 +2,7 @@ import { useEffect, useState } from "react"; import { useParams } from "next/navigation"; -import { useAuth } from "@/hooks/use-auth"; -import { fetchAPI } from "@/lib/api"; +import { fetchAPI, getErrorMessage } from "@/lib/api"; import { Card, CardContent } from "@/components/ui/card"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; @@ -13,17 +12,18 @@ import Link from "next/link"; export default function MemberDetailPage() { const params = useParams(); - const { user } = useAuth(); const [member, setMember] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const id = params.id as string; - setError(null); fetchAPI(`/api/directory/${id}`) - .then(setMember) - .catch((err: any) => setError(err.message || "加载失败")) + .then((data) => { + setMember(data); + setError(null); + }) + .catch((err: unknown) => setError(getErrorMessage(err, "加载失败"))) .finally(() => setLoading(false)); }, [params.id]); @@ -40,7 +40,7 @@ export default function MemberDetailPage() { return (
- ← 返回花名册 + ← 返回成员名录 window.location.reload()} />
@@ -51,35 +51,33 @@ export default function MemberDetailPage() { ); } - const showContact = user?.class_id === member.id; // Privacy: same class check handled by API - return (
- - ← 返回花名册 + + ← 返回成员名录 - + {/* Avatar centered at top */}
- + {member.name[0]} -

{member.name}

+

{member.name}

{member.committee_role && ( - + {member.committee_role} )} {member.student_id && ( -

学号: {member.student_id}

+

学号: {member.student_id}

)} {member.company && ( -

+

{member.company} {member.position ? ` · ${member.position}` : ""}

@@ -91,20 +89,20 @@ export default function MemberDetailPage() { {member.bio && (
-

自我介绍

-

{member.bio}

+

自我介绍

+

{member.bio}

)} {(member.wechat_id || member.phone) && (
-

联系方式

+

联系方式

{member.wechat_id && ( -

微信: {member.wechat_id}

+

微信: {member.wechat_id}

)} {member.phone && ( -

手机: {member.phone}

+

手机: {member.phone}

)}
diff --git a/frontend/src/app/(app)/directory/page.tsx b/frontend/src/app/(app)/directory/page.tsx index bbe0390..f6ed95a 100644 --- a/frontend/src/app/(app)/directory/page.tsx +++ b/frontend/src/app/(app)/directory/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useCallback } from "react"; import { useActiveClass } from "@/hooks/use-active-class"; -import { fetchAPI } from "@/lib/api"; +import { fetchAPI, getErrorMessage } from "@/lib/api"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { @@ -16,7 +16,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { ErrorState } from "@/components/error-state"; import { Pagination } from "@/components/pagination"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import type { UserPublic } from "@/lib/types"; +import type { PageResponse, UserPublic } from "@/lib/types"; import { INDUSTRY_OPTIONS } from "@/lib/constants"; import Link from "next/link"; @@ -46,12 +46,12 @@ export default function DirectoryPage() { if (search) params.search = search; if (industry) params.industry = industry; if (company) params.company = company; - const res = await fetchAPI("/api/directory/", params); - setMembers(res.items || []); - setTotal(res.total || 0); - setTotalPages(res.total_pages || 1); - } catch (err: any) { - setError(err.message || "加载失败"); + const res = await fetchAPI>("/api/directory/", params); + setMembers(res.items ?? []); + setTotal(res.total ?? 0); + setTotalPages(res.total_pages ?? 1); + } catch (err: unknown) { + setError(getErrorMessage(err, "加载失败")); } finally { setLoading(false); } @@ -63,20 +63,21 @@ export default function DirectoryPage() { }, [search, industry, company]); useEffect(() => { - if (!activeClassId) return; const timer = setTimeout(loadMembers, 300); return () => clearTimeout(timer); - }, [loadMembers]); + }, [activeClassId, loadMembers]); return (
-

花名册

-

共 {total} 位同学

+
Directory
+

成员名录

+

共 {total} 位成员,按行业、公司与研究兴趣建立连接

{/* Search & Filters */} -
+ + setCompany(e.target.value)} className="w-full sm:w-40" /> -
+ + {/* Member List */} {loading ? (
{[1, 2, 3, 4, 5, 6].map((i) => ( - +
@@ -118,24 +120,24 @@ export default function DirectoryPage() { ) : error ? ( ) : members.length === 0 ? ( -
没有找到匹配的同学
+
没有找到匹配的成员
) : (
{members.map((member) => ( - + - + {member.name[0]}
-

{member.name}

+

{member.name}

{member.committee_role && ( - + {member.committee_role} )} @@ -146,18 +148,18 @@ export default function DirectoryPage() { )}
{member.company && ( -

+

{member.company} {member.position ? ` · ${member.position}` : ""}

)} {member.student_id && ( -

+

学号: {member.student_id}

)} {member.bio && ( -

+

{member.bio}

)} diff --git a/frontend/src/app/(app)/fund/page.tsx b/frontend/src/app/(app)/fund/page.tsx index 2d57529..1caae51 100644 --- a/frontend/src/app/(app)/fund/page.tsx +++ b/frontend/src/app/(app)/fund/page.tsx @@ -1,9 +1,9 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useActiveClass } from "@/hooks/use-active-class"; import { useAuth } from "@/hooks/use-auth"; -import { fetchAPI, postAPI, putAPI, deleteAPI } from "@/lib/api"; +import { fetchAPI, postAPI, putAPI, deleteAPI, getErrorMessage } from "@/lib/api"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -28,13 +28,14 @@ import { ConfirmDialog } from "@/components/confirm-dialog"; import { ErrorState } from "@/components/error-state"; import { Pagination } from "@/components/pagination"; import { toast } from "sonner"; -import type { FundRecord, FundStatistics } from "@/lib/types"; +import type { FundRecord, FundStatistics, PageResponse } from "@/lib/types"; import { FUND_TYPES, FUND_INCOME_CATEGORIES, FUND_EXPENSE_CATEGORIES } from "@/lib/constants"; +import { hasClassPermission } from "@/lib/permissions"; export default function FundPage() { const { user } = useAuth(); const { activeClassId } = useActiveClass(); - const isAdmin = user?.role === "super_admin" || user?.role === "class_admin"; + const isAdmin = hasClassPermission(user, "fund_manage", activeClassId); // Statistics const [stats, setStats] = useState(null); @@ -61,12 +62,16 @@ export default function FundPage() { // Delete state const [deleteTarget, setDeleteTarget] = useState(null); - const loadStats = async () => { - if (!activeClassId) { setStatsLoading(false); return; } + const loadStats = useCallback(async () => { + if (!activeClassId) { + setStats(null); + setStatsLoading(false); + return; + } setStatsLoading(true); try { const params: Record = {}; - if (user?.role === "super_admin" && activeClassId) { + if (activeClassId) { params.class_id = String(activeClassId); } const res = await fetchAPI( @@ -79,35 +84,39 @@ export default function FundPage() { } finally { setStatsLoading(false); } - }; + }, [activeClassId]); - const loadRecords = async () => { - if (!activeClassId) { setLoading(false); return; } + const loadRecords = useCallback(async () => { + if (!activeClassId) { + setRecords([]); + setError(null); + setLoading(false); + return; + } setLoading(true); setError(null); try { const params: Record = { page: String(page), page_size: "20", + class_id: String(activeClassId), }; if (typeFilter !== "all") params.type = typeFilter; - if (user?.role === "super_admin") params.class_id = String(activeClassId); - const res = await fetchAPI(`/api/fund/`, params); - setRecords(res.items || []); - setTotalPages(res.total_pages || 1); - } catch (err: any) { - setError(err.message || "加载失败"); + const res = await fetchAPI>(`/api/fund/`, params); + setRecords(res.items ?? []); + setTotalPages(res.total_pages ?? 1); + } catch (err: unknown) { + setError(getErrorMessage(err, "加载失败")); } finally { setLoading(false); } - }; + }, [activeClassId, page, typeFilter]); useEffect(() => { - if (!activeClassId) return; - loadStats(); - loadRecords(); - }, [activeClassId, page, typeFilter]); + void loadStats(); + void loadRecords(); + }, [loadRecords, loadStats]); const resetForm = () => { setFormType("income"); @@ -163,17 +172,15 @@ export default function FundPage() { await putAPI(`/api/fund/${editingId}`, payload); toast.success("记录已更新"); } else { - const url = user?.role === "super_admin" && activeClassId - ? `/api/fund/?class_id=${activeClassId}` - : `/api/fund/`; + const url = `/api/fund/?class_id=${activeClassId}`; await postAPI(url, payload); toast.success("记录已添加"); } setDialogOpen(false); - loadStats(); - loadRecords(); - } catch (err: any) { - toast.error(err.message || "操作失败"); + await loadStats(); + await loadRecords(); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "操作失败")); } finally { setSubmitting(false); } @@ -185,10 +192,10 @@ export default function FundPage() { await deleteAPI(`/api/fund/${deleteTarget}`); toast.success("记录已删除"); setDeleteTarget(null); - loadStats(); - loadRecords(); - } catch (err: any) { - toast.error(err.message || "删除失败"); + await loadStats(); + await loadRecords(); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "删除失败")); } }; @@ -205,7 +212,7 @@ export default function FundPage() {

班费管理

记录和管理班费收支

- +
@@ -448,4 +455,4 @@ export default function FundPage() { />
); -} \ No newline at end of file +} diff --git a/frontend/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx index 206e1cc..d6a7d9b 100644 --- a/frontend/src/app/(app)/layout.tsx +++ b/frontend/src/app/(app)/layout.tsx @@ -11,11 +11,13 @@ export default function AppLayout({ children }: { children: React.ReactNode }) { -
+
-
{children}
+
+
{children}
+
diff --git a/frontend/src/app/(app)/profile/page.tsx b/frontend/src/app/(app)/profile/page.tsx index 4226b9a..14faaf9 100644 --- a/frontend/src/app/(app)/profile/page.tsx +++ b/frontend/src/app/(app)/profile/page.tsx @@ -1,8 +1,9 @@ "use client"; +import Image from "next/image"; import { useEffect, useState, useRef } from "react"; import { useAuth } from "@/hooks/use-auth"; -import { putAPI, uploadAPI } from "@/lib/api"; +import { getErrorMessage, putAPI, uploadAPI } from "@/lib/api"; import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -45,6 +46,38 @@ export default function ProfilePage() { } }, [user]); + const normalizedCurrent = { + email: email.trim(), + name: name.trim(), + industry: industry.trim(), + company: company.trim(), + position: position.trim(), + wechatId: wechatId.trim(), + phone: phone.trim(), + bio: bio.trim(), + }; + + const normalizedSaved = { + email: (user?.email ?? "").trim(), + name: (user?.name ?? "").trim(), + industry: (user?.industry ?? "").trim(), + company: (user?.company ?? "").trim(), + position: (user?.position ?? "").trim(), + wechatId: (user?.wechat_id ?? "").trim(), + phone: (user?.phone ?? "").trim(), + bio: (user?.bio ?? "").trim(), + }; + + const hasChanges = + normalizedCurrent.email !== normalizedSaved.email || + normalizedCurrent.name !== normalizedSaved.name || + normalizedCurrent.industry !== normalizedSaved.industry || + normalizedCurrent.company !== normalizedSaved.company || + normalizedCurrent.position !== normalizedSaved.position || + normalizedCurrent.wechatId !== normalizedSaved.wechatId || + normalizedCurrent.phone !== normalizedSaved.phone || + normalizedCurrent.bio !== normalizedSaved.bio; + const handleAvatarChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; @@ -59,8 +92,8 @@ export default function ProfilePage() { await uploadAPI("/api/users/me/avatar", formData); await refreshUser(); toast.success("头像已更新"); - } catch (err: any) { - toast.error(err.message || "头像上传失败"); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "头像上传失败")); } finally { setAvatarUploading(false); if (fileInputRef.current) fileInputRef.current.value = ""; @@ -69,22 +102,23 @@ export default function ProfilePage() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + if (!hasChanges) return; setLoading(true); try { await putAPI("/api/users/me", { - email: email || undefined, - name: name || undefined, - industry: industry || undefined, - company: company || undefined, - position: position || undefined, - wechat_id: wechatId || undefined, - phone: phone || undefined, - bio: bio || undefined, + email: normalizedCurrent.email || undefined, + name: normalizedCurrent.name || undefined, + industry: normalizedCurrent.industry || null, + company: normalizedCurrent.company || null, + position: normalizedCurrent.position || null, + wechat_id: normalizedCurrent.wechatId || null, + phone: normalizedCurrent.phone || null, + bio: normalizedCurrent.bio || null, }); await refreshUser(); toast.success("资料已更新"); - } catch (err: any) { - toast.error(err.message || "更新失败"); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "更新失败")); } finally { setLoading(false); } @@ -92,14 +126,18 @@ export default function ProfilePage() { return (
-

编辑个人资料

- +
+
Profile
+

个人资料

+

完善你的职业背景、联系方式与个人介绍

+
+ {/* Avatar */}
-
+
{user?.avatar_url ? ( - + ) : ( user?.name?.[0] || "?" )} @@ -120,7 +158,7 @@ export default function ProfilePage() { > {avatarUploading ? "上传中..." : "更换头像"} -

支持 JPG/PNG/GIF/WebP,最大 5MB

+

支持 JPG/PNG/GIF/WebP,最大 5MB

@@ -144,8 +182,19 @@ export default function ProfilePage() {
- - {industry || "选择行业"} @@ -211,8 +260,8 @@ export default function ProfilePage() {
- diff --git a/frontend/src/app/(app)/resources/page.tsx b/frontend/src/app/(app)/resources/page.tsx index 40cf3a4..5e59655 100644 --- a/frontend/src/app/(app)/resources/page.tsx +++ b/frontend/src/app/(app)/resources/page.tsx @@ -1,8 +1,8 @@ "use client"; -import { useEffect, useState, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useActiveClass } from "@/hooks/use-active-class"; -import { fetchAPI, postAPI, deleteAPI, uploadAPI } from "@/lib/api"; +import { fetchAPI, postAPI, deleteAPI, uploadAPI, getErrorMessage } from "@/lib/api"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -27,7 +27,11 @@ import { ConfirmDialog } from "@/components/confirm-dialog"; import { ErrorState } from "@/components/error-state"; import { Pagination } from "@/components/pagination"; import { toast } from "sonner"; -import type { Resource } from "@/lib/types"; +import type { PageResponse, Resource } from "@/lib/types"; + +type ResourceDownloadResponse = { + file_url: string; +}; const RESOURCE_CATEGORIES: Record = { all: "全部", @@ -74,8 +78,15 @@ export default function ResourcesPage() { // Delete state const [deleteTarget, setDeleteTarget] = useState(null); - const loadResources = async () => { + const loadResources = useCallback(async () => { + if (!activeClassId) { + setResources([]); + setTotalPages(1); + setLoading(false); + return; + } setError(null); + setLoading(true); try { const params: Record = { page_size: "20", @@ -83,20 +94,19 @@ export default function ResourcesPage() { class_id: String(activeClassId), }; if (category !== "all") params.category = category; - const res = await fetchAPI("/api/resources/", params); + const res = await fetchAPI>("/api/resources/", params); setResources(res.items || []); setTotalPages(res.total_pages || 1); - } catch (err: any) { - setError(err.message || "加载失败"); + } catch (err: unknown) { + setError(getErrorMessage(err, "加载失败")); } finally { setLoading(false); } - }; + }, [activeClassId, category, page]); useEffect(() => { - if (!activeClassId) return; - loadResources(); - }, [activeClassId, page, category]); + void loadResources(); + }, [loadResources]); const resetForm = () => { setFormTitle(""); @@ -122,8 +132,8 @@ export default function ResourcesPage() { setDialogOpen(false); resetForm(); loadResources(); - } catch (err: any) { - toast.error(err.message || "上传失败"); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "上传失败")); } finally { setSubmitting(false); } @@ -131,10 +141,10 @@ export default function ResourcesPage() { const handleDownload = async (resource: Resource) => { try { - const res = await postAPI(`/api/resources/${resource.id}/download`); + const res = await postAPI(`/api/resources/${resource.id}/download`); window.open(res.file_url, "_blank"); - } catch (err: any) { - toast.error(err.message || "下载失败"); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "下载失败")); } }; @@ -144,8 +154,8 @@ export default function ResourcesPage() { toast.success("已删除"); setDeleteTarget(null); loadResources(); - } catch (err: any) { - toast.error(err.message || "删除失败"); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "删除失败")); } }; @@ -153,10 +163,11 @@ export default function ResourcesPage() {
-

资源库

-

共享课件、文档与学习资料

+
Library
+

资源库

+

共享课件、阅读材料与班级文档档案

- + { setDialogOpen(open); if (!open) resetForm(); @@ -164,9 +175,9 @@ export default function ResourcesPage() { - + - 上传资源 + 上传资源
setSelectedFile(e.target.files?.[0] || null)} /> -

支持 PDF, Word, Excel, PPT, ZIP 等,最大 50MB

+

支持 PDF、Word、Excel、PPT、ZIP 等,最大 50MB

{/* Category tabs */} -
+
{Object.entries(RESOURCE_CATEGORIES).map(([key, label]) => ( - +
- + { setDialogOpen(open); if (!open) resetForm(); @@ -295,7 +301,7 @@ export default function SchedulePage() { {item.location && (

{item.location}

)} - +
{/* Next arrow */} {images.length > 1 && ( @@ -137,7 +197,9 @@ function Lightbox({ }`} onClick={() => setIndex(i)} > - +
+ +
))}
@@ -180,23 +242,33 @@ export default function TimelinePage() { }; const closeLightbox = () => setLightboxImages(null); - const loadPosts = async () => { + const loadPosts = useCallback(async () => { + if (!activeClassId) { + setPosts([]); + setError(null); + setLoading(false); + return; + } + setLoading(true); setError(null); try { - const res = await fetchAPI("/api/timeline/", { page_size: "10", page: String(page), class_id: String(activeClassId) }); - setPosts(res.items || []); - setTotalPages(res.total_pages || 1); - } catch (err: any) { - setError(err.message || "加载失败"); + const res = await fetchAPI>("/api/timeline/", { + page_size: "10", + page: String(page), + class_id: String(activeClassId), + }); + setPosts(res.items ?? []); + setTotalPages(res.total_pages ?? 1); + } catch (err: unknown) { + setError(getErrorMessage(err, "加载失败")); } finally { setLoading(false); } - }; + }, [activeClassId, page]); useEffect(() => { - if (!activeClassId) return; - loadPosts(); - }, [activeClassId, page]); + void loadPosts(); + }, [loadPosts]); /* ---------- File upload helpers ---------- */ const handleFileChange = (e: React.ChangeEvent) => { @@ -254,7 +326,7 @@ export default function TimelinePage() { }); if (selectedFiles.length > 0) { setUploadProgress(`压缩图片 (0/${selectedFiles.length})...`); - const compressed = []; + const compressed: File[] = []; for (let i = 0; i < selectedFiles.length; i++) { compressed.push(await compressImage(selectedFiles[i])); setUploadProgress(`压缩图片 (${i + 1}/${selectedFiles.length})...`); @@ -270,11 +342,11 @@ export default function TimelinePage() { const formData = new FormData(); formData.append("title", newTitle); if (newContent) formData.append("content", newContent); - if (user?.role === "super_admin" && activeClassId) formData.append("class_id", String(activeClassId)); + if (activeClassId) formData.append("class_id", String(activeClassId)); if (selectedFiles.length > 0) { setUploadProgress(`压缩图片 (0/${selectedFiles.length})...`); - const compressed = []; + const compressed: File[] = []; for (let i = 0; i < selectedFiles.length; i++) { compressed.push(await compressImage(selectedFiles[i])); setUploadProgress(`压缩图片 (${i + 1}/${selectedFiles.length})...`); @@ -289,9 +361,9 @@ export default function TimelinePage() { resetForm(); setDialogOpen(false); - loadPosts(); - } catch (err: any) { - toast.error(err.message || (editingId ? "更新失败" : "发布失败")); + await loadPosts(); + } catch (err: unknown) { + toast.error(getErrorMessage(err, editingId ? "更新失败" : "发布失败")); } finally { setSubmitting(false); setUploadProgress(""); @@ -306,16 +378,16 @@ export default function TimelinePage() { await deleteAPI(`/api/timeline/${id}`); toast.success("已删除"); setDeleteTarget(null); - loadPosts(); - } catch (err: any) { - toast.error(err.message || "删除失败"); + await loadPosts(); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "删除失败")); } }; /* ---------- Like ---------- */ const handleLike = async (postId: number) => { try { - const res = await postAPI<{ liked: boolean; like_count: number }>(`/api/timeline/${postId}/like`); + const res = await postAPI(`/api/timeline/${postId}/like`); setPosts((prev) => prev.map((p) => p.id === postId @@ -323,13 +395,14 @@ export default function TimelinePage() { : p ) ); - } catch (err: any) { - toast.error(err.message || "操作失败"); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "操作失败")); } }; /* ---------- Comments ---------- */ const toggleComments = async (postId: number) => { + const isExpanded = expandedComments.has(postId); setExpandedComments((prev) => { const next = new Set(prev); if (next.has(postId)) { @@ -340,19 +413,27 @@ export default function TimelinePage() { return next; }); - // Fetch comments when expanding (if not already loaded) - if (!expandedComments.has(postId)) { - try { - const res = await fetchAPI(`/api/timeline/${postId}/comments`); - const comments = res.items || []; - setPosts((prev) => - prev.map((p) => - p.id === postId ? { ...p, comments } : p - ) - ); - } catch { - // Silently fail — user can still try to post a new comment - } + if (isExpanded) { + return; + } + + const targetPost = posts.find((post) => post.id === postId); + if (targetPost?.comments) { + return; + } + + try { + const res = await fetchAPI>( + `/api/timeline/${postId}/comments` + ); + const comments = res.items ?? []; + setPosts((prev) => + prev.map((p) => + p.id === postId ? { ...p, comments } : p + ) + ); + } catch { + // Silently fail; user can still try to post a new comment. } }; @@ -370,8 +451,8 @@ export default function TimelinePage() { }) ); setCommentInputs((prev) => ({ ...prev, [postId]: "" })); - } catch (err: any) { - toast.error(err.message || "评论失败"); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "评论失败")); } finally { setSubmittingComment((prev) => ({ ...prev, [postId]: false })); } @@ -388,21 +469,26 @@ export default function TimelinePage() { }) ); toast.success("已删除评论"); - } catch (err: any) { - toast.error(err.message || "删除评论失败"); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "删除评论失败")); } }; /* ---------- Permission helpers ---------- */ const canEditDelete = (post: TimelinePost): boolean => { if (!user) return false; - if (user.role === "class_admin" || user.role === "super_admin") return true; + if (hasClassPermission(user, "timeline_manage", post.class_id)) { + return true; + } return user.id === post.author_id; }; const canDeleteComment = (comment: TimelineComment): boolean => { if (!user) return false; - if (user.role === "class_admin" || user.role === "super_admin") return true; + const post = posts.find((item) => item.id === comment.post_id); + if (post && hasClassPermission(user, "timeline_manage", post.class_id)) { + return true; + } return user.id === comment.author_id; }; @@ -450,13 +536,13 @@ export default function TimelinePage() { {/* Existing images (edit mode) */} {editingImageUrls.map((url, idx) => (
- +
))} {/* New image previews */} {previewUrls.map((url, idx) => (
- +
))}
diff --git a/frontend/src/app/(app)/votes/page.tsx b/frontend/src/app/(app)/votes/page.tsx index 8809ad3..782d1fb 100644 --- a/frontend/src/app/(app)/votes/page.tsx +++ b/frontend/src/app/(app)/votes/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState, useCallback } from "react"; import { useActiveClass } from "@/hooks/use-active-class"; import { useAuth } from "@/hooks/use-auth"; -import { fetchAPI, postAPI, putAPI, deleteAPI } from "@/lib/api"; +import { fetchAPI, postAPI, putAPI, deleteAPI, getErrorMessage } from "@/lib/api"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -21,7 +21,8 @@ import { import { ErrorState } from "@/components/error-state"; import { Pagination } from "@/components/pagination"; import { toast } from "sonner"; -import type { Vote, VoteOption } from "@/lib/types"; +import type { Vote, PageResponse } from "@/lib/types"; +import { hasClassPermission } from "@/lib/permissions"; export default function VotesPage() { const { activeClassId } = useActiveClass(); @@ -60,18 +61,15 @@ export default function VotesPage() { setError(null); setLoading(true); try { - const res = await fetchAPI<{ - items: Vote[]; - total_pages: number; - }>("/api/votes/", { + const res = await fetchAPI>("/api/votes/", { class_id: String(activeClassId), page: String(page), page_size: "10", }); setVotes(res.items || []); setTotalPages(res.total_pages || 1); - } catch (err: any) { - setError(err.message || "加载失败"); + } catch (err: unknown) { + setError(getErrorMessage(err, "加载失败")); } finally { setLoading(false); } @@ -118,7 +116,7 @@ export default function VotesPage() { } setSubmitting(true); try { - await postAPI("/api/votes/", { + await postAPI(`/api/votes/?class_id=${activeClassId}`, { title: formTitle.trim(), description: formDesc.trim() || null, vote_type: formVoteType, @@ -126,14 +124,13 @@ export default function VotesPage() { max_choices: formVoteType === "multiple" ? formMaxChoices : 1, deadline: formDeadline || null, options: validOptions, - class_id: activeClassId, }); toast.success("投票已创建"); setCreateOpen(false); resetCreateForm(); loadVotes(); - } catch (err: any) { - toast.error(err.message || "创建失败"); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "创建失败")); } finally { setSubmitting(false); } @@ -161,8 +158,8 @@ export default function VotesPage() { try { const data = await fetchAPI(`/api/votes/${voteId}`); setDetailVote(data); - } catch (err: any) { - toast.error(err.message || "加载投票详情失败"); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "加载投票详情失败")); setDetailOpen(false); } finally { setDetailLoading(false); @@ -190,8 +187,8 @@ export default function VotesPage() { setDetailVote(data); setSelectedOptions([]); loadVotes(); - } catch (err: any) { - toast.error(err.message || "投票失败"); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "投票失败")); } finally { setSubmittingVote(false); } @@ -203,8 +200,8 @@ export default function VotesPage() { toast.success("投票已关闭"); setDetailOpen(false); loadVotes(); - } catch (err: any) { - toast.error(err.message || "关闭失败"); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "关闭失败")); } }; @@ -215,13 +212,17 @@ export default function VotesPage() { setDeleteTarget(null); setDetailOpen(false); loadVotes(); - } catch (err: any) { - toast.error(err.message || "删除失败"); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "删除失败")); } }; const canManage = (vote: Vote) => - user && (user.role === "super_admin" || user.role === "class_admin" || user.id === vote.creator_id); + user && + ( + hasClassPermission(user, "vote_manage", vote.class_id) || + user.id === vote.creator_id + ); // ---------- Render helpers ---------- diff --git a/frontend/src/app/register/page.tsx b/frontend/src/app/activate/page.tsx similarity index 60% rename from frontend/src/app/register/page.tsx rename to frontend/src/app/activate/page.tsx index 0968062..08dd264 100644 --- a/frontend/src/app/register/page.tsx +++ b/frontend/src/app/activate/page.tsx @@ -6,13 +6,13 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { postAPI } from "@/lib/api"; +import { getErrorMessage, postAPI } from "@/lib/api"; import Link from "next/link"; +import type { LoginResponse } from "@/lib/types"; -export default function RegisterPage() { +export default function ActivatePage() { const [inviteCode, setInviteCode] = useState(""); const [studentId, setStudentId] = useState(""); - const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); @@ -28,42 +28,56 @@ export default function RegisterPage() { setError("两次密码输入不一致"); return; } - if (password.length < 6) { - setError("密码至少6位"); + if (password.length < 8) { + setError("密码至少8位"); return; } setLoading(true); try { - const res = await postAPI("/api/auth/register", { + const res = await postAPI("/api/auth/activate", { invite_code: inviteCode, student_id: studentId, - name, email, password, }); - // Registration returns token — auto-login if (res.token) { - localStorage.setItem("token", res.token); - localStorage.setItem("user", JSON.stringify(res.user)); + localStorage.setItem("auth_token", res.token); + localStorage.setItem("auth_user", JSON.stringify(res.user)); router.push("/"); } else { router.push("/login"); } - } catch (err: any) { - setError(err.message || "注册失败"); + } catch (err: unknown) { + setError(getErrorMessage(err, "激活失败")); } finally { setLoading(false); } }; return ( -
- +
+
+
+
+
+
+ HKU ICB Cohort Access +
+

+ 完成账号激活,进入你的班级空间 +

+

+ 成员导入后,只需使用激活码与学号确认身份,再补全邮箱和密码即可启用账号。 +

+
+ + - HKU ICB - 班级资源平台 - 注册 +
Activate Account
+ 激活账号 + 输入班级激活码与学号,完成账号启用
@@ -87,16 +101,6 @@ export default function RegisterPage() { required />
-
- - setName(e.target.value)} - required - /> -
setPassword(e.target.value)} required @@ -131,22 +135,24 @@ export default function RegisterPage() { />
{error && ( -

+

{error}

)} -
+
已有账号?{" "} - + 登录
+
+
); } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index c56032b..252aa34 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -9,7 +9,7 @@ --color-foreground: var(--foreground); --font-sans: var(--font-sans); --font-mono: var(--font-geist-mono); - --font-heading: var(--font-sans); + --font-heading: var(--font-heading); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); @@ -49,38 +49,40 @@ } :root { - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --radius: 0.625rem; - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --background: oklch(0.985 0.012 82.5); + --foreground: oklch(0.27 0.02 23); + --card: oklch(0.995 0.008 85); + --card-foreground: oklch(0.27 0.02 23); + --popover: oklch(0.995 0.008 85); + --popover-foreground: oklch(0.27 0.02 23); + --primary: oklch(0.38 0.14 18); + --primary-foreground: oklch(0.985 0.01 80); + --secondary: oklch(0.952 0.02 75); + --secondary-foreground: oklch(0.32 0.04 18); + --muted: oklch(0.958 0.015 82); + --muted-foreground: oklch(0.52 0.03 30); + --accent: oklch(0.935 0.042 55); + --accent-foreground: oklch(0.33 0.05 22); + --destructive: oklch(0.61 0.22 25); + --border: oklch(0.905 0.018 65); + --input: oklch(0.92 0.018 72); + --ring: oklch(0.61 0.11 22); + --chart-1: oklch(0.56 0.16 20); + --chart-2: oklch(0.67 0.11 64); + --chart-3: oklch(0.54 0.08 220); + --chart-4: oklch(0.74 0.09 145); + --chart-5: oklch(0.82 0.07 92); + --radius: 1rem; + --sidebar: oklch(0.27 0.035 18); + --sidebar-foreground: oklch(0.95 0.01 80); + --sidebar-primary: oklch(0.67 0.12 56); + --sidebar-primary-foreground: oklch(0.19 0.01 30); + --sidebar-accent: oklch(0.33 0.03 18); + --sidebar-accent-foreground: oklch(0.96 0.01 80); + --sidebar-border: oklch(0.39 0.03 18 / 45%); + --sidebar-ring: oklch(0.72 0.1 56); + --font-sans: var(--font-inter); + --font-heading: var(--font-serif-sc); } .dark { @@ -123,8 +125,20 @@ } body { @apply bg-background text-foreground; + background-image: + radial-gradient(circle at top left, color-mix(in oklab, var(--accent) 34%, transparent) 0, transparent 34%), + radial-gradient(circle at top right, color-mix(in oklab, var(--primary) 11%, transparent) 0, transparent 30%), + linear-gradient(180deg, color-mix(in oklab, var(--background) 92%, white) 0%, var(--background) 100%); + background-attachment: fixed; } html { @apply font-sans; } -} \ No newline at end of file + h1, + h2, + h3, + h4 { + font-family: var(--font-heading); + letter-spacing: -0.01em; + } +} diff --git a/frontend/src/app/inactive-account/page.tsx b/frontend/src/app/inactive-account/page.tsx new file mode 100644 index 0000000..ccb317e --- /dev/null +++ b/frontend/src/app/inactive-account/page.tsx @@ -0,0 +1,43 @@ +import Link from "next/link"; + +export default function InactiveAccountPage() { + return ( +
+
+
+
+
+ + + +
+
+
Activation Required
+

账号未激活

+
+

+ 你的账号尚未完成激活,暂时无法进入系统。 +
+ 请联系班级管理员确认你已被导入成员名录,并使用激活码完成账号激活。 +

+ + 去激活账号 + +
+
+
+ ); +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index f8ed4ae..ff252ac 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,12 +1,12 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import { Noto_Serif_SC, Inter, Geist_Mono } from "next/font/google"; import { AuthProvider } from "@/hooks/use-auth"; import { AuthGuard } from "@/components/auth-guard"; import { Toaster } from "@/components/ui/sonner"; import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", +const bodySans = Inter({ + variable: "--font-inter", subsets: ["latin"], }); @@ -15,9 +15,15 @@ const geistMono = Geist_Mono({ subsets: ["latin"], }); +const serifHeading = Noto_Serif_SC({ + variable: "--font-serif-sc", + subsets: ["latin"], + weight: ["400", "500", "600", "700"], +}); + export const metadata: Metadata = { - title: "HKU ICB - 班级资源平台", - description: "研究生班级资源连接平台", + title: "香港大学中国商业学院 - 班级信息管理平台", + description: "香港大学中国商业学院班级信息管理平台", }; export default function RootLayout({ @@ -28,7 +34,7 @@ export default function RootLayout({ return ( diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index ff1e95b..0489f9f 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { getErrorMessage } from "@/lib/api"; import Link from "next/link"; export default function LoginPage() { @@ -24,19 +25,40 @@ export default function LoginPage() { try { await login(email, password); router.push("/dashboard"); - } catch (err: any) { - setError(err.message || "登录失败"); + } catch (err: unknown) { + setError(getErrorMessage(err, "登录失败")); } finally { setLoading(false); } }; return ( -
- +
+
+
+
+
+
+ The University of Hong Kong +
+

+ 香港大学中国商业学院 +

+

+ 班级信息管理平台 +

+
+ 公告与课程通知 + 成员名录与班级连接 + 活动排期与资源协同 +
+
+ + - HKU ICB - 班级资源平台 - 登录 +
HKU ICB
+ 欢迎回来 + 登录班级信息管理平台
@@ -63,7 +85,7 @@ export default function LoginPage() { />
{error && ( -

+

{error}

)} @@ -71,14 +93,16 @@ export default function LoginPage() { {loading ? "登录中..." : "登录"} -
+
还没有账号?{" "} - - 注册申请 + + 激活账号
+
+
); } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 5558b5b..5b979f0 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -12,8 +12,8 @@ export default function Home() { if (loading) return; if (!user) { router.replace("/login"); - } else if (user.status === "pending") { - router.replace("/pending"); + } else if (user.status === "inactive") { + router.replace("/inactive-account"); } else { router.replace("/dashboard"); } diff --git a/frontend/src/app/pending/page.tsx b/frontend/src/app/pending/page.tsx deleted file mode 100644 index 415e88b..0000000 --- a/frontend/src/app/pending/page.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import Link from "next/link"; - -export default function PendingPage() { - return ( -
-
-
- - - -
-

注册审核中

-

- 你的注册申请已提交,班级管理员正在审核中。 -
- 审核通过后你将收到邮件通知。 -

- - 返回登录 - -
-
- ); -} diff --git a/frontend/src/components/auth-guard.tsx b/frontend/src/components/auth-guard.tsx index f5cd880..4898718 100644 --- a/frontend/src/components/auth-guard.tsx +++ b/frontend/src/components/auth-guard.tsx @@ -4,7 +4,7 @@ import { useEffect } from "react"; import { usePathname, useRouter } from "next/navigation"; import { useAuth } from "@/hooks/use-auth"; -const PUBLIC_PATHS = ["/login", "/register"]; +const PUBLIC_PATHS = ["/login", "/activate"]; export function AuthGuard({ children }: { children: React.ReactNode }) { const { user, loading } = useAuth(); diff --git a/frontend/src/components/calendar-view.tsx b/frontend/src/components/calendar-view.tsx index c9268ea..ef16994 100644 --- a/frontend/src/components/calendar-view.tsx +++ b/frontend/src/components/calendar-view.tsx @@ -53,9 +53,8 @@ export function CalendarView({ events, onEventClick }: CalendarViewProps) { return (
- {/* Navigation */} -
-

+
+

{year} 年 {month + 1} 月

@@ -71,24 +70,21 @@ export function CalendarView({ events, onEventClick }: CalendarViewProps) {
- {/* Calendar grid */} -
- {/* Week day headers */} +
+
{weekDays.map((d) => (
{d}
))} - {/* Leading empty cells */} {Array.from({ length: startDayOfWeek }, (_, i) => ( -
+
))} - {/* Day cells */} {Array.from({ length: daysInMonth }, (_, i) => { const day = i + 1; const key = `${year}-${month}-${day}`; @@ -102,16 +98,16 @@ export function CalendarView({ events, onEventClick }: CalendarViewProps) { return (
setSelectedDate(new Date(year, month, day))} > {day} @@ -129,14 +125,14 @@ export function CalendarView({ events, onEventClick }: CalendarViewProps) { }} >
- + {event.title}
); })} {dayEvents.length > 3 && ( - + +{dayEvents.length - 3} )} @@ -145,16 +141,15 @@ export function CalendarView({ events, onEventClick }: CalendarViewProps) { ); })} - {/* Trailing empty cells */} {Array.from({ length: (7 - (startDayOfWeek + daysInMonth) % 7) % 7 }, (_, i) => ( -
+
))}
+
- {/* Selected date detail */} {selectedDate && selectedEvents.length > 0 && ( -
-

+
+

{selectedDate.getMonth() + 1} 月 {selectedDate.getDate()} 日

@@ -163,14 +158,14 @@ export function CalendarView({ events, onEventClick }: CalendarViewProps) { return (
onEventClick?.(event)} >
-

{event.title}

-

+

{event.title}

+

{new Date(event.start_time).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", diff --git a/frontend/src/components/error-state.tsx b/frontend/src/components/error-state.tsx index f085432..06207f7 100644 --- a/frontend/src/components/error-state.tsx +++ b/frontend/src/components/error-state.tsx @@ -9,14 +9,17 @@ interface ErrorStateProps { export function ErrorState({ message = "加载失败", onRetry }: ErrorStateProps) { return ( -

- +
+
+ -

{message}

+

暂时无法加载内容

+

{message}

{onRetry && ( - + )} +
); } diff --git a/frontend/src/components/header.tsx b/frontend/src/components/header.tsx index 31451b6..f873707 100644 --- a/frontend/src/components/header.tsx +++ b/frontend/src/components/header.tsx @@ -1,12 +1,13 @@ "use client"; +import Image from "next/image"; import { useState } from "react"; import { useAuth } from "@/hooks/use-auth"; import { useActiveClass } from "@/hooks/use-active-class"; import { useSidebar } from "@/hooks/use-sidebar"; import { useNotifications } from "@/hooks/use-notifications"; import { useRouter } from "next/navigation"; -import { putAPI } from "@/lib/api"; +import { getErrorMessage, putAPI } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -51,6 +52,9 @@ export function Header() { const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [passwordLoading, setPasswordLoading] = useState(false); + const classDescriptor = activeClassName + ? activeClassName.split(" ").slice(0, 2).join(" ") + : "香港大学中国商业学院"; const handleChangePassword = async (e: React.FormEvent) => { e.preventDefault(); @@ -73,28 +77,38 @@ export function Header() { setConfirmPassword(""); setPasswordOpen(false); toast.success("密码已修改"); - } catch (err: any) { - toast.error(err.message || "修改密码失败"); + } catch (err: unknown) { + toast.error(getErrorMessage(err, "修改密码失败")); } finally { setPasswordLoading(false); } }; return ( -
+
+
+
{/* Mobile hamburger */} - +
+

+ HKU ICB +

+

+ {classDescriptor} +

+
{canSwitchClass ? ( ) : activeClassName ? ( - - 当前班级:{activeClassName} + + 当前班级:{activeClassName} ) : null}
-
+
{/* Notification bell */} {user && ( { setNotifOpen(open); if (open) refresh(); }}> - + @@ -133,8 +147,8 @@ export function Header() { )} - -
+ +
通知 {unreadCount > 0 && ( @@ -238,6 +257,7 @@ export function Header() {

+
); } diff --git a/frontend/src/components/pagination.tsx b/frontend/src/components/pagination.tsx index 4aea182..fc8172a 100644 --- a/frontend/src/components/pagination.tsx +++ b/frontend/src/components/pagination.tsx @@ -28,7 +28,7 @@ export function Pagination({ page, totalPages, onPageChange }: PaginationProps) }; return ( -
+