全面更新

This commit is contained in:
aaron 2026-04-27 09:21:20 +08:00
parent 32cd7c5f8c
commit b8422f0589
75 changed files with 3374 additions and 1470 deletions

View File

@ -14,4 +14,4 @@ RUN mkdir -p /app/data
EXPOSE 8000 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"]

View File

@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import async_engine_from_config
from app.config import settings from app.config import settings
from app.db.base import Base 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 = context.config
config.set_main_option("sqlalchemy.url", settings.database_url) config.set_main_option("sqlalchemy.url", settings.database_url)

View File

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

View File

@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession 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.database import get_db
from app.db.models import User from app.db.models import User
from app.schemas.announcement import AnnouncementCreate, AnnouncementUpdate, AnnouncementOut from app.schemas.announcement import AnnouncementCreate, AnnouncementUpdate, AnnouncementOut
@ -22,12 +22,13 @@ async def get_announcements(
page: int = 1, page: int = 1,
page_size: int = 20, page_size: int = 20,
class_id: int | 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), 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: if effective_class_id is None:
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) 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) announcements, total = await list_announcements(db, effective_class_id, page, page_size)
total_pages = (total + page_size - 1) // page_size total_pages = (total + page_size - 1) // page_size
@ -57,12 +58,13 @@ async def get_announcements(
async def create_new_announcement( async def create_new_announcement(
data: AnnouncementCreate, data: AnnouncementCreate,
class_id: int | None = None, 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), 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: if effective_class_id is None:
raise HTTPException(status_code=400, detail="You are not assigned to a class") 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) announcement = await create_announcement(db, effective_class_id, user.id, data)
return AnnouncementOut( return AnnouncementOut(
@ -82,14 +84,13 @@ async def create_new_announcement(
async def update_existing_announcement( async def update_existing_announcement(
announcement_id: int, announcement_id: int,
data: AnnouncementUpdate, 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), db: AsyncSession = Depends(get_db),
): ):
announcement = await get_announcement_by_id(db, announcement_id) announcement = await get_announcement_by_id(db, announcement_id)
if announcement is None: if announcement is None:
raise HTTPException(status_code=404, detail="Announcement not found") raise HTTPException(status_code=404, detail="Announcement not found")
if user.role != "super_admin" and announcement.class_id != user.class_id: ensure_class_permission(user, "announcement_manage", announcement.class_id)
raise HTTPException(status_code=403, detail="Access denied")
updated = await update_announcement(db, announcement, data) updated = await update_announcement(db, announcement, data)
return AnnouncementOut( return AnnouncementOut(
@ -108,14 +109,13 @@ async def update_existing_announcement(
@router.delete("/{announcement_id}") @router.delete("/{announcement_id}")
async def delete_existing_announcement( async def delete_existing_announcement(
announcement_id: int, 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), db: AsyncSession = Depends(get_db),
): ):
announcement = await get_announcement_by_id(db, announcement_id) announcement = await get_announcement_by_id(db, announcement_id)
if announcement is None: if announcement is None:
raise HTTPException(status_code=404, detail="Announcement not found") raise HTTPException(status_code=404, detail="Announcement not found")
if user.role != "super_admin" and announcement.class_id != user.class_id: ensure_class_permission(user, "announcement_manage", announcement.class_id)
raise HTTPException(status_code=403, detail="Access denied")
await delete_announcement(db, announcement) await delete_announcement(db, announcement)
return {"message": "Announcement deleted"} return {"message": "Announcement deleted"}

View File

@ -3,7 +3,12 @@ from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload 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.database import get_db
from app.db.models import User, Class_ from app.db.models import User, Class_
from app.schemas.assignment import ( from app.schemas.assignment import (
@ -28,10 +33,11 @@ from app.services.cos_service import upload_file
router = APIRouter(prefix="/api/assignments", tags=["assignments"]) router = APIRouter(prefix="/api/assignments", tags=["assignments"])
async def _get_roster_count(db: AsyncSession, class_id: int) -> int: async def _get_member_count(db: AsyncSession, class_id: int) -> int:
from app.db.models import StudentRoster
result = await db.execute( 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 return result.scalar() or 0
@ -81,17 +87,18 @@ async def get_assignments(
page: int = 1, page: int = 1,
page_size: int = 20, page_size: int = 20,
class_id: int | 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), 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: if effective_class_id is None:
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) 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) assignments, total = await list_assignments(db, effective_class_id, page, page_size)
total_pages = (total + page_size - 1) // page_size total_pages = (total + page_size - 1) // page_size
roster_count = await _get_roster_count(db, effective_class_id) member_count = await _get_member_count(db, effective_class_id)
items = [_build_assignment_out(a, user.id, roster_count) for a in assignments] 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) 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( async def create_new_assignment(
data: AssignmentCreate, data: AssignmentCreate,
class_id: int | None = None, 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), 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: if effective_class_id is None:
raise HTTPException(status_code=400, detail="You are not assigned to a class") 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) assignment = await create_assignment(db, effective_class_id, user.id, data)
roster_count = await _get_roster_count(db, effective_class_id) member_count = await _get_member_count(db, effective_class_id)
return _build_assignment_out(assignment, user.id, roster_count) return _build_assignment_out(assignment, user.id, member_count)
@router.post("/{assignment_id}/attachments") @router.post("/{assignment_id}/attachments")
async def upload_assignment_attachments( async def upload_assignment_attachments(
assignment_id: int, assignment_id: int,
files: list[UploadFile] = File(...), 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), db: AsyncSession = Depends(get_db),
): ):
assignment = await get_assignment_by_id(db, assignment_id) assignment = await get_assignment_by_id(db, assignment_id)
if assignment is None: if assignment is None:
raise HTTPException(status_code=404, detail="Assignment not found") raise HTTPException(status_code=404, detail="Assignment not found")
if user.role != "super_admin" and assignment.class_id != user.class_id: ensure_class_permission(user, "assignment_manage", assignment.class_id)
raise HTTPException(status_code=403, detail="Access denied")
urls = [] urls = []
for f in files: for f in files:
@ -142,19 +149,18 @@ async def upload_assignment_attachments(
@router.get("/{assignment_id}", response_model=AssignmentDetailOut) @router.get("/{assignment_id}", response_model=AssignmentDetailOut)
async def get_assignment_detail( async def get_assignment_detail(
assignment_id: int, 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), db: AsyncSession = Depends(get_db),
): ):
assignment = await get_assignment_by_id(db, assignment_id) assignment = await get_assignment_by_id(db, assignment_id)
if assignment is None: if assignment is None:
raise HTTPException(status_code=404, detail="Assignment not found") raise HTTPException(status_code=404, detail="Assignment not found")
if user.role != "super_admin" and assignment.class_id != user.class_id: ensure_class_permission(user, "class_view", assignment.class_id)
raise HTTPException(status_code=403, detail="Access denied")
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 # 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 my_submission = None
for s in (assignment.submissions or []): for s in (assignment.submissions or []):
if s.student_id == user.id: if s.student_id == user.id:
@ -171,30 +177,28 @@ async def get_assignment_detail(
async def update_existing_assignment( async def update_existing_assignment(
assignment_id: int, assignment_id: int,
data: AssignmentUpdate, 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), db: AsyncSession = Depends(get_db),
): ):
assignment = await get_assignment_by_id(db, assignment_id) assignment = await get_assignment_by_id(db, assignment_id)
if assignment is None: if assignment is None:
raise HTTPException(status_code=404, detail="Assignment not found") raise HTTPException(status_code=404, detail="Assignment not found")
if user.role != "super_admin" and assignment.class_id != user.class_id: ensure_class_permission(user, "assignment_manage", assignment.class_id)
raise HTTPException(status_code=403, detail="Access denied")
updated = await update_assignment(db, assignment, data) 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}") @router.delete("/{assignment_id}")
async def delete_existing_assignment( async def delete_existing_assignment(
assignment_id: int, 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), db: AsyncSession = Depends(get_db),
): ):
assignment = await get_assignment_by_id(db, assignment_id) assignment = await get_assignment_by_id(db, assignment_id)
if assignment is None: if assignment is None:
raise HTTPException(status_code=404, detail="Assignment not found") raise HTTPException(status_code=404, detail="Assignment not found")
if user.role != "super_admin" and assignment.class_id != user.class_id: ensure_class_permission(user, "assignment_manage", assignment.class_id)
raise HTTPException(status_code=403, detail="Access denied")
await delete_assignment(db, assignment) await delete_assignment(db, assignment)
return {"message": "Assignment deleted"} return {"message": "Assignment deleted"}
@ -205,14 +209,13 @@ async def submit_assignment(
assignment_id: int, assignment_id: int,
notes: str = "", notes: str = "",
file: UploadFile = File(...), 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), db: AsyncSession = Depends(get_db),
): ):
assignment = await get_assignment_by_id(db, assignment_id) assignment = await get_assignment_by_id(db, assignment_id)
if assignment is None: if assignment is None:
raise HTTPException(status_code=404, detail="Assignment not found") raise HTTPException(status_code=404, detail="Assignment not found")
if user.role != "super_admin" and assignment.class_id != user.class_id: ensure_class_permission(user, "class_view", assignment.class_id)
raise HTTPException(status_code=403, detail="Access denied")
# Upload file # Upload file
file_url = None file_url = None
@ -250,7 +253,7 @@ async def submit_assignment(
async def grade_assignment_submission( async def grade_assignment_submission(
submission_id: int, submission_id: int,
data: SubmissionGrade, 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), db: AsyncSession = Depends(get_db),
): ):
from sqlalchemy import select from sqlalchemy import select
@ -262,6 +265,10 @@ async def grade_assignment_submission(
submission = result.scalar_one_or_none() submission = result.scalar_one_or_none()
if submission is None: if submission is None:
raise HTTPException(status_code=404, detail="Submission not found") 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) graded = await grade_submission(db, submission, data)

View File

@ -1,90 +1,98 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession 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.auth import hash_password, verify_password, create_access_token
from app.core.deps import get_current_user from app.core.deps import get_current_user
from app.db.database import get_db from app.db.database import get_db
from app.db.models import User from app.db.models import ClassMembership, User
from app.schemas.auth import LoginRequest, RegisterRequest, ChangePasswordRequest from app.schemas.auth import LoginRequest, RegisterRequest, ChangePasswordRequest
from app.schemas.user import TokenResponse, UserOut from app.schemas.user import TokenResponse, UserOut, build_user_out
from app.services.roster_service import validate_registration from app.services.member_activation_service import validate_registration
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/register") @router.post("/activate")
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)): async def activate_account(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
# 1. Check if email is already registered # 1. Check if email is already in use
existing = await db.execute(select(User).where(User.email == req.email)) existing = await db.execute(select(User).where(User.email == req.email))
if existing.scalar_one_or_none(): if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="该邮箱已注册") raise HTTPException(status_code=400, detail="该邮箱已注册")
# 2. Validate invite_code + student_id against roster # 2. Validate invite_code + student_id against inactive class member
roster_entry = await validate_registration(db, req.invite_code, req.student_id) activation_target = await validate_registration(db, req.invite_code, req.student_id)
if roster_entry is None: if activation_target is None:
raise HTTPException( raise HTTPException(status_code=400, detail="邀请码或学号无效,或账号已激活")
status_code=400, detail="邀请码或学号无效,或该学号已注册"
)
# 3. Create user with approved status directly user, class_id = activation_target
user = User( user.email = req.email
email=req.email, user.password_hash = hash_password(req.password)
password_hash=hash_password(req.password), user.status = "approved"
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
await db.commit() 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}) token = create_access_token({"sub": str(user.id), "role": user.role})
return { return {
"message": "注册成功", "message": "账号激活成功",
"token": token, "token": token,
"user": UserOut.model_validate(user), "user": build_user_out(user, class_id),
} }
@router.post("/login", response_model=TokenResponse) @router.post("/login", response_model=TokenResponse)
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)): 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() user = result.scalar_one_or_none()
if user is None: if user is None:
raise HTTPException(status_code=401, detail="邮箱或密码错误") raise HTTPException(status_code=401, detail="邮箱或密码错误")
if user.status == "inactive":
raise HTTPException(status_code=401, detail="账号尚未激活")
if user.status == "disabled": if user.status == "disabled":
raise HTTPException(status_code=401, detail="账号已被禁用") 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="邮箱或密码错误") raise HTTPException(status_code=401, detail="邮箱或密码错误")
token = create_access_token({"sub": str(user.id), "role": user.role}) token = create_access_token({"sub": str(user.id), "role": user.role})
return TokenResponse( return TokenResponse(
token=token, token=token,
user=UserOut.model_validate(user), user=build_user_out(user),
) )
@router.get("/me", response_model=UserOut) @router.get("/me", response_model=UserOut)
async def get_me(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): 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 # Attach enabled_modules from active class
if user.class_id: if default_membership:
from app.db.models import Class_ from app.db.models import Class_
from sqlalchemy import select 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() class_ = result.scalar_one_or_none()
if class_: if class_:
user_out.enabled_modules = class_.get_enabled_modules() user_out.enabled_modules = class_.get_enabled_modules()
@ -98,7 +106,7 @@ async def change_password(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), 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") raise HTTPException(status_code=400, detail="Old password is incorrect")
user.password_hash = hash_password(req.new_password) user.password_hash = hash_password(req.new_password)
await db.commit() await db.commit()

View File

@ -4,12 +4,20 @@ import io
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession 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.database import get_db
from app.db.models import User from app.db.models import User
from app.schemas.class_ import ClassCreate, ClassUpdate, ClassOut, ModuleUpdate from app.schemas.class_ import ClassCreate, ClassUpdate, ClassOut, ModuleUpdate
from app.schemas.user import UserListItem from app.schemas.user import UserListItem, build_user_list_item
from app.schemas.roster import RosterOut, RosterImportRequest from app.schemas.inactive_member import (
InactiveMemberOut,
MemberImportRequest,
build_inactive_member_out,
)
from app.schemas.common import PageResponse from app.schemas.common import PageResponse
from app.services.class_service import ( from app.services.class_service import (
create_class, create_class,
@ -20,13 +28,13 @@ from app.services.class_service import (
get_member_count, get_member_count,
get_class_members, get_class_members,
) )
from app.services.roster_service import ( from app.services.member_activation_service import (
ensure_invite_code, ensure_invite_code,
regenerate_invite_code, regenerate_invite_code,
import_roster, import_members,
get_roster, get_inactive_members,
delete_roster_entry, delete_inactive_member,
clear_unregistered_roster, clear_inactive_members,
) )
router = APIRouter(prefix="/api/classes", tags=["classes"]) router = APIRouter(prefix="/api/classes", tags=["classes"])
@ -36,8 +44,21 @@ router = APIRouter(prefix="/api/classes", tags=["classes"])
async def get_classes( async def get_classes(
page: int = 1, page: int = 1,
page_size: int = 50, page_size: int = 50,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), 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) classes, total = await list_classes(db, page, page_size)
total_pages = (total + page_size - 1) // page_size total_pages = (total + page_size - 1) // page_size
result = [] result = []
@ -98,16 +119,15 @@ async def get_members(
status: str | None = None, status: str | None = None,
page: int = 1, page: int = 1,
page_size: int = 50, 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), db: AsyncSession = Depends(get_db),
): ):
if admin.role == "class_admin" and admin.class_id != class_id: ensure_class_permission(admin, "member_view", class_id)
raise HTTPException(status_code=403, detail="Access denied for this class")
members, total = await get_class_members(db, class_id, status, page, page_size) members, total = await get_class_members(db, class_id, status, page, page_size)
total_pages = (total + page_size - 1) // page_size total_pages = (total + page_size - 1) // page_size
return PageResponse( 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, total=total,
page=page, page=page,
page_size=page_size, 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]) @router.get("/{class_id}/inactive-members", response_model=PageResponse[InactiveMemberOut])
async def get_class_roster( async def get_class_inactive_members(
class_id: int, class_id: int,
page: int = 1, page: int = 1,
page_size: int = 50, 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), db: AsyncSession = Depends(get_db),
): ):
if admin.role == "class_admin" and admin.class_id != class_id: ensure_class_permission(admin, "member_manage", class_id)
raise HTTPException(status_code=403, detail="Access denied") entries, total = await get_inactive_members(db, class_id, page, page_size)
entries, total = await get_roster(db, class_id, page, page_size)
total_pages = (total + page_size - 1) // page_size total_pages = (total + page_size - 1) // page_size
return PageResponse( return PageResponse(
items=[RosterOut.model_validate(e) for e in entries], items=[build_inactive_member_out(entry) for entry in entries],
total=total, total=total,
page=page, page=page,
page_size=page_size, page_size=page_size,
@ -139,28 +158,26 @@ async def get_class_roster(
) )
@router.post("/{class_id}/roster/import") @router.post("/{class_id}/inactive-members/import")
async def import_class_roster( async def import_class_members(
class_id: int, class_id: int,
data: RosterImportRequest, data: MemberImportRequest,
admin: User = Depends(require_role("super_admin", "class_admin")), admin: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
if admin.role == "class_admin" and admin.class_id != class_id: ensure_class_permission(admin, "member_manage", class_id)
raise HTTPException(status_code=403, detail="Access denied") count = await import_members(db, class_id, data.entries)
count = await import_roster(db, class_id, data.entries) return {"message": f"成功导入 {count} 位成员"}
return {"message": f"成功导入 {count} 条记录"}
@router.post("/{class_id}/roster/upload") @router.post("/{class_id}/inactive-members/upload")
async def upload_roster_file( async def upload_member_file(
class_id: int, class_id: int,
file: UploadFile = File(...), 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), db: AsyncSession = Depends(get_db),
): ):
if admin.role == "class_admin" and admin.class_id != class_id: ensure_class_permission(admin, "member_manage", class_id)
raise HTTPException(status_code=403, detail="Access denied")
contents = await file.read() contents = await file.read()
filename = file.filename or "" filename = file.filename or ""
@ -214,33 +231,33 @@ async def upload_roster_file(
if not entries: if not entries:
raise HTTPException(status_code=400, detail="未找到有效数据") raise HTTPException(status_code=400, detail="未找到有效数据")
count = await import_roster(db, class_id, entries) count = await import_members(db, class_id, entries)
return {"message": f"成功导入 {count} 条记录"} return {"message": f"成功导入 {count} 位成员"}
@router.delete("/{class_id}/roster/{roster_id}") @router.delete("/{class_id}/inactive-members/{user_id}")
async def delete_roster_item( async def delete_inactive_member_item(
class_id: int, class_id: int,
roster_id: int, user_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), 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: if not success:
raise HTTPException(status_code=400, detail="无法删除(已注册或不存在)") raise HTTPException(status_code=400, detail="无法删除(已激活、已加入其他班级或不存在)")
return {"message": "已删除"} return {"message": "已删除"}
@router.post("/{class_id}/roster/clear") @router.post("/{class_id}/inactive-members/clear")
async def clear_roster( async def clear_class_inactive_members(
class_id: int, 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), db: AsyncSession = Depends(get_db),
): ):
if admin.role == "class_admin" and admin.class_id != class_id: ensure_class_permission(admin, "member_manage", class_id)
raise HTTPException(status_code=403, detail="Access denied") count = await clear_inactive_members(db, class_id)
count = await clear_unregistered_roster(db, class_id) return {"message": f"已清除 {count} 位未激活成员"}
return {"message": f"已清除 {count} 条未注册记录"}
# --- Invite code management --- # --- Invite code management ---
@ -249,9 +266,10 @@ async def clear_roster(
@router.get("/{class_id}/invite-code") @router.get("/{class_id}/invite-code")
async def get_invite_code( async def get_invite_code(
class_id: int, 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), db: AsyncSession = Depends(get_db),
): ):
ensure_class_permission(admin, "member_manage", class_id)
code = await ensure_invite_code(db, class_id) code = await ensure_invite_code(db, class_id)
if not code: if not code:
raise HTTPException(status_code=404, detail="Class not found") 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") @router.post("/{class_id}/invite-code/regenerate")
async def regenerate_invite( async def regenerate_invite(
class_id: int, 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), db: AsyncSession = Depends(get_db),
): ):
ensure_class_permission(admin, "member_manage", class_id)
code = await regenerate_invite_code(db, class_id) code = await regenerate_invite_code(db, class_id)
if not code: if not code:
raise HTTPException(status_code=404, detail="Class not found") raise HTTPException(status_code=404, detail="Class not found")
@ -276,11 +295,10 @@ async def regenerate_invite(
@router.get("/{class_id}/modules") @router.get("/{class_id}/modules")
async def get_class_modules( async def get_class_modules(
class_id: int, 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), db: AsyncSession = Depends(get_db),
): ):
if admin.role == "class_admin" and admin.class_id != class_id: ensure_class_permission(admin, "module_manage", class_id)
raise HTTPException(status_code=403, detail="Access denied")
class_ = await get_class_by_id(db, class_id) class_ = await get_class_by_id(db, class_id)
if class_ is None: if class_ is None:
@ -297,11 +315,10 @@ async def get_class_modules(
async def update_class_modules( async def update_class_modules(
class_id: int, class_id: int,
data: ModuleUpdate, 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), db: AsyncSession = Depends(get_db),
): ):
if admin.role == "class_admin" and admin.class_id != class_id: ensure_class_permission(admin, "module_manage", class_id)
raise HTTPException(status_code=403, detail="Access denied")
class_ = await get_class_by_id(db, class_id) class_ = await get_class_by_id(db, class_id)
if class_ is None: if class_ is None:

View File

@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession 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.database import get_db
from app.db.models import User from app.db.models import User
from app.schemas.user import UserPublic from app.schemas.user import UserPublic
@ -23,18 +23,21 @@ async def search_members(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
# Determine effective class_id: super_admin can specify one, others use their own effective_class_id = resolve_class_id_for_user(user, class_id)
effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
if effective_class_id is None: if effective_class_id is None:
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) 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( users, total = await search_directory(
db, effective_class_id, search, industry, company, page, page_size db, effective_class_id, search, industry, company, page, page_size
) )
total_pages = (total + page_size - 1) // 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( 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, total=total,
page=page, page=page,
page_size=page_size, page_size=page_size,
@ -53,5 +56,11 @@ async def get_member_detail(
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
# Privacy: only show contact info to same-class members # Privacy: only show contact info to same-class members
include_contact = user.class_id == target.class_id shared_class_ids = {
return user_to_public(target, include_contact=include_contact) 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)

View File

@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession 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.database import get_db
from app.db.models import FundRecord, User from app.db.models import FundRecord, User
from app.schemas.fund import FundRecordCreate, FundRecordUpdate, FundRecordOut, FundStatistics 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) @router.get("/statistics", response_model=FundStatistics)
async def get_statistics( async def get_statistics(
class_id: int | 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), 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: if effective_class_id is None:
return FundStatistics( return FundStatistics(
total_income=0, total_expense=0, balance=0, total_income=0, total_expense=0, balance=0,
income_by_category=[], expense_by_category=[] income_by_category=[], expense_by_category=[]
) )
ensure_class_permission(user, "class_view", effective_class_id)
return await get_fund_statistics(db, 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, type: str | None = None,
category: str | None = None, category: str | None = None,
class_id: int | 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), 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: if effective_class_id is None:
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) 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) records, total = await list_fund_records(db, effective_class_id, page, page_size, type, category)
total_pages = (total + page_size - 1) // page_size total_pages = (total + page_size - 1) // page_size
@ -69,12 +71,13 @@ async def get_fund_records(
async def create_new_record( async def create_new_record(
data: FundRecordCreate, data: FundRecordCreate,
class_id: int | None = None, 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), 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: if effective_class_id is None:
raise HTTPException(status_code=400, detail="No class specified") 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"): if data.type not in ("income", "expense"):
raise HTTPException(status_code=400, detail="Type must be 'income' or '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( async def update_existing_record(
record_id: int, record_id: int,
data: FundRecordUpdate, 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), db: AsyncSession = Depends(get_db),
): ):
record = await get_fund_record_by_id(db, record_id) record = await get_fund_record_by_id(db, record_id)
if record is None: if record is None:
raise HTTPException(status_code=404, detail="Record not found") raise HTTPException(status_code=404, detail="Record not found")
if user.role != "super_admin" and record.class_id != user.class_id: ensure_class_permission(user, "fund_manage", record.class_id)
raise HTTPException(status_code=403, detail="Access denied")
if data.type is not None and data.type not in ("income", "expense"): 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'") 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}") @router.delete("/{record_id}")
async def delete_existing_record( async def delete_existing_record(
record_id: int, 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), db: AsyncSession = Depends(get_db),
): ):
record = await get_fund_record_by_id(db, record_id) record = await get_fund_record_by_id(db, record_id)
if record is None: if record is None:
raise HTTPException(status_code=404, detail="Record not found") raise HTTPException(status_code=404, detail="Record not found")
if user.role != "super_admin" and record.class_id != user.class_id: ensure_class_permission(user, "fund_manage", record.class_id)
raise HTTPException(status_code=403, detail="Access denied")
await delete_fund_record(db, record) await delete_fund_record(db, record)
return {"message": "Record deleted"} return {"message": "Record deleted"}

View File

@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from sqlalchemy.ext.asyncio import AsyncSession 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.database import get_db
from app.db.models import User from app.db.models import User
from app.schemas.resource import ResourceCreate, ResourceOut from app.schemas.resource import ResourceCreate, ResourceOut
@ -40,12 +40,13 @@ async def get_resources(
page_size: int = 20, page_size: int = 20,
category: str | None = None, category: str | None = None,
class_id: int | 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), 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: if effective_class_id is None:
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) 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) resources, total = await list_resources(db, effective_class_id, category, page, page_size)
total_pages = (total + page_size - 1) // page_size total_pages = (total + page_size - 1) // page_size
@ -81,12 +82,13 @@ async def upload_new_resource(
category: str = Form(...), category: str = Form(...),
description: str | None = Form(None), description: str | None = Form(None),
class_id: int | 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), 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: if effective_class_id is None:
raise HTTPException(status_code=400, detail="You are not assigned to a class") 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() contents = await file.read()
if len(contents) > 50 * 1024 * 1024: # 50MB limit if len(contents) > 50 * 1024 * 1024: # 50MB limit
@ -126,12 +128,13 @@ async def upload_new_resource(
@router.post("/{resource_id}/download") @router.post("/{resource_id}/download")
async def download_resource( async def download_resource(
resource_id: int, 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), db: AsyncSession = Depends(get_db),
): ):
resource = await get_resource_by_id(db, resource_id) resource = await get_resource_by_id(db, resource_id)
if resource is None: if resource is None:
raise HTTPException(status_code=404, detail="Resource not found") raise HTTPException(status_code=404, detail="Resource not found")
ensure_class_permission(user, "class_view", resource.class_id)
await increment_download_count(db, resource) await increment_download_count(db, resource)
return {"file_url": resource.file_url} return {"file_url": resource.file_url}
@ -140,14 +143,13 @@ async def download_resource(
@router.delete("/{resource_id}") @router.delete("/{resource_id}")
async def delete_existing_resource( async def delete_existing_resource(
resource_id: int, 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), db: AsyncSession = Depends(get_db),
): ):
resource = await get_resource_by_id(db, resource_id) resource = await get_resource_by_id(db, resource_id)
if resource is None: if resource is None:
raise HTTPException(status_code=404, detail="Resource not found") raise HTTPException(status_code=404, detail="Resource not found")
if user.role != "super_admin" and resource.class_id != user.class_id: ensure_class_permission(user, "resource_manage", resource.class_id)
raise HTTPException(status_code=403, detail="Access denied")
await delete_resource(db, resource) await delete_resource(db, resource)
return {"message": "Resource deleted"} return {"message": "Resource deleted"}

View File

@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession 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.database import get_db
from app.db.models import User from app.db.models import User
from app.schemas.schedule import ScheduleCreate, ScheduleUpdate, ScheduleOut from app.schemas.schedule import ScheduleCreate, ScheduleUpdate, ScheduleOut
@ -22,12 +22,13 @@ router = APIRouter(prefix="/api/schedule", tags=["schedule"])
async def get_upcoming( async def get_upcoming(
limit: int = 10, limit: int = 10,
class_id: int | 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), 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: if effective_class_id is None:
return [] return []
ensure_class_permission(user, "class_view", effective_class_id)
items = await get_upcoming_schedules(db, effective_class_id, limit) items = await get_upcoming_schedules(db, effective_class_id, limit)
return [ScheduleOut.model_validate(i) for i in items] return [ScheduleOut.model_validate(i) for i in items]
@ -38,12 +39,13 @@ async def get_schedules(
page: int = 1, page: int = 1,
page_size: int = 50, page_size: int = 50,
class_id: int | 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), 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: if effective_class_id is None:
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) 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) items, total = await list_schedules(db, effective_class_id, type, page, page_size)
total_pages = (total + page_size - 1) // page_size total_pages = (total + page_size - 1) // page_size
@ -60,12 +62,13 @@ async def get_schedules(
async def create_new_schedule( async def create_new_schedule(
data: ScheduleCreate, data: ScheduleCreate,
class_id: int | None = None, 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), 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: if effective_class_id is None:
raise HTTPException(status_code=400, detail="You are not assigned to a class") 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) item = await create_schedule(db, effective_class_id, data)
return ScheduleOut.model_validate(item) return ScheduleOut.model_validate(item)
@ -75,14 +78,13 @@ async def create_new_schedule(
async def update_existing_schedule( async def update_existing_schedule(
schedule_id: int, schedule_id: int,
data: ScheduleUpdate, 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), db: AsyncSession = Depends(get_db),
): ):
item = await get_schedule_by_id(db, schedule_id) item = await get_schedule_by_id(db, schedule_id)
if item is None: if item is None:
raise HTTPException(status_code=404, detail="Schedule not found") raise HTTPException(status_code=404, detail="Schedule not found")
if user.role != "super_admin" and item.class_id != user.class_id: ensure_class_permission(user, "schedule_manage", item.class_id)
raise HTTPException(status_code=403, detail="Access denied")
updated = await update_schedule(db, item, data) updated = await update_schedule(db, item, data)
return ScheduleOut.model_validate(updated) return ScheduleOut.model_validate(updated)
@ -91,14 +93,13 @@ async def update_existing_schedule(
@router.delete("/{schedule_id}") @router.delete("/{schedule_id}")
async def delete_existing_schedule( async def delete_existing_schedule(
schedule_id: int, 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), db: AsyncSession = Depends(get_db),
): ):
item = await get_schedule_by_id(db, schedule_id) item = await get_schedule_by_id(db, schedule_id)
if item is None: if item is None:
raise HTTPException(status_code=404, detail="Schedule not found") raise HTTPException(status_code=404, detail="Schedule not found")
if user.role != "super_admin" and item.class_id != user.class_id: ensure_class_permission(user, "schedule_manage", item.class_id)
raise HTTPException(status_code=403, detail="Access denied")
await delete_schedule(db, item) await delete_schedule(db, item)
return {"message": "Schedule deleted"} return {"message": "Schedule deleted"}

View File

@ -2,7 +2,12 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
import asyncio 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.database import get_db
from app.db.models import User from app.db.models import User
from app.schemas.timeline import ( from app.schemas.timeline import (
@ -68,12 +73,13 @@ async def get_timelines(
page: int = 1, page: int = 1,
page_size: int = 20, page_size: int = 20,
class_id: int | 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), 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: if effective_class_id is None:
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) 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) posts, total = await list_timelines(db, effective_class_id, page, page_size)
total_pages = (total + page_size - 1) // page_size total_pages = (total + page_size - 1) // page_size
@ -91,12 +97,13 @@ async def create_new_timeline(
content: str | None = Form(None), content: str | None = Form(None),
class_id: int | None = Form(None), class_id: int | None = Form(None),
files: list[UploadFile] = File(default=[]), 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), 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: if effective_class_id is None:
raise HTTPException(status_code=400, detail="You are not assigned to a class") 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) data = TimelineCreate(title=title, content=content)
post = await create_timeline(db, effective_class_id, user.id, data) 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( async def upload_timeline_images(
post_id: int, post_id: int,
files: list[UploadFile] = File(...), 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), db: AsyncSession = Depends(get_db),
): ):
post = await get_timeline_by_id(db, post_id) 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") 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 # 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: can_manage = "timeline_manage" in get_effective_class_permissions(user, post.class_id)
raise HTTPException(status_code=403, detail="Access denied") if not can_manage and post.author_id != user.id:
if user.role != "super_admin" and post.class_id != user.class_id:
raise HTTPException(status_code=403, detail="Access denied") raise HTTPException(status_code=403, detail="Access denied")
ensure_class_permission(user, "class_view", post.class_id)
urls = [] urls = []
for f in files: for f in files:
@ -166,16 +173,16 @@ async def upload_timeline_images(
async def update_existing_timeline( async def update_existing_timeline(
post_id: int, post_id: int,
data: TimelineUpdate, 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), db: AsyncSession = Depends(get_db),
): ):
post = await get_timeline_by_id(db, post_id) post = await get_timeline_by_id(db, post_id)
if post is None: if post is None:
raise HTTPException(status_code=404, detail="Timeline post not found") raise HTTPException(status_code=404, detail="Timeline post not found")
if user.role == "student" and post.author_id != user.id: can_manage = "timeline_manage" in get_effective_class_permissions(user, post.class_id)
raise HTTPException(status_code=403, detail="Access denied") if not can_manage and post.author_id != user.id:
if user.role != "super_admin" and post.class_id != user.class_id:
raise HTTPException(status_code=403, detail="Access denied") raise HTTPException(status_code=403, detail="Access denied")
ensure_class_permission(user, "class_view", post.class_id)
updated = await update_timeline(db, post, data) updated = await update_timeline(db, post, data)
return _build_timeline_out(updated, user.id) return _build_timeline_out(updated, user.id)
@ -184,16 +191,16 @@ async def update_existing_timeline(
@router.delete("/{post_id}") @router.delete("/{post_id}")
async def delete_existing_timeline( async def delete_existing_timeline(
post_id: int, 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), db: AsyncSession = Depends(get_db),
): ):
post = await get_timeline_by_id(db, post_id) post = await get_timeline_by_id(db, post_id)
if post is None: if post is None:
raise HTTPException(status_code=404, detail="Timeline post not found") raise HTTPException(status_code=404, detail="Timeline post not found")
if user.role == "student" and post.author_id != user.id: can_manage = "timeline_manage" in get_effective_class_permissions(user, post.class_id)
raise HTTPException(status_code=403, detail="Access denied") if not can_manage and post.author_id != user.id:
if user.role != "super_admin" and post.class_id != user.class_id:
raise HTTPException(status_code=403, detail="Access denied") raise HTTPException(status_code=403, detail="Access denied")
ensure_class_permission(user, "class_view", post.class_id)
await delete_timeline(db, post) await delete_timeline(db, post)
return {"message": "Timeline post deleted"} return {"message": "Timeline post deleted"}
@ -204,14 +211,13 @@ async def delete_existing_timeline(
@router.post("/{post_id}/like") @router.post("/{post_id}/like")
async def like_timeline_post( async def like_timeline_post(
post_id: int, 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), db: AsyncSession = Depends(get_db),
): ):
post = await get_timeline_by_id(db, post_id) post = await get_timeline_by_id(db, post_id)
if post is None: if post is None:
raise HTTPException(status_code=404, detail="Timeline post not found") raise HTTPException(status_code=404, detail="Timeline post not found")
if user.role != "super_admin" and post.class_id != user.class_id: ensure_class_permission(user, "class_view", post.class_id)
raise HTTPException(status_code=403, detail="Access denied")
return await toggle_like(db, post_id, user.id) return await toggle_like(db, post_id, user.id)
@ -220,9 +226,13 @@ async def get_post_comments(
post_id: int, post_id: int,
page: int = 1, page: int = 1,
page_size: int = 50, 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), 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) comments, total = await list_comments(db, post_id, page, page_size)
total_pages = (total + page_size - 1) // page_size total_pages = (total + page_size - 1) // page_size
items = [ items = [
@ -244,14 +254,13 @@ async def get_post_comments(
async def add_post_comment( async def add_post_comment(
post_id: int, post_id: int,
data: TimelineCommentCreate, 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), db: AsyncSession = Depends(get_db),
): ):
post = await get_timeline_by_id(db, post_id) post = await get_timeline_by_id(db, post_id)
if post is None: if post is None:
raise HTTPException(status_code=404, detail="Timeline post not found") raise HTTPException(status_code=404, detail="Timeline post not found")
if user.role != "super_admin" and post.class_id != user.class_id: ensure_class_permission(user, "class_view", post.class_id)
raise HTTPException(status_code=403, detail="Access denied")
comment = await create_comment(db, post_id, user.id, data) comment = await create_comment(db, post_id, user.id, data)
return TimelineCommentOut( return TimelineCommentOut(
@ -268,14 +277,19 @@ async def add_post_comment(
@router.delete("/comments/{comment_id}") @router.delete("/comments/{comment_id}")
async def delete_timeline_comment( async def delete_timeline_comment(
comment_id: int, 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), db: AsyncSession = Depends(get_db),
): ):
comment = await get_comment_by_id(db, comment_id) comment = await get_comment_by_id(db, comment_id)
if comment is None: if comment is None:
raise HTTPException(status_code=404, detail="Comment not found") 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") 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) await delete_comment(db, comment)
return {"message": "Comment deleted"} return {"message": "Comment deleted"}

View File

@ -10,7 +10,7 @@ router = APIRouter(prefix="/api/upload", tags=["upload"])
@router.post("/image") @router.post("/image")
async def upload_image_api( async def upload_image_api(
file: UploadFile = File(...), 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.""" """Upload an image to Tencent COS."""
contents = await file.read() contents = await file.read()

View File

@ -1,29 +1,50 @@
import json from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
from sqlalchemy.ext.asyncio import AsyncSession 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.database import get_db
from app.db.models import User 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.schemas.common import PageResponse
from app.services.user_service import ( from app.services.user_service import (
update_profile, update_profile,
update_user_status, 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, list_users,
get_user_by_id, get_user_by_id,
) )
from app.services.cos_service import upload_image 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 = APIRouter(prefix="/api/users", tags=["users"])
@router.get("/me", response_model=UserOut) @router.get("/me", response_model=UserOut)
async def get_my_profile(user: User = Depends(get_current_user)): 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) @router.put("/me", response_model=UserOut)
@ -36,7 +57,7 @@ async def update_my_profile(
updated = await update_profile(db, user, data) updated = await update_profile(db, user, data)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
return UserOut.model_validate(updated) return build_user_out(updated)
@router.post("/me/avatar") @router.post("/me/avatar")
@ -65,13 +86,18 @@ async def list_all_users(
class_id: int | None = None, class_id: int | None = None,
status: str | None = None, status: str | None = None,
role: 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), 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) users, total = await list_users(db, page, page_size, class_id, status, role)
total_pages = (total + page_size - 1) // page_size total_pages = (total + page_size - 1) // page_size
return PageResponse( 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, total=total,
page=page, page=page,
page_size=page_size, page_size=page_size,
@ -83,18 +109,15 @@ async def list_all_users(
async def change_user_status( async def change_user_status(
user_id: int, user_id: int,
data: UserStatusUpdate, 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), db: AsyncSession = Depends(get_db),
): ):
target = await get_user_by_id(db, user_id) target = await get_user_by_id(db, user_id)
if target is None: if target is None:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
# Class admin can only manage users in their own class target_class_id = target.get_default_membership().class_id if target.get_default_membership() else None
if admin.role == "class_admin" and target.class_id != admin.class_id: ensure_class_permission(admin, "member_manage", target_class_id)
raise HTTPException(
status_code=403, detail="Cannot manage users outside your class"
)
# Only super_admin can change roles # Only super_admin can change roles
if data.role and admin.role != "super_admin": 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) updated = await update_user_status(db, user_id, data.status, data.role)
return {"message": f"用户状态已更新为 {data.status}"}
# 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}"}
@router.put("/{user_id}/role") @router.put("/{user_id}/role")
async def change_user_role( async def change_user_role(
user_id: int, user_id: int,
role: str, data: UserRoleUpdate,
admin: User = Depends(require_role("super_admin")), admin: User = Depends(require_role("super_admin")),
db: AsyncSession = Depends(get_db), 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") raise HTTPException(status_code=400, detail="Invalid role")
target = await get_user_by_id(db, user_id) target = await get_user_by_id(db, user_id)
if target is None: if target is None:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
target.role = role await update_user_role(db, target, data.role)
await db.commit() return {"message": f"User role updated to {data.role}"}
return {"message": f"User role updated to {role}"}
class CommitteeRoleUpdate(BaseModel):
committee_role: str | None = None
@router.put("/{user_id}/committee-role") @router.put("/{user_id}/committee-role")
async def change_committee_role( async def change_committee_role(
user_id: int, user_id: int,
data: CommitteeRoleUpdate, 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), db: AsyncSession = Depends(get_db),
): ):
target = await get_user_by_id(db, user_id) target = await get_user_by_id(db, user_id)
if target is None: if target is None:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
if admin.role == "class_admin" and target.class_id != admin.class_id: ensure_class_permission(admin, "committee_manage", data.class_id)
raise HTTPException(
status_code=403, detail="Cannot manage users outside your class"
)
target.committee_role = data.committee_role try:
await db.commit() 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"} 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),
)

View File

@ -1,7 +1,12 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession 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.database import get_db
from app.db.models import User from app.db.models import User
from app.schemas.vote import VoteCreate, VoteUpdate, VoteSubmit, VoteOptionOut, VoteOut from app.schemas.vote import VoteCreate, VoteUpdate, VoteSubmit, VoteOptionOut, VoteOut
@ -70,12 +75,13 @@ async def get_votes(
page: int = 1, page: int = 1,
page_size: int = 20, page_size: int = 20,
class_id: int | 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), 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: if effective_class_id is None:
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) 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) votes, total = await list_votes(db, effective_class_id, page, page_size)
total_pages = (total + page_size - 1) // page_size total_pages = (total + page_size - 1) // page_size
@ -87,7 +93,7 @@ async def get_votes(
async def create_new_vote( async def create_new_vote(
data: VoteCreate, data: VoteCreate,
class_id: int | 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), db: AsyncSession = Depends(get_db),
): ):
if len(data.options) < 2: if len(data.options) < 2:
@ -95,9 +101,10 @@ async def create_new_vote(
if data.vote_type == "multiple" and data.max_choices < 2: if data.vote_type == "multiple" and data.max_choices < 2:
raise HTTPException(status_code=400, detail="多选投票最多可选数不能小于 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: if effective_class_id is None:
raise HTTPException(status_code=400, detail="You are not assigned to a class") 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) vote = await create_vote(db, effective_class_id, user.id, data)
# Reload with relationships # Reload with relationships
@ -108,14 +115,13 @@ async def create_new_vote(
@router.get("/{vote_id}", response_model=VoteOut) @router.get("/{vote_id}", response_model=VoteOut)
async def get_vote_detail( async def get_vote_detail(
vote_id: int, 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), db: AsyncSession = Depends(get_db),
): ):
vote = await get_vote_by_id(db, vote_id) vote = await get_vote_by_id(db, vote_id)
if vote is None: if vote is None:
raise HTTPException(status_code=404, detail="Vote not found") raise HTTPException(status_code=404, detail="Vote not found")
if user.role != "super_admin" and vote.class_id != user.class_id: ensure_class_permission(user, "class_view", vote.class_id)
raise HTTPException(status_code=403, detail="Access denied")
return _build_vote_out(vote, user.id) return _build_vote_out(vote, user.id)
@ -123,14 +129,13 @@ async def get_vote_detail(
async def submit_vote_response( async def submit_vote_response(
vote_id: int, vote_id: int,
data: VoteSubmit, 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), db: AsyncSession = Depends(get_db),
): ):
vote = await get_vote_by_id(db, vote_id) vote = await get_vote_by_id(db, vote_id)
if vote is None: if vote is None:
raise HTTPException(status_code=404, detail="Vote not found") raise HTTPException(status_code=404, detail="Vote not found")
if user.role != "super_admin" and vote.class_id != user.class_id: ensure_class_permission(user, "class_view", vote.class_id)
raise HTTPException(status_code=403, detail="Access denied")
try: try:
await submit_vote(db, vote_id, user.id, data.option_ids) 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") @router.put("/{vote_id}/close")
async def close_vote_endpoint( async def close_vote_endpoint(
vote_id: int, 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), db: AsyncSession = Depends(get_db),
): ):
vote = await get_vote_by_id(db, vote_id) vote = await get_vote_by_id(db, vote_id)
if vote is None: if vote is None:
raise HTTPException(status_code=404, detail="Vote not found") raise HTTPException(status_code=404, detail="Vote not found")
# Only creator or admin can close # 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="只有创建者或管理员可以关闭投票") raise HTTPException(status_code=403, detail="只有创建者或管理员可以关闭投票")
if user.role != "super_admin" and vote.class_id != user.class_id: ensure_class_permission(user, "class_view", vote.class_id)
raise HTTPException(status_code=403, detail="Access denied")
await close_vote(db, vote) await close_vote(db, vote)
return {"message": "投票已关闭"} return {"message": "投票已关闭"}
@ -162,16 +167,16 @@ async def close_vote_endpoint(
@router.delete("/{vote_id}") @router.delete("/{vote_id}")
async def delete_vote_endpoint( async def delete_vote_endpoint(
vote_id: int, 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), db: AsyncSession = Depends(get_db),
): ):
vote = await get_vote_by_id(db, vote_id) vote = await get_vote_by_id(db, vote_id)
if vote is None: if vote is None:
raise HTTPException(status_code=404, detail="Vote not found") 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="只有创建者或管理员可以删除投票") raise HTTPException(status_code=403, detail="只有创建者或管理员可以删除投票")
if user.role != "super_admin" and vote.class_id != user.class_id: ensure_class_permission(user, "class_view", vote.class_id)
raise HTTPException(status_code=403, detail="Access denied")
await delete_vote(db, vote) await delete_vote(db, vote)
return {"message": "投票已删除"} return {"message": "投票已删除"}

View File

@ -1,14 +1,44 @@
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth import decode_access_token from app.core.auth import decode_access_token
from app.db.database import get_db from app.db.database import get_db
from app.db.models import User from app.db.models import ClassMembership, User
security = HTTPBearer() 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( async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security), credentials: HTTPAuthorizationCredentials = Depends(security),
@ -28,13 +58,24 @@ async def get_current_user(
detail="Invalid token format", 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() user = result.scalar_one_or_none()
if user is None: if user is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found" 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": if user.status == "disabled":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Account disabled" status_code=status.HTTP_403_FORBIDDEN, detail="Account disabled"
@ -55,3 +96,68 @@ def require_role(*roles: str):
return user return user
return _check 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",
)

View File

@ -9,9 +9,3 @@ async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit
async def get_db(): async def get_db():
async with async_session() as session: async with async_session() as session:
yield 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)

View File

@ -35,7 +35,9 @@ class Class_(Base):
def set_enabled_modules(self, modules: list[str]): def set_enabled_modules(self, modules: list[str]):
self.enabled_modules = json.dumps(modules, ensure_ascii=False) if modules else None 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( timelines: Mapped[list["Timeline"]] = relationship(
"Timeline", back_populates="class_", cascade="all, delete-orphan" "Timeline", back_populates="class_", cascade="all, delete-orphan"
) )
@ -48,9 +50,6 @@ class Class_(Base):
resources: Mapped[list["Resource"]] = relationship( resources: Mapped[list["Resource"]] = relationship(
"Resource", back_populates="class_", cascade="all, delete-orphan" "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( assignments: Mapped[list["Assignment"]] = relationship(
"Assignment", back_populates="class_", cascade="all, delete-orphan" "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) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=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) name: Mapped[str] = mapped_column(String(100), nullable=False)
student_id: Mapped[str | None] = mapped_column(String(50), nullable=True, unique=True) 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) role: Mapped[str] = mapped_column(String(20), default="student", nullable=False)
# status: pending | approved | rejected | disabled # status: inactive | approved | disabled
status: Mapped[str] = mapped_column(String(20), default="pending", nullable=False) status: Mapped[str] = mapped_column(String(20), default="inactive", nullable=False)
class_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("classes.id"), nullable=True
)
class_: Mapped["Class_ | None"] = relationship("Class_", back_populates="members")
# Profile # Profile
industry: Mapped[str | None] = mapped_column(String(100), nullable=True) industry: Mapped[str | None] = mapped_column(String(100), nullable=True)
company: 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) 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 skills_tags: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array
wechat_id: Mapped[str | None] = mapped_column(String(100), nullable=True) wechat_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
phone: Mapped[str | None] = mapped_column(String(20), 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() 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_posts: Mapped[list["Timeline"]] = relationship(
"Timeline", back_populates="author" "Timeline", back_populates="author"
) )
@ -121,6 +117,65 @@ class User(Base):
def set_skills_list(self, tags: list[str]): def set_skills_list(self, tags: list[str]):
self.skills_tags = json.dumps(tags, ensure_ascii=False) if tags else None 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): class Timeline(Base):
__tablename__ = "timelines" __tablename__ = "timelines"
@ -245,27 +300,6 @@ class Notification(Base):
user: Mapped["User"] = relationship("User") 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): class TimelineLike(Base):
__tablename__ = "timeline_likes" __tablename__ = "timeline_likes"

View File

@ -5,7 +5,6 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.config import settings 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 from app.api import auth, users, classes, directory, timeline, schedule, upload, announcements, resources, notifications, votes, assignments, fund
logging.basicConfig( logging.basicConfig(
@ -37,45 +36,9 @@ async def ensure_super_admin():
logger.info("Super admin seeded: %s", settings.super_admin_email) 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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
await create_tables()
await migrate_add_enabled_modules()
await ensure_super_admin() await ensure_super_admin()
await ensure_sample_class()
yield yield

View File

@ -9,7 +9,6 @@ class LoginRequest(BaseModel):
class RegisterRequest(BaseModel): class RegisterRequest(BaseModel):
invite_code: str invite_code: str
student_id: str student_id: str
name: str
email: EmailStr email: EmailStr
password: str password: str

View File

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

View File

@ -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": "..."}, ...]

View File

@ -1,7 +1,17 @@
import json
from datetime import datetime 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): class UserOut(BaseModel):
@ -11,34 +21,21 @@ class UserOut(BaseModel):
student_id: str | None student_id: str | None
role: str role: str
status: str status: str
class_id: int | None
industry: str | None industry: str | None
company: str | None company: str | None
position: str | None position: str | None
committee_role: str | None
skills_tags: list[str] | None skills_tags: list[str] | None
wechat_id: str | None wechat_id: str | None
phone: str | None phone: str | None
avatar_url: str | None avatar_url: str | None
bio: str | None bio: str | None
created_at: datetime created_at: datetime
memberships: list[MembershipOut]
active_membership: MembershipOut | None = None
enabled_modules: list[str] | 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): class UserPublic(BaseModel):
"""Shown to same-class approved members (includes contact info)."""
id: int id: int
name: str name: str
student_id: str | None student_id: str | None
@ -53,20 +50,19 @@ class UserPublic(BaseModel):
class UserListItem(BaseModel): class UserListItem(BaseModel):
"""For admin user management list."""
id: int id: int
email: str email: str
name: str name: str
student_id: str | None student_id: str | None
role: str role: str
status: str status: str
class_id: int | None
industry: str | None industry: str | None
company: str | None company: str | None
committee_role: str | None = None committee_role: str | None = None
class_permissions: list[str]
created_at: datetime created_at: datetime
memberships: list[MembershipOut]
model_config = {"from_attributes": True} active_membership: MembershipOut | None = None
class UserUpdate(BaseModel): class UserUpdate(BaseModel):
@ -81,10 +77,101 @@ class UserUpdate(BaseModel):
class UserStatusUpdate(BaseModel): class UserStatusUpdate(BaseModel):
status: str # approved | rejected | disabled status: str
role: str | None = None 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): class TokenResponse(BaseModel):
token: str token: str
user: UserOut 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,
)

View File

@ -1,7 +1,8 @@
from sqlalchemy import select, func from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession 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 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: async def get_member_count(db: AsyncSession, class_id: int) -> int:
result = await db.execute( result = await db.execute(
select(func.count(User.id)).where( select(func.count(ClassMembership.id))
User.class_id == class_id, User.status == "approved" .join(User, User.id == ClassMembership.user_id)
.where(
ClassMembership.class_id == class_id,
User.status == "approved",
) )
) )
return result.scalar() or 0 return result.scalar() or 0
@ -63,8 +67,18 @@ async def get_class_members(
page: int = 1, page: int = 1,
page_size: int = 50, page_size: int = 50,
) -> tuple[list[User], int]: ) -> tuple[list[User], int]:
query = select(User).where(User.class_id == class_id) query = (
count_query = select(func.count(User.id)).where(User.class_id == class_id) 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: if status:
query = query.where(User.status == status) query = query.where(User.status == status)
@ -76,4 +90,4 @@ async def get_class_members(
result = await db.execute( result = await db.execute(
query.order_by(User.name).offset((page - 1) * page_size).limit(page_size) 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

View File

@ -1,8 +1,9 @@
import json import json
from sqlalchemy import select, or_, func, case from sqlalchemy import select, or_, func, case
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession 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 from app.schemas.user import UserPublic
@ -15,12 +16,20 @@ async def search_directory(
page: int = 1, page: int = 1,
page_size: int = 20, page_size: int = 20,
) -> tuple[list[User], int]: ) -> tuple[list[User], int]:
"""Search approved members in a class.""" """Search active members in a class."""
query = select(User).where( query = (
User.class_id == class_id, User.status == "approved" 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( count_query = (
User.class_id == class_id, User.status == "approved" select(func.count(User.id))
.join(ClassMembership)
.where(ClassMembership.class_id == class_id, User.status == "approved")
) )
if search: if search:
@ -54,23 +63,26 @@ async def search_directory(
# Committee role priority: 班长(1) > 副班长(2) > other roles(3) > no role(4) # Committee role priority: 班长(1) > 副班长(2) > other roles(3) > no role(4)
committee_order = case( committee_order = case(
(User.committee_role == None, 4), (ClassMembership.committee_role == None, 4),
(User.committee_role == "班长", 1), (ClassMembership.committee_role == "班长", 1),
(User.committee_role == "副班长", 2), (ClassMembership.committee_role == "副班长", 2),
else_=3, else_=3,
) )
result = await db.execute( 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) .offset((page - 1) * page_size)
.limit(page_size) .limit(page_size)
) )
users = list(result.scalars().all()) users = list(result.scalars().unique().all())
return users, total 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.""" """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( return UserPublic(
id=user.id, id=user.id,
name=user.name, name=user.name,
@ -78,7 +90,7 @@ def user_to_public(user: User, include_contact: bool = True) -> UserPublic:
industry=user.industry, industry=user.industry,
company=user.company, company=user.company,
position=user.position, 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, wechat_id=user.wechat_id if include_contact else None,
phone=user.phone if include_contact else None, phone=user.phone if include_contact else None,
avatar_url=user.avatar_url, avatar_url=user.avatar_url,

View File

@ -36,27 +36,13 @@ async def send_email(to: str, subject: str, html_body: str) -> bool:
return False return False
async def send_registration_notification( async def send_account_activated_email(member_email: str):
admin_email: str, student_name: str, class_name: str html = """
): <h2>Account Activated</h2>
html = f""" <p>Your HKU ICB account has been activated successfully.</p>
<h2>New Registration Pending Approval</h2> <p>You can now log in to the platform.</p>
<p><strong>{student_name}</strong> has registered for <strong>{class_name}</strong>.</p>
<p>Please log in to HKU ICB to review and approve.</p>
""" """
await send_email(admin_email, "HKU ICB: New Registration", html) await send_email(member_email, "HKU ICB: Account Activated", html)
async def send_approval_notification(student_email: str, approved: bool):
status_text = "approved" if approved else "rejected"
html = f"""
<h2>Registration {status_text.capitalize()}</h2>
<p>Your registration has been <strong>{status_text}</strong>.</p>
{"<p>You can now log in to HKU ICB.</p>" if approved else ""}
"""
await send_email(
student_email, f"HKU ICB: Registration {status_text.capitalize()}", html
)
async def send_class_notification_email( async def send_class_notification_email(

View File

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

View File

@ -4,7 +4,7 @@ import logging
from sqlalchemy import select, func, update from sqlalchemy import select, func, update
from sqlalchemy.ext.asyncio import AsyncSession 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 from app.services.email_service import send_class_notification_email
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -43,10 +43,12 @@ async def create_notifications_for_class(
email_body: str | None = None, email_body: str | None = None,
email_action_path: 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( result = await db.execute(
select(User.id, User.email).where( select(User.id, User.email)
User.class_id == class_id, .join(ClassMembership, ClassMembership.user_id == User.id)
.where(
ClassMembership.class_id == class_id,
User.status == "approved", User.status == "approved",
) )
) )

View File

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

View File

@ -1,17 +1,33 @@
from sqlalchemy import select, func from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import User from app.db.models import ClassMembership, User
from app.schemas.user import UserOut, UserUpdate 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: 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() return result.scalar_one_or_none()
async def get_user_by_id(db: AsyncSession, user_id: int) -> User | 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() return result.scalar_one_or_none()
@ -48,6 +64,39 @@ async def update_user_status(
return user 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( async def list_users(
db: AsyncSession, db: AsyncSession,
page: int = 1, page: int = 1,
@ -56,12 +105,15 @@ async def list_users(
status: str | None = None, status: str | None = None,
role: str | None = None, role: str | None = None,
) -> tuple[list[User], int]: ) -> 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)) count_query = select(func.count(User.id))
if class_id is not None: if class_id is not None:
query = query.where(User.class_id == class_id) query = query.join(ClassMembership).where(ClassMembership.class_id == class_id)
count_query = count_query.where(User.class_id == class_id) count_query = count_query.join(ClassMembership).where(ClassMembership.class_id == class_id)
if status is not None: if status is not None:
query = query.where(User.status == status) query = query.where(User.status == status)
count_query = count_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.order_by(User.created_at.desc())
query = query.offset((page - 1) * page_size).limit(page_size) query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query) result = await db.execute(query)
users = list(result.scalars().all()) users = list(result.scalars().unique().all())
return users, total 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

View File

@ -18,7 +18,7 @@ from app.db.base import Base
from app.db.models import ( from app.db.models import (
Class_, User, Timeline, TimelineLike, TimelineComment, Class_, User, Timeline, TimelineLike, TimelineComment,
Schedule, Announcement, Resource, Notification, Schedule, Announcement, Resource, Notification,
StudentRoster, Vote, VoteOption, VoteResponse, ClassMembership, Vote, VoteOption, VoteResponse,
Assignment, AssignmentSubmission, Assignment, AssignmentSubmission,
) )
from app.core.auth import hash_password from app.core.auth import hash_password
@ -231,23 +231,29 @@ async def seed():
await db.flush() await db.flush()
print(f"[+] Class: {cls.name} (invite code: {cls.invite_code})") print(f"[+] Class: {cls.name} (invite code: {cls.invite_code})")
# ── 2. Create class admin ──────────────────────────────────────── # ── 2. Create teacher ────────────────────────────────────────────
admin = User( teacher = User(
email=CLASS_ADMIN["email"], email=CLASS_ADMIN["email"],
password_hash=pwd_hash, password_hash=pwd_hash,
name=CLASS_ADMIN["name"], name=CLASS_ADMIN["name"],
role="class_admin", role="teacher",
status="approved", status="approved",
class_id=cls.id,
industry="教育", industry="教育",
company="香港大学", company="香港大学",
position="教授", position="教授",
bio="香港大学中国商业学院教授,专注于战略管理和企业转型研究。", bio="香港大学中国商业学院教授,专注于战略管理和企业转型研究。",
wechat_id="lin_prof_hku", wechat_id="lin_prof_hku",
) )
db.add(admin) db.add(teacher)
await db.flush() 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 ─────────────────────────────────────────── # ── 3. Create students ───────────────────────────────────────────
COMMITTEE_MAP = {0: "班长", 1: "副班长", 3: "学习委员", 5: "组织委员", 7: "宣传委员", 9: "文体委员"} COMMITTEE_MAP = {0: "班长", 1: "副班长", 3: "学习委员", 5: "组织委员", 7: "宣传委员", 9: "文体委员"}
@ -261,11 +267,9 @@ async def seed():
student_id=s["student_id"], student_id=s["student_id"],
role="student", role="student",
status="approved", status="approved",
class_id=cls.id,
industry=INDUSTRIES[i % len(INDUSTRIES)], industry=INDUSTRIES[i % len(INDUSTRIES)],
company=COMPANIES[i % len(COMPANIES)], company=COMPANIES[i % len(COMPANIES)],
position=POSITIONS[i % len(POSITIONS)], position=POSITIONS[i % len(POSITIONS)],
committee_role=COMMITTEE_MAP.get(i),
skills_tags='["' + '", "'.join(skills) + '"]', skills_tags='["' + '", "'.join(skills) + '"]',
bio=f"{COMPANIES[i % len(COMPANIES)]}担任{POSITIONS[i % len(POSITIONS)]},拥有丰富的{INDUSTRIES[i % len(INDUSTRIES)]}行业经验。", bio=f"{COMPANIES[i % len(COMPANIES)]}担任{POSITIONS[i % len(POSITIONS)]},拥有丰富的{INDUSTRIES[i % len(INDUSTRIES)]}行业经验。",
wechat_id=f"wx_{s['student_id']}", wechat_id=f"wx_{s['student_id']}",
@ -275,23 +279,19 @@ async def seed():
students.append(user) students.append(user)
await db.flush() 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)") print(f"[+] {len(students)} students created (password: demo123)")
# ── 4. Student roster ──────────────────────────────────────────── # ── 4. Timelines with likes and comments ─────────────────────────
for s in students: all_users = [teacher] + 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
for i, post_data in enumerate(TIMELINE_POSTS): for i, post_data in enumerate(TIMELINE_POSTS):
author = random.choice(all_users) author = random.choice(all_users)
post = Timeline( post = Timeline(

View File

@ -3,6 +3,18 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
allowedDevOrigins: ["192.168.31.172"], allowedDevOrigins: ["192.168.31.172"],
images: {
remotePatterns: [
{
protocol: "http",
hostname: "**",
},
{
protocol: "https",
hostname: "**",
},
],
},
}; };
export default nextConfig; export default nextConfig;

View File

@ -1,8 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useAuth } from "@/hooks/use-auth"; import { fetchAPI, postAPI, putAPI, deleteAPI, getErrorMessage } from "@/lib/api";
import { fetchAPI, postAPI, putAPI, deleteAPI } from "@/lib/api";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -21,7 +20,6 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
export default function ClassesPage() { export default function ClassesPage() {
const { user } = useAuth();
const [classes, setClasses] = useState<ClassInfo[]>([]); const [classes, setClasses] = useState<ClassInfo[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -35,10 +33,10 @@ export default function ClassesPage() {
const loadClasses = async () => { const loadClasses = async () => {
setError(null); setError(null);
try { try {
const res = await fetchAPI<any>("/api/classes/"); const res = await fetchAPI<{ items: ClassInfo[] }>("/api/classes/");
setClasses(res.items || []); setClasses(res.items || []);
} catch (err: any) { } catch (err: unknown) {
setError(err.message || "加载失败"); setError(getErrorMessage(err, "加载失败"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -90,8 +88,8 @@ export default function ClassesPage() {
setDialogOpen(false); setDialogOpen(false);
resetForm(); resetForm();
loadClasses(); loadClasses();
} catch (err: any) { } catch (err: unknown) {
toast.error(err.message || "操作失败"); toast.error(getErrorMessage(err, "操作失败"));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@ -105,8 +103,8 @@ export default function ClassesPage() {
toast.success("已删除"); toast.success("已删除");
setDeleteTarget(null); setDeleteTarget(null);
loadClasses(); loadClasses();
} catch (err: any) { } catch (err: unknown) {
toast.error(err.message || "删除失败"); toast.error(getErrorMessage(err, "删除失败"));
} }
}; };
@ -114,8 +112,9 @@ export default function ClassesPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold"></h1> <div className="text-[11px] uppercase tracking-[0.24em] text-[#976746]">Classes</div>
<p className="text-gray-500 mt-1"></p> <h1 className="mt-2 text-3xl font-semibold text-[#4e1d1a]"></h1>
<p className="mt-2 text-[#765a4d]"></p>
</div> </div>
<RoleGuard roles={["super_admin"]}> <RoleGuard roles={["super_admin"]}>
<Dialog open={dialogOpen} onOpenChange={(open) => { setDialogOpen(open); if (!open) resetForm(); }}> <Dialog open={dialogOpen} onOpenChange={(open) => { setDialogOpen(open); if (!open) resetForm(); }}>
@ -163,7 +162,7 @@ export default function ClassesPage() {
{loading ? ( {loading ? (
<div className="animate-pulse space-y-4"> <div className="animate-pulse space-y-4">
{[1, 2].map((i) => ( {[1, 2].map((i) => (
<Card key={i}><CardContent className="p-6"><div className="h-20 bg-gray-200 rounded" /></CardContent></Card> <Card key={i} className="bg-[#fffaf2]"><CardContent className="p-6"><div className="h-20 bg-gray-200 rounded" /></CardContent></Card>
))} ))}
</div> </div>
) : error ? ( ) : error ? (
@ -171,11 +170,11 @@ export default function ClassesPage() {
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{classes.map((cls) => ( {classes.map((cls) => (
<Card key={cls.id}> <Card key={cls.id} className="bg-[#fffdf8]">
<CardContent className="p-4 flex items-center justify-between"> <CardContent className="p-4 flex items-center justify-between">
<div> <div>
<h3 className="font-medium">{cls.name}</h3> <h3 className="font-medium text-[#4e1d1a]">{cls.name}</h3>
<p className="text-sm text-gray-500"> <p className="text-sm text-[#73594a]">
{cls.cohort_year} · {cls.member_count} {cls.cohort_year} · {cls.member_count}
</p> </p>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@ -1,17 +1,17 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useActiveClass } from "@/hooks/use-active-class"; import { useActiveClass } from "@/hooks/use-active-class";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { fetchAPI, putAPI } from "@/lib/api"; import { fetchAPI, getErrorMessage, putAPI } from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { ErrorState } from "@/components/error-state"; import { ErrorState } from "@/components/error-state";
import { toast } from "sonner"; import { toast } from "sonner";
const ALL_MODULES = [ const ALL_MODULES = [
{ key: "announcements", label: "公告", desc: "发布和管理班级公告" }, { key: "announcements", label: "公告", desc: "发布和管理班级公告" },
{ key: "directory", label: "花名册", desc: "查看班级成员花名册" }, { key: "directory", label: "成员名录", desc: "查看班级成员名录" },
{ key: "timeline", label: "班级动态", desc: "分享班级动态和互动" }, { key: "timeline", label: "班级动态", desc: "分享班级动态和互动" },
{ key: "assignments", label: "作业", desc: "发布和提交课程作业" }, { key: "assignments", label: "作业", desc: "发布和提交课程作业" },
{ key: "votes", label: "投票", desc: "发起班级投票活动" }, { key: "votes", label: "投票", desc: "发起班级投票活动" },
@ -33,7 +33,7 @@ export default function ModulesPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const loadModules = async () => { const loadModules = useCallback(async () => {
if (!activeClassId) { if (!activeClassId) {
setLoading(false); setLoading(false);
return; return;
@ -43,16 +43,16 @@ export default function ModulesPage() {
try { try {
const res = await fetchAPI<ModuleConfig>(`/api/classes/${activeClassId}/modules`); const res = await fetchAPI<ModuleConfig>(`/api/classes/${activeClassId}/modules`);
setConfig(res); setConfig(res);
} catch (err: any) { } catch (err: unknown) {
setError(err.message || "加载失败"); setError(getErrorMessage(err, "加载失败"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [activeClassId]);
useEffect(() => { useEffect(() => {
loadModules(); void loadModules();
}, [activeClassId]); }, [loadModules]);
const handleToggle = async (moduleKey: string, enabled: boolean) => { const handleToggle = async (moduleKey: string, enabled: boolean) => {
if (!config || !activeClassId) return; 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}`
: `已禁用「${ALL_MODULES.find((m) => m.key === moduleKey)?.label}` : `已禁用「${ALL_MODULES.find((m) => m.key === moduleKey)?.label}`
); );
} catch (err: any) { } catch (err: unknown) {
// Rollback // Rollback
setConfig(config); setConfig(config);
toast.error(err.message || "操作失败"); toast.error(getErrorMessage(err, "操作失败"));
} }
}; };
if (!activeClassId) { if (!activeClassId) {
return ( return (
<div className="text-center py-12 text-gray-400"> <div className="py-12 text-center text-[#9d806f]">
</div> </div>
); );
@ -94,11 +94,12 @@ export default function ModulesPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-2xl font-bold"></h1> <div className="text-[11px] uppercase tracking-[0.24em] text-[#976746]">Modules</div>
<p className="text-gray-500 mt-1"> <h1 className="mt-2 text-3xl font-semibold text-[#4e1d1a]"></h1>
<p className="mt-2 text-[#765a4d]">
: {activeClassName} : {activeClassName}
</p> </p>
<p className="text-gray-400 text-sm mt-1"> <p className="mt-1 text-sm text-[#9a7b68]">
</p> </p>
</div> </div>
@ -106,7 +107,7 @@ export default function ModulesPage() {
{loading ? ( {loading ? (
<div className="animate-pulse space-y-4"> <div className="animate-pulse space-y-4">
{[1, 2, 3, 4].map((i) => ( {[1, 2, 3, 4].map((i) => (
<Card key={i}> <Card key={i} className="bg-[#fffaf2]">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="h-16 bg-gray-200 rounded" /> <div className="h-16 bg-gray-200 rounded" />
</CardContent> </CardContent>
@ -118,11 +119,11 @@ export default function ModulesPage() {
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{ALL_MODULES.map((module) => ( {ALL_MODULES.map((module) => (
<Card key={module.key}> <Card key={module.key} className="bg-[#fffdf8]">
<CardContent className="p-4 flex items-center justify-between"> <CardContent className="p-4 flex items-center justify-between">
<div> <div>
<p className="font-medium">{module.label}</p> <p className="font-medium text-[#4e1d1a]">{module.label}</p>
<p className="text-sm text-gray-500">{module.desc}</p> <p className="text-sm text-[#73594a]">{module.desc}</p>
</div> </div>
<Switch <Switch
checked={config?.enabled_modules.includes(module.key) ?? false} checked={config?.enabled_modules.includes(module.key) ?? false}

View File

@ -11,33 +11,34 @@ export default function AdminPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-2xl font-bold"></h1> <div className="text-[11px] uppercase tracking-[0.24em] text-[#976746]">Administration</div>
<p className="text-gray-500 mt-1"> <h1 className="mt-2 text-3xl font-semibold text-[#4e1d1a]"></h1>
<p className="mt-2 text-[#765a4d]">
: {user?.role ? ROLES[user.role] : "-"} : {user?.role ? ROLES[user.role] : "-"}
</p> </p>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Link href="/admin/members"> <Link href="/admin/members">
<Card className="hover:shadow-md transition-shadow cursor-pointer"> <Card className="cursor-pointer bg-[#fffaf2] transition-all hover:-translate-y-0.5 hover:shadow-[0_24px_45px_-32px_rgba(84,29,23,0.38)]">
<CardHeader> <CardHeader>
<CardTitle className="text-lg"></CardTitle> <CardTitle className="text-lg text-[#4e1d1a]"></CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-gray-500 text-sm"> <p className="text-sm text-[#765a4d]">
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>
<Link href="/admin/classes"> <Link href="/admin/classes">
<Card className="hover:shadow-md transition-shadow cursor-pointer"> <Card className="cursor-pointer bg-[#fffaf2] transition-all hover:-translate-y-0.5 hover:shadow-[0_24px_45px_-32px_rgba(84,29,23,0.38)]">
<CardHeader> <CardHeader>
<CardTitle className="text-lg"></CardTitle> <CardTitle className="text-lg text-[#4e1d1a]"></CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-gray-500 text-sm"> <p className="text-sm text-[#765a4d]">
</p> </p>
</CardContent> </CardContent>
@ -45,12 +46,12 @@ export default function AdminPage() {
</Link> </Link>
<Link href="/admin/modules"> <Link href="/admin/modules">
<Card className="hover:shadow-md transition-shadow cursor-pointer"> <Card className="cursor-pointer bg-[#fffaf2] transition-all hover:-translate-y-0.5 hover:shadow-[0_24px_45px_-32px_rgba(84,29,23,0.38)]">
<CardHeader> <CardHeader>
<CardTitle className="text-lg"></CardTitle> <CardTitle className="text-lg text-[#4e1d1a]"></CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-gray-500 text-sm"> <p className="text-sm text-[#765a4d]">
</p> </p>
</CardContent> </CardContent>

View File

@ -1,8 +1,8 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useActiveClass } from "@/hooks/use-active-class"; 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 { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -20,7 +20,7 @@ import { ConfirmDialog } from "@/components/confirm-dialog";
import { ErrorState } from "@/components/error-state"; import { ErrorState } from "@/components/error-state";
import { Pagination } from "@/components/pagination"; import { Pagination } from "@/components/pagination";
import { toast } from "sonner"; import { toast } from "sonner";
import type { Announcement } from "@/lib/types"; import type { Announcement, PageResponse } from "@/lib/types";
export default function AnnouncementsPage() { export default function AnnouncementsPage() {
const { activeClassId } = useActiveClass(); const { activeClassId } = useActiveClass();
@ -41,27 +41,33 @@ export default function AnnouncementsPage() {
// Delete state // Delete state
const [deleteTarget, setDeleteTarget] = useState<number | null>(null); const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
const loadAnnouncements = async () => { const loadAnnouncements = useCallback(async () => {
if (!activeClassId) {
setAnnouncements([]);
setError(null);
setLoading(false);
return;
}
setLoading(true);
setError(null); setError(null);
try { try {
const res = await fetchAPI<any>("/api/announcements/", { const res = await fetchAPI<PageResponse<Announcement>>("/api/announcements/", {
page_size: "10", page_size: "10",
page: String(page), page: String(page),
class_id: String(activeClassId), class_id: String(activeClassId),
}); });
setAnnouncements(res.items || []); setAnnouncements(res.items ?? []);
setTotalPages(res.total_pages || 1); setTotalPages(res.total_pages ?? 1);
} catch (err: any) { } catch (err: unknown) {
setError(err.message || "加载失败"); setError(getErrorMessage(err, "加载失败"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [activeClassId, page]);
useEffect(() => { useEffect(() => {
if (!activeClassId) return; void loadAnnouncements();
loadAnnouncements(); }, [loadAnnouncements]);
}, [activeClassId, page]);
const resetForm = () => { const resetForm = () => {
setEditingId(null); setEditingId(null);
@ -90,19 +96,18 @@ export default function AnnouncementsPage() {
}); });
toast.success("公告已更新"); toast.success("公告已更新");
} else { } else {
await postAPI("/api/announcements/", { await postAPI(`/api/announcements/?class_id=${activeClassId}`, {
title: newTitle, title: newTitle,
content: newContent || null, content: newContent || null,
is_pinned: newIsPinned, is_pinned: newIsPinned,
class_id: activeClassId,
}); });
toast.success("公告已发布"); toast.success("公告已发布");
} }
setDialogOpen(false); setDialogOpen(false);
resetForm(); resetForm();
loadAnnouncements(); await loadAnnouncements();
} catch (err: any) { } catch (err: unknown) {
toast.error(err.message || (editingId ? "更新失败" : "发布失败")); toast.error(getErrorMessage(err, editingId ? "更新失败" : "发布失败"));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@ -113,9 +118,9 @@ export default function AnnouncementsPage() {
await deleteAPI(`/api/announcements/${id}`); await deleteAPI(`/api/announcements/${id}`);
toast.success("已删除"); toast.success("已删除");
setDeleteTarget(null); setDeleteTarget(null);
loadAnnouncements(); await loadAnnouncements();
} catch (err: any) { } catch (err: unknown) {
toast.error(err.message || "删除失败"); toast.error(getErrorMessage(err, "删除失败"));
} }
}; };
@ -123,10 +128,11 @@ export default function AnnouncementsPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold"></h1> <div className="text-[11px] uppercase tracking-[0.24em] text-[#976746]">Announcements</div>
<p className="text-gray-500 mt-1"></p> <h1 className="mt-2 text-3xl font-semibold text-[#4e1d1a]"></h1>
<p className="mt-2 text-[#765a4d]"></p>
</div> </div>
<RoleGuard roles={["class_admin", "super_admin"]}> <RoleGuard permissions={["announcement_manage"]}>
<Dialog open={dialogOpen} onOpenChange={(open) => { <Dialog open={dialogOpen} onOpenChange={(open) => {
setDialogOpen(open); setDialogOpen(open);
if (!open) resetForm(); if (!open) resetForm();
@ -173,7 +179,7 @@ export default function AnnouncementsPage() {
{loading ? ( {loading ? (
<div className="space-y-4"> <div className="space-y-4">
{[1, 2, 3].map((i) => ( {[1, 2, 3].map((i) => (
<Card key={i} className="animate-pulse"> <Card key={i} className="animate-pulse bg-[#fffaf2]">
<CardContent className="p-6"> <CardContent className="p-6">
<div className="h-24 bg-gray-200 rounded" /> <div className="h-24 bg-gray-200 rounded" />
</CardContent> </CardContent>
@ -183,20 +189,23 @@ export default function AnnouncementsPage() {
) : error ? ( ) : error ? (
<ErrorState message={error} onRetry={loadAnnouncements} /> <ErrorState message={error} onRetry={loadAnnouncements} />
) : announcements.length === 0 ? ( ) : announcements.length === 0 ? (
<div className="text-center py-12 text-gray-400"></div> <div className="py-14 text-center text-[#9d806f]"></div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{announcements.map((item) => ( {announcements.map((item) => (
<Card key={item.id} className={item.is_pinned ? "border-blue-200 bg-blue-50/30" : ""}> <Card
key={item.id}
className={item.is_pinned ? "border-[#e4c37f] bg-[#fff6e6]" : "bg-[#fffdf8]"}
>
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{item.is_pinned && ( {item.is_pinned && (
<Badge className="bg-blue-500 text-white text-xs"></Badge> <Badge className="bg-[#d29a36] text-white text-xs"></Badge>
)} )}
<h3 className="text-lg font-semibold">{item.title}</h3> <h3 className="text-lg font-semibold text-[#4e1d1a]">{item.title}</h3>
</div> </div>
<RoleGuard roles={["class_admin", "super_admin"]}> <RoleGuard permissions={["announcement_manage"]}>
<div className="flex gap-1"> <div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => openEdit(item)}> <Button variant="ghost" size="sm" onClick={() => openEdit(item)}>
@ -213,9 +222,9 @@ export default function AnnouncementsPage() {
</RoleGuard> </RoleGuard>
</div> </div>
{item.content && ( {item.content && (
<p className="mt-3 text-gray-700 whitespace-pre-wrap">{item.content}</p> <p className="mt-3 whitespace-pre-wrap text-[#5f473b]">{item.content}</p>
)} )}
<p className="text-sm text-gray-400 mt-3"> <p className="mt-3 text-sm text-[#92735f]">
{item.author_name} ·{" "} {item.author_name} ·{" "}
{new Date(item.created_at).toLocaleDateString("zh-CN", { {new Date(item.created_at).toLocaleDateString("zh-CN", {
year: "numeric", year: "numeric",

View File

@ -1,9 +1,9 @@
"use client"; "use client";
import { useEffect, useState, useRef } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useAuth } from "@/hooks/use-auth"; 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 { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
@ -75,24 +75,22 @@ export default function AssignmentDetailPage() {
return `${days} 天后截止`; return `${days} 天后截止`;
}; };
const isAdmin = const loadAssignment = useCallback(async () => {
user?.role === "class_admin" || user?.role === "super_admin"; setLoading(true);
const loadAssignment = async () => {
setError(null); setError(null);
try { try {
const res = await fetchAPI<Assignment>(`/api/assignments/${id}`); const res = await fetchAPI<Assignment>(`/api/assignments/${id}`);
setAssignment(res); setAssignment(res);
} catch (err: any) { } catch (err: unknown) {
setError(err.message || "加载失败"); setError(getErrorMessage(err, "加载失败"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [id]);
useEffect(() => { useEffect(() => {
loadAssignment(); void loadAssignment();
}, [id]); }, [loadAssignment]);
const handleSubmit = async () => { const handleSubmit = async () => {
if (!selectedFile) { if (!selectedFile) {
@ -110,9 +108,9 @@ export default function AssignmentDetailPage() {
setSelectedFile(null); setSelectedFile(null);
setNotes(""); setNotes("");
if (fileInputRef.current) fileInputRef.current.value = ""; if (fileInputRef.current) fileInputRef.current.value = "";
loadAssignment(); await loadAssignment();
} catch (err: any) { } catch (err: unknown) {
toast.error(err.message || "提交失败"); toast.error(getErrorMessage(err, "提交失败"));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@ -123,9 +121,9 @@ export default function AssignmentDetailPage() {
try { try {
await putAPI(`/api/assignments/${id}`, { status: "closed" }); await putAPI(`/api/assignments/${id}`, { status: "closed" });
toast.success("作业已关闭"); toast.success("作业已关闭");
loadAssignment(); await loadAssignment();
} catch (err: any) { } catch (err: unknown) {
toast.error(err.message || "操作失败"); toast.error(getErrorMessage(err, "操作失败"));
} finally { } finally {
setClosing(false); setClosing(false);
} }
@ -145,9 +143,9 @@ export default function AssignmentDetailPage() {
}); });
toast.success("评分已保存"); toast.success("评分已保存");
setActiveGradeId(null); setActiveGradeId(null);
loadAssignment(); await loadAssignment();
} catch (err: any) { } catch (err: unknown) {
toast.error(err.message || "评分失败"); toast.error(getErrorMessage(err, "评分失败"));
} finally { } finally {
setGradingSubmitting(false); setGradingSubmitting(false);
} }
@ -193,12 +191,8 @@ export default function AssignmentDetailPage() {
(s) => s.student_id === user?.id (s) => s.student_id === user?.id
) ?? null; ) ?? 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 // 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. // we display submission info from what's available.
return ( return (
@ -336,7 +330,7 @@ export default function AssignmentDetailPage() {
</div> </div>
{/* Admin actions */} {/* Admin actions */}
<RoleGuard roles={["class_admin", "super_admin"]}> <RoleGuard permissions={["assignment_manage"]}>
<div className="flex justify-end"> <div className="flex justify-end">
{assignment.status === "open" && ( {assignment.status === "open" && (
<Button <Button
@ -478,7 +472,7 @@ export default function AssignmentDetailPage() {
)} )}
{/* Admin: Submissions table */} {/* Admin: Submissions table */}
<RoleGuard roles={["class_admin", "super_admin"]}> <RoleGuard permissions={["assignment_manage"]}>
<Card> <Card>
<CardContent className="p-6"> <CardContent className="p-6">
<h2 className="text-lg font-semibold mb-4"> <h2 className="text-lg font-semibold mb-4">

View File

@ -3,7 +3,7 @@
import { useEffect, useState, useRef, useCallback } from "react"; import { useEffect, useState, useRef, useCallback } from "react";
import { useActiveClass } from "@/hooks/use-active-class"; import { useActiveClass } from "@/hooks/use-active-class";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
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 { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -22,7 +22,8 @@ import { ErrorState } from "@/components/error-state";
import { Pagination } from "@/components/pagination"; import { Pagination } from "@/components/pagination";
import { toast } from "sonner"; import { toast } from "sonner";
import Link from "next/link"; import Link from "next/link";
import type { Assignment } from "@/lib/types"; import type { Assignment, PageResponse } from "@/lib/types";
import { hasClassPermission } from "@/lib/permissions";
function formatDeadline(deadline: string | null): { function formatDeadline(deadline: string | null): {
text: string; text: string;
@ -68,15 +69,15 @@ export default function AssignmentsPage() {
const loadAssignments = useCallback(async () => { const loadAssignments = useCallback(async () => {
setError(null); setError(null);
try { try {
const res = await fetchAPI<any>("/api/assignments/", { const res = await fetchAPI<PageResponse<Assignment>>("/api/assignments/", {
page_size: "10", page_size: "10",
page: String(page), page: String(page),
class_id: String(activeClassId), class_id: String(activeClassId),
}); });
setAssignments(res.items || []); setAssignments(res.items || []);
setTotalPages(res.total_pages || 1); setTotalPages(res.total_pages || 1);
} catch (err: any) { } catch (err: unknown) {
setError(err.message || "加载失败"); setError(getErrorMessage(err, "加载失败"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -99,11 +100,10 @@ export default function AssignmentsPage() {
if (!newTitle.trim()) return; if (!newTitle.trim()) return;
setSubmitting(true); setSubmitting(true);
try { try {
const assignment = await postAPI<Assignment>("/api/assignments/", { const assignment = await postAPI<Assignment>(`/api/assignments/?class_id=${activeClassId}`, {
title: newTitle, title: newTitle,
description: newDescription || null, description: newDescription || null,
deadline: newDeadline || null, deadline: newDeadline || null,
class_id: activeClassId,
}); });
// Upload attachments if any // Upload attachments if any
@ -119,8 +119,8 @@ export default function AssignmentsPage() {
setDialogOpen(false); setDialogOpen(false);
resetForm(); resetForm();
loadAssignments(); loadAssignments();
} catch (err: any) { } catch (err: unknown) {
toast.error(err.message || "发布失败"); toast.error(getErrorMessage(err, "发布失败"));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@ -132,13 +132,12 @@ export default function AssignmentsPage() {
toast.success("已删除"); toast.success("已删除");
setDeleteTarget(null); setDeleteTarget(null);
loadAssignments(); loadAssignments();
} catch (err: any) { } catch (err: unknown) {
toast.error(err.message || "删除失败"); toast.error(getErrorMessage(err, "删除失败"));
} }
}; };
const isAdmin = const isAdmin = hasClassPermission(user, "assignment_manage", activeClassId);
user?.role === "class_admin" || user?.role === "super_admin";
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -147,7 +146,7 @@ export default function AssignmentsPage() {
<h1 className="text-2xl font-bold"></h1> <h1 className="text-2xl font-bold"></h1>
<p className="text-gray-500 mt-1"></p> <p className="text-gray-500 mt-1"></p>
</div> </div>
<RoleGuard roles={["class_admin", "super_admin"]}> <RoleGuard permissions={["assignment_manage"]}>
<Dialog <Dialog
open={dialogOpen} open={dialogOpen}
onOpenChange={(open) => { onOpenChange={(open) => {

View File

@ -1,11 +1,12 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { fetchAPI } from "@/lib/api"; import { fetchAPI, getErrorMessage } from "@/lib/api";
import { useActiveClass } from "@/hooks/use-active-class"; import { useActiveClass } from "@/hooks/use-active-class";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -17,61 +18,123 @@ import type { ScheduleItem, TimelinePost, Announcement } from "@/lib/types";
import { SCHEDULE_TYPES } from "@/lib/constants"; import { SCHEDULE_TYPES } from "@/lib/constants";
export default function DashboardPage() { export default function DashboardPage() {
const { activeClassId } = useActiveClass(); const { activeClassId, activeClassName } = useActiveClass();
const [upcoming, setUpcoming] = useState<ScheduleItem[]>([]); const [upcoming, setUpcoming] = useState<ScheduleItem[]>([]);
const [recentTimeline, setRecentTimeline] = useState<TimelinePost[]>([]); const [recentTimeline, setRecentTimeline] = useState<TimelinePost[]>([]);
const [announcements, setAnnouncements] = useState<Announcement[]>([]); const [announcements, setAnnouncements] = useState<Announcement[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedAnnouncement, setSelectedAnnouncement] = useState<Announcement | null>(null); const [selectedAnnouncement, setSelectedAnnouncement] = useState<Announcement | null>(null);
const [selectedSchedule, setSelectedSchedule] = useState<ScheduleItem | null>(null); const [selectedSchedule, setSelectedSchedule] = useState<ScheduleItem | null>(null);
const [now, setNow] = useState(() => Date.now());
const loadData = async () => {
if (!activeClassId) return;
setError(null);
try {
const [upcomingRes, timelineRes, announcementsRes] = await Promise.all([
fetchAPI<ScheduleItem[]>("/api/schedule/upcoming", { limit: "3", class_id: String(activeClassId) }),
fetchAPI<any>("/api/timeline/", { page_size: "3", class_id: String(activeClassId) }),
fetchAPI<any>("/api/announcements/", { page_size: "3", class_id: String(activeClassId) }),
]);
setUpcoming(upcomingRes);
setRecentTimeline(timelineRes.items || []);
setAnnouncements(announcementsRes.items || []);
} catch (err: any) {
setError(err.message || "加载失败");
}
};
useEffect(() => { useEffect(() => {
loadData(); if (!activeClassId) return;
let cancelled = false;
async function run() {
try {
const [upcomingRes, timelineRes, announcementsRes] = await Promise.all([
fetchAPI<ScheduleItem[]>("/api/schedule/upcoming", { limit: "3", class_id: String(activeClassId) }),
fetchAPI<{ items: TimelinePost[] }>("/api/timeline/", { page_size: "3", class_id: String(activeClassId) }),
fetchAPI<{ items: Announcement[] }>("/api/announcements/", { page_size: "3", class_id: String(activeClassId) }),
]);
if (cancelled) return;
setError(null);
setUpcoming(upcomingRes);
setRecentTimeline(timelineRes.items || []);
setAnnouncements(announcementsRes.items || []);
} catch (err: unknown) {
if (cancelled) return;
setError(getErrorMessage(err, "加载失败"));
}
}
void run();
return () => {
cancelled = true;
};
}, [activeClassId]); }, [activeClassId]);
const getCountdown = (startTime: string) => { useEffect(() => {
const diff = new Date(startTime).getTime() - Date.now(); const timer = window.setInterval(() => setNow(Date.now()), 60_000);
const days = Math.ceil(diff / (1000 * 60 * 60 * 24)); return () => window.clearInterval(timer);
if (days <= 0) return "已开始"; }, []);
if (days === 1) return "明天";
if (days <= 7) return `${days}天后`; const countdownByScheduleId = useMemo(() => {
return `${days}天后`; return Object.fromEntries(
}; upcoming.map((item) => {
const diff = new Date(item.start_time).getTime() - now;
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
const label =
days <= 0 ? "已开始" : days === 1 ? "明天" : `${days}天后`;
return [item.id, label];
})
);
}, [upcoming, now]);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <section className="relative overflow-hidden rounded-[2rem] border border-[#e7d3ba] bg-[linear-gradient(135deg,rgba(108,26,37,0.96),rgba(145,84,53,0.92)_58%,rgba(233,206,160,0.9)_130%)] px-6 py-8 text-white shadow-[0_30px_80px_-42px_rgba(83,25,24,0.58)] md:px-8 md:py-10">
<h1 className="text-2xl font-bold">HKU ICB </h1> <div className="absolute inset-y-0 right-0 w-1/2 bg-[radial-gradient(circle_at_center,rgba(255,237,207,0.26),transparent_62%)]" />
<p className="text-gray-500 mt-1"></p> <div className="relative max-w-3xl space-y-4">
<div className="inline-flex items-center rounded-full border border-white/20 bg-white/10 px-3 py-1 text-[11px] uppercase tracking-[0.24em] text-[#f7e8cb]">
HKU ICB
</div>
<div className="space-y-2">
<h1 className="text-3xl font-semibold tracking-tight md:text-4xl">
{activeClassName || "香港大学中国商业学院"}
</h1>
<p className="max-w-2xl text-sm leading-6 text-white/78 md:text-base">
</p>
</div>
<div className="flex flex-wrap gap-3">
<Link href="/announcements">
<Button className="bg-[#f1d39d] text-[#4c1d17] hover:bg-[#f4ddb2]"></Button>
</Link>
<Link href="/timeline">
<Button variant="outline" className="border-white/28 bg-white/8 text-white hover:bg-white/14 hover:text-white">
</Button>
</Link>
</div>
</div>
</section>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<Card className="bg-[#fffaf2]">
<CardContent className="p-6">
<p className="text-xs uppercase tracking-[0.2em] text-[#9a6a49]">Pinned Notes</p>
<p className="mt-3 text-3xl font-semibold text-[#5b1f1a]">{announcements.length}</p>
<p className="mt-1 text-sm text-[#7a5a48]"></p>
</CardContent>
</Card>
<Card className="bg-[#fffaf2]">
<CardContent className="p-6">
<p className="text-xs uppercase tracking-[0.2em] text-[#9a6a49]">Upcoming</p>
<p className="mt-3 text-3xl font-semibold text-[#5b1f1a]">{upcoming.length}</p>
<p className="mt-1 text-sm text-[#7a5a48]"></p>
</CardContent>
</Card>
<Card className="bg-[#fffaf2]">
<CardContent className="p-6">
<p className="text-xs uppercase tracking-[0.2em] text-[#9a6a49]">Class Feed</p>
<p className="mt-3 text-3xl font-semibold text-[#5b1f1a]">{recentTimeline.length}</p>
<p className="mt-1 text-sm text-[#7a5a48]"></p>
</CardContent>
</Card>
</div> </div>
{error ? ( {error ? (
<ErrorState message={error} onRetry={loadData} /> <ErrorState message={error} onRetry={() => window.location.reload()} />
) : ( ) : (
<> <>
{/* Latest announcements */} {/* Latest announcements */}
{announcements.length > 0 && ( {announcements.length > 0 && (
<Card> <Card className="bg-[#fffdf8]">
<CardHeader className="flex flex-row items-center justify-between pb-2"> <CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg"></CardTitle> <CardTitle className="text-lg text-[#4a1f1a]"></CardTitle>
<Link href="/announcements" className="text-sm text-gray-500 hover:text-gray-900 transition-colors"> <Link href="/announcements" className="text-sm text-[#8a6045] transition-colors hover:text-[#4a1f1a]">
</Link> </Link>
</CardHeader> </CardHeader>
@ -80,17 +143,19 @@ export default function DashboardPage() {
{announcements.map((a) => ( {announcements.map((a) => (
<div <div
key={a.id} key={a.id}
className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100 transition-colors" className="cursor-pointer rounded-2xl border border-[#eadbc8] bg-[#fff8ef] p-4 transition-colors hover:bg-[#fff2df]"
onClick={() => setSelectedAnnouncement(a)} onClick={() => setSelectedAnnouncement(a)}
> >
{a.is_pinned && ( <div className="flex items-start gap-3">
<Badge variant="secondary" className="shrink-0 bg-amber-100 text-amber-700 text-xs"></Badge> {a.is_pinned && (
)} <Badge variant="secondary" className="shrink-0 bg-[#f3ddab] text-[#74411f] text-xs"></Badge>
<div className="flex-1 min-w-0"> )}
<p className="text-sm font-medium truncate">{a.title}</p> <div className="min-w-0 flex-1">
<p className="text-xs text-gray-500 mt-1"> <p className="text-sm font-medium truncate text-[#4a1f1a]">{a.title}</p>
<p className="mt-1 text-xs text-[#866556]">
{a.author_name} · {new Date(a.created_at).toLocaleDateString("zh-CN")} {a.author_name} · {new Date(a.created_at).toLocaleDateString("zh-CN")}
</p> </p>
</div>
</div> </div>
</div> </div>
))} ))}
@ -101,19 +166,19 @@ export default function DashboardPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Upcoming schedules */} {/* Upcoming schedules */}
<Card> <Card className="bg-[#fffdf8]">
<CardHeader> <CardHeader>
<CardTitle className="text-lg"></CardTitle> <CardTitle className="text-lg text-[#4a1f1a]"></CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{upcoming.length === 0 ? ( {upcoming.length === 0 ? (
<p className="text-gray-400 text-sm"></p> <p className="text-sm text-[#9a7d69]"></p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{upcoming.map((item) => ( {upcoming.map((item) => (
<div <div
key={item.id} key={item.id}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100 transition-colors" className="flex cursor-pointer items-center justify-between rounded-2xl border border-[#eadbc8] bg-[#fff8ef] p-4 transition-colors hover:bg-[#fff2df]"
onClick={() => setSelectedSchedule(item)} onClick={() => setSelectedSchedule(item)}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -123,14 +188,14 @@ export default function DashboardPage() {
}`} }`}
/> />
<div> <div>
<p className="text-sm font-medium">{item.title}</p> <p className="text-sm font-medium text-[#4a1f1a]">{item.title}</p>
<p className="text-xs text-gray-500"> <p className="text-xs text-[#866556]">
{new Date(item.start_time).toLocaleDateString("zh-CN")} {new Date(item.start_time).toLocaleDateString("zh-CN")}
</p> </p>
</div> </div>
</div> </div>
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="bg-[#efe2c8] text-[#6b4d39] text-xs">
{getCountdown(item.start_time)} {countdownByScheduleId[item.id] ?? "即将开始"}
</Badge> </Badge>
</div> </div>
))} ))}
@ -140,19 +205,19 @@ export default function DashboardPage() {
</Card> </Card>
{/* Recent timeline */} {/* Recent timeline */}
<Card> <Card className="bg-[#fffdf8]">
<CardHeader> <CardHeader>
<CardTitle className="text-lg"></CardTitle> <CardTitle className="text-lg text-[#4a1f1a]"></CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{recentTimeline.length === 0 ? ( {recentTimeline.length === 0 ? (
<p className="text-gray-400 text-sm"></p> <p className="text-sm text-[#9a7d69]"></p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{recentTimeline.map((post) => ( {recentTimeline.map((post) => (
<div key={post.id} className="p-3 bg-gray-50 rounded-lg"> <div key={post.id} className="rounded-2xl border border-[#eadbc8] bg-[#fff8ef] p-4">
<p className="text-sm font-medium">{post.title}</p> <p className="text-sm font-medium text-[#4a1f1a]">{post.title}</p>
<p className="text-xs text-gray-500 mt-1"> <p className="mt-1 text-xs text-[#866556]">
{post.author_name} ·{" "} {post.author_name} ·{" "}
{new Date(post.created_at).toLocaleDateString("zh-CN")} {new Date(post.created_at).toLocaleDateString("zh-CN")}
</p> </p>
@ -186,9 +251,9 @@ export default function DashboardPage() {
})}</span> })}</span>
</div> </div>
{selectedAnnouncement.content ? ( {selectedAnnouncement.content ? (
<p className="text-gray-700 whitespace-pre-wrap">{selectedAnnouncement.content}</p> <p className="whitespace-pre-wrap text-[#5b4336]">{selectedAnnouncement.content}</p>
) : ( ) : (
<p className="text-gray-400"></p> <p className="text-[#9a7d69]"></p>
)} )}
</div> </div>
</> </>
@ -211,10 +276,10 @@ export default function DashboardPage() {
{SCHEDULE_TYPES[selectedSchedule.type]?.label || selectedSchedule.type} {SCHEDULE_TYPES[selectedSchedule.type]?.label || selectedSchedule.type}
</Badge> </Badge>
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{getCountdown(selectedSchedule.start_time)} {countdownByScheduleId[selectedSchedule.id] ?? "即将开始"}
</Badge> </Badge>
</div> </div>
<div className="text-sm text-gray-500 space-y-1"> <div className="space-y-1 text-sm text-[#7f6352]">
<p>{new Date(selectedSchedule.start_time).toLocaleString("zh-CN", { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" })}</p> <p>{new Date(selectedSchedule.start_time).toLocaleString("zh-CN", { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" })}</p>
{selectedSchedule.end_time && ( {selectedSchedule.end_time && (
<p>{new Date(selectedSchedule.end_time).toLocaleString("zh-CN", { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" })}</p> <p>{new Date(selectedSchedule.end_time).toLocaleString("zh-CN", { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" })}</p>
@ -224,9 +289,9 @@ export default function DashboardPage() {
)} )}
</div> </div>
{selectedSchedule.description ? ( {selectedSchedule.description ? (
<p className="text-gray-700 whitespace-pre-wrap">{selectedSchedule.description}</p> <p className="whitespace-pre-wrap text-[#5b4336]">{selectedSchedule.description}</p>
) : ( ) : (
<p className="text-gray-400"></p> <p className="text-[#9a7d69]"></p>
)} )}
</div> </div>
</> </>

View File

@ -2,8 +2,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useAuth } from "@/hooks/use-auth"; import { fetchAPI, getErrorMessage } from "@/lib/api";
import { fetchAPI } from "@/lib/api";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@ -13,17 +12,18 @@ import Link from "next/link";
export default function MemberDetailPage() { export default function MemberDetailPage() {
const params = useParams(); const params = useParams();
const { user } = useAuth();
const [member, setMember] = useState<UserPublic | null>(null); const [member, setMember] = useState<UserPublic | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const id = params.id as string; const id = params.id as string;
setError(null);
fetchAPI<UserPublic>(`/api/directory/${id}`) fetchAPI<UserPublic>(`/api/directory/${id}`)
.then(setMember) .then((data) => {
.catch((err: any) => setError(err.message || "加载失败")) setMember(data);
setError(null);
})
.catch((err: unknown) => setError(getErrorMessage(err, "加载失败")))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [params.id]); }, [params.id]);
@ -40,7 +40,7 @@ export default function MemberDetailPage() {
return ( return (
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
<Link href="/directory" className="text-sm text-gray-500 hover:text-gray-700 mb-4 inline-block"> <Link href="/directory" className="text-sm text-gray-500 hover:text-gray-700 mb-4 inline-block">
&larr; &larr;
</Link> </Link>
<ErrorState message={error} onRetry={() => window.location.reload()} /> <ErrorState message={error} onRetry={() => window.location.reload()} />
</div> </div>
@ -51,35 +51,33 @@ export default function MemberDetailPage() {
); );
} }
const showContact = user?.class_id === member.id; // Privacy: same class check handled by API
return ( return (
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
<Link href="/directory" className="text-sm text-gray-500 hover:text-gray-700 mb-4 inline-block"> <Link href="/directory" className="mb-4 inline-block text-sm text-[#896650] hover:text-[#4e1d1a]">
&larr; &larr;
</Link> </Link>
<Card> <Card className="bg-[#fffdf8]">
<CardContent className="p-6"> <CardContent className="p-6">
{/* Avatar centered at top */} {/* Avatar centered at top */}
<div className="flex flex-col items-center text-center mb-6"> <div className="flex flex-col items-center text-center mb-6">
<Avatar className="h-28 w-28 mb-4"> <Avatar className="h-28 w-28 mb-4">
<AvatarImage src={member.avatar_url || undefined} alt={member.name} /> <AvatarImage src={member.avatar_url || undefined} alt={member.name} />
<AvatarFallback className="bg-gray-900 text-white text-4xl"> <AvatarFallback className="bg-gradient-to-br from-[#6f2030] to-[#a4633f] text-white text-4xl">
{member.name[0]} {member.name[0]}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<h1 className="text-2xl font-bold">{member.name}</h1> <h1 className="text-3xl font-semibold text-[#4e1d1a]">{member.name}</h1>
{member.committee_role && ( {member.committee_role && (
<Badge className="mt-1 bg-amber-100 text-amber-800 hover:bg-amber-100 border-amber-200"> <Badge className="mt-1 bg-[#f3ddab] text-[#74411f] hover:bg-[#f3ddab] border-[#e5c884]">
{member.committee_role} {member.committee_role}
</Badge> </Badge>
)} )}
{member.student_id && ( {member.student_id && (
<p className="text-sm text-gray-500 mt-1">: {member.student_id}</p> <p className="mt-1 text-sm text-[#896c5a]">: {member.student_id}</p>
)} )}
{member.company && ( {member.company && (
<p className="text-gray-600 mt-1"> <p className="mt-1 text-[#6c5245]">
{member.company} {member.company}
{member.position ? ` · ${member.position}` : ""} {member.position ? ` · ${member.position}` : ""}
</p> </p>
@ -91,20 +89,20 @@ export default function MemberDetailPage() {
{member.bio && ( {member.bio && (
<div className="mt-6"> <div className="mt-6">
<h3 className="text-sm font-medium text-gray-500 mb-2"></h3> <h3 className="mb-2 text-sm font-medium text-[#8a6d5e]"></h3>
<p className="text-gray-700 whitespace-pre-wrap">{member.bio}</p> <p className="whitespace-pre-wrap text-[#5f473b]">{member.bio}</p>
</div> </div>
)} )}
{(member.wechat_id || member.phone) && ( {(member.wechat_id || member.phone) && (
<div className="mt-6"> <div className="mt-6">
<h3 className="text-sm font-medium text-gray-500 mb-2"></h3> <h3 className="mb-2 text-sm font-medium text-[#8a6d5e]"></h3>
<div className="space-y-1"> <div className="space-y-1">
{member.wechat_id && ( {member.wechat_id && (
<p className="text-gray-700">: {member.wechat_id}</p> <p className="text-[#5f473b]">: {member.wechat_id}</p>
)} )}
{member.phone && ( {member.phone && (
<p className="text-gray-700">: {member.phone}</p> <p className="text-[#5f473b]">: {member.phone}</p>
)} )}
</div> </div>
</div> </div>

View File

@ -2,7 +2,7 @@
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import { useActiveClass } from "@/hooks/use-active-class"; 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 { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import {
@ -16,7 +16,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { ErrorState } from "@/components/error-state"; import { ErrorState } from "@/components/error-state";
import { Pagination } from "@/components/pagination"; import { Pagination } from "@/components/pagination";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 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 { INDUSTRY_OPTIONS } from "@/lib/constants";
import Link from "next/link"; import Link from "next/link";
@ -46,12 +46,12 @@ export default function DirectoryPage() {
if (search) params.search = search; if (search) params.search = search;
if (industry) params.industry = industry; if (industry) params.industry = industry;
if (company) params.company = company; if (company) params.company = company;
const res = await fetchAPI<any>("/api/directory/", params); const res = await fetchAPI<PageResponse<UserPublic>>("/api/directory/", params);
setMembers(res.items || []); setMembers(res.items ?? []);
setTotal(res.total || 0); setTotal(res.total ?? 0);
setTotalPages(res.total_pages || 1); setTotalPages(res.total_pages ?? 1);
} catch (err: any) { } catch (err: unknown) {
setError(err.message || "加载失败"); setError(getErrorMessage(err, "加载失败"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -63,20 +63,21 @@ export default function DirectoryPage() {
}, [search, industry, company]); }, [search, industry, company]);
useEffect(() => { useEffect(() => {
if (!activeClassId) return;
const timer = setTimeout(loadMembers, 300); const timer = setTimeout(loadMembers, 300);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [loadMembers]); }, [activeClassId, loadMembers]);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-2xl font-bold"></h1> <div className="text-[11px] uppercase tracking-[0.24em] text-[#976746]">Directory</div>
<p className="text-gray-500 mt-1"> {total} </p> <h1 className="mt-2 text-3xl font-semibold text-[#4e1d1a]"></h1>
<p className="mt-2 text-[#765a4d]"> {total} </p>
</div> </div>
{/* Search & Filters */} {/* Search & Filters */}
<div className="flex flex-wrap gap-3"> <Card className="bg-[#fffaf2]">
<CardContent className="flex flex-wrap gap-3 p-5">
<Input <Input
placeholder="搜索姓名、公司、职位..." placeholder="搜索姓名、公司、职位..."
value={search} value={search}
@ -102,13 +103,14 @@ export default function DirectoryPage() {
onChange={(e) => setCompany(e.target.value)} onChange={(e) => setCompany(e.target.value)}
className="w-full sm:w-40" className="w-full sm:w-40"
/> />
</div> </CardContent>
</Card>
{/* Member List */} {/* Member List */}
{loading ? ( {loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{[1, 2, 3, 4, 5, 6].map((i) => ( {[1, 2, 3, 4, 5, 6].map((i) => (
<Card key={i} className="animate-pulse"> <Card key={i} className="animate-pulse bg-[#fffaf2]">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="h-20 bg-gray-200 rounded" /> <div className="h-20 bg-gray-200 rounded" />
</CardContent> </CardContent>
@ -118,24 +120,24 @@ export default function DirectoryPage() {
) : error ? ( ) : error ? (
<ErrorState message={error} onRetry={loadMembers} /> <ErrorState message={error} onRetry={loadMembers} />
) : members.length === 0 ? ( ) : members.length === 0 ? (
<div className="text-center py-12 text-gray-400"></div> <div className="py-12 text-center text-[#9d806f]"></div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{members.map((member) => ( {members.map((member) => (
<Link key={member.id} href={`/directory/${member.id}`}> <Link key={member.id} href={`/directory/${member.id}`}>
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full"> <Card className="h-full cursor-pointer bg-[#fffdf8] transition-all hover:-translate-y-0.5 hover:shadow-[0_24px_45px_-32px_rgba(84,29,23,0.38)]">
<CardContent className="p-4 flex items-start gap-3"> <CardContent className="p-4 flex items-start gap-3">
<Avatar className="h-11 w-11 shrink-0"> <Avatar className="h-11 w-11 shrink-0">
<AvatarImage src={member.avatar_url || undefined} alt={member.name} /> <AvatarImage src={member.avatar_url || undefined} alt={member.name} />
<AvatarFallback className="bg-gray-900 text-white text-base"> <AvatarFallback className="bg-gradient-to-br from-[#6f2030] to-[#a4633f] text-white text-base">
{member.name[0]} {member.name[0]}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
<p className="font-medium">{member.name}</p> <p className="font-medium text-[#4e1d1a]">{member.name}</p>
{member.committee_role && ( {member.committee_role && (
<Badge className="text-xs bg-amber-100 text-amber-800 hover:bg-amber-100 border-amber-200"> <Badge className="text-xs bg-[#f3ddab] text-[#74411f] hover:bg-[#f3ddab] border-[#e5c884]">
{member.committee_role} {member.committee_role}
</Badge> </Badge>
)} )}
@ -146,18 +148,18 @@ export default function DirectoryPage() {
)} )}
</div> </div>
{member.company && ( {member.company && (
<p className="text-sm text-gray-600 mt-0.5 truncate"> <p className="mt-0.5 truncate text-sm text-[#73594a]">
{member.company} {member.company}
{member.position ? ` · ${member.position}` : ""} {member.position ? ` · ${member.position}` : ""}
</p> </p>
)} )}
{member.student_id && ( {member.student_id && (
<p className="text-xs text-gray-400 mt-0.5"> <p className="mt-0.5 text-xs text-[#9a7b68]">
: {member.student_id} : {member.student_id}
</p> </p>
)} )}
{member.bio && ( {member.bio && (
<p className="text-sm text-gray-500 mt-1 line-clamp-2"> <p className="mt-1 line-clamp-2 text-sm text-[#8a6d5e]">
{member.bio} {member.bio}
</p> </p>
)} )}

View File

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

View File

@ -11,11 +11,13 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
<ActiveClassProvider> <ActiveClassProvider>
<NotificationProvider> <NotificationProvider>
<SidebarProvider> <SidebarProvider>
<div className="flex h-screen bg-gray-50"> <div className="flex h-screen bg-transparent">
<Sidebar /> <Sidebar />
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
<Header /> <Header />
<main className="flex-1 overflow-y-auto p-4 md:p-6">{children}</main> <main className="flex-1 overflow-y-auto px-4 pb-8 pt-4 md:px-8 md:pb-10 md:pt-6">
<div className="mx-auto w-full max-w-7xl">{children}</div>
</main>
</div> </div>
</div> </div>
</SidebarProvider> </SidebarProvider>

View File

@ -1,8 +1,9 @@
"use client"; "use client";
import Image from "next/image";
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef } from "react";
import { useAuth } from "@/hooks/use-auth"; 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 { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@ -45,6 +46,38 @@ export default function ProfilePage() {
} }
}, [user]); }, [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<HTMLInputElement>) => { const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
@ -59,8 +92,8 @@ export default function ProfilePage() {
await uploadAPI("/api/users/me/avatar", formData); await uploadAPI("/api/users/me/avatar", formData);
await refreshUser(); await refreshUser();
toast.success("头像已更新"); toast.success("头像已更新");
} catch (err: any) { } catch (err: unknown) {
toast.error(err.message || "头像上传失败"); toast.error(getErrorMessage(err, "头像上传失败"));
} finally { } finally {
setAvatarUploading(false); setAvatarUploading(false);
if (fileInputRef.current) fileInputRef.current.value = ""; if (fileInputRef.current) fileInputRef.current.value = "";
@ -69,22 +102,23 @@ export default function ProfilePage() {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!hasChanges) return;
setLoading(true); setLoading(true);
try { try {
await putAPI("/api/users/me", { await putAPI("/api/users/me", {
email: email || undefined, email: normalizedCurrent.email || undefined,
name: name || undefined, name: normalizedCurrent.name || undefined,
industry: industry || undefined, industry: normalizedCurrent.industry || null,
company: company || undefined, company: normalizedCurrent.company || null,
position: position || undefined, position: normalizedCurrent.position || null,
wechat_id: wechatId || undefined, wechat_id: normalizedCurrent.wechatId || null,
phone: phone || undefined, phone: normalizedCurrent.phone || null,
bio: bio || undefined, bio: normalizedCurrent.bio || null,
}); });
await refreshUser(); await refreshUser();
toast.success("资料已更新"); toast.success("资料已更新");
} catch (err: any) { } catch (err: unknown) {
toast.error(err.message || "更新失败"); toast.error(getErrorMessage(err, "更新失败"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -92,14 +126,18 @@ export default function ProfilePage() {
return ( return (
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold mb-6"></h1> <div className="mb-6">
<Card> <div className="text-[11px] uppercase tracking-[0.24em] text-[#976746]">Profile</div>
<h1 className="mt-2 text-3xl font-semibold text-[#4e1d1a]"></h1>
<p className="mt-2 text-[#765a4d]"></p>
</div>
<Card className="bg-[#fffdf8]">
<CardContent className="pt-6"> <CardContent className="pt-6">
{/* Avatar */} {/* Avatar */}
<div className="flex items-center gap-4 mb-6"> <div className="flex items-center gap-4 mb-6">
<div className="w-20 h-20 rounded-full bg-gray-900 text-white flex items-center justify-center text-2xl font-medium overflow-hidden shrink-0"> <div className="flex h-20 w-20 shrink-0 items-center justify-center overflow-hidden rounded-full bg-gradient-to-br from-[#6f2030] to-[#a4633f] text-2xl font-medium text-white">
{user?.avatar_url ? ( {user?.avatar_url ? (
<img src={user.avatar_url} alt="" className="w-full h-full object-cover" /> <Image src={user.avatar_url} alt="" fill className="object-cover" />
) : ( ) : (
user?.name?.[0] || "?" user?.name?.[0] || "?"
)} )}
@ -120,7 +158,7 @@ export default function ProfilePage() {
> >
{avatarUploading ? "上传中..." : "更换头像"} {avatarUploading ? "上传中..." : "更换头像"}
</Button> </Button>
<p className="text-xs text-gray-400 mt-1"> JPG/PNG/GIF/WebP 5MB</p> <p className="mt-1 text-xs text-[#9a7b68]"> JPG/PNG/GIF/WebP 5MB</p>
</div> </div>
</div> </div>
@ -144,8 +182,19 @@ export default function ProfilePage() {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label></Label> <div className="flex items-center justify-between gap-3">
<Select value={industry} onValueChange={(v) => v && setIndustry(v)}> <Label></Label>
{industry && (
<button
type="button"
className="text-xs text-gray-500 hover:text-gray-900"
onClick={() => setIndustry("")}
>
</button>
)}
</div>
<Select value={industry} onValueChange={setIndustry}>
<SelectTrigger> <SelectTrigger>
<SelectValue>{industry || "选择行业"}</SelectValue> <SelectValue>{industry || "选择行业"}</SelectValue>
</SelectTrigger> </SelectTrigger>
@ -211,8 +260,8 @@ export default function ProfilePage() {
</div> </div>
</div> </div>
<Button type="submit" disabled={loading}> <Button type="submit" disabled={loading || !hasChanges}>
{loading ? "保存中..." : "保存资料"} {loading ? "保存中..." : hasChanges ? "保存资料" : "资料未修改"}
</Button> </Button>
</form> </form>
</CardContent> </CardContent>

View File

@ -1,8 +1,8 @@
"use client"; "use client";
import { useEffect, useState, useRef } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useActiveClass } from "@/hooks/use-active-class"; 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 { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -27,7 +27,11 @@ import { ConfirmDialog } from "@/components/confirm-dialog";
import { ErrorState } from "@/components/error-state"; import { ErrorState } from "@/components/error-state";
import { Pagination } from "@/components/pagination"; import { Pagination } from "@/components/pagination";
import { toast } from "sonner"; 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<string, string> = { const RESOURCE_CATEGORIES: Record<string, string> = {
all: "全部", all: "全部",
@ -74,8 +78,15 @@ export default function ResourcesPage() {
// Delete state // Delete state
const [deleteTarget, setDeleteTarget] = useState<number | null>(null); const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
const loadResources = async () => { const loadResources = useCallback(async () => {
if (!activeClassId) {
setResources([]);
setTotalPages(1);
setLoading(false);
return;
}
setError(null); setError(null);
setLoading(true);
try { try {
const params: Record<string, string> = { const params: Record<string, string> = {
page_size: "20", page_size: "20",
@ -83,20 +94,19 @@ export default function ResourcesPage() {
class_id: String(activeClassId), class_id: String(activeClassId),
}; };
if (category !== "all") params.category = category; if (category !== "all") params.category = category;
const res = await fetchAPI<any>("/api/resources/", params); const res = await fetchAPI<PageResponse<Resource>>("/api/resources/", params);
setResources(res.items || []); setResources(res.items || []);
setTotalPages(res.total_pages || 1); setTotalPages(res.total_pages || 1);
} catch (err: any) { } catch (err: unknown) {
setError(err.message || "加载失败"); setError(getErrorMessage(err, "加载失败"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [activeClassId, category, page]);
useEffect(() => { useEffect(() => {
if (!activeClassId) return; void loadResources();
loadResources(); }, [loadResources]);
}, [activeClassId, page, category]);
const resetForm = () => { const resetForm = () => {
setFormTitle(""); setFormTitle("");
@ -122,8 +132,8 @@ export default function ResourcesPage() {
setDialogOpen(false); setDialogOpen(false);
resetForm(); resetForm();
loadResources(); loadResources();
} catch (err: any) { } catch (err: unknown) {
toast.error(err.message || "上传失败"); toast.error(getErrorMessage(err, "上传失败"));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@ -131,10 +141,10 @@ export default function ResourcesPage() {
const handleDownload = async (resource: Resource) => { const handleDownload = async (resource: Resource) => {
try { try {
const res = await postAPI<any>(`/api/resources/${resource.id}/download`); const res = await postAPI<ResourceDownloadResponse>(`/api/resources/${resource.id}/download`);
window.open(res.file_url, "_blank"); window.open(res.file_url, "_blank");
} catch (err: any) { } catch (err: unknown) {
toast.error(err.message || "下载失败"); toast.error(getErrorMessage(err, "下载失败"));
} }
}; };
@ -144,8 +154,8 @@ export default function ResourcesPage() {
toast.success("已删除"); toast.success("已删除");
setDeleteTarget(null); setDeleteTarget(null);
loadResources(); loadResources();
} catch (err: any) { } catch (err: unknown) {
toast.error(err.message || "删除失败"); toast.error(getErrorMessage(err, "删除失败"));
} }
}; };
@ -153,10 +163,11 @@ export default function ResourcesPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold"></h1> <div className="text-[11px] uppercase tracking-[0.24em] text-[#976746]">Library</div>
<p className="text-gray-500 mt-1"></p> <h1 className="mt-2 text-3xl font-semibold text-[#4e1d1a]"></h1>
<p className="mt-2 text-[#765a4d]"></p>
</div> </div>
<RoleGuard roles={["class_admin", "super_admin"]}> <RoleGuard permissions={["resource_manage"]}>
<Dialog open={dialogOpen} onOpenChange={(open) => { <Dialog open={dialogOpen} onOpenChange={(open) => {
setDialogOpen(open); setDialogOpen(open);
if (!open) resetForm(); if (!open) resetForm();
@ -164,9 +175,9 @@ export default function ResourcesPage() {
<DialogTrigger> <DialogTrigger>
<Button></Button> <Button></Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent className="border-[#eadbc8] bg-[#fffdf8] sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle></DialogTitle> <DialogTitle className="text-xl text-[#4e1d1a]"></DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 pt-2"> <div className="space-y-4 pt-2">
<Input <Input
@ -195,10 +206,10 @@ export default function ResourcesPage() {
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-medium file:bg-gray-100 file:text-gray-700 hover:file:bg-gray-200" className="block w-full text-sm text-[#7a5e4f] file:mr-4 file:rounded-xl file:border-0 file:bg-[#f3e4cf] file:px-4 file:py-2 file:text-sm file:font-medium file:text-[#74411f] hover:file:bg-[#ecd6b7]"
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)} onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
/> />
<p className="text-xs text-gray-400 mt-1"> PDF, Word, Excel, PPT, ZIP 50MB</p> <p className="mt-1 text-xs text-[#9d806f]"> PDFWordExcelPPTZIP 50MB</p>
</div> </div>
<Button onClick={handleSubmit} disabled={submitting || !selectedFile} className="w-full"> <Button onClick={handleSubmit} disabled={submitting || !selectedFile} className="w-full">
{submitting ? "上传中..." : "上传"} {submitting ? "上传中..." : "上传"}
@ -210,7 +221,7 @@ export default function ResourcesPage() {
</div> </div>
{/* Category tabs */} {/* Category tabs */}
<div className="flex gap-2"> <div className="flex flex-wrap gap-2 rounded-[1.5rem] border border-[#eadbc8] bg-[#fffaf2] p-2">
{Object.entries(RESOURCE_CATEGORIES).map(([key, label]) => ( {Object.entries(RESOURCE_CATEGORIES).map(([key, label]) => (
<Button <Button
key={key} key={key}
@ -226,7 +237,7 @@ export default function ResourcesPage() {
{loading ? ( {loading ? (
<div className="space-y-2"> <div className="space-y-2">
{[1, 2, 3].map((i) => ( {[1, 2, 3].map((i) => (
<Card key={i} className="animate-pulse"> <Card key={i} className="animate-pulse bg-[#fffaf2]">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="h-16 bg-gray-200 rounded" /> <div className="h-16 bg-gray-200 rounded" />
</CardContent> </CardContent>
@ -236,23 +247,28 @@ export default function ResourcesPage() {
) : error ? ( ) : error ? (
<ErrorState message={error} onRetry={loadResources} /> <ErrorState message={error} onRetry={loadResources} />
) : resources.length === 0 ? ( ) : resources.length === 0 ? (
<div className="text-center py-12 text-gray-400"></div> <div className="rounded-[2rem] border border-dashed border-[#dcc6ab] bg-[#fffaf2] py-12 text-center text-[#9d806f]"></div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-3">
{resources.map((r) => ( {resources.map((r) => (
<Card key={r.id}> <Card key={r.id} className="bg-[#fffdf8]">
<CardContent className="p-4 flex items-center justify-between"> <CardContent className="flex items-center justify-between p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-2xl">{getFileIcon(r.file_type)}</span> <span className="text-2xl">{getFileIcon(r.file_type)}</span>
<div> <div>
<p className="font-medium">{r.title}</p> <p className="font-medium text-[#4e1d1a]">{r.title}</p>
<p className="text-sm text-gray-500"> <p className="text-sm text-[#7a5e4f]">
{r.uploader_name} · {formatFileSize(r.file_size)} · {r.download_count} {r.uploader_name} · {formatFileSize(r.file_size)} · {r.download_count}
</p> </p>
{r.description && (
<p className="mt-1 text-xs text-[#9d806f]">{r.description}</p>
)}
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="outline">{RESOURCE_CATEGORIES[r.category] || r.category}</Badge> <Badge variant="outline" className="border-[#dcc6ab] bg-[#fff8ef] text-[#7a5e4f]">
{RESOURCE_CATEGORIES[r.category] || r.category}
</Badge>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@ -260,7 +276,7 @@ export default function ResourcesPage() {
> >
</Button> </Button>
<RoleGuard roles={["class_admin", "super_admin"]}> <RoleGuard permissions={["resource_manage"]}>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"

View File

@ -1,8 +1,8 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useActiveClass } from "@/hooks/use-active-class"; 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 { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -28,7 +28,7 @@ import { ErrorState } from "@/components/error-state";
import { Pagination } from "@/components/pagination"; import { Pagination } from "@/components/pagination";
import { CalendarView } from "@/components/calendar-view"; import { CalendarView } from "@/components/calendar-view";
import { toast } from "sonner"; import { toast } from "sonner";
import type { ScheduleItem } from "@/lib/types"; import type { PageResponse, ScheduleItem } from "@/lib/types";
import { SCHEDULE_TYPES } from "@/lib/constants"; import { SCHEDULE_TYPES } from "@/lib/constants";
export default function SchedulePage() { export default function SchedulePage() {
@ -52,28 +52,35 @@ export default function SchedulePage() {
const [formLocation, setFormLocation] = useState(""); const [formLocation, setFormLocation] = useState("");
const [formDesc, setFormDesc] = useState(""); const [formDesc, setFormDesc] = useState("");
const loadData = async () => { const loadData = useCallback(async () => {
if (!activeClassId) {
setItems([]);
setUpcoming([]);
setError(null);
setLoading(false);
return;
}
setLoading(true);
setError(null); setError(null);
try { try {
const params = { class_id: String(activeClassId), page: String(page), page_size: "20" }; const params = { class_id: String(activeClassId), page: String(page), page_size: "20" };
const [allRes, upcomingRes] = await Promise.all([ const [allRes, upcomingRes] = await Promise.all([
fetchAPI<any>("/api/schedule/", params), fetchAPI<PageResponse<ScheduleItem>>("/api/schedule/", params),
fetchAPI<ScheduleItem[]>("/api/schedule/upcoming", { limit: "10", class_id: String(activeClassId) }), fetchAPI<ScheduleItem[]>("/api/schedule/upcoming", { limit: "10", class_id: String(activeClassId) }),
]); ]);
setItems(allRes.items || []); setItems(allRes.items ?? []);
setTotalPages(allRes.total_pages || 1); setTotalPages(allRes.total_pages ?? 1);
setUpcoming(upcomingRes); setUpcoming(upcomingRes);
} catch (err: any) { } catch (err: unknown) {
setError(err.message || "加载失败"); setError(getErrorMessage(err, "加载失败"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [activeClassId, page]);
useEffect(() => { useEffect(() => {
if (!activeClassId) return; void loadData();
loadData(); }, [loadData]);
}, [activeClassId, page]);
const getCountdown = (startTime: string) => { const getCountdown = (startTime: string) => {
const diff = new Date(startTime).getTime() - Date.now(); const diff = new Date(startTime).getTime() - Date.now();
@ -100,22 +107,21 @@ export default function SchedulePage() {
}); });
toast.success("排期已更新"); toast.success("排期已更新");
} else { } else {
await postAPI("/api/schedule/", { await postAPI(`/api/schedule/?class_id=${activeClassId}`, {
type: formType, type: formType,
title: formTitle, title: formTitle,
start_time: formStartTime, start_time: formStartTime,
end_time: formEndTime || null, end_time: formEndTime || null,
location: formLocation || null, location: formLocation || null,
description: formDesc || null, description: formDesc || null,
class_id: activeClassId,
}); });
toast.success("排期已创建"); toast.success("排期已创建");
} }
setDialogOpen(false); setDialogOpen(false);
resetForm(); resetForm();
loadData(); await loadData();
} catch (err: any) { } catch (err: unknown) {
toast.error(err.message || (editingId ? "更新失败" : "创建失败")); toast.error(getErrorMessage(err, editingId ? "更新失败" : "创建失败"));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@ -145,9 +151,9 @@ export default function SchedulePage() {
await deleteAPI(`/api/schedule/${id}`); await deleteAPI(`/api/schedule/${id}`);
toast.success("已删除"); toast.success("已删除");
setDeleteTarget(null); setDeleteTarget(null);
loadData(); await loadData();
} catch (err: any) { } catch (err: unknown) {
toast.error(err.message || "删除失败"); toast.error(getErrorMessage(err, "删除失败"));
} }
}; };
@ -186,7 +192,7 @@ export default function SchedulePage() {
</Button> </Button>
</div> </div>
<RoleGuard roles={["class_admin", "super_admin"]}> <RoleGuard permissions={["schedule_manage"]}>
<Dialog open={dialogOpen} onOpenChange={(open) => { <Dialog open={dialogOpen} onOpenChange={(open) => {
setDialogOpen(open); setDialogOpen(open);
if (!open) resetForm(); if (!open) resetForm();
@ -295,7 +301,7 @@ export default function SchedulePage() {
{item.location && ( {item.location && (
<p className="text-sm text-gray-400 mt-1">{item.location}</p> <p className="text-sm text-gray-400 mt-1">{item.location}</p>
)} )}
<RoleGuard roles={["class_admin", "super_admin"]}> <RoleGuard permissions={["schedule_manage"]}>
<div className="flex gap-2 mt-2"> <div className="flex gap-2 mt-2">
<Button <Button
variant="ghost" variant="ghost"
@ -359,7 +365,7 @@ export default function SchedulePage() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="outline">{typeInfo.label}</Badge> <Badge variant="outline">{typeInfo.label}</Badge>
<RoleGuard roles={["class_admin", "super_admin"]}> <RoleGuard permissions={["schedule_manage"]}>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"

View File

@ -1,9 +1,18 @@
"use client"; "use client";
import Image from "next/image";
import { useEffect, useState, useRef, useCallback } from "react"; import { useEffect, useState, useRef, useCallback } from "react";
import { useActiveClass } from "@/hooks/use-active-class"; import { useActiveClass } from "@/hooks/use-active-class";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { fetchAPI, postAPI, putAPI, deleteAPI, uploadAPI, compressImage } from "@/lib/api"; import {
compressImage,
deleteAPI,
fetchAPI,
getErrorMessage,
postAPI,
putAPI,
uploadAPI,
} from "@/lib/api";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -19,7 +28,48 @@ import { ConfirmDialog } from "@/components/confirm-dialog";
import { ErrorState } from "@/components/error-state"; import { ErrorState } from "@/components/error-state";
import { Pagination } from "@/components/pagination"; import { Pagination } from "@/components/pagination";
import { toast } from "sonner"; import { toast } from "sonner";
import type { TimelinePost, TimelineComment } from "@/lib/types"; import type {
PageResponse,
TimelineComment,
TimelinePost,
} from "@/lib/types";
import { hasClassPermission } from "@/lib/permissions";
type LikeResponse = {
liked: boolean;
like_count: number;
};
function TimelineImage({
src,
alt,
className,
fill = false,
width,
height,
sizes,
}: {
src: string;
alt: string;
className?: string;
fill?: boolean;
width?: number;
height?: number;
sizes?: string;
}) {
return (
<Image
src={src}
alt={alt}
className={className}
unoptimized
fill={fill}
width={fill ? undefined : width}
height={fill ? undefined : height}
sizes={sizes}
/>
);
}
/* ---------- Relative time helper ---------- */ /* ---------- Relative time helper ---------- */
function relativeTime(dateStr: string): string { function relativeTime(dateStr: string): string {
@ -71,7 +121,11 @@ function Lightbox({
const handleTouchEnd = (e: React.TouchEvent) => { const handleTouchEnd = (e: React.TouchEvent) => {
const diff = e.changedTouches[0].clientX - touchStartX.current; const diff = e.changedTouches[0].clientX - touchStartX.current;
if (Math.abs(diff) > 50) { if (Math.abs(diff) > 50) {
diff > 0 ? prev() : next(); if (diff > 0) {
prev();
} else {
next();
}
} }
}; };
@ -104,14 +158,20 @@ function Lightbox({
)} )}
{/* Image */} {/* Image */}
<img <div
src={images[index]} className="relative w-[90vw] h-[85vh]"
alt=""
className="max-w-[90vw] max-h-[85vh] object-contain select-none"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
/> >
<TimelineImage
src={images[index]}
alt=""
className="object-contain select-none"
fill
sizes="90vw"
/>
</div>
{/* Next arrow */} {/* Next arrow */}
{images.length > 1 && ( {images.length > 1 && (
@ -137,7 +197,9 @@ function Lightbox({
}`} }`}
onClick={() => setIndex(i)} onClick={() => setIndex(i)}
> >
<img src={url} alt="" className="w-full h-full object-cover" /> <div className="relative w-full h-full">
<TimelineImage src={url} alt="" className="object-cover" fill sizes="48px" />
</div>
</button> </button>
))} ))}
</div> </div>
@ -180,23 +242,33 @@ export default function TimelinePage() {
}; };
const closeLightbox = () => setLightboxImages(null); const closeLightbox = () => setLightboxImages(null);
const loadPosts = async () => { const loadPosts = useCallback(async () => {
if (!activeClassId) {
setPosts([]);
setError(null);
setLoading(false);
return;
}
setLoading(true);
setError(null); setError(null);
try { try {
const res = await fetchAPI<any>("/api/timeline/", { page_size: "10", page: String(page), class_id: String(activeClassId) }); const res = await fetchAPI<PageResponse<TimelinePost>>("/api/timeline/", {
setPosts(res.items || []); page_size: "10",
setTotalPages(res.total_pages || 1); page: String(page),
} catch (err: any) { class_id: String(activeClassId),
setError(err.message || "加载失败"); });
setPosts(res.items ?? []);
setTotalPages(res.total_pages ?? 1);
} catch (err: unknown) {
setError(getErrorMessage(err, "加载失败"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [activeClassId, page]);
useEffect(() => { useEffect(() => {
if (!activeClassId) return; void loadPosts();
loadPosts(); }, [loadPosts]);
}, [activeClassId, page]);
/* ---------- File upload helpers ---------- */ /* ---------- File upload helpers ---------- */
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -254,7 +326,7 @@ export default function TimelinePage() {
}); });
if (selectedFiles.length > 0) { if (selectedFiles.length > 0) {
setUploadProgress(`压缩图片 (0/${selectedFiles.length})...`); setUploadProgress(`压缩图片 (0/${selectedFiles.length})...`);
const compressed = []; const compressed: File[] = [];
for (let i = 0; i < selectedFiles.length; i++) { for (let i = 0; i < selectedFiles.length; i++) {
compressed.push(await compressImage(selectedFiles[i])); compressed.push(await compressImage(selectedFiles[i]));
setUploadProgress(`压缩图片 (${i + 1}/${selectedFiles.length})...`); setUploadProgress(`压缩图片 (${i + 1}/${selectedFiles.length})...`);
@ -270,11 +342,11 @@ export default function TimelinePage() {
const formData = new FormData(); const formData = new FormData();
formData.append("title", newTitle); formData.append("title", newTitle);
if (newContent) formData.append("content", newContent); 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) { if (selectedFiles.length > 0) {
setUploadProgress(`压缩图片 (0/${selectedFiles.length})...`); setUploadProgress(`压缩图片 (0/${selectedFiles.length})...`);
const compressed = []; const compressed: File[] = [];
for (let i = 0; i < selectedFiles.length; i++) { for (let i = 0; i < selectedFiles.length; i++) {
compressed.push(await compressImage(selectedFiles[i])); compressed.push(await compressImage(selectedFiles[i]));
setUploadProgress(`压缩图片 (${i + 1}/${selectedFiles.length})...`); setUploadProgress(`压缩图片 (${i + 1}/${selectedFiles.length})...`);
@ -289,9 +361,9 @@ export default function TimelinePage() {
resetForm(); resetForm();
setDialogOpen(false); setDialogOpen(false);
loadPosts(); await loadPosts();
} catch (err: any) { } catch (err: unknown) {
toast.error(err.message || (editingId ? "更新失败" : "发布失败")); toast.error(getErrorMessage(err, editingId ? "更新失败" : "发布失败"));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
setUploadProgress(""); setUploadProgress("");
@ -306,16 +378,16 @@ export default function TimelinePage() {
await deleteAPI(`/api/timeline/${id}`); await deleteAPI(`/api/timeline/${id}`);
toast.success("已删除"); toast.success("已删除");
setDeleteTarget(null); setDeleteTarget(null);
loadPosts(); await loadPosts();
} catch (err: any) { } catch (err: unknown) {
toast.error(err.message || "删除失败"); toast.error(getErrorMessage(err, "删除失败"));
} }
}; };
/* ---------- Like ---------- */ /* ---------- Like ---------- */
const handleLike = async (postId: number) => { const handleLike = async (postId: number) => {
try { try {
const res = await postAPI<{ liked: boolean; like_count: number }>(`/api/timeline/${postId}/like`); const res = await postAPI<LikeResponse>(`/api/timeline/${postId}/like`);
setPosts((prev) => setPosts((prev) =>
prev.map((p) => prev.map((p) =>
p.id === postId p.id === postId
@ -323,13 +395,14 @@ export default function TimelinePage() {
: p : p
) )
); );
} catch (err: any) { } catch (err: unknown) {
toast.error(err.message || "操作失败"); toast.error(getErrorMessage(err, "操作失败"));
} }
}; };
/* ---------- Comments ---------- */ /* ---------- Comments ---------- */
const toggleComments = async (postId: number) => { const toggleComments = async (postId: number) => {
const isExpanded = expandedComments.has(postId);
setExpandedComments((prev) => { setExpandedComments((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(postId)) { if (next.has(postId)) {
@ -340,19 +413,27 @@ export default function TimelinePage() {
return next; return next;
}); });
// Fetch comments when expanding (if not already loaded) if (isExpanded) {
if (!expandedComments.has(postId)) { return;
try { }
const res = await fetchAPI<any>(`/api/timeline/${postId}/comments`);
const comments = res.items || []; const targetPost = posts.find((post) => post.id === postId);
setPosts((prev) => if (targetPost?.comments) {
prev.map((p) => return;
p.id === postId ? { ...p, comments } : p }
)
); try {
} catch { const res = await fetchAPI<PageResponse<TimelineComment>>(
// Silently fail — user can still try to post a new comment `/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]: "" })); setCommentInputs((prev) => ({ ...prev, [postId]: "" }));
} catch (err: any) { } catch (err: unknown) {
toast.error(err.message || "评论失败"); toast.error(getErrorMessage(err, "评论失败"));
} finally { } finally {
setSubmittingComment((prev) => ({ ...prev, [postId]: false })); setSubmittingComment((prev) => ({ ...prev, [postId]: false }));
} }
@ -388,21 +469,26 @@ export default function TimelinePage() {
}) })
); );
toast.success("已删除评论"); toast.success("已删除评论");
} catch (err: any) { } catch (err: unknown) {
toast.error(err.message || "删除评论失败"); toast.error(getErrorMessage(err, "删除评论失败"));
} }
}; };
/* ---------- Permission helpers ---------- */ /* ---------- Permission helpers ---------- */
const canEditDelete = (post: TimelinePost): boolean => { const canEditDelete = (post: TimelinePost): boolean => {
if (!user) return false; 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; return user.id === post.author_id;
}; };
const canDeleteComment = (comment: TimelineComment): boolean => { const canDeleteComment = (comment: TimelineComment): boolean => {
if (!user) return false; 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; return user.id === comment.author_id;
}; };
@ -450,13 +536,13 @@ export default function TimelinePage() {
{/* Existing images (edit mode) */} {/* Existing images (edit mode) */}
{editingImageUrls.map((url, idx) => ( {editingImageUrls.map((url, idx) => (
<div key={`existing-${idx}`} className="relative w-20 h-20 rounded-lg overflow-hidden border"> <div key={`existing-${idx}`} className="relative w-20 h-20 rounded-lg overflow-hidden border">
<img src={url} alt="" className="w-full h-full object-cover" /> <TimelineImage src={url} alt="" className="object-cover" fill sizes="80px" />
</div> </div>
))} ))}
{/* New image previews */} {/* New image previews */}
{previewUrls.map((url, idx) => ( {previewUrls.map((url, idx) => (
<div key={idx} className="relative w-20 h-20 rounded-lg overflow-hidden border"> <div key={idx} className="relative w-20 h-20 rounded-lg overflow-hidden border">
<img src={url} alt="" className="w-full h-full object-cover" /> <TimelineImage src={url} alt="" className="object-cover" fill sizes="80px" />
<button <button
type="button" type="button"
className="absolute top-0 right-0 w-5 h-5 bg-black/60 text-white text-xs flex items-center justify-center rounded-bl" className="absolute top-0 right-0 w-5 h-5 bg-black/60 text-white text-xs flex items-center justify-center rounded-bl"
@ -550,11 +636,15 @@ export default function TimelinePage() {
className="aspect-video bg-gray-100 rounded-lg overflow-hidden cursor-pointer hover:opacity-90 transition-opacity" className="aspect-video bg-gray-100 rounded-lg overflow-hidden cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => openLightbox(post.image_urls!, idx)} onClick={() => openLightbox(post.image_urls!, idx)}
> >
<img <div className="relative w-full h-full">
src={url} <TimelineImage
alt="" src={url}
className="w-full h-full object-cover" alt=""
/> className="object-cover"
fill
sizes="(min-width: 768px) 33vw, 50vw"
/>
</div>
</div> </div>
))} ))}
</div> </div>

View File

@ -3,7 +3,7 @@
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import { useActiveClass } from "@/hooks/use-active-class"; import { useActiveClass } from "@/hooks/use-active-class";
import { useAuth } from "@/hooks/use-auth"; 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 { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -21,7 +21,8 @@ import {
import { ErrorState } from "@/components/error-state"; import { ErrorState } from "@/components/error-state";
import { Pagination } from "@/components/pagination"; import { Pagination } from "@/components/pagination";
import { toast } from "sonner"; 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() { export default function VotesPage() {
const { activeClassId } = useActiveClass(); const { activeClassId } = useActiveClass();
@ -60,18 +61,15 @@ export default function VotesPage() {
setError(null); setError(null);
setLoading(true); setLoading(true);
try { try {
const res = await fetchAPI<{ const res = await fetchAPI<PageResponse<Vote>>("/api/votes/", {
items: Vote[];
total_pages: number;
}>("/api/votes/", {
class_id: String(activeClassId), class_id: String(activeClassId),
page: String(page), page: String(page),
page_size: "10", page_size: "10",
}); });
setVotes(res.items || []); setVotes(res.items || []);
setTotalPages(res.total_pages || 1); setTotalPages(res.total_pages || 1);
} catch (err: any) { } catch (err: unknown) {
setError(err.message || "加载失败"); setError(getErrorMessage(err, "加载失败"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -118,7 +116,7 @@ export default function VotesPage() {
} }
setSubmitting(true); setSubmitting(true);
try { try {
await postAPI("/api/votes/", { await postAPI(`/api/votes/?class_id=${activeClassId}`, {
title: formTitle.trim(), title: formTitle.trim(),
description: formDesc.trim() || null, description: formDesc.trim() || null,
vote_type: formVoteType, vote_type: formVoteType,
@ -126,14 +124,13 @@ export default function VotesPage() {
max_choices: formVoteType === "multiple" ? formMaxChoices : 1, max_choices: formVoteType === "multiple" ? formMaxChoices : 1,
deadline: formDeadline || null, deadline: formDeadline || null,
options: validOptions, options: validOptions,
class_id: activeClassId,
}); });
toast.success("投票已创建"); toast.success("投票已创建");
setCreateOpen(false); setCreateOpen(false);
resetCreateForm(); resetCreateForm();
loadVotes(); loadVotes();
} catch (err: any) { } catch (err: unknown) {
toast.error(err.message || "创建失败"); toast.error(getErrorMessage(err, "创建失败"));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@ -161,8 +158,8 @@ export default function VotesPage() {
try { try {
const data = await fetchAPI<Vote>(`/api/votes/${voteId}`); const data = await fetchAPI<Vote>(`/api/votes/${voteId}`);
setDetailVote(data); setDetailVote(data);
} catch (err: any) { } catch (err: unknown) {
toast.error(err.message || "加载投票详情失败"); toast.error(getErrorMessage(err, "加载投票详情失败"));
setDetailOpen(false); setDetailOpen(false);
} finally { } finally {
setDetailLoading(false); setDetailLoading(false);
@ -190,8 +187,8 @@ export default function VotesPage() {
setDetailVote(data); setDetailVote(data);
setSelectedOptions([]); setSelectedOptions([]);
loadVotes(); loadVotes();
} catch (err: any) { } catch (err: unknown) {
toast.error(err.message || "投票失败"); toast.error(getErrorMessage(err, "投票失败"));
} finally { } finally {
setSubmittingVote(false); setSubmittingVote(false);
} }
@ -203,8 +200,8 @@ export default function VotesPage() {
toast.success("投票已关闭"); toast.success("投票已关闭");
setDetailOpen(false); setDetailOpen(false);
loadVotes(); loadVotes();
} catch (err: any) { } catch (err: unknown) {
toast.error(err.message || "关闭失败"); toast.error(getErrorMessage(err, "关闭失败"));
} }
}; };
@ -215,13 +212,17 @@ export default function VotesPage() {
setDeleteTarget(null); setDeleteTarget(null);
setDetailOpen(false); setDetailOpen(false);
loadVotes(); loadVotes();
} catch (err: any) { } catch (err: unknown) {
toast.error(err.message || "删除失败"); toast.error(getErrorMessage(err, "删除失败"));
} }
}; };
const canManage = (vote: Vote) => 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 ---------- // ---------- Render helpers ----------

View File

@ -6,13 +6,13 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; 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 Link from "next/link";
import type { LoginResponse } from "@/lib/types";
export default function RegisterPage() { export default function ActivatePage() {
const [inviteCode, setInviteCode] = useState(""); const [inviteCode, setInviteCode] = useState("");
const [studentId, setStudentId] = useState(""); const [studentId, setStudentId] = useState("");
const [name, setName] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
@ -28,42 +28,56 @@ export default function RegisterPage() {
setError("两次密码输入不一致"); setError("两次密码输入不一致");
return; return;
} }
if (password.length < 6) { if (password.length < 8) {
setError("密码至少6位"); setError("密码至少8位");
return; return;
} }
setLoading(true); setLoading(true);
try { try {
const res = await postAPI<any>("/api/auth/register", { const res = await postAPI<LoginResponse>("/api/auth/activate", {
invite_code: inviteCode, invite_code: inviteCode,
student_id: studentId, student_id: studentId,
name,
email, email,
password, password,
}); });
// Registration returns token — auto-login
if (res.token) { if (res.token) {
localStorage.setItem("token", res.token); localStorage.setItem("auth_token", res.token);
localStorage.setItem("user", JSON.stringify(res.user)); localStorage.setItem("auth_user", JSON.stringify(res.user));
router.push("/"); router.push("/");
} else { } else {
router.push("/login"); router.push("/login");
} }
} catch (err: any) { } catch (err: unknown) {
setError(err.message || "注册失败"); setError(getErrorMessage(err, "激活失败"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4"> <div className="relative min-h-screen overflow-hidden bg-[linear-gradient(180deg,#f7efe2_0%,#f4ebdf_38%,#f9f5ee_100%)] px-4 py-10">
<Card className="w-full max-w-md"> <div className="absolute inset-x-0 top-0 h-72 bg-[radial-gradient(circle_at_top,rgba(115,25,37,0.18),transparent_60%)]" />
<div className="relative mx-auto flex min-h-[calc(100vh-5rem)] max-w-6xl items-center justify-center">
<div className="grid w-full items-center gap-10 lg:grid-cols-[1.05fr_0.95fr]">
<div className="hidden lg:block">
<div className="inline-flex items-center rounded-full border border-[#c9ac82] bg-white/55 px-4 py-1.5 text-[11px] uppercase tracking-[0.28em] text-[#84553c]">
HKU ICB Cohort Access
</div>
<h1 className="mt-6 max-w-2xl text-5xl font-semibold leading-tight text-[#4e1d1a]">
</h1>
<p className="mt-5 max-w-xl text-base leading-8 text-[#775a4a]">
使
</p>
</div>
<Card className="mx-auto w-full max-w-md bg-[#fffaf3]">
<CardHeader className="text-center"> <CardHeader className="text-center">
<CardTitle className="text-2xl">HKU ICB</CardTitle> <div className="text-[11px] uppercase tracking-[0.24em] text-[#976746]">Activate Account</div>
<CardDescription> - </CardDescription> <CardTitle className="text-3xl text-[#4e1d1a]"></CardTitle>
<CardDescription className="text-[#7a5e4f]"></CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
@ -87,16 +101,6 @@ export default function RegisterPage() {
required required
/> />
</div> </div>
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Input
id="name"
placeholder="请输入真实姓名"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email"></Label> <Label htmlFor="email"></Label>
<Input <Input
@ -113,7 +117,7 @@ export default function RegisterPage() {
<Input <Input
id="password" id="password"
type="password" type="password"
placeholder="至少6位" placeholder="至少8位"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
@ -131,22 +135,24 @@ export default function RegisterPage() {
/> />
</div> </div>
{error && ( {error && (
<p className="text-sm text-red-600 bg-red-50 p-3 rounded-lg"> <p className="rounded-2xl bg-red-50 p-3 text-sm text-red-600">
{error} {error}
</p> </p>
)} )}
<Button type="submit" className="w-full" disabled={loading}> <Button type="submit" className="w-full" disabled={loading}>
{loading ? "注册中..." : "注册"} {loading ? "激活中..." : "激活账号"}
</Button> </Button>
</form> </form>
<div className="mt-4 text-center text-sm text-gray-600"> <div className="mt-5 text-center text-sm text-[#6f5648]">
{" "} {" "}
<Link href="/login" className="text-blue-600 hover:underline"> <Link href="/login" className="text-[#8a4527] hover:underline">
</Link> </Link>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div>
</div>
</div> </div>
); );
} }

View File

@ -9,7 +9,7 @@
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-sans); --font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans); --font-heading: var(--font-heading);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@ -49,38 +49,40 @@
} }
:root { :root {
--background: oklch(1 0 0); --background: oklch(0.985 0.012 82.5);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.27 0.02 23);
--card: oklch(1 0 0); --card: oklch(0.995 0.008 85);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.27 0.02 23);
--popover: oklch(1 0 0); --popover: oklch(0.995 0.008 85);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.27 0.02 23);
--primary: oklch(0.205 0 0); --primary: oklch(0.38 0.14 18);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.985 0.01 80);
--secondary: oklch(0.97 0 0); --secondary: oklch(0.952 0.02 75);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.32 0.04 18);
--muted: oklch(0.97 0 0); --muted: oklch(0.958 0.015 82);
--muted-foreground: oklch(0.556 0 0); --muted-foreground: oklch(0.52 0.03 30);
--accent: oklch(0.97 0 0); --accent: oklch(0.935 0.042 55);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.33 0.05 22);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.61 0.22 25);
--border: oklch(0.922 0 0); --border: oklch(0.905 0.018 65);
--input: oklch(0.922 0 0); --input: oklch(0.92 0.018 72);
--ring: oklch(0.708 0 0); --ring: oklch(0.61 0.11 22);
--chart-1: oklch(0.87 0 0); --chart-1: oklch(0.56 0.16 20);
--chart-2: oklch(0.556 0 0); --chart-2: oklch(0.67 0.11 64);
--chart-3: oklch(0.439 0 0); --chart-3: oklch(0.54 0.08 220);
--chart-4: oklch(0.371 0 0); --chart-4: oklch(0.74 0.09 145);
--chart-5: oklch(0.269 0 0); --chart-5: oklch(0.82 0.07 92);
--radius: 0.625rem; --radius: 1rem;
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.27 0.035 18);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.95 0.01 80);
--sidebar-primary: oklch(0.205 0 0); --sidebar-primary: oklch(0.67 0.12 56);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.19 0.01 30);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: oklch(0.33 0.03 18);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.96 0.01 80);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.39 0.03 18 / 45%);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.72 0.1 56);
--font-sans: var(--font-inter);
--font-heading: var(--font-serif-sc);
} }
.dark { .dark {
@ -123,8 +125,20 @@
} }
body { body {
@apply bg-background text-foreground; @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 { html {
@apply font-sans; @apply font-sans;
} }
} h1,
h2,
h3,
h4 {
font-family: var(--font-heading);
letter-spacing: -0.01em;
}
}

View File

@ -0,0 +1,43 @@
import Link from "next/link";
export default function InactiveAccountPage() {
return (
<div className="relative min-h-screen overflow-hidden bg-[linear-gradient(180deg,#f8f1e6_0%,#f5ebde_45%,#faf6ef_100%)] px-4 py-10">
<div className="absolute inset-x-0 top-0 h-72 bg-[radial-gradient(circle_at_top,rgba(128,71,39,0.14),transparent_60%)]" />
<div className="relative mx-auto flex min-h-[calc(100vh-5rem)] max-w-xl items-center justify-center">
<div className="w-full space-y-6 rounded-[2rem] border border-[#e6d5bf] bg-[#fffaf3]/95 px-8 py-10 text-center shadow-[0_30px_80px_-45px_rgba(88,42,29,0.35)]">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-[#f5e2b6]">
<svg
className="h-8 w-8 text-[#88552d]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div className="space-y-2">
<div className="text-[11px] uppercase tracking-[0.24em] text-[#9b6b48]">Activation Required</div>
<h1 className="text-3xl font-semibold text-[#4e1d1a]"></h1>
</div>
<p className="leading-7 text-[#73594a]">
<br />
使
</p>
<Link
href="/activate"
className="inline-flex rounded-xl bg-[#6f2030] px-5 py-2.5 text-sm font-medium text-white transition hover:bg-[#611b29]"
>
</Link>
</div>
</div>
</div>
);
}

View File

@ -1,12 +1,12 @@
import type { Metadata } from "next"; 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 { AuthProvider } from "@/hooks/use-auth";
import { AuthGuard } from "@/components/auth-guard"; import { AuthGuard } from "@/components/auth-guard";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const bodySans = Inter({
variable: "--font-geist-sans", variable: "--font-inter",
subsets: ["latin"], subsets: ["latin"],
}); });
@ -15,9 +15,15 @@ const geistMono = Geist_Mono({
subsets: ["latin"], subsets: ["latin"],
}); });
const serifHeading = Noto_Serif_SC({
variable: "--font-serif-sc",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "HKU ICB - 班级资源平台", title: "香港大学中国商业学院 - 班级信息管理平台",
description: "研究生班级资源连接平台", description: "香港大学中国商业学院班级信息管理平台",
}; };
export default function RootLayout({ export default function RootLayout({
@ -28,7 +34,7 @@ export default function RootLayout({
return ( return (
<html <html
lang="zh-CN" lang="zh-CN"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${bodySans.variable} ${geistMono.variable} ${serifHeading.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col"> <body className="min-h-full flex flex-col">
<AuthProvider> <AuthProvider>

View File

@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { getErrorMessage } from "@/lib/api";
import Link from "next/link"; import Link from "next/link";
export default function LoginPage() { export default function LoginPage() {
@ -24,19 +25,40 @@ export default function LoginPage() {
try { try {
await login(email, password); await login(email, password);
router.push("/dashboard"); router.push("/dashboard");
} catch (err: any) { } catch (err: unknown) {
setError(err.message || "登录失败"); setError(getErrorMessage(err, "登录失败"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4"> <div className="relative min-h-screen overflow-hidden bg-[linear-gradient(180deg,#f7efe2_0%,#f4ebdf_38%,#f9f5ee_100%)] px-4 py-10">
<Card className="w-full max-w-md"> <div className="absolute inset-x-0 top-0 h-72 bg-[radial-gradient(circle_at_top,rgba(115,25,37,0.18),transparent_60%)]" />
<div className="relative mx-auto flex min-h-[calc(100vh-5rem)] max-w-6xl items-center justify-center">
<div className="grid w-full items-center gap-10 lg:grid-cols-[1.1fr_0.9fr]">
<div className="hidden lg:block">
<div className="inline-flex items-center rounded-full border border-[#c9ac82] bg-white/55 px-4 py-1.5 text-[11px] uppercase tracking-[0.28em] text-[#84553c]">
The University of Hong Kong
</div>
<h1 className="mt-6 max-w-2xl text-5xl font-semibold leading-tight text-[#4e1d1a]">
</h1>
<p className="mt-5 max-w-xl text-base leading-8 text-[#775a4a]">
</p>
<div className="mt-8 flex flex-wrap gap-3 text-sm text-[#6d4d3d]">
<span className="rounded-full border border-[#dec8aa] bg-white/60 px-4 py-2"></span>
<span className="rounded-full border border-[#dec8aa] bg-white/60 px-4 py-2"></span>
<span className="rounded-full border border-[#dec8aa] bg-white/60 px-4 py-2"></span>
</div>
</div>
<Card className="mx-auto w-full max-w-md bg-[#fffaf3]">
<CardHeader className="text-center"> <CardHeader className="text-center">
<CardTitle className="text-2xl">HKU ICB</CardTitle> <div className="text-[11px] uppercase tracking-[0.24em] text-[#976746]">HKU ICB</div>
<CardDescription> - </CardDescription> <CardTitle className="text-3xl text-[#4e1d1a]"></CardTitle>
<CardDescription className="text-[#7a5e4f]"></CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
@ -63,7 +85,7 @@ export default function LoginPage() {
/> />
</div> </div>
{error && ( {error && (
<p className="text-sm text-red-600 bg-red-50 p-3 rounded-lg"> <p className="rounded-2xl bg-red-50 p-3 text-sm text-red-600">
{error} {error}
</p> </p>
)} )}
@ -71,14 +93,16 @@ export default function LoginPage() {
{loading ? "登录中..." : "登录"} {loading ? "登录中..." : "登录"}
</Button> </Button>
</form> </form>
<div className="mt-4 text-center text-sm text-gray-600"> <div className="mt-5 text-center text-sm text-[#6f5648]">
{" "} {" "}
<Link href="/register" className="text-blue-600 hover:underline"> <Link href="/activate" className="text-[#8a4527] hover:underline">
</Link> </Link>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div>
</div>
</div> </div>
); );
} }

View File

@ -12,8 +12,8 @@ export default function Home() {
if (loading) return; if (loading) return;
if (!user) { if (!user) {
router.replace("/login"); router.replace("/login");
} else if (user.status === "pending") { } else if (user.status === "inactive") {
router.replace("/pending"); router.replace("/inactive-account");
} else { } else {
router.replace("/dashboard"); router.replace("/dashboard");
} }

View File

@ -1,37 +0,0 @@
import Link from "next/link";
export default function PendingPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="max-w-md w-full text-center space-y-6">
<div className="w-16 h-16 bg-yellow-100 rounded-full flex items-center justify-center mx-auto">
<svg
className="w-8 h-8 text-yellow-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-gray-600">
<br />
</p>
<Link
href="/login"
className="inline-block text-blue-600 hover:underline text-sm"
>
</Link>
</div>
</div>
);
}

View File

@ -4,7 +4,7 @@ import { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
const PUBLIC_PATHS = ["/login", "/register"]; const PUBLIC_PATHS = ["/login", "/activate"];
export function AuthGuard({ children }: { children: React.ReactNode }) { export function AuthGuard({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth(); const { user, loading } = useAuth();

View File

@ -53,9 +53,8 @@ export function CalendarView({ events, onEventClick }: CalendarViewProps) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Navigation */} <div className="flex items-center justify-between rounded-[1.5rem] border border-[#eadbc8] bg-[#fffaf2] px-4 py-3">
<div className="flex items-center justify-between"> <h3 className="font-heading text-lg font-medium text-[#4e1d1a]">
<h3 className="text-lg font-medium">
{year} {month + 1} {year} {month + 1}
</h3> </h3>
<div className="flex gap-1"> <div className="flex gap-1">
@ -71,24 +70,21 @@ export function CalendarView({ events, onEventClick }: CalendarViewProps) {
</div> </div>
</div> </div>
{/* Calendar grid */} <div className="overflow-hidden rounded-[1.75rem] border border-[#eadbc8] bg-[#eadbc8] shadow-[0_24px_60px_-42px_rgba(84,29,23,0.28)]">
<div className="grid grid-cols-7 gap-px bg-gray-200 rounded-lg overflow-hidden"> <div className="grid grid-cols-7 gap-px">
{/* Week day headers */}
{weekDays.map((d) => ( {weekDays.map((d) => (
<div <div
key={d} key={d}
className="bg-gray-50 text-center text-xs font-medium text-gray-500 py-2" className="bg-[#f7eee1] py-2 text-center text-xs font-medium text-[#8a6c59]"
> >
{d} {d}
</div> </div>
))} ))}
{/* Leading empty cells */}
{Array.from({ length: startDayOfWeek }, (_, i) => ( {Array.from({ length: startDayOfWeek }, (_, i) => (
<div key={`empty-${i}`} className="bg-white min-h-[80px] p-1" /> <div key={`empty-${i}`} className="min-h-[96px] bg-[#fffdf8] p-1" />
))} ))}
{/* Day cells */}
{Array.from({ length: daysInMonth }, (_, i) => { {Array.from({ length: daysInMonth }, (_, i) => {
const day = i + 1; const day = i + 1;
const key = `${year}-${month}-${day}`; const key = `${year}-${month}-${day}`;
@ -102,16 +98,16 @@ export function CalendarView({ events, onEventClick }: CalendarViewProps) {
return ( return (
<div <div
key={day} key={day}
className={`bg-white min-h-[80px] p-1 cursor-pointer hover:bg-gray-50 transition-colors ${ className={`min-h-[96px] cursor-pointer bg-[#fffdf8] p-2 transition-colors hover:bg-[#fff6ea] ${
isSelected ? "ring-2 ring-blue-500 ring-inset" : "" isSelected ? "ring-2 ring-inset ring-[#7b2331]" : ""
} ${isToday(day) ? "bg-blue-50" : ""}`} } ${isToday(day) ? "bg-[#fff2df]" : ""}`}
onClick={() => setSelectedDate(new Date(year, month, day))} onClick={() => setSelectedDate(new Date(year, month, day))}
> >
<span <span
className={`text-sm ${ className={`text-sm ${
isToday(day) isToday(day)
? "font-bold text-blue-600" ? "font-bold text-[#7b2331]"
: "text-gray-700" : "text-[#5b463c]"
}`} }`}
> >
{day} {day}
@ -129,14 +125,14 @@ export function CalendarView({ events, onEventClick }: CalendarViewProps) {
}} }}
> >
<div className={`w-1.5 h-1.5 rounded-full ${typeInfo.color} shrink-0`} /> <div className={`w-1.5 h-1.5 rounded-full ${typeInfo.color} shrink-0`} />
<span className="text-[10px] text-gray-600 truncate"> <span className="truncate text-[10px] text-[#7a5e4f]">
{event.title} {event.title}
</span> </span>
</div> </div>
); );
})} })}
{dayEvents.length > 3 && ( {dayEvents.length > 3 && (
<span className="text-[10px] text-gray-400"> <span className="text-[10px] text-[#aa8b75]">
+{dayEvents.length - 3} +{dayEvents.length - 3}
</span> </span>
)} )}
@ -145,16 +141,15 @@ export function CalendarView({ events, onEventClick }: CalendarViewProps) {
); );
})} })}
{/* Trailing empty cells */}
{Array.from({ length: (7 - (startDayOfWeek + daysInMonth) % 7) % 7 }, (_, i) => ( {Array.from({ length: (7 - (startDayOfWeek + daysInMonth) % 7) % 7 }, (_, i) => (
<div key={`trail-${i}`} className="bg-white min-h-[80px] p-1" /> <div key={`trail-${i}`} className="min-h-[96px] bg-[#fffdf8] p-1" />
))} ))}
</div> </div>
</div>
{/* Selected date detail */}
{selectedDate && selectedEvents.length > 0 && ( {selectedDate && selectedEvents.length > 0 && (
<div className="border rounded-lg p-4"> <div className="rounded-[1.5rem] border border-[#eadbc8] bg-[#fffaf2] p-4">
<h4 className="text-sm font-medium text-gray-500 mb-2"> <h4 className="mb-2 text-sm font-medium text-[#8a6c59]">
{selectedDate.getMonth() + 1} {selectedDate.getDate()} {selectedDate.getMonth() + 1} {selectedDate.getDate()}
</h4> </h4>
<div className="space-y-2"> <div className="space-y-2">
@ -163,14 +158,14 @@ export function CalendarView({ events, onEventClick }: CalendarViewProps) {
return ( return (
<div <div
key={event.id} key={event.id}
className="flex items-center justify-between p-2 bg-gray-50 rounded cursor-pointer hover:bg-gray-100" className="flex cursor-pointer items-center justify-between rounded-2xl border border-[#eadbc8] bg-[#fffdf8] p-3 hover:bg-[#fff6ea]"
onClick={() => onEventClick?.(event)} onClick={() => onEventClick?.(event)}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className={`w-3 h-3 rounded-full ${typeInfo.color}`} /> <div className={`w-3 h-3 rounded-full ${typeInfo.color}`} />
<div> <div>
<p className="text-sm font-medium">{event.title}</p> <p className="text-sm font-medium text-[#4e1d1a]">{event.title}</p>
<p className="text-xs text-gray-500"> <p className="text-xs text-[#7a5e4f]">
{new Date(event.start_time).toLocaleTimeString("zh-CN", { {new Date(event.start_time).toLocaleTimeString("zh-CN", {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",

View File

@ -9,14 +9,17 @@ interface ErrorStateProps {
export function ErrorState({ message = "加载失败", onRetry }: ErrorStateProps) { export function ErrorState({ message = "加载失败", onRetry }: ErrorStateProps) {
return ( return (
<div className="flex flex-col items-center justify-center py-16 text-center"> <div className="rounded-[2rem] border border-[#eadbc8] bg-[#fffaf2]/95 px-6 py-14 text-center shadow-[0_24px_60px_-38px_rgba(84,29,23,0.28)]">
<svg className="w-12 h-12 text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <div className="mx-auto flex max-w-md flex-col items-center justify-center">
<svg className="mb-4 h-12 w-12 text-[#c5aa8f]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg> </svg>
<p className="text-gray-500 mb-4">{message}</p> <p className="font-heading text-lg text-[#4e1d1a]"></p>
<p className="mt-2 text-sm leading-6 text-[#7a5e4f]">{message}</p>
{onRetry && ( {onRetry && (
<Button variant="outline" onClick={onRetry}></Button> <Button variant="outline" className="mt-5" onClick={onRetry}></Button>
)} )}
</div>
</div> </div>
); );
} }

View File

@ -1,12 +1,13 @@
"use client"; "use client";
import Image from "next/image";
import { useState } from "react"; import { useState } from "react";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { useActiveClass } from "@/hooks/use-active-class"; import { useActiveClass } from "@/hooks/use-active-class";
import { useSidebar } from "@/hooks/use-sidebar"; import { useSidebar } from "@/hooks/use-sidebar";
import { useNotifications } from "@/hooks/use-notifications"; import { useNotifications } from "@/hooks/use-notifications";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { putAPI } from "@/lib/api"; import { getErrorMessage, putAPI } from "@/lib/api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@ -51,6 +52,9 @@ export function Header() {
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [passwordLoading, setPasswordLoading] = useState(false); const [passwordLoading, setPasswordLoading] = useState(false);
const classDescriptor = activeClassName
? activeClassName.split(" ").slice(0, 2).join(" ")
: "香港大学中国商业学院";
const handleChangePassword = async (e: React.FormEvent) => { const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -73,28 +77,38 @@ export function Header() {
setConfirmPassword(""); setConfirmPassword("");
setPasswordOpen(false); setPasswordOpen(false);
toast.success("密码已修改"); toast.success("密码已修改");
} catch (err: any) { } catch (err: unknown) {
toast.error(err.message || "修改密码失败"); toast.error(getErrorMessage(err, "修改密码失败"));
} finally { } finally {
setPasswordLoading(false); setPasswordLoading(false);
} }
}; };
return ( return (
<header className="h-14 md:h-16 bg-white border-b border-gray-200 flex items-center justify-between px-4 md:px-6"> <header className="relative border-b border-[#d6c2aa]/65 bg-[linear-gradient(180deg,rgba(255,250,242,0.95),rgba(251,244,232,0.9))] px-4 py-3 backdrop-blur md:px-8">
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[#b7794b]/40 to-transparent" />
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Mobile hamburger */} {/* Mobile hamburger */}
<Button variant="ghost" size="icon" className="md:hidden shrink-0" onClick={toggle}> <Button variant="ghost" size="icon" className="md:hidden shrink-0 rounded-xl" onClick={toggle}>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg> </svg>
</Button> </Button>
<div className="hidden min-w-0 md:block">
<p className="text-[11px] uppercase tracking-[0.24em] text-[#94613e]">
HKU ICB
</p>
<h2 className="truncate text-lg font-semibold text-[#4a1f1a]">
{classDescriptor}
</h2>
</div>
{canSwitchClass ? ( {canSwitchClass ? (
<Select <Select
value={activeClassId ? String(activeClassId) : ""} value={activeClassId ? String(activeClassId) : ""}
onValueChange={(v) => v && setActiveClassId(parseInt(v))} onValueChange={(v) => v && setActiveClassId(parseInt(v))}
> >
<SelectTrigger className="w-56"> <SelectTrigger className="w-56 border-[#d7c0a0] bg-white/75 shadow-none">
<SelectValue> <SelectValue>
{activeClassId {activeClassId
? availableClasses.find((c) => c.id === activeClassId)?.name || ? availableClasses.find((c) => c.id === activeClassId)?.name ||
@ -111,19 +125,19 @@ export function Header() {
</SelectContent> </SelectContent>
</Select> </Select>
) : activeClassName ? ( ) : activeClassName ? (
<span className="text-sm text-gray-700"> <span className="text-sm text-[#6f4b38] md:hidden">
<span className="font-medium">{activeClassName}</span> <span className="font-medium text-[#4a1f1a]">{activeClassName}</span>
</span> </span>
) : null} ) : null}
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-3">
{/* Notification bell */} {/* Notification bell */}
{user && ( {user && (
<Popover open={notifOpen} onOpenChange={(open) => { <Popover open={notifOpen} onOpenChange={(open) => {
setNotifOpen(open); setNotifOpen(open);
if (open) refresh(); if (open) refresh();
}}> }}>
<PopoverTrigger className="relative inline-flex items-center justify-center shrink-0 rounded-md text-sm font-medium transition-colors hover:bg-gray-100 h-9 w-9"> <PopoverTrigger className="relative inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-[#dcc5a3] bg-white/70 text-sm font-medium text-[#5a2a22] transition-colors hover:bg-white">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg> </svg>
@ -133,8 +147,8 @@ export function Header() {
</span> </span>
)} )}
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0"> <PopoverContent align="end" className="w-80 overflow-hidden rounded-3xl border-[#e0ccb0] bg-[#fffaf2] p-0">
<div className="flex items-center justify-between px-4 py-3 border-b"> <div className="flex items-center justify-between border-b border-[#eedfc8] px-4 py-3">
<span className="font-medium text-sm"></span> <span className="font-medium text-sm"></span>
{unreadCount > 0 && ( {unreadCount > 0 && (
<Button variant="ghost" size="sm" className="text-xs h-auto py-0.5 px-2" onClick={async () => { <Button variant="ghost" size="sm" className="text-xs h-auto py-0.5 px-2" onClick={async () => {
@ -151,7 +165,7 @@ export function Header() {
notifications.map((n) => ( notifications.map((n) => (
<div <div
key={n.id} key={n.id}
className={`px-4 py-3 border-b last:border-b-0 cursor-pointer hover:bg-gray-50 ${!n.is_read ? "bg-blue-50/50" : ""}`} className={`cursor-pointer border-b border-[#f0e2cd] px-4 py-3 last:border-b-0 hover:bg-[#fff5e7] ${!n.is_read ? "bg-[#fff1db]" : ""}`}
onClick={async () => { onClick={async () => {
if (!n.is_read) await markRead(n.id); if (!n.is_read) await markRead(n.id);
}} }}
@ -186,15 +200,20 @@ export function Header() {
{user && ( {user && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger>
<Button variant="ghost" className="flex items-center gap-2"> <Button
<div className="w-8 h-8 rounded-full bg-gray-900 text-white flex items-center justify-center text-sm font-medium overflow-hidden shrink-0"> variant="ghost"
className="h-11 gap-2 rounded-2xl border border-[#dcc5a3] bg-white/65 px-2 pr-3 hover:bg-white/90"
>
<div className="relative flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full bg-gradient-to-br from-[#6f2030] to-[#a4633f] text-sm font-medium text-white">
{user.avatar_url ? ( {user.avatar_url ? (
<img src={user.avatar_url} alt="" className="w-full h-full object-cover" /> <Image src={user.avatar_url} alt="" fill className="object-cover" />
) : ( ) : (
user.name?.[0] || "?" user.name?.[0] || "?"
)} )}
</div> </div>
<span className="text-sm hidden sm:inline">{user.name || "User"}</span> <span className="hidden max-w-32 truncate text-sm leading-none text-[#4a1f1a] sm:inline">
{user.name || "User"}
</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
@ -238,6 +257,7 @@ export function Header() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
</div>
</header> </header>
); );
} }

View File

@ -28,7 +28,7 @@ export function Pagination({ page, totalPages, onPageChange }: PaginationProps)
}; };
return ( return (
<div className="flex items-center justify-center gap-1 pt-4"> <div className="flex items-center justify-center gap-1 pt-6">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@ -39,7 +39,7 @@ export function Pagination({ page, totalPages, onPageChange }: PaginationProps)
</Button> </Button>
{getPages().map((p, i) => {getPages().map((p, i) =>
p === "..." ? ( p === "..." ? (
<span key={`dots-${i}`} className="px-2 text-gray-400"> <span key={`dots-${i}`} className="px-2 text-[#aa8b75]">
... ...
</span> </span>
) : ( ) : (

View File

@ -1,16 +1,32 @@
"use client"; "use client";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import type { UserRole } from "@/lib/types"; import { useActiveClass } from "@/hooks/use-active-class";
import type { ClassPermission, UserRole } from "@/lib/types";
import { hasClassPermission } from "@/lib/permissions";
interface RoleGuardProps { interface RoleGuardProps {
roles: UserRole[]; roles?: UserRole[];
permissions?: ClassPermission[];
children: React.ReactNode; children: React.ReactNode;
fallback?: React.ReactNode; fallback?: React.ReactNode;
} }
export function RoleGuard({ roles, children, fallback = null }: RoleGuardProps) { export function RoleGuard({
roles,
permissions,
children,
fallback = null,
}: RoleGuardProps) {
const { user } = useAuth(); const { user } = useAuth();
if (!user || !roles.includes(user.role)) return <>{fallback}</>; const { activeClassId } = useActiveClass();
if (!user) return <>{fallback}</>;
const roleMatch = !roles || roles.includes(user.role);
const permissionMatch =
!permissions ||
permissions.every((permission) => hasClassPermission(user, permission, activeClassId));
if (!roleMatch || !permissionMatch) return <>{fallback}</>;
return <>{children}</>; return <>{children}</>;
} }

View File

@ -5,16 +5,14 @@ import { usePathname } from "next/navigation";
import { useSidebar } from "@/hooks/use-sidebar"; import { useSidebar } from "@/hooks/use-sidebar";
import { useActiveClass } from "@/hooks/use-active-class"; import { useActiveClass } from "@/hooks/use-active-class";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { UserRole } from "@/lib/types"; import type { ClassPermission, UserRole } from "@/lib/types";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { hasClassPermission } from "@/lib/permissions";
// Module keys that can be toggled
const TOGGLEABLE_MODULES = ["announcements", "directory", "timeline", "assignments", "votes", "schedule", "resources", "fund"];
const navItems = [ const navItems = [
{ href: "/dashboard", label: "首页", icon: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6", moduleKey: undefined }, { href: "/dashboard", label: "首页", icon: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6", moduleKey: undefined },
{ href: "/announcements", label: "公告", icon: "M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z", moduleKey: "announcements" }, { href: "/announcements", label: "公告", icon: "M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z", moduleKey: "announcements" },
{ href: "/directory", label: "花名册", icon: "M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z", moduleKey: "directory" }, { href: "/directory", label: "成员名录", icon: "M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z", moduleKey: "directory" },
{ href: "/timeline", label: "班级动态", icon: "M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z", moduleKey: "timeline" }, { href: "/timeline", label: "班级动态", icon: "M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z", moduleKey: "timeline" },
{ href: "/assignments", label: "作业", icon: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z", moduleKey: "assignments" }, { href: "/assignments", label: "作业", icon: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z", moduleKey: "assignments" },
{ href: "/votes", label: "投票", icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4", moduleKey: "votes" }, { href: "/votes", label: "投票", icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4", moduleKey: "votes" },
@ -25,18 +23,26 @@ const navItems = [
]; ];
const adminItems = [ const adminItems = [
{ href: "/admin/members", label: "成员管理", icon: "M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z", roles: ["super_admin", "class_admin"] as UserRole[] }, { href: "/admin/members", label: "成员管理", icon: "M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z", roles: ["super_admin", "teacher", "student"] as UserRole[], permissions: ["member_view"] as ClassPermission[] },
{ href: "/admin/classes", label: "班级管理", icon: "M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4", roles: ["super_admin"] as UserRole[] }, { href: "/admin/classes", label: "班级管理", icon: "M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4", roles: ["super_admin"] as UserRole[] },
{ href: "/admin/modules", label: "模块管理", icon: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z", roles: ["super_admin", "class_admin"] as UserRole[] }, { href: "/admin/modules", label: "模块管理", icon: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z", roles: ["super_admin", "teacher", "student"] as UserRole[], permissions: ["module_manage"] as ClassPermission[] },
]; ];
export function Sidebar() { export function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
const { isOpen, close } = useSidebar(); const { isOpen, close } = useSidebar();
const { user } = useAuth(); const { user } = useAuth();
const { enabledModules } = useActiveClass(); const { enabledModules, activeClassId } = useActiveClass();
const visibleAdminItems = user const visibleAdminItems = user
? adminItems.filter((item) => item.roles.includes(user.role)) ? adminItems.filter((item) => {
if (!item.roles.includes(user.role)) return false;
if (user.role === "super_admin") {
return true;
}
return (item.permissions ?? []).every((permission) =>
hasClassPermission(user, permission, activeClassId)
);
})
: []; : [];
// Default to all modules enabled if not loaded yet // Default to all modules enabled if not loaded yet
@ -53,35 +59,35 @@ export function Sidebar() {
{/* Mobile backdrop */} {/* Mobile backdrop */}
{isOpen && ( {isOpen && (
<div <div
className="fixed inset-0 z-40 bg-black/50 md:hidden" className="fixed inset-0 z-40 bg-[#2b1514]/45 backdrop-blur-[2px] md:hidden"
onClick={close} onClick={close}
/> />
)} )}
<aside <aside
className={cn( className={cn(
"w-64 bg-white border-r border-gray-200 h-screen flex flex-col shrink-0", "w-72 border-r border-sidebar-border bg-sidebar text-sidebar-foreground h-screen flex flex-col shrink-0 shadow-[18px_0_60px_-42px_rgba(29,14,12,0.7)]",
// Mobile: overlay mode // Mobile: overlay mode
"fixed z-50 top-0 left-0 transition-transform duration-200 md:relative md:z-auto", "fixed z-50 top-0 left-0 transition-transform duration-200 md:relative md:z-auto",
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0" isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
)} )}
> >
<div className="p-6 border-b border-gray-200"> <div className="border-b border-sidebar-border px-6 pb-6 pt-7">
<h1 className="text-xl font-bold text-gray-900">HKU ICB</h1> <h1 className="text-2xl font-semibold tracking-tight text-white"></h1>
<p className="text-xs text-gray-500 mt-1"></p> <p className="mt-1 text-sm text-white/65">HKU ICB</p>
</div> </div>
<nav className="flex-1 p-4 space-y-1"> <nav className="flex-1 space-y-1 overflow-y-auto px-4 py-5">
{visibleNavItems.map((item) => ( {visibleNavItems.map((item) => (
<Link <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
onClick={close} onClick={close}
className={cn( className={cn(
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors", "flex items-center gap-3 rounded-2xl px-3.5 py-2.5 text-sm transition-all",
pathname === item.href || pathname.startsWith(item.href + "/") pathname === item.href || pathname.startsWith(item.href + "/")
? "bg-gray-900 text-white" ? "bg-gradient-to-r from-[#e7c98d] to-[#d9ae59] text-[#31130f] shadow-[0_12px_28px_-20px_rgba(217,174,89,0.95)]"
: "text-gray-600 hover:bg-gray-100" : "text-white/72 hover:bg-white/6 hover:text-white"
)} )}
> >
<svg <svg
@ -104,7 +110,7 @@ export function Sidebar() {
{visibleAdminItems.length > 0 && ( {visibleAdminItems.length > 0 && (
<> <>
<div className="pt-4 pb-2"> <div className="pt-4 pb-2">
<p className="px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider"> <p className="px-3 text-[11px] font-semibold uppercase tracking-[0.24em] text-white/35">
</p> </p>
</div> </div>
@ -114,10 +120,10 @@ export function Sidebar() {
href={item.href} href={item.href}
onClick={close} onClick={close}
className={cn( className={cn(
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors", "flex items-center gap-3 rounded-2xl px-3.5 py-2.5 text-sm transition-all",
pathname.startsWith(item.href) pathname.startsWith(item.href)
? "bg-gray-900 text-white" ? "bg-gradient-to-r from-[#e7c98d] to-[#d9ae59] text-[#31130f] shadow-[0_12px_28px_-20px_rgba(217,174,89,0.95)]"
: "text-gray-600 hover:bg-gray-100" : "text-white/72 hover:bg-white/6 hover:text-white"
)} )}
> >
<svg <svg
@ -140,9 +146,11 @@ export function Sidebar() {
)} )}
</nav> </nav>
<div className="p-4 border-t border-gray-200"> <div className="border-t border-sidebar-border px-5 py-4">
<p className="text-xs text-gray-400">&copy; {new Date().getFullYear()} HKU ICB</p> <div className="rounded-2xl border border-white/8 bg-white/[0.03] px-4 py-3">
<p className="text-xs text-gray-400 mt-0.5">By @ FBM05</p> <p className="text-xs text-white/68">&copy; {new Date().getFullYear()} </p>
<p className="mt-1 text-[11px] text-white/42"></p>
</div>
</div> </div>
</aside> </aside>
</> </>

View File

@ -8,11 +8,11 @@ const buttonVariants = cva(
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", default: "bg-primary text-primary-foreground shadow-[0_10px_24px_-16px_color-mix(in_oklab,var(--primary)_70%,black)] hover:brightness-[1.04] [a]:hover:bg-primary/80",
outline: outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", "border-border/80 bg-white/70 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", "bg-secondary text-secondary-foreground hover:bg-secondary/88 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost: ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive: destructive:
@ -21,7 +21,7 @@ const buttonVariants = cva(
}, },
size: { size: {
default: default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", "h-9 gap-1.5 rounded-xl px-3.5 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",

View File

@ -12,7 +12,7 @@ function Card({
data-slot="card" data-slot="card"
data-size={size} data-size={size}
className={cn( className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", "group/card flex flex-col gap-4 overflow-hidden rounded-3xl border border-white/60 bg-card/92 py-4 text-sm text-card-foreground shadow-[0_18px_45px_-28px_rgba(82,34,24,0.32)] backdrop-blur-sm ring-1 ring-[#7f1d1d]/6 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-3xl *:[img:last-child]:rounded-b-3xl",
className className
)} )}
{...props} {...props}
@ -84,7 +84,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="card-footer" data-slot="card-footer"
className={cn( className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3", "flex items-center rounded-b-3xl border-t border-border/70 bg-muted/55 p-4 group-data-[size=sm]/card:p-3",
className className
)} )}
{...props} {...props}

View File

@ -11,8 +11,30 @@ function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} /> return <DialogPrimitive.Root data-slot="dialog" {...props} />
} }
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { function DialogTrigger({
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> children,
...props
}: DialogPrimitive.Trigger.Props) {
const renderChild =
React.Children.count(children) === 1
? React.Children.only(children)
: null
if (React.isValidElement(renderChild)) {
return (
<DialogPrimitive.Trigger
data-slot="dialog-trigger"
render={renderChild}
{...props}
/>
)
}
return (
<DialogPrimitive.Trigger data-slot="dialog-trigger" {...props}>
{children}
</DialogPrimitive.Trigger>
)
} }
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {

View File

@ -14,8 +14,30 @@ function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} /> return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
} }
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) { function DropdownMenuTrigger({
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} /> children,
...props
}: MenuPrimitive.Trigger.Props) {
const renderChild =
React.Children.count(children) === 1
? React.Children.only(children)
: null
if (React.isValidElement(renderChild)) {
return (
<MenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
render={renderChild}
{...props}
/>
)
}
return (
<MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props}>
{children}
</MenuPrimitive.Trigger>
)
} }
function DropdownMenuContent({ function DropdownMenuContent({

View File

@ -9,8 +9,30 @@ function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} /> return <PopoverPrimitive.Root data-slot="popover" {...props} />
} }
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) { function PopoverTrigger({
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} /> children,
...props
}: PopoverPrimitive.Trigger.Props) {
const renderChild =
React.Children.count(children) === 1
? React.Children.only(children)
: null
if (React.isValidElement(renderChild)) {
return (
<PopoverPrimitive.Trigger
data-slot="popover-trigger"
render={renderChild}
{...props}
/>
)
}
return (
<PopoverPrimitive.Trigger data-slot="popover-trigger" {...props}>
{children}
</PopoverPrimitive.Trigger>
)
} }
function PopoverContent({ function PopoverContent({

View File

@ -10,7 +10,7 @@ import {
} from "react"; } from "react";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { fetchAPI } from "@/lib/api"; import { fetchAPI } from "@/lib/api";
import type { ClassInfo } from "@/lib/types"; import type { ClassInfo, PageResponse } from "@/lib/types";
interface ActiveClassContextValue { interface ActiveClassContextValue {
/** The class ID to use for all data queries */ /** The class ID to use for all data queries */
@ -45,21 +45,24 @@ export function ActiveClassProvider({ children }: { children: ReactNode }) {
const [selectedClassId, setSelectedClassId] = useState<number | null>(null); const [selectedClassId, setSelectedClassId] = useState<number | null>(null);
const [userClassName, setUserClassName] = useState<string | null>(null); const [userClassName, setUserClassName] = useState<string | null>(null);
const isSuperAdmin = user?.role === "super_admin"; const canManageMultipleClasses =
// For non-super-admin, use their own class_id user?.role === "super_admin" ||
const activeClassId = isSuperAdmin ? selectedClassId : user?.class_id ?? null; user?.role === "teacher" ||
(user?.memberships?.length ?? 0) > 1;
const fallbackClassId = user?.active_membership?.class_id ?? user?.memberships?.[0]?.class_id ?? null;
const activeClassId = canManageMultipleClasses ? selectedClassId : fallbackClassId;
// Super admin: derive class name from availableClasses // Cross-class roles: derive class name from availableClasses
const superAdminClassName = isSuperAdmin && activeClassId const selectedClassName = canManageMultipleClasses && activeClassId
? availableClasses.find((c) => c.id === activeClassId)?.name ?? null ? availableClasses.find((c) => c.id === activeClassId)?.name ?? null
: null; : null;
const activeClassName = isSuperAdmin ? superAdminClassName : userClassName; const activeClassName = canManageMultipleClasses ? selectedClassName : userClassName;
// Derive enabled modules based on active class // Derive enabled modules based on active class
const enabledModules = (() => { const enabledModules = (() => {
if (!activeClassId) return null; if (!activeClassId) return null;
// For super admin, get from availableClasses // For super admin, get from availableClasses
if (isSuperAdmin) { if (canManageMultipleClasses) {
const cls = availableClasses.find((c) => c.id === activeClassId); const cls = availableClasses.find((c) => c.id === activeClassId);
return cls?.enabled_modules ?? null; return cls?.enabled_modules ?? null;
} }
@ -67,10 +70,10 @@ export function ActiveClassProvider({ children }: { children: ReactNode }) {
return user?.enabled_modules ?? null; return user?.enabled_modules ?? null;
})(); })();
// Super admin: load all classes and auto-select first // Cross-class roles: load all classes and auto-select first
useEffect(() => { useEffect(() => {
if (!isSuperAdmin) return; if (!canManageMultipleClasses) return;
fetchAPI<any>("/api/classes/").then((res) => { fetchAPI<PageResponse<ClassInfo>>("/api/classes/").then((res) => {
const items = res.items || []; const items = res.items || [];
setAvailableClasses(items); setAvailableClasses(items);
// Restore from localStorage or pick first // Restore from localStorage or pick first
@ -83,17 +86,17 @@ export function ActiveClassProvider({ children }: { children: ReactNode }) {
setSelectedClassId(items[0].id); setSelectedClassId(items[0].id);
} }
}); });
}, [isSuperAdmin]); }, [canManageMultipleClasses]);
// Non-super-admin: fetch class name once // Single-class users: fetch class name once
useEffect(() => { useEffect(() => {
if (isSuperAdmin || !user?.class_id) return; if (canManageMultipleClasses || !fallbackClassId) return;
fetchAPI<any>("/api/classes/").then((res) => { fetchAPI<PageResponse<ClassInfo>>("/api/classes/").then((res) => {
const items = res.items || []; const items = res.items || [];
const cls = items.find((c: ClassInfo) => c.id === user.class_id); const cls = items.find((c: ClassInfo) => c.id === fallbackClassId);
setUserClassName(cls?.name ?? null); setUserClassName(cls?.name ?? null);
}).catch(() => {}); }).catch(() => {});
}, [isSuperAdmin, user?.class_id]); }, [canManageMultipleClasses, fallbackClassId]);
const setActiveClassId = useCallback((id: number) => { const setActiveClassId = useCallback((id: number) => {
setSelectedClassId(id); setSelectedClassId(id);
@ -102,7 +105,7 @@ export function ActiveClassProvider({ children }: { children: ReactNode }) {
const refreshClasses = useCallback(async () => { const refreshClasses = useCallback(async () => {
try { try {
const res = await fetchAPI<any>("/api/classes/"); const res = await fetchAPI<PageResponse<ClassInfo>>("/api/classes/");
const items = res.items || []; const items = res.items || [];
setAvailableClasses(items); setAvailableClasses(items);
} catch { } catch {
@ -115,7 +118,7 @@ export function ActiveClassProvider({ children }: { children: ReactNode }) {
value={{ value={{
activeClassId, activeClassId,
activeClassName, activeClassName,
canSwitchClass: isSuperAdmin, canSwitchClass: canManageMultipleClasses,
availableClasses, availableClasses,
setActiveClassId, setActiveClassId,
enabledModules, enabledModules,

View File

@ -3,9 +3,8 @@
import { import {
createContext, createContext,
useContext, useContext,
useState,
useEffect,
useCallback, useCallback,
useSyncExternalStore,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { type AuthUser } from "@/lib/types"; import { type AuthUser } from "@/lib/types";
@ -28,23 +27,75 @@ const AuthContext = createContext<AuthContextValue>({
refreshUser: async () => {}, refreshUser: async () => {},
}); });
export function AuthProvider({ children }: { children: ReactNode }) { type AuthSnapshot = {
const [user, setUser] = useState<AuthUser | null>(null); user: AuthUser | null;
const [loading, setLoading] = useState(true); loading: boolean;
};
useEffect(() => { const authListeners = new Set<() => void>();
const token = localStorage.getItem("auth_token"); const SERVER_SNAPSHOT: AuthSnapshot = { user: null, loading: true };
const storedUser = localStorage.getItem("auth_user"); const EMPTY_CLIENT_SNAPSHOT: AuthSnapshot = { user: null, loading: false };
if (token && storedUser) {
try { let cachedToken: string | null | undefined;
setUser(JSON.parse(storedUser)); let cachedStoredUser: string | null | undefined;
} catch { let cachedSnapshot: AuthSnapshot = SERVER_SNAPSHOT;
localStorage.removeItem("auth_token");
localStorage.removeItem("auth_user"); function emitAuthChange() {
} authListeners.forEach((listener) => listener());
} }
setLoading(false);
}, []); function subscribeAuth(listener: () => void) {
authListeners.add(listener);
return () => authListeners.delete(listener);
}
function clearStoredAuth() {
localStorage.removeItem("auth_token");
localStorage.removeItem("auth_user");
}
function readAuthSnapshot(): AuthSnapshot {
if (typeof window === "undefined") {
return SERVER_SNAPSHOT;
}
const token = localStorage.getItem("auth_token");
const storedUser = localStorage.getItem("auth_user");
if (token === cachedToken && storedUser === cachedStoredUser) {
return cachedSnapshot;
}
if (!token || !storedUser) {
cachedToken = token;
cachedStoredUser = storedUser;
cachedSnapshot = EMPTY_CLIENT_SNAPSHOT;
return cachedSnapshot;
}
try {
cachedToken = token;
cachedStoredUser = storedUser;
cachedSnapshot = {
user: JSON.parse(storedUser) as AuthUser,
loading: false,
};
return cachedSnapshot;
} catch {
clearStoredAuth();
cachedToken = null;
cachedStoredUser = null;
cachedSnapshot = EMPTY_CLIENT_SNAPSHOT;
return cachedSnapshot;
}
}
export function AuthProvider({ children }: { children: ReactNode }) {
const { user, loading } = useSyncExternalStore(
subscribeAuth,
readAuthSnapshot,
() => SERVER_SNAPSHOT
);
const login = useCallback(async (email: string, password: string) => { const login = useCallback(async (email: string, password: string) => {
const res = await postAPI<LoginResponse>("/api/auth/login", { const res = await postAPI<LoginResponse>("/api/auth/login", {
@ -52,19 +103,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
password, password,
}); });
localStorage.setItem("auth_token", res.token); localStorage.setItem("auth_token", res.token);
// Temporarily set user from login response, then refresh to get full data (enabled_modules etc.)
setUser(res.user);
localStorage.setItem("auth_user", JSON.stringify(res.user)); localStorage.setItem("auth_user", JSON.stringify(res.user));
// Refresh to get complete user data including enabled_modules emitAuthChange();
const userData = await fetchAPI<AuthUser>("/api/auth/me"); const userData = await fetchAPI<AuthUser>("/api/auth/me");
localStorage.setItem("auth_user", JSON.stringify(userData)); localStorage.setItem("auth_user", JSON.stringify(userData));
setUser(userData); emitAuthChange();
}, []); }, []);
const logout = useCallback(() => { const logout = useCallback(() => {
localStorage.removeItem("auth_token"); clearStoredAuth();
localStorage.removeItem("auth_user"); emitAuthChange();
setUser(null);
window.location.href = "/login"; window.location.href = "/login";
}, []); }, []);
@ -72,9 +120,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
try { try {
const userData = await fetchAPI<AuthUser>("/api/auth/me"); const userData = await fetchAPI<AuthUser>("/api/auth/me");
localStorage.setItem("auth_user", JSON.stringify(userData)); localStorage.setItem("auth_user", JSON.stringify(userData));
setUser(userData); emitAuthChange();
} catch { } catch {
// Token might be invalid clearStoredAuth();
emitAuthChange();
} }
}, []); }, []);

View File

@ -3,7 +3,7 @@
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react"; import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { fetchAPI, putAPI } from "@/lib/api"; import { fetchAPI, putAPI } from "@/lib/api";
import type { NotificationItem } from "@/lib/types"; import type { NotificationItem, PageResponse } from "@/lib/types";
interface NotificationContextType { interface NotificationContextType {
unreadCount: number; unreadCount: number;
@ -39,7 +39,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
const fetchNotifications = useCallback(async () => { const fetchNotifications = useCallback(async () => {
if (!user) return; if (!user) return;
try { try {
const res = await fetchAPI<any>("/api/notifications/", { page_size: "10" }); const res = await fetchAPI<PageResponse<NotificationItem>>("/api/notifications/", { page_size: "10" });
setNotifications(res.items || []); setNotifications(res.items || []);
} catch { } catch {
// ignore // ignore
@ -47,15 +47,21 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
}, [user]); }, [user]);
const refresh = useCallback(() => { const refresh = useCallback(() => {
fetchUnreadCount(); void fetchUnreadCount();
fetchNotifications(); void fetchNotifications();
}, [fetchUnreadCount, fetchNotifications]); }, [fetchUnreadCount, fetchNotifications]);
useEffect(() => { useEffect(() => {
if (!user) return; if (!user) return;
fetchUnreadCount();
const initialRefresh = window.setTimeout(() => {
void fetchUnreadCount();
}, 0);
const interval = setInterval(fetchUnreadCount, 30000); const interval = setInterval(fetchUnreadCount, 30000);
return () => clearInterval(interval); return () => {
window.clearTimeout(initialRefresh);
clearInterval(interval);
};
}, [user, fetchUnreadCount]); }, [user, fetchUnreadCount]);
const markRead = useCallback(async (id: number) => { const markRead = useCallback(async (id: number) => {

View File

@ -1,5 +1,20 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ""; const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
export function getErrorMessage(error: unknown, fallback = "操作失败"): string {
if (error instanceof Error && error.message) {
return error.message;
}
if (
typeof error === "object" &&
error !== null &&
"message" in error &&
typeof (error as { message?: unknown }).message === "string"
) {
return (error as { message: string }).message;
}
return fallback;
}
function getToken(): string | null { function getToken(): string | null {
if (typeof window === "undefined") return null; if (typeof window === "undefined") return null;
return localStorage.getItem("auth_token"); return localStorage.getItem("auth_token");

View File

@ -1,13 +1,41 @@
export const ROLES = { export const ROLES = {
super_admin: "超级管理员", super_admin: "超级管理员",
class_admin: "班级管理员", teacher: "老师",
student: "同学", student: "同学",
} as const; } as const;
export const 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: "模块管理",
} as const;
export const 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",
] as const;
export const USER_STATUS = { export const USER_STATUS = {
pending: "待审核", inactive: "未激活",
approved: "已通过", approved: "已激活",
rejected: "已拒绝",
disabled: "已禁用", disabled: "已禁用",
} as const; } as const;

View File

@ -0,0 +1,55 @@
import { TEACHER_DEFAULT_PERMISSIONS } from "@/lib/constants";
import type { AuthUser, ClassMembership, ClassPermission } from "@/lib/types";
export function getActiveMembership(
user: AuthUser | null | undefined,
classId: number | null
): ClassMembership | null {
if (!user) return null;
return (
user.memberships.find((membership) => membership.class_id === classId) ??
user.active_membership ??
null
);
}
export function getEffectiveClassPermissions(
user: AuthUser | null | undefined,
classId: number | null
): Set<ClassPermission> {
if (!user) return new Set();
if (user.role === "super_admin") {
return new Set([
"class_view",
"member_view",
"member_manage",
"committee_manage",
"announcement_manage",
"timeline_manage",
"vote_manage",
"schedule_manage",
"resource_manage",
"assignment_manage",
"fund_manage",
"module_manage",
]);
}
const activeMembership = getActiveMembership(user, classId);
const membershipPermissions = new Set(activeMembership?.class_permissions ?? []);
if (user.role === "teacher") {
return new Set([
...Array.from(TEACHER_DEFAULT_PERMISSIONS),
...Array.from(membershipPermissions),
]);
}
return membershipPermissions;
}
export function hasClassPermission(
user: AuthUser | null | undefined,
permission: ClassPermission,
classId: number | null
): boolean {
return getEffectiveClassPermissions(user, classId).has(permission);
}

View File

@ -1,6 +1,28 @@
export type UserRole = "super_admin" | "class_admin" | "student"; export type UserRole = "super_admin" | "teacher" | "student";
export type UserStatus = "pending" | "approved" | "rejected" | "disabled"; export type UserStatus = "inactive" | "approved" | "disabled";
export type ScheduleType = "course" | "deadline" | "activity"; export type ScheduleType = "course" | "deadline" | "activity";
export type ClassPermission =
| "class_view"
| "member_view"
| "member_manage"
| "committee_manage"
| "announcement_manage"
| "timeline_manage"
| "vote_manage"
| "schedule_manage"
| "resource_manage"
| "assignment_manage"
| "fund_manage"
| "module_manage";
export interface ClassMembership {
id: number;
class_id: number;
class_name: string | null;
membership_role: "teacher" | "student";
committee_role: string | null;
class_permissions: ClassPermission[];
}
export interface AuthUser { export interface AuthUser {
id: number; id: number;
@ -9,17 +31,17 @@ export interface AuthUser {
student_id: string | null; student_id: string | null;
role: UserRole; role: UserRole;
status: UserStatus; status: UserStatus;
class_id: number | null;
industry: string | null; industry: string | null;
company: string | null; company: string | null;
position: string | null; position: string | null;
committee_role: string | null;
skills_tags: string[] | null; skills_tags: string[] | null;
wechat_id: string | null; wechat_id: string | null;
phone: string | null; phone: string | null;
avatar_url: string | null; avatar_url: string | null;
bio: string | null; bio: string | null;
created_at: string; created_at: string;
memberships: ClassMembership[];
active_membership: ClassMembership | null;
enabled_modules: string[] | null; enabled_modules: string[] | null;
} }
@ -60,11 +82,13 @@ export interface UserListItem {
student_id: string | null; student_id: string | null;
role: UserRole; role: UserRole;
status: UserStatus; status: UserStatus;
class_id: number | null;
industry: string | null; industry: string | null;
company: string | null; company: string | null;
committee_role: string | null; committee_role: string | null;
class_permissions: ClassPermission[];
created_at: string; created_at: string;
memberships: ClassMembership[];
active_membership: ClassMembership | null;
} }
export interface TimelineComment { export interface TimelineComment {
@ -150,11 +174,11 @@ export interface NotificationItem {
created_at: string; created_at: string;
} }
export interface RosterEntry { export interface InactiveMemberEntry {
id: number; id: number;
student_id: string; student_id: string;
name: string; name: string;
status: "unregistered" | "registered"; status: "inactive";
user_id: number | null; user_id: number | null;
} }
@ -239,6 +263,6 @@ export interface FundStatistics {
total_income: number; total_income: number;
total_expense: number; total_expense: number;
balance: number; balance: number;
income_by_category: { category: string; amount: number }[]; income_by_category: Record<string, number>;
expense_by_category: { category: string; amount: number }[]; expense_by_category: Record<string, number>;
} }