全面更新
This commit is contained in:
parent
32cd7c5f8c
commit
b8422f0589
@ -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"]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"])
|
||||
@ -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"}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"}
|
||||
@ -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"}
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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),
|
||||
)
|
||||
|
||||
@ -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": "投票已删除"}
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -9,7 +9,6 @@ class LoginRequest(BaseModel):
|
||||
class RegisterRequest(BaseModel):
|
||||
invite_code: str
|
||||
student_id: str
|
||||
name: str
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
25
backend/app/schemas/inactive_member.py
Normal file
25
backend/app/schemas/inactive_member.py
Normal 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,
|
||||
)
|
||||
@ -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": "..."}, ...]
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
208
backend/app/services/member_activation_service.py
Normal file
208
backend/app/services/member_activation_service.py
Normal 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
|
||||
@ -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",
|
||||
)
|
||||
)
|
||||
|
||||
@ -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)
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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">
|
||||
← 返回花名册
|
||||
← 返回成员名录
|
||||
</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">
|
||||
← 返回花名册
|
||||
<Link href="/directory" className="mb-4 inline-block text-sm text-[#896650] hover:text-[#4e1d1a]">
|
||||
← 返回成员名录
|
||||
</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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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]">支持 PDF、Word、Excel、PPT、ZIP 等,最大 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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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 ----------
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
43
frontend/src/app/inactive-account/page.tsx
Normal file
43
frontend/src/app/inactive-account/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
) : (
|
||||
|
||||
@ -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}</>;
|
||||
}
|
||||
|
||||
@ -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">© {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">© {new Date().getFullYear()} 香港大学中国商业学院</p>
|
||||
<p className="mt-1 text-[11px] text-white/42">班级信息管理平台</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
55
frontend/src/lib/permissions.ts
Normal file
55
frontend/src/lib/permissions.ts
Normal 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);
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user