全面更新

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
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.db.base import Base
from app.db.models import Class_, User, Timeline, Schedule, Announcement, Resource, Notification # noqa: ensure models registered
from app.db import models as _models # noqa: F401 ensure all models registered
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user
from app.core.deps import ensure_class_permission, get_current_user, resolve_class_id_for_user
from app.db.database import get_db
from app.db.models import User
from app.schemas.user import UserPublic
@ -23,18 +23,21 @@ async def search_members(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
# Determine effective class_id: super_admin can specify one, others use their own
effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None:
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
ensure_class_permission(user, "class_view", effective_class_id)
users, total = await search_directory(
db, effective_class_id, search, industry, company, page, page_size
)
total_pages = (total + page_size - 1) // page_size
include_contact = True # Same class, approved users can see contact
include_contact = True # Same class, active members can see contact
return PageResponse(
items=[user_to_public(u, include_contact=include_contact) for u in users],
items=[
user_to_public(u, effective_class_id, include_contact=include_contact)
for u in users
],
total=total,
page=page,
page_size=page_size,
@ -53,5 +56,11 @@ async def get_member_detail(
raise HTTPException(status_code=404, detail="User not found")
# Privacy: only show contact info to same-class members
include_contact = user.class_id == target.class_id
return user_to_public(target, include_contact=include_contact)
shared_class_ids = {
membership.class_id for membership in user.memberships
} & {
membership.class_id for membership in target.memberships
}
include_contact = bool(shared_class_ids)
scoped_class_id = next(iter(shared_class_ids), None)
return user_to_public(target, scoped_class_id, include_contact=include_contact)

View File

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

View File

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

View File

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

View File

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

View File

@ -10,7 +10,7 @@ router = APIRouter(prefix="/api/upload", tags=["upload"])
@router.post("/image")
async def upload_image_api(
file: UploadFile = File(...),
user: User = Depends(require_role("super_admin", "class_admin")),
user: User = Depends(require_role("super_admin", "teacher", "student")),
):
"""Upload an image to Tencent COS."""
contents = await file.read()

View File

@ -1,29 +1,50 @@
import json
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, require_role
from app.core.deps import (
CLASS_PERMISSIONS,
ensure_class_access,
ensure_class_permission,
get_current_user,
require_role,
)
from app.db.database import get_db
from app.db.models import User
from app.schemas.user import UserOut, UserUpdate, UserListItem, UserStatusUpdate
from app.schemas.user import (
TeacherAssignRequest,
TeacherAssignResponse,
TeacherCreateRequest,
TeacherCreateResponse,
UserOut,
UserUpdate,
UserListItem,
UserStatusUpdate,
UserRoleUpdate,
CommitteeRoleUpdate,
ClassPermissionsUpdate,
build_user_list_item,
build_user_out,
)
from app.schemas.common import PageResponse
from app.services.user_service import (
update_profile,
update_user_status,
update_user_role,
update_user_committee_role,
update_user_class_permissions,
assign_existing_teacher_to_class,
create_or_assign_teacher,
list_users,
get_user_by_id,
)
from app.services.cos_service import upload_image
from app.services.email_service import send_approval_notification
router = APIRouter(prefix="/api/users", tags=["users"])
@router.get("/me", response_model=UserOut)
async def get_my_profile(user: User = Depends(get_current_user)):
return UserOut.model_validate(user)
return build_user_out(user)
@router.put("/me", response_model=UserOut)
@ -36,7 +57,7 @@ async def update_my_profile(
updated = await update_profile(db, user, data)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return UserOut.model_validate(updated)
return build_user_out(updated)
@router.post("/me/avatar")
@ -65,13 +86,18 @@ async def list_all_users(
class_id: int | None = None,
status: str | None = None,
role: str | None = None,
admin: User = Depends(require_role("super_admin")),
admin: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
if class_id is not None:
ensure_class_permission(admin, "member_view", class_id)
elif admin.role != "super_admin":
raise HTTPException(status_code=400, detail="class_id is required")
users, total = await list_users(db, page, page_size, class_id, status, role)
total_pages = (total + page_size - 1) // page_size
return PageResponse(
items=[UserListItem.model_validate(u) for u in users],
items=[build_user_list_item(u, class_id) for u in users],
total=total,
page=page,
page_size=page_size,
@ -83,18 +109,15 @@ async def list_all_users(
async def change_user_status(
user_id: int,
data: UserStatusUpdate,
admin: User = Depends(require_role("super_admin", "class_admin")),
admin: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
target = await get_user_by_id(db, user_id)
if target is None:
raise HTTPException(status_code=404, detail="User not found")
# Class admin can only manage users in their own class
if admin.role == "class_admin" and target.class_id != admin.class_id:
raise HTTPException(
status_code=403, detail="Cannot manage users outside your class"
)
target_class_id = target.get_default_membership().class_id if target.get_default_membership() else None
ensure_class_permission(admin, "member_manage", target_class_id)
# Only super_admin can change roles
if data.role and admin.role != "super_admin":
@ -103,53 +126,107 @@ async def change_user_status(
)
updated = await update_user_status(db, user_id, data.status, data.role)
# Send email notification
if data.status in ("approved", "rejected"):
await send_approval_notification(target.email, data.status == "approved")
return {"message": f"User status updated to {data.status}"}
return {"message": f"用户状态已更新为 {data.status}"}
@router.put("/{user_id}/role")
async def change_user_role(
user_id: int,
role: str,
data: UserRoleUpdate,
admin: User = Depends(require_role("super_admin")),
db: AsyncSession = Depends(get_db),
):
if role not in ("super_admin", "class_admin", "student"):
if data.role not in ("super_admin", "teacher", "student"):
raise HTTPException(status_code=400, detail="Invalid role")
target = await get_user_by_id(db, user_id)
if target is None:
raise HTTPException(status_code=404, detail="User not found")
target.role = role
await db.commit()
return {"message": f"User role updated to {role}"}
class CommitteeRoleUpdate(BaseModel):
committee_role: str | None = None
await update_user_role(db, target, data.role)
return {"message": f"User role updated to {data.role}"}
@router.put("/{user_id}/committee-role")
async def change_committee_role(
user_id: int,
data: CommitteeRoleUpdate,
admin: User = Depends(require_role("super_admin", "class_admin")),
admin: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
target = await get_user_by_id(db, user_id)
if target is None:
raise HTTPException(status_code=404, detail="User not found")
if admin.role == "class_admin" and target.class_id != admin.class_id:
raise HTTPException(
status_code=403, detail="Cannot manage users outside your class"
ensure_class_permission(admin, "committee_manage", data.class_id)
try:
await update_user_committee_role(db, target, data.class_id, data.committee_role)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return {"message": "Committee role updated"}
@router.put("/{user_id}/class-permissions")
async def change_class_permissions(
user_id: int,
data: ClassPermissionsUpdate,
admin: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
target = await get_user_by_id(db, user_id)
if target is None:
raise HTTPException(status_code=404, detail="User not found")
ensure_class_permission(admin, "committee_manage", data.class_id)
invalid = [permission for permission in data.class_permissions if permission not in CLASS_PERMISSIONS]
if invalid:
raise HTTPException(status_code=400, detail=f"Invalid permissions: {', '.join(invalid)}")
try:
await update_user_class_permissions(db, target, data.class_id, data.class_permissions)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return {"message": "Class permissions updated"}
@router.post("/teachers", response_model=TeacherCreateResponse)
async def create_teacher_user(
data: TeacherCreateRequest,
admin: User = Depends(require_role("super_admin")),
db: AsyncSession = Depends(get_db),
):
try:
user, created, assigned = await create_or_assign_teacher(db, data)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
if created:
message = "老师账号已创建并加入当前班级"
elif assigned:
message = "老师账号已加入当前班级"
else:
message = "该老师已存在于当前班级"
return TeacherCreateResponse(
message=message,
user=build_user_out(user, data.class_id),
)
target.committee_role = data.committee_role
await db.commit()
return {"message": "Committee role updated"}
@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 sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import require_role
from app.core.deps import (
ensure_class_permission,
get_effective_class_permissions,
require_role,
resolve_class_id_for_user,
)
from app.db.database import get_db
from app.db.models import User
from app.schemas.vote import VoteCreate, VoteUpdate, VoteSubmit, VoteOptionOut, VoteOut
@ -70,12 +75,13 @@ async def get_votes(
page: int = 1,
page_size: int = 20,
class_id: int | None = None,
user: User = Depends(require_role("super_admin", "class_admin", "student")),
user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None:
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
ensure_class_permission(user, "class_view", effective_class_id)
votes, total = await list_votes(db, effective_class_id, page, page_size)
total_pages = (total + page_size - 1) // page_size
@ -87,7 +93,7 @@ async def get_votes(
async def create_new_vote(
data: VoteCreate,
class_id: int | None = None,
user: User = Depends(require_role("super_admin", "class_admin", "student")),
user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
if len(data.options) < 2:
@ -95,9 +101,10 @@ async def create_new_vote(
if data.vote_type == "multiple" and data.max_choices < 2:
raise HTTPException(status_code=400, detail="多选投票最多可选数不能小于 2")
effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
effective_class_id = resolve_class_id_for_user(user, class_id)
if effective_class_id is None:
raise HTTPException(status_code=400, detail="You are not assigned to a class")
ensure_class_permission(user, "class_view", effective_class_id)
vote = await create_vote(db, effective_class_id, user.id, data)
# Reload with relationships
@ -108,14 +115,13 @@ async def create_new_vote(
@router.get("/{vote_id}", response_model=VoteOut)
async def get_vote_detail(
vote_id: int,
user: User = Depends(require_role("super_admin", "class_admin", "student")),
user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
vote = await get_vote_by_id(db, vote_id)
if vote is None:
raise HTTPException(status_code=404, detail="Vote not found")
if user.role != "super_admin" and vote.class_id != user.class_id:
raise HTTPException(status_code=403, detail="Access denied")
ensure_class_permission(user, "class_view", vote.class_id)
return _build_vote_out(vote, user.id)
@ -123,14 +129,13 @@ async def get_vote_detail(
async def submit_vote_response(
vote_id: int,
data: VoteSubmit,
user: User = Depends(require_role("super_admin", "class_admin", "student")),
user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
vote = await get_vote_by_id(db, vote_id)
if vote is None:
raise HTTPException(status_code=404, detail="Vote not found")
if user.role != "super_admin" and vote.class_id != user.class_id:
raise HTTPException(status_code=403, detail="Access denied")
ensure_class_permission(user, "class_view", vote.class_id)
try:
await submit_vote(db, vote_id, user.id, data.option_ids)
@ -143,17 +148,17 @@ async def submit_vote_response(
@router.put("/{vote_id}/close")
async def close_vote_endpoint(
vote_id: int,
user: User = Depends(require_role("super_admin", "class_admin", "student")),
user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
vote = await get_vote_by_id(db, vote_id)
if vote is None:
raise HTTPException(status_code=404, detail="Vote not found")
# Only creator or admin can close
if user.role == "student" and vote.creator_id != user.id:
can_manage = "vote_manage" in get_effective_class_permissions(user, vote.class_id)
if not can_manage and user.role == "student" and vote.creator_id != user.id:
raise HTTPException(status_code=403, detail="只有创建者或管理员可以关闭投票")
if user.role != "super_admin" and vote.class_id != user.class_id:
raise HTTPException(status_code=403, detail="Access denied")
ensure_class_permission(user, "class_view", vote.class_id)
await close_vote(db, vote)
return {"message": "投票已关闭"}
@ -162,16 +167,16 @@ async def close_vote_endpoint(
@router.delete("/{vote_id}")
async def delete_vote_endpoint(
vote_id: int,
user: User = Depends(require_role("super_admin", "class_admin", "student")),
user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
vote = await get_vote_by_id(db, vote_id)
if vote is None:
raise HTTPException(status_code=404, detail="Vote not found")
if user.role == "student" and vote.creator_id != user.id:
can_manage = "vote_manage" in get_effective_class_permissions(user, vote.class_id)
if not can_manage and user.role == "student" and vote.creator_id != user.id:
raise HTTPException(status_code=403, detail="只有创建者或管理员可以删除投票")
if user.role != "super_admin" and vote.class_id != user.class_id:
raise HTTPException(status_code=403, detail="Access denied")
ensure_class_permission(user, "class_view", vote.class_id)
await delete_vote(db, vote)
return {"message": "投票已删除"}

View File

@ -1,14 +1,44 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth import decode_access_token
from app.db.database import get_db
from app.db.models import User
from app.db.models import ClassMembership, User
security = HTTPBearer()
CLASS_PERMISSIONS = {
"class_view",
"member_view",
"member_manage",
"committee_manage",
"announcement_manage",
"timeline_manage",
"vote_manage",
"schedule_manage",
"resource_manage",
"assignment_manage",
"fund_manage",
"module_manage",
}
TEACHER_DEFAULT_PERMISSIONS = {
"class_view",
"member_view",
"member_manage",
"committee_manage",
"announcement_manage",
"timeline_manage",
"vote_manage",
"schedule_manage",
"resource_manage",
"assignment_manage",
"module_manage",
}
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
@ -28,13 +58,24 @@ async def get_current_user(
detail="Invalid token format",
)
result = await db.execute(select(User).where(User.id == int(user_id)))
result = await db.execute(
select(User)
.options(
selectinload(User.memberships),
selectinload(User.memberships).selectinload(ClassMembership.class_),
)
.where(User.id == int(user_id))
)
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found"
)
if user.status == "inactive":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Account inactive"
)
if user.status == "disabled":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Account disabled"
@ -55,3 +96,68 @@ def require_role(*roles: str):
return user
return _check
def get_membership_for_class(user: User, class_id: int | None) -> ClassMembership | None:
return user.get_membership(class_id)
def get_active_membership(
user: User, class_id: int | None = None
) -> ClassMembership | None:
membership = get_membership_for_class(user, class_id)
if membership is not None:
return membership
return user.get_default_membership()
def get_effective_class_permissions(user: User, class_id: int | None = None) -> set[str]:
if user.role == "super_admin":
return set(CLASS_PERMISSIONS)
membership = get_active_membership(user, class_id)
scoped_permissions = membership.get_class_permissions() if membership else []
if user.role == "teacher":
return set(TEACHER_DEFAULT_PERMISSIONS) | set(scoped_permissions)
return set(scoped_permissions)
def can_access_class(user: User, class_id: int | None) -> bool:
if class_id is None:
return False
if user.role in {"super_admin", "teacher"}:
return True
return get_membership_for_class(user, class_id) is not None
def resolve_class_id_for_user(user: User, requested_class_id: int | None) -> int | None:
if user.role in {"super_admin", "teacher"}:
return requested_class_id
if requested_class_id is not None and can_access_class(user, requested_class_id):
return requested_class_id
membership = user.get_default_membership()
return membership.class_id if membership else None
def ensure_class_access(user: User, class_id: int | None):
if not can_access_class(user, class_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied for this class",
)
def ensure_class_permission(user: User, permission: str, class_id: int | None = None):
if permission not in CLASS_PERMISSIONS:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Unknown permission: {permission}",
)
if class_id is not None:
ensure_class_access(user, class_id)
if user.role == "super_admin":
return
if permission not in get_effective_class_permissions(user, class_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions",
)

View File

@ -9,9 +9,3 @@ async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit
async def get_db():
async with async_session() as session:
yield session
async def create_tables():
from app.db.base import Base
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

View File

@ -35,7 +35,9 @@ class Class_(Base):
def set_enabled_modules(self, modules: list[str]):
self.enabled_modules = json.dumps(modules, ensure_ascii=False) if modules else None
members: Mapped[list["User"]] = relationship("User", back_populates="class_")
memberships: Mapped[list["ClassMembership"]] = relationship(
"ClassMembership", back_populates="class_", cascade="all, delete-orphan"
)
timelines: Mapped[list["Timeline"]] = relationship(
"Timeline", back_populates="class_", cascade="all, delete-orphan"
)
@ -48,9 +50,6 @@ class Class_(Base):
resources: Mapped[list["Resource"]] = relationship(
"Resource", back_populates="class_", cascade="all, delete-orphan"
)
roster: Mapped[list["StudentRoster"]] = relationship(
"StudentRoster", back_populates="class_", cascade="all, delete-orphan"
)
assignments: Mapped[list["Assignment"]] = relationship(
"Assignment", back_populates="class_", cascade="all, delete-orphan"
)
@ -67,25 +66,19 @@ class User(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
password_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
student_id: Mapped[str | None] = mapped_column(String(50), nullable=True, unique=True)
# role: super_admin | class_admin | student
# role: super_admin | teacher | student
role: Mapped[str] = mapped_column(String(20), default="student", nullable=False)
# status: pending | approved | rejected | disabled
status: Mapped[str] = mapped_column(String(20), default="pending", nullable=False)
class_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("classes.id"), nullable=True
)
class_: Mapped["Class_ | None"] = relationship("Class_", back_populates="members")
# status: inactive | approved | disabled
status: Mapped[str] = mapped_column(String(20), default="inactive", nullable=False)
# Profile
industry: Mapped[str | None] = mapped_column(String(100), nullable=True)
company: Mapped[str | None] = mapped_column(String(100), nullable=True)
position: Mapped[str | None] = mapped_column(String(100), nullable=True)
committee_role: Mapped[str | None] = mapped_column(String(50), nullable=True)
skills_tags: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array
wechat_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
phone: Mapped[str | None] = mapped_column(String(20), nullable=True)
@ -97,6 +90,9 @@ class User(Base):
DateTime, server_default=func.now(), onupdate=func.now()
)
memberships: Mapped[list["ClassMembership"]] = relationship(
"ClassMembership", back_populates="user", cascade="all, delete-orphan"
)
timeline_posts: Mapped[list["Timeline"]] = relationship(
"Timeline", back_populates="author"
)
@ -121,6 +117,65 @@ class User(Base):
def set_skills_list(self, tags: list[str]):
self.skills_tags = json.dumps(tags, ensure_ascii=False) if tags else None
def get_membership(self, class_id: int | None) -> "ClassMembership | None":
if class_id is None:
return None
return next((m for m in self.memberships if m.class_id == class_id), None)
def get_default_membership(self) -> "ClassMembership | None":
active_membership = getattr(self, "_active_membership", None)
if active_membership is not None:
return active_membership
if len(self.memberships) == 1:
return self.memberships[0]
return self.memberships[0] if self.memberships else None
def set_active_membership(self, class_id: int | None = None):
setattr(self, "_active_membership", self.get_membership(class_id))
class ClassMembership(Base):
__tablename__ = "class_memberships"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id"), nullable=False, index=True
)
class_id: Mapped[int] = mapped_column(
Integer, ForeignKey("classes.id"), nullable=False, index=True
)
membership_role: Mapped[str] = mapped_column(
String(20), default="student", nullable=False
)
committee_role: Mapped[str | None] = mapped_column(String(50), nullable=True)
class_permissions: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
user: Mapped["User"] = relationship("User", back_populates="memberships")
class_: Mapped["Class_"] = relationship("Class_", back_populates="memberships")
__table_args__ = (
UniqueConstraint("user_id", "class_id", name="uq_class_membership_user_class"),
)
def get_class_permissions(self) -> list[str]:
if not self.class_permissions:
return []
try:
return json.loads(self.class_permissions)
except (json.JSONDecodeError, TypeError):
return []
def set_class_permissions(self, permissions: list[str]):
self.class_permissions = (
json.dumps(sorted(set(permissions)), ensure_ascii=False)
if permissions
else None
)
class Timeline(Base):
__tablename__ = "timelines"
@ -245,27 +300,6 @@ class Notification(Base):
user: Mapped["User"] = relationship("User")
class StudentRoster(Base):
__tablename__ = "student_rosters"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
class_id: Mapped[int] = mapped_column(
Integer, ForeignKey("classes.id"), nullable=False, index=True
)
student_id: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False)
status: Mapped[str] = mapped_column(
String(20), default="unregistered", nullable=False
)
user_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
class_: Mapped["Class_"] = relationship("Class_", back_populates="roster")
user: Mapped["User | None"] = relationship("User")
class TimelineLike(Base):
__tablename__ = "timeline_likes"

View File

@ -5,7 +5,6 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.db.database import create_tables
from app.api import auth, users, classes, directory, timeline, schedule, upload, announcements, resources, notifications, votes, assignments, fund
logging.basicConfig(
@ -37,45 +36,9 @@ async def ensure_super_admin():
logger.info("Super admin seeded: %s", settings.super_admin_email)
async def ensure_sample_class():
"""Seed a sample class if none exists."""
from sqlalchemy import select, func, text
from app.db.database import async_session
from app.db.models import Class_
async with async_session() as db:
result = await db.execute(select(func.count(Class_.id)))
count = result.scalar()
if count == 0:
sample = Class_(
name="HKU ICB Sample Class",
cohort_year=2025,
description="Sample class for testing",
)
db.add(sample)
await db.commit()
logger.info("Sample class seeded")
async def migrate_add_enabled_modules():
"""Add enabled_modules column to classes table if not exists."""
from sqlalchemy import text
from app.db.database import engine
async with engine.begin() as conn:
result = await conn.execute(text("PRAGMA table_info(classes)"))
columns = [row[1] for row in result.fetchall()]
if "enabled_modules" not in columns:
await conn.execute(text("ALTER TABLE classes ADD COLUMN enabled_modules TEXT"))
logger.info("Migration: added enabled_modules column to classes table")
@asynccontextmanager
async def lifespan(app: FastAPI):
await create_tables()
await migrate_add_enabled_modules()
await ensure_super_admin()
await ensure_sample_class()
yield

View File

@ -9,7 +9,6 @@ class LoginRequest(BaseModel):
class RegisterRequest(BaseModel):
invite_code: str
student_id: str
name: str
email: EmailStr
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 pydantic import BaseModel, EmailStr, field_validator
from pydantic import BaseModel, EmailStr, Field
from app.db.models import ClassMembership, User
class MembershipOut(BaseModel):
id: int
class_id: int
class_name: str | None
membership_role: str
committee_role: str | None = None
class_permissions: list[str]
class UserOut(BaseModel):
@ -11,34 +21,21 @@ class UserOut(BaseModel):
student_id: str | None
role: str
status: str
class_id: int | None
industry: str | None
company: str | None
position: str | None
committee_role: str | None
skills_tags: list[str] | None
wechat_id: str | None
phone: str | None
avatar_url: str | None
bio: str | None
created_at: datetime
memberships: list[MembershipOut]
active_membership: MembershipOut | None = None
enabled_modules: list[str] | None = None
model_config = {"from_attributes": True}
@field_validator("skills_tags", mode="before")
@classmethod
def parse_skills_tags(cls, v):
if isinstance(v, str):
try:
return json.loads(v)
except (json.JSONDecodeError, TypeError):
return []
return v
class UserPublic(BaseModel):
"""Shown to same-class approved members (includes contact info)."""
id: int
name: str
student_id: str | None
@ -53,20 +50,19 @@ class UserPublic(BaseModel):
class UserListItem(BaseModel):
"""For admin user management list."""
id: int
email: str
name: str
student_id: str | None
role: str
status: str
class_id: int | None
industry: str | None
company: str | None
committee_role: str | None = None
class_permissions: list[str]
created_at: datetime
model_config = {"from_attributes": True}
memberships: list[MembershipOut]
active_membership: MembershipOut | None = None
class UserUpdate(BaseModel):
@ -81,10 +77,101 @@ class UserUpdate(BaseModel):
class UserStatusUpdate(BaseModel):
status: str # approved | rejected | disabled
status: str
role: str | None = None
class UserRoleUpdate(BaseModel):
role: str
class TeacherCreateRequest(BaseModel):
class_id: int
name: str = Field(min_length=1, max_length=100)
email: EmailStr
password: str = Field(min_length=8, max_length=128)
class TeacherCreateResponse(BaseModel):
message: str
user: UserOut
class TeacherAssignRequest(BaseModel):
class_id: int
email: EmailStr
class TeacherAssignResponse(BaseModel):
message: str
user: UserOut
class CommitteeRoleUpdate(BaseModel):
class_id: int
committee_role: str | None = None
class ClassPermissionsUpdate(BaseModel):
class_id: int
class_permissions: list[str]
class TokenResponse(BaseModel):
token: str
user: UserOut
def build_membership_out(membership: ClassMembership) -> MembershipOut:
return MembershipOut(
id=membership.id,
class_id=membership.class_id,
class_name=membership.class_.name if membership.class_ else None,
membership_role=membership.membership_role,
committee_role=membership.committee_role,
class_permissions=membership.get_class_permissions(),
)
def build_user_out(user: User, class_id: int | None = None) -> UserOut:
active_membership = user.get_membership(class_id) if class_id is not None else user.get_default_membership()
memberships = [build_membership_out(membership) for membership in user.memberships]
return UserOut(
id=user.id,
email=user.email,
name=user.name,
student_id=user.student_id,
role=user.role,
status=user.status,
industry=user.industry,
company=user.company,
position=user.position,
skills_tags=user.get_skills_list(),
wechat_id=user.wechat_id,
phone=user.phone,
avatar_url=user.avatar_url,
bio=user.bio,
created_at=user.created_at,
memberships=memberships,
active_membership=build_membership_out(active_membership) if active_membership else None,
)
def build_user_list_item(user: User, class_id: int | None = None) -> UserListItem:
active_membership = user.get_membership(class_id) if class_id is not None else user.get_default_membership()
memberships = [build_membership_out(membership) for membership in user.memberships]
return UserListItem(
id=user.id,
email=user.email,
name=user.name,
student_id=user.student_id,
role=user.role,
status=user.status,
industry=user.industry,
company=user.company,
committee_role=active_membership.committee_role if active_membership else None,
class_permissions=active_membership.get_class_permissions() if active_membership else [],
created_at=user.created_at,
memberships=memberships,
active_membership=build_membership_out(active_membership) if active_membership else None,
)

View File

@ -1,7 +1,8 @@
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Class_, User
from app.db.models import Class_, ClassMembership, User
from app.schemas.class_ import ClassCreate, ClassUpdate
@ -49,8 +50,11 @@ async def list_classes(
async def get_member_count(db: AsyncSession, class_id: int) -> int:
result = await db.execute(
select(func.count(User.id)).where(
User.class_id == class_id, User.status == "approved"
select(func.count(ClassMembership.id))
.join(User, User.id == ClassMembership.user_id)
.where(
ClassMembership.class_id == class_id,
User.status == "approved",
)
)
return result.scalar() or 0
@ -63,8 +67,18 @@ async def get_class_members(
page: int = 1,
page_size: int = 50,
) -> tuple[list[User], int]:
query = select(User).where(User.class_id == class_id)
count_query = select(func.count(User.id)).where(User.class_id == class_id)
query = (
select(User)
.options(
selectinload(User.memberships),
selectinload(User.memberships).selectinload(ClassMembership.class_),
)
.join(ClassMembership)
.where(ClassMembership.class_id == class_id)
)
count_query = select(func.count(User.id)).join(ClassMembership).where(
ClassMembership.class_id == class_id
)
if status:
query = query.where(User.status == status)
@ -76,4 +90,4 @@ async def get_class_members(
result = await db.execute(
query.order_by(User.name).offset((page - 1) * page_size).limit(page_size)
)
return list(result.scalars().all()), total
return list(result.scalars().unique().all()), total

View File

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

View File

@ -36,27 +36,13 @@ async def send_email(to: str, subject: str, html_body: str) -> bool:
return False
async def send_registration_notification(
admin_email: str, student_name: str, class_name: str
):
html = f"""
<h2>New Registration Pending Approval</h2>
<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>
async def send_account_activated_email(member_email: str):
html = """
<h2>Account Activated</h2>
<p>Your HKU ICB account has been activated successfully.</p>
<p>You can now log in to the platform.</p>
"""
await send_email(admin_email, "HKU ICB: New Registration", html)
async def send_approval_notification(student_email: str, approved: bool):
status_text = "approved" if approved else "rejected"
html = f"""
<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
)
await send_email(member_email, "HKU ICB: Account Activated", html)
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.ext.asyncio import AsyncSession
from app.db.models import Notification, User
from app.db.models import ClassMembership, Notification, User
from app.services.email_service import send_class_notification_email
logger = logging.getLogger(__name__)
@ -43,10 +43,12 @@ async def create_notifications_for_class(
email_body: str | None = None,
email_action_path: str | None = None,
):
"""Create in-app notifications + send email for all approved users in a class."""
"""Create in-app notifications + send email for all active users in a class."""
result = await db.execute(
select(User.id, User.email).where(
User.class_id == class_id,
select(User.id, User.email)
.join(ClassMembership, ClassMembership.user_id == User.id)
.where(
ClassMembership.class_id == class_id,
User.status == "approved",
)
)

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.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import User
from app.schemas.user import UserOut, UserUpdate
from app.db.models import ClassMembership, User
from app.core.auth import hash_password
from app.schemas.user import TeacherCreateRequest, TeacherAssignRequest, UserUpdate
async def get_user_by_email(db: AsyncSession, email: str) -> User | None:
result = await db.execute(select(User).where(User.email == email))
result = await db.execute(
select(User)
.options(
selectinload(User.memberships),
selectinload(User.memberships).selectinload(ClassMembership.class_),
)
.where(User.email == email)
)
return result.scalar_one_or_none()
async def get_user_by_id(db: AsyncSession, user_id: int) -> User | None:
result = await db.execute(select(User).where(User.id == user_id))
result = await db.execute(
select(User)
.options(
selectinload(User.memberships),
selectinload(User.memberships).selectinload(ClassMembership.class_),
)
.where(User.id == user_id)
)
return result.scalar_one_or_none()
@ -48,6 +64,39 @@ async def update_user_status(
return user
async def update_user_role(
db: AsyncSession, user: User, role: str
) -> User:
user.role = role
await db.commit()
await db.refresh(user)
return user
async def update_user_committee_role(
db: AsyncSession, user: User, class_id: int, committee_role: str | None
) -> User:
membership = user.get_membership(class_id)
if membership is None:
raise ValueError("User is not a member of the class")
membership.committee_role = committee_role
await db.commit()
await db.refresh(user)
return user
async def update_user_class_permissions(
db: AsyncSession, user: User, class_id: int, class_permissions: list[str]
) -> User:
membership = user.get_membership(class_id)
if membership is None:
raise ValueError("User is not a member of the class")
membership.set_class_permissions(class_permissions)
await db.commit()
await db.refresh(user)
return user
async def list_users(
db: AsyncSession,
page: int = 1,
@ -56,12 +105,15 @@ async def list_users(
status: str | None = None,
role: str | None = None,
) -> tuple[list[User], int]:
query = select(User)
query = select(User).options(
selectinload(User.memberships),
selectinload(User.memberships).selectinload(ClassMembership.class_),
)
count_query = select(func.count(User.id))
if class_id is not None:
query = query.where(User.class_id == class_id)
count_query = count_query.where(User.class_id == class_id)
query = query.join(ClassMembership).where(ClassMembership.class_id == class_id)
count_query = count_query.join(ClassMembership).where(ClassMembership.class_id == class_id)
if status is not None:
query = query.where(User.status == status)
count_query = count_query.where(User.status == status)
@ -75,6 +127,96 @@ async def list_users(
query = query.order_by(User.created_at.desc())
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
users = list(result.scalars().all())
users = list(result.scalars().unique().all())
return users, total
async def create_or_assign_teacher(
db: AsyncSession,
data: TeacherCreateRequest,
) -> tuple[User, bool, bool]:
existing_user = await get_user_by_email(db, data.email)
created = False
assigned = False
if existing_user is not None:
if existing_user.role != "teacher":
raise ValueError("该邮箱已存在且不是老师账号")
membership = existing_user.get_membership(data.class_id)
if membership is None:
db.add(
ClassMembership(
user_id=existing_user.id,
class_id=data.class_id,
membership_role="teacher",
)
)
assigned = True
await db.commit()
refreshed = await get_user_by_id(db, existing_user.id)
if refreshed is None:
raise ValueError("老师账号创建失败")
refreshed.set_active_membership(data.class_id)
return refreshed, created, assigned
existing_user.set_active_membership(data.class_id)
return existing_user, created, assigned
user = User(
email=data.email,
password_hash=hash_password(data.password),
name=data.name.strip(),
student_id=None,
role="teacher",
status="approved",
)
db.add(user)
await db.flush()
db.add(
ClassMembership(
user_id=user.id,
class_id=data.class_id,
membership_role="teacher",
)
)
await db.commit()
created = True
assigned = True
refreshed = await get_user_by_id(db, user.id)
if refreshed is None:
raise ValueError("老师账号创建失败")
refreshed.set_active_membership(data.class_id)
return refreshed, created, assigned
async def assign_existing_teacher_to_class(
db: AsyncSession,
data: TeacherAssignRequest,
) -> tuple[User, bool]:
user = await get_user_by_email(db, data.email)
if user is None:
raise ValueError("未找到该邮箱对应的老师账号")
if user.role != "teacher":
raise ValueError("该邮箱对应的账号不是老师")
membership = user.get_membership(data.class_id)
if membership is not None:
user.set_active_membership(data.class_id)
return user, False
db.add(
ClassMembership(
user_id=user.id,
class_id=data.class_id,
membership_role="teacher",
)
)
await db.commit()
refreshed = await get_user_by_id(db, user.id)
if refreshed is None:
raise ValueError("老师分配失败")
refreshed.set_active_membership(data.class_id)
return refreshed, True

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -11,33 +11,34 @@ export default function AdminPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-gray-500 mt-1">
<div className="text-[11px] uppercase tracking-[0.24em] text-[#976746]">Administration</div>
<h1 className="mt-2 text-3xl font-semibold text-[#4e1d1a]"></h1>
<p className="mt-2 text-[#765a4d]">
: {user?.role ? ROLES[user.role] : "-"}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<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>
<CardTitle className="text-lg"></CardTitle>
<CardTitle className="text-lg text-[#4e1d1a]"></CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-500 text-sm">
<p className="text-sm text-[#765a4d]">
</p>
</CardContent>
</Card>
</Link>
<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>
<CardTitle className="text-lg"></CardTitle>
<CardTitle className="text-lg text-[#4e1d1a]"></CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-500 text-sm">
<p className="text-sm text-[#765a4d]">
</p>
</CardContent>
@ -45,12 +46,12 @@ export default function AdminPage() {
</Link>
<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>
<CardTitle className="text-lg"></CardTitle>
<CardTitle className="text-lg text-[#4e1d1a]"></CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-500 text-sm">
<p className="text-sm text-[#765a4d]">
</p>
</CardContent>

View File

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

View File

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

View File

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

View File

@ -1,11 +1,12 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { fetchAPI } from "@/lib/api";
import { fetchAPI, getErrorMessage } from "@/lib/api";
import { useActiveClass } from "@/hooks/use-active-class";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@ -17,61 +18,123 @@ import type { ScheduleItem, TimelinePost, Announcement } from "@/lib/types";
import { SCHEDULE_TYPES } from "@/lib/constants";
export default function DashboardPage() {
const { activeClassId } = useActiveClass();
const { activeClassId, activeClassName } = useActiveClass();
const [upcoming, setUpcoming] = useState<ScheduleItem[]>([]);
const [recentTimeline, setRecentTimeline] = useState<TimelinePost[]>([]);
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
const [error, setError] = useState<string | null>(null);
const [selectedAnnouncement, setSelectedAnnouncement] = useState<Announcement | null>(null);
const [selectedSchedule, setSelectedSchedule] = useState<ScheduleItem | null>(null);
const [now, setNow] = useState(() => Date.now());
const loadData = async () => {
useEffect(() => {
if (!activeClassId) return;
setError(null);
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<any>("/api/timeline/", { page_size: "3", class_id: String(activeClassId) }),
fetchAPI<any>("/api/announcements/", { page_size: "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: any) {
setError(err.message || "加载失败");
} catch (err: unknown) {
if (cancelled) return;
setError(getErrorMessage(err, "加载失败"));
}
}
};
useEffect(() => {
loadData();
void run();
return () => {
cancelled = true;
};
}, [activeClassId]);
const getCountdown = (startTime: string) => {
const diff = new Date(startTime).getTime() - Date.now();
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 60_000);
return () => window.clearInterval(timer);
}, []);
const countdownByScheduleId = useMemo(() => {
return Object.fromEntries(
upcoming.map((item) => {
const diff = new Date(item.start_time).getTime() - now;
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
if (days <= 0) return "已开始";
if (days === 1) return "明天";
if (days <= 7) return `${days}天后`;
return `${days}天后`;
};
const label =
days <= 0 ? "已开始" : days === 1 ? "明天" : `${days}天后`;
return [item.id, label];
})
);
}, [upcoming, now]);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">HKU ICB </h1>
<p className="text-gray-500 mt-1"></p>
<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">
<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%)]" />
<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>
{error ? (
<ErrorState message={error} onRetry={loadData} />
<ErrorState message={error} onRetry={() => window.location.reload()} />
) : (
<>
{/* Latest announcements */}
{announcements.length > 0 && (
<Card>
<Card className="bg-[#fffdf8]">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg"></CardTitle>
<Link href="/announcements" className="text-sm text-gray-500 hover:text-gray-900 transition-colors">
<CardTitle className="text-lg text-[#4a1f1a]"></CardTitle>
<Link href="/announcements" className="text-sm text-[#8a6045] transition-colors hover:text-[#4a1f1a]">
</Link>
</CardHeader>
@ -80,19 +143,21 @@ export default function DashboardPage() {
{announcements.map((a) => (
<div
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)}
>
<div className="flex items-start gap-3">
{a.is_pinned && (
<Badge variant="secondary" className="shrink-0 bg-amber-100 text-amber-700 text-xs"></Badge>
<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>
<p className="text-xs text-gray-500 mt-1">
<div className="min-w-0 flex-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")}
</p>
</div>
</div>
</div>
))}
</div>
</CardContent>
@ -101,19 +166,19 @@ export default function DashboardPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Upcoming schedules */}
<Card>
<Card className="bg-[#fffdf8]">
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardTitle className="text-lg text-[#4a1f1a]"></CardTitle>
</CardHeader>
<CardContent>
{upcoming.length === 0 ? (
<p className="text-gray-400 text-sm"></p>
<p className="text-sm text-[#9a7d69]"></p>
) : (
<div className="space-y-3">
{upcoming.map((item) => (
<div
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)}
>
<div className="flex items-center gap-3">
@ -123,14 +188,14 @@ export default function DashboardPage() {
}`}
/>
<div>
<p className="text-sm font-medium">{item.title}</p>
<p className="text-xs text-gray-500">
<p className="text-sm font-medium text-[#4a1f1a]">{item.title}</p>
<p className="text-xs text-[#866556]">
{new Date(item.start_time).toLocaleDateString("zh-CN")}
</p>
</div>
</div>
<Badge variant="secondary" className="text-xs">
{getCountdown(item.start_time)}
<Badge variant="secondary" className="bg-[#efe2c8] text-[#6b4d39] text-xs">
{countdownByScheduleId[item.id] ?? "即将开始"}
</Badge>
</div>
))}
@ -140,19 +205,19 @@ export default function DashboardPage() {
</Card>
{/* Recent timeline */}
<Card>
<Card className="bg-[#fffdf8]">
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardTitle className="text-lg text-[#4a1f1a]"></CardTitle>
</CardHeader>
<CardContent>
{recentTimeline.length === 0 ? (
<p className="text-gray-400 text-sm"></p>
<p className="text-sm text-[#9a7d69]"></p>
) : (
<div className="space-y-3">
{recentTimeline.map((post) => (
<div key={post.id} className="p-3 bg-gray-50 rounded-lg">
<p className="text-sm font-medium">{post.title}</p>
<p className="text-xs text-gray-500 mt-1">
<div key={post.id} className="rounded-2xl border border-[#eadbc8] bg-[#fff8ef] p-4">
<p className="text-sm font-medium text-[#4a1f1a]">{post.title}</p>
<p className="mt-1 text-xs text-[#866556]">
{post.author_name} ·{" "}
{new Date(post.created_at).toLocaleDateString("zh-CN")}
</p>
@ -186,9 +251,9 @@ export default function DashboardPage() {
})}</span>
</div>
{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>
</>
@ -211,10 +276,10 @@ export default function DashboardPage() {
{SCHEDULE_TYPES[selectedSchedule.type]?.label || selectedSchedule.type}
</Badge>
<Badge variant="secondary" className="text-xs">
{getCountdown(selectedSchedule.start_time)}
{countdownByScheduleId[selectedSchedule.id] ?? "即将开始"}
</Badge>
</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>
{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>
@ -224,9 +289,9 @@ export default function DashboardPage() {
)}
</div>
{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>
</>

View File

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

View File

@ -2,7 +2,7 @@
import { useEffect, useState, useCallback } from "react";
import { useActiveClass } from "@/hooks/use-active-class";
import { fetchAPI } from "@/lib/api";
import { fetchAPI, getErrorMessage } from "@/lib/api";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
@ -16,7 +16,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { ErrorState } from "@/components/error-state";
import { Pagination } from "@/components/pagination";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import type { UserPublic } from "@/lib/types";
import type { PageResponse, UserPublic } from "@/lib/types";
import { INDUSTRY_OPTIONS } from "@/lib/constants";
import Link from "next/link";
@ -46,12 +46,12 @@ export default function DirectoryPage() {
if (search) params.search = search;
if (industry) params.industry = industry;
if (company) params.company = company;
const res = await fetchAPI<any>("/api/directory/", params);
setMembers(res.items || []);
setTotal(res.total || 0);
setTotalPages(res.total_pages || 1);
} catch (err: any) {
setError(err.message || "加载失败");
const res = await fetchAPI<PageResponse<UserPublic>>("/api/directory/", params);
setMembers(res.items ?? []);
setTotal(res.total ?? 0);
setTotalPages(res.total_pages ?? 1);
} catch (err: unknown) {
setError(getErrorMessage(err, "加载失败"));
} finally {
setLoading(false);
}
@ -63,20 +63,21 @@ export default function DirectoryPage() {
}, [search, industry, company]);
useEffect(() => {
if (!activeClassId) return;
const timer = setTimeout(loadMembers, 300);
return () => clearTimeout(timer);
}, [loadMembers]);
}, [activeClassId, loadMembers]);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-gray-500 mt-1"> {total} </p>
<div className="text-[11px] uppercase tracking-[0.24em] text-[#976746]">Directory</div>
<h1 className="mt-2 text-3xl font-semibold text-[#4e1d1a]"></h1>
<p className="mt-2 text-[#765a4d]"> {total} </p>
</div>
{/* Search & Filters */}
<div className="flex flex-wrap gap-3">
<Card className="bg-[#fffaf2]">
<CardContent className="flex flex-wrap gap-3 p-5">
<Input
placeholder="搜索姓名、公司、职位..."
value={search}
@ -102,13 +103,14 @@ export default function DirectoryPage() {
onChange={(e) => setCompany(e.target.value)}
className="w-full sm:w-40"
/>
</div>
</CardContent>
</Card>
{/* Member List */}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{[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">
<div className="h-20 bg-gray-200 rounded" />
</CardContent>
@ -118,24 +120,24 @@ export default function DirectoryPage() {
) : error ? (
<ErrorState message={error} onRetry={loadMembers} />
) : 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">
{members.map((member) => (
<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">
<Avatar className="h-11 w-11 shrink-0">
<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]}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<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 && (
<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}
</Badge>
)}
@ -146,18 +148,18 @@ export default function DirectoryPage() {
)}
</div>
{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.position ? ` · ${member.position}` : ""}
</p>
)}
{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}
</p>
)}
{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}
</p>
)}

View File

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

View File

@ -11,11 +11,13 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
<ActiveClassProvider>
<NotificationProvider>
<SidebarProvider>
<div className="flex h-screen bg-gray-50">
<div className="flex h-screen bg-transparent">
<Sidebar />
<div className="flex-1 flex flex-col overflow-hidden">
<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>
</SidebarProvider>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,13 +6,13 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { postAPI } from "@/lib/api";
import { getErrorMessage, postAPI } from "@/lib/api";
import Link from "next/link";
import type { LoginResponse } from "@/lib/types";
export default function RegisterPage() {
export default function ActivatePage() {
const [inviteCode, setInviteCode] = useState("");
const [studentId, setStudentId] = useState("");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
@ -28,42 +28,56 @@ export default function RegisterPage() {
setError("两次密码输入不一致");
return;
}
if (password.length < 6) {
setError("密码至少6位");
if (password.length < 8) {
setError("密码至少8位");
return;
}
setLoading(true);
try {
const res = await postAPI<any>("/api/auth/register", {
const res = await postAPI<LoginResponse>("/api/auth/activate", {
invite_code: inviteCode,
student_id: studentId,
name,
email,
password,
});
// Registration returns token — auto-login
if (res.token) {
localStorage.setItem("token", res.token);
localStorage.setItem("user", JSON.stringify(res.user));
localStorage.setItem("auth_token", res.token);
localStorage.setItem("auth_user", JSON.stringify(res.user));
router.push("/");
} else {
router.push("/login");
}
} catch (err: any) {
setError(err.message || "注册失败");
} catch (err: unknown) {
setError(getErrorMessage(err, "激活失败"));
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<Card className="w-full max-w-md">
<div className="relative min-h-screen overflow-hidden bg-[linear-gradient(180deg,#f7efe2_0%,#f4ebdf_38%,#f9f5ee_100%)] px-4 py-10">
<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">
<CardTitle className="text-2xl">HKU ICB</CardTitle>
<CardDescription> - </CardDescription>
<div className="text-[11px] uppercase tracking-[0.24em] text-[#976746]">Activate Account</div>
<CardTitle className="text-3xl text-[#4e1d1a]"></CardTitle>
<CardDescription className="text-[#7a5e4f]"></CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
@ -87,16 +101,6 @@ export default function RegisterPage() {
required
/>
</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">
<Label htmlFor="email"></Label>
<Input
@ -113,7 +117,7 @@ export default function RegisterPage() {
<Input
id="password"
type="password"
placeholder="至少6位"
placeholder="至少8位"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
@ -131,22 +135,24 @@ export default function RegisterPage() {
/>
</div>
{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}
</p>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "注册中..." : "注册"}
{loading ? "激活中..." : "激活账号"}
</Button>
</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>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@ -9,7 +9,7 @@
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--font-heading: var(--font-heading);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@ -49,38 +49,40 @@
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--background: oklch(0.985 0.012 82.5);
--foreground: oklch(0.27 0.02 23);
--card: oklch(0.995 0.008 85);
--card-foreground: oklch(0.27 0.02 23);
--popover: oklch(0.995 0.008 85);
--popover-foreground: oklch(0.27 0.02 23);
--primary: oklch(0.38 0.14 18);
--primary-foreground: oklch(0.985 0.01 80);
--secondary: oklch(0.952 0.02 75);
--secondary-foreground: oklch(0.32 0.04 18);
--muted: oklch(0.958 0.015 82);
--muted-foreground: oklch(0.52 0.03 30);
--accent: oklch(0.935 0.042 55);
--accent-foreground: oklch(0.33 0.05 22);
--destructive: oklch(0.61 0.22 25);
--border: oklch(0.905 0.018 65);
--input: oklch(0.92 0.018 72);
--ring: oklch(0.61 0.11 22);
--chart-1: oklch(0.56 0.16 20);
--chart-2: oklch(0.67 0.11 64);
--chart-3: oklch(0.54 0.08 220);
--chart-4: oklch(0.74 0.09 145);
--chart-5: oklch(0.82 0.07 92);
--radius: 1rem;
--sidebar: oklch(0.27 0.035 18);
--sidebar-foreground: oklch(0.95 0.01 80);
--sidebar-primary: oklch(0.67 0.12 56);
--sidebar-primary-foreground: oklch(0.19 0.01 30);
--sidebar-accent: oklch(0.33 0.03 18);
--sidebar-accent-foreground: oklch(0.96 0.01 80);
--sidebar-border: oklch(0.39 0.03 18 / 45%);
--sidebar-ring: oklch(0.72 0.1 56);
--font-sans: var(--font-inter);
--font-heading: var(--font-serif-sc);
}
.dark {
@ -123,8 +125,20 @@
}
body {
@apply bg-background text-foreground;
background-image:
radial-gradient(circle at top left, color-mix(in oklab, var(--accent) 34%, transparent) 0, transparent 34%),
radial-gradient(circle at top right, color-mix(in oklab, var(--primary) 11%, transparent) 0, transparent 30%),
linear-gradient(180deg, color-mix(in oklab, var(--background) 92%, white) 0%, var(--background) 100%);
background-attachment: fixed;
}
html {
@apply font-sans;
}
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 { Geist, Geist_Mono } from "next/font/google";
import { Noto_Serif_SC, Inter, Geist_Mono } from "next/font/google";
import { AuthProvider } from "@/hooks/use-auth";
import { AuthGuard } from "@/components/auth-guard";
import { Toaster } from "@/components/ui/sonner";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
const bodySans = Inter({
variable: "--font-inter",
subsets: ["latin"],
});
@ -15,9 +15,15 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});
const serifHeading = Noto_Serif_SC({
variable: "--font-serif-sc",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
});
export const metadata: Metadata = {
title: "HKU ICB - 班级资源平台",
description: "研究生班级资源连接平台",
title: "香港大学中国商业学院 - 班级信息管理平台",
description: "香港大学中国商业学院班级信息管理平台",
};
export default function RootLayout({
@ -28,7 +34,7 @@ export default function RootLayout({
return (
<html
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">
<AuthProvider>

View File

@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { getErrorMessage } from "@/lib/api";
import Link from "next/link";
export default function LoginPage() {
@ -24,19 +25,40 @@ export default function LoginPage() {
try {
await login(email, password);
router.push("/dashboard");
} catch (err: any) {
setError(err.message || "登录失败");
} catch (err: unknown) {
setError(getErrorMessage(err, "登录失败"));
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<Card className="w-full max-w-md">
<div className="relative min-h-screen overflow-hidden bg-[linear-gradient(180deg,#f7efe2_0%,#f4ebdf_38%,#f9f5ee_100%)] px-4 py-10">
<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">
<CardTitle className="text-2xl">HKU ICB</CardTitle>
<CardDescription> - </CardDescription>
<div className="text-[11px] uppercase tracking-[0.24em] text-[#976746]">HKU ICB</div>
<CardTitle className="text-3xl text-[#4e1d1a]"></CardTitle>
<CardDescription className="text-[#7a5e4f]"></CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
@ -63,7 +85,7 @@ export default function LoginPage() {
/>
</div>
{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}
</p>
)}
@ -71,14 +93,16 @@ export default function LoginPage() {
{loading ? "登录中..." : "登录"}
</Button>
</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>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

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

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 { useAuth } from "@/hooks/use-auth";
const PUBLIC_PATHS = ["/login", "/register"];
const PUBLIC_PATHS = ["/login", "/activate"];
export function AuthGuard({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();

View File

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

View File

@ -9,14 +9,17 @@ interface ErrorStateProps {
export function ErrorState({ message = "加载失败", onRetry }: ErrorStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<svg className="w-12 h-12 text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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)]">
<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" />
</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 && (
<Button variant="outline" onClick={onRetry}></Button>
<Button variant="outline" className="mt-5" onClick={onRetry}></Button>
)}
</div>
</div>
);
}

View File

@ -1,12 +1,13 @@
"use client";
import Image from "next/image";
import { useState } from "react";
import { useAuth } from "@/hooks/use-auth";
import { useActiveClass } from "@/hooks/use-active-class";
import { useSidebar } from "@/hooks/use-sidebar";
import { useNotifications } from "@/hooks/use-notifications";
import { useRouter } from "next/navigation";
import { putAPI } from "@/lib/api";
import { getErrorMessage, putAPI } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -51,6 +52,9 @@ export function Header() {
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [passwordLoading, setPasswordLoading] = useState(false);
const classDescriptor = activeClassName
? activeClassName.split(" ").slice(0, 2).join(" ")
: "香港大学中国商业学院";
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
@ -73,28 +77,38 @@ export function Header() {
setConfirmPassword("");
setPasswordOpen(false);
toast.success("密码已修改");
} catch (err: any) {
toast.error(err.message || "修改密码失败");
} catch (err: unknown) {
toast.error(getErrorMessage(err, "修改密码失败"));
} finally {
setPasswordLoading(false);
}
};
return (
<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">
{/* 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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</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 ? (
<Select
value={activeClassId ? String(activeClassId) : ""}
onValueChange={(v) => v && setActiveClassId(parseInt(v))}
>
<SelectTrigger className="w-56">
<SelectTrigger className="w-56 border-[#d7c0a0] bg-white/75 shadow-none">
<SelectValue>
{activeClassId
? availableClasses.find((c) => c.id === activeClassId)?.name ||
@ -111,19 +125,19 @@ export function Header() {
</SelectContent>
</Select>
) : activeClassName ? (
<span className="text-sm text-gray-700">
<span className="font-medium">{activeClassName}</span>
<span className="text-sm text-[#6f4b38] md:hidden">
<span className="font-medium text-[#4a1f1a]">{activeClassName}</span>
</span>
) : null}
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-3">
{/* Notification bell */}
{user && (
<Popover open={notifOpen} onOpenChange={(open) => {
setNotifOpen(open);
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">
<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>
@ -133,8 +147,8 @@ export function Header() {
</span>
)}
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">
<div className="flex items-center justify-between px-4 py-3 border-b">
<PopoverContent align="end" className="w-80 overflow-hidden rounded-3xl border-[#e0ccb0] bg-[#fffaf2] p-0">
<div className="flex items-center justify-between border-b border-[#eedfc8] px-4 py-3">
<span className="font-medium text-sm"></span>
{unreadCount > 0 && (
<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) => (
<div
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 () => {
if (!n.is_read) await markRead(n.id);
}}
@ -186,15 +200,20 @@ export function Header() {
{user && (
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="ghost" className="flex items-center gap-2">
<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">
<Button
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 ? (
<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] || "?"
)}
</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>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@ -238,6 +257,7 @@ export function Header() {
</DialogContent>
</Dialog>
</div>
</div>
</header>
);
}

View File

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

View File

@ -1,16 +1,32 @@
"use client";
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 {
roles: UserRole[];
roles?: UserRole[];
permissions?: ClassPermission[];
children: 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();
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}</>;
}

View File

@ -5,16 +5,14 @@ import { usePathname } from "next/navigation";
import { useSidebar } from "@/hooks/use-sidebar";
import { useActiveClass } from "@/hooks/use-active-class";
import { cn } from "@/lib/utils";
import type { UserRole } from "@/lib/types";
import type { ClassPermission, UserRole } from "@/lib/types";
import { useAuth } from "@/hooks/use-auth";
// Module keys that can be toggled
const TOGGLEABLE_MODULES = ["announcements", "directory", "timeline", "assignments", "votes", "schedule", "resources", "fund"];
import { hasClassPermission } from "@/lib/permissions";
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: "/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: "/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" },
@ -25,18 +23,26 @@ const navItems = [
];
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/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() {
const pathname = usePathname();
const { isOpen, close } = useSidebar();
const { user } = useAuth();
const { enabledModules } = useActiveClass();
const { enabledModules, activeClassId } = useActiveClass();
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
@ -53,35 +59,35 @@ export function Sidebar() {
{/* Mobile backdrop */}
{isOpen && (
<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}
/>
)}
<aside
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
"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"
)}
>
<div className="p-6 border-b border-gray-200">
<h1 className="text-xl font-bold text-gray-900">HKU ICB</h1>
<p className="text-xs text-gray-500 mt-1"></p>
<div className="border-b border-sidebar-border px-6 pb-6 pt-7">
<h1 className="text-2xl font-semibold tracking-tight text-white"></h1>
<p className="mt-1 text-sm text-white/65">HKU ICB</p>
</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) => (
<Link
key={item.href}
href={item.href}
onClick={close}
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 + "/")
? "bg-gray-900 text-white"
: "text-gray-600 hover:bg-gray-100"
? "bg-gradient-to-r from-[#e7c98d] to-[#d9ae59] text-[#31130f] shadow-[0_12px_28px_-20px_rgba(217,174,89,0.95)]"
: "text-white/72 hover:bg-white/6 hover:text-white"
)}
>
<svg
@ -104,7 +110,7 @@ export function Sidebar() {
{visibleAdminItems.length > 0 && (
<>
<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>
</div>
@ -114,10 +120,10 @@ export function Sidebar() {
href={item.href}
onClick={close}
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)
? "bg-gray-900 text-white"
: "text-gray-600 hover:bg-gray-100"
? "bg-gradient-to-r from-[#e7c98d] to-[#d9ae59] text-[#31130f] shadow-[0_12px_28px_-20px_rgba(217,174,89,0.95)]"
: "text-white/72 hover:bg-white/6 hover:text-white"
)}
>
<svg
@ -140,9 +146,11 @@ export function Sidebar() {
)}
</nav>
<div className="p-4 border-t border-gray-200">
<p className="text-xs text-gray-400">&copy; {new Date().getFullYear()} HKU ICB</p>
<p className="text-xs text-gray-400 mt-0.5">By @ FBM05</p>
<div className="border-t border-sidebar-border px-5 py-4">
<div className="rounded-2xl border border-white/8 bg-white/[0.03] px-4 py-3">
<p className="text-xs text-white/68">&copy; {new Date().getFullYear()} </p>
<p className="mt-1 text-[11px] text-white/42"></p>
</div>
</div>
</aside>
</>

View File

@ -8,11 +8,11 @@ const buttonVariants = cva(
{
variants: {
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:
"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:
"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:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
@ -21,7 +21,7 @@ const buttonVariants = cva(
},
size: {
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",
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",

View File

@ -12,7 +12,7 @@ function Card({
data-slot="card"
data-size={size}
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
)}
{...props}
@ -84,7 +84,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card-footer"
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
)}
{...props}

View File

@ -11,8 +11,30 @@ function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
function DialogTrigger({
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) {

View File

@ -14,8 +14,30 @@ function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
function DropdownMenuTrigger({
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({

View File

@ -9,8 +9,30 @@ function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
function PopoverTrigger({
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({

View File

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

View File

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

View File

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

View File

@ -1,5 +1,20 @@
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 {
if (typeof window === "undefined") return null;
return localStorage.getItem("auth_token");

View File

@ -1,13 +1,41 @@
export const ROLES = {
super_admin: "超级管理员",
class_admin: "班级管理员",
teacher: "老师",
student: "同学",
} 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 = {
pending: "待审核",
approved: "已通过",
rejected: "已拒绝",
inactive: "未激活",
approved: "已激活",
disabled: "已禁用",
} 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 UserStatus = "pending" | "approved" | "rejected" | "disabled";
export type UserRole = "super_admin" | "teacher" | "student";
export type UserStatus = "inactive" | "approved" | "disabled";
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 {
id: number;
@ -9,17 +31,17 @@ export interface AuthUser {
student_id: string | null;
role: UserRole;
status: UserStatus;
class_id: number | null;
industry: string | null;
company: string | null;
position: string | null;
committee_role: string | null;
skills_tags: string[] | null;
wechat_id: string | null;
phone: string | null;
avatar_url: string | null;
bio: string | null;
created_at: string;
memberships: ClassMembership[];
active_membership: ClassMembership | null;
enabled_modules: string[] | null;
}
@ -60,11 +82,13 @@ export interface UserListItem {
student_id: string | null;
role: UserRole;
status: UserStatus;
class_id: number | null;
industry: string | null;
company: string | null;
committee_role: string | null;
class_permissions: ClassPermission[];
created_at: string;
memberships: ClassMembership[];
active_membership: ClassMembership | null;
}
export interface TimelineComment {
@ -150,11 +174,11 @@ export interface NotificationItem {
created_at: string;
}
export interface RosterEntry {
export interface InactiveMemberEntry {
id: number;
student_id: string;
name: string;
status: "unregistered" | "registered";
status: "inactive";
user_id: number | null;
}
@ -239,6 +263,6 @@ export interface FundStatistics {
total_income: number;
total_expense: number;
balance: number;
income_by_category: { category: string; amount: number }[];
expense_by_category: { category: string; amount: number }[];
income_by_category: Record<string, number>;
expense_by_category: Record<string, number>;
}