全面更新
This commit is contained in:
parent
32cd7c5f8c
commit
b8422f0589
@ -14,4 +14,4 @@ RUN mkdir -p /app/data
|
|||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"]
|
||||||
|
|||||||
@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import async_engine_from_config
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
from app.db.models import Class_, User, Timeline, Schedule, Announcement, Resource, Notification # noqa: ensure models registered
|
from app.db import models as _models # noqa: F401 ensure all models registered
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||||
|
|||||||
@ -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 fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.deps import require_role
|
from app.core.deps import ensure_class_permission, require_role, resolve_class_id_for_user
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.db.models import User
|
from app.db.models import User
|
||||||
from app.schemas.announcement import AnnouncementCreate, AnnouncementUpdate, AnnouncementOut
|
from app.schemas.announcement import AnnouncementCreate, AnnouncementUpdate, AnnouncementOut
|
||||||
@ -22,12 +22,13 @@ async def get_announcements(
|
|||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 20,
|
page_size: int = 20,
|
||||||
class_id: int | None = None,
|
class_id: int | None = None,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin", "student")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
|
effective_class_id = resolve_class_id_for_user(user, class_id)
|
||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
||||||
|
ensure_class_permission(user, "class_view", effective_class_id)
|
||||||
|
|
||||||
announcements, total = await list_announcements(db, effective_class_id, page, page_size)
|
announcements, total = await list_announcements(db, effective_class_id, page, page_size)
|
||||||
total_pages = (total + page_size - 1) // page_size
|
total_pages = (total + page_size - 1) // page_size
|
||||||
@ -57,12 +58,13 @@ async def get_announcements(
|
|||||||
async def create_new_announcement(
|
async def create_new_announcement(
|
||||||
data: AnnouncementCreate,
|
data: AnnouncementCreate,
|
||||||
class_id: int | None = None,
|
class_id: int | None = None,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
|
effective_class_id = resolve_class_id_for_user(user, class_id)
|
||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
raise HTTPException(status_code=400, detail="You are not assigned to a class")
|
raise HTTPException(status_code=400, detail="You are not assigned to a class")
|
||||||
|
ensure_class_permission(user, "announcement_manage", effective_class_id)
|
||||||
|
|
||||||
announcement = await create_announcement(db, effective_class_id, user.id, data)
|
announcement = await create_announcement(db, effective_class_id, user.id, data)
|
||||||
return AnnouncementOut(
|
return AnnouncementOut(
|
||||||
@ -82,14 +84,13 @@ async def create_new_announcement(
|
|||||||
async def update_existing_announcement(
|
async def update_existing_announcement(
|
||||||
announcement_id: int,
|
announcement_id: int,
|
||||||
data: AnnouncementUpdate,
|
data: AnnouncementUpdate,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
announcement = await get_announcement_by_id(db, announcement_id)
|
announcement = await get_announcement_by_id(db, announcement_id)
|
||||||
if announcement is None:
|
if announcement is None:
|
||||||
raise HTTPException(status_code=404, detail="Announcement not found")
|
raise HTTPException(status_code=404, detail="Announcement not found")
|
||||||
if user.role != "super_admin" and announcement.class_id != user.class_id:
|
ensure_class_permission(user, "announcement_manage", announcement.class_id)
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
|
||||||
|
|
||||||
updated = await update_announcement(db, announcement, data)
|
updated = await update_announcement(db, announcement, data)
|
||||||
return AnnouncementOut(
|
return AnnouncementOut(
|
||||||
@ -108,14 +109,13 @@ async def update_existing_announcement(
|
|||||||
@router.delete("/{announcement_id}")
|
@router.delete("/{announcement_id}")
|
||||||
async def delete_existing_announcement(
|
async def delete_existing_announcement(
|
||||||
announcement_id: int,
|
announcement_id: int,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
announcement = await get_announcement_by_id(db, announcement_id)
|
announcement = await get_announcement_by_id(db, announcement_id)
|
||||||
if announcement is None:
|
if announcement is None:
|
||||||
raise HTTPException(status_code=404, detail="Announcement not found")
|
raise HTTPException(status_code=404, detail="Announcement not found")
|
||||||
if user.role != "super_admin" and announcement.class_id != user.class_id:
|
ensure_class_permission(user, "announcement_manage", announcement.class_id)
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
|
||||||
|
|
||||||
await delete_announcement(db, announcement)
|
await delete_announcement(db, announcement)
|
||||||
return {"message": "Announcement deleted"}
|
return {"message": "Announcement deleted"}
|
||||||
|
|||||||
@ -3,7 +3,12 @@ from sqlalchemy import select, func
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.core.deps import require_role
|
from app.core.deps import (
|
||||||
|
ensure_class_permission,
|
||||||
|
get_effective_class_permissions,
|
||||||
|
require_role,
|
||||||
|
resolve_class_id_for_user,
|
||||||
|
)
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.db.models import User, Class_
|
from app.db.models import User, Class_
|
||||||
from app.schemas.assignment import (
|
from app.schemas.assignment import (
|
||||||
@ -28,10 +33,11 @@ from app.services.cos_service import upload_file
|
|||||||
router = APIRouter(prefix="/api/assignments", tags=["assignments"])
|
router = APIRouter(prefix="/api/assignments", tags=["assignments"])
|
||||||
|
|
||||||
|
|
||||||
async def _get_roster_count(db: AsyncSession, class_id: int) -> int:
|
async def _get_member_count(db: AsyncSession, class_id: int) -> int:
|
||||||
from app.db.models import StudentRoster
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(func.count(StudentRoster.id)).where(StudentRoster.class_id == class_id)
|
select(func.count(User.id))
|
||||||
|
.join(Class_.memberships)
|
||||||
|
.where(Class_.id == class_id)
|
||||||
)
|
)
|
||||||
return result.scalar() or 0
|
return result.scalar() or 0
|
||||||
|
|
||||||
@ -81,17 +87,18 @@ async def get_assignments(
|
|||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 20,
|
page_size: int = 20,
|
||||||
class_id: int | None = None,
|
class_id: int | None = None,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin", "student")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
|
effective_class_id = resolve_class_id_for_user(user, class_id)
|
||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
||||||
|
ensure_class_permission(user, "class_view", effective_class_id)
|
||||||
|
|
||||||
assignments, total = await list_assignments(db, effective_class_id, page, page_size)
|
assignments, total = await list_assignments(db, effective_class_id, page, page_size)
|
||||||
total_pages = (total + page_size - 1) // page_size
|
total_pages = (total + page_size - 1) // page_size
|
||||||
roster_count = await _get_roster_count(db, effective_class_id)
|
member_count = await _get_member_count(db, effective_class_id)
|
||||||
items = [_build_assignment_out(a, user.id, roster_count) for a in assignments]
|
items = [_build_assignment_out(a, user.id, member_count) for a in assignments]
|
||||||
return PageResponse(items=items, total=total, page=page, page_size=page_size, total_pages=total_pages)
|
return PageResponse(items=items, total=total, page=page, page_size=page_size, total_pages=total_pages)
|
||||||
|
|
||||||
|
|
||||||
@ -99,30 +106,30 @@ async def get_assignments(
|
|||||||
async def create_new_assignment(
|
async def create_new_assignment(
|
||||||
data: AssignmentCreate,
|
data: AssignmentCreate,
|
||||||
class_id: int | None = None,
|
class_id: int | None = None,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
|
effective_class_id = resolve_class_id_for_user(user, class_id)
|
||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
raise HTTPException(status_code=400, detail="You are not assigned to a class")
|
raise HTTPException(status_code=400, detail="You are not assigned to a class")
|
||||||
|
ensure_class_permission(user, "assignment_manage", effective_class_id)
|
||||||
|
|
||||||
assignment = await create_assignment(db, effective_class_id, user.id, data)
|
assignment = await create_assignment(db, effective_class_id, user.id, data)
|
||||||
roster_count = await _get_roster_count(db, effective_class_id)
|
member_count = await _get_member_count(db, effective_class_id)
|
||||||
return _build_assignment_out(assignment, user.id, roster_count)
|
return _build_assignment_out(assignment, user.id, member_count)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{assignment_id}/attachments")
|
@router.post("/{assignment_id}/attachments")
|
||||||
async def upload_assignment_attachments(
|
async def upload_assignment_attachments(
|
||||||
assignment_id: int,
|
assignment_id: int,
|
||||||
files: list[UploadFile] = File(...),
|
files: list[UploadFile] = File(...),
|
||||||
user: User = Depends(require_role("super_admin", "class_admin")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
assignment = await get_assignment_by_id(db, assignment_id)
|
assignment = await get_assignment_by_id(db, assignment_id)
|
||||||
if assignment is None:
|
if assignment is None:
|
||||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||||
if user.role != "super_admin" and assignment.class_id != user.class_id:
|
ensure_class_permission(user, "assignment_manage", assignment.class_id)
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
|
||||||
|
|
||||||
urls = []
|
urls = []
|
||||||
for f in files:
|
for f in files:
|
||||||
@ -142,19 +149,18 @@ async def upload_assignment_attachments(
|
|||||||
@router.get("/{assignment_id}", response_model=AssignmentDetailOut)
|
@router.get("/{assignment_id}", response_model=AssignmentDetailOut)
|
||||||
async def get_assignment_detail(
|
async def get_assignment_detail(
|
||||||
assignment_id: int,
|
assignment_id: int,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin", "student")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
assignment = await get_assignment_by_id(db, assignment_id)
|
assignment = await get_assignment_by_id(db, assignment_id)
|
||||||
if assignment is None:
|
if assignment is None:
|
||||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||||
if user.role != "super_admin" and assignment.class_id != user.class_id:
|
ensure_class_permission(user, "class_view", assignment.class_id)
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
|
||||||
|
|
||||||
base = _build_assignment_out(assignment, user.id, await _get_roster_count(db, assignment.class_id))
|
base = _build_assignment_out(assignment, user.id, await _get_member_count(db, assignment.class_id))
|
||||||
|
|
||||||
# Student only sees their own submission
|
# Student only sees their own submission
|
||||||
if user.role == "student":
|
if "assignment_manage" not in get_effective_class_permissions(user, assignment.class_id) and user.role == "student":
|
||||||
my_submission = None
|
my_submission = None
|
||||||
for s in (assignment.submissions or []):
|
for s in (assignment.submissions or []):
|
||||||
if s.student_id == user.id:
|
if s.student_id == user.id:
|
||||||
@ -171,30 +177,28 @@ async def get_assignment_detail(
|
|||||||
async def update_existing_assignment(
|
async def update_existing_assignment(
|
||||||
assignment_id: int,
|
assignment_id: int,
|
||||||
data: AssignmentUpdate,
|
data: AssignmentUpdate,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
assignment = await get_assignment_by_id(db, assignment_id)
|
assignment = await get_assignment_by_id(db, assignment_id)
|
||||||
if assignment is None:
|
if assignment is None:
|
||||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||||
if user.role != "super_admin" and assignment.class_id != user.class_id:
|
ensure_class_permission(user, "assignment_manage", assignment.class_id)
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
|
||||||
|
|
||||||
updated = await update_assignment(db, assignment, data)
|
updated = await update_assignment(db, assignment, data)
|
||||||
return _build_assignment_out(updated, user.id, await _get_roster_count(db, updated.class_id))
|
return _build_assignment_out(updated, user.id, await _get_member_count(db, updated.class_id))
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{assignment_id}")
|
@router.delete("/{assignment_id}")
|
||||||
async def delete_existing_assignment(
|
async def delete_existing_assignment(
|
||||||
assignment_id: int,
|
assignment_id: int,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
assignment = await get_assignment_by_id(db, assignment_id)
|
assignment = await get_assignment_by_id(db, assignment_id)
|
||||||
if assignment is None:
|
if assignment is None:
|
||||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||||
if user.role != "super_admin" and assignment.class_id != user.class_id:
|
ensure_class_permission(user, "assignment_manage", assignment.class_id)
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
|
||||||
|
|
||||||
await delete_assignment(db, assignment)
|
await delete_assignment(db, assignment)
|
||||||
return {"message": "Assignment deleted"}
|
return {"message": "Assignment deleted"}
|
||||||
@ -205,14 +209,13 @@ async def submit_assignment(
|
|||||||
assignment_id: int,
|
assignment_id: int,
|
||||||
notes: str = "",
|
notes: str = "",
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
user: User = Depends(require_role("super_admin", "class_admin", "student")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
assignment = await get_assignment_by_id(db, assignment_id)
|
assignment = await get_assignment_by_id(db, assignment_id)
|
||||||
if assignment is None:
|
if assignment is None:
|
||||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||||
if user.role != "super_admin" and assignment.class_id != user.class_id:
|
ensure_class_permission(user, "class_view", assignment.class_id)
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
|
||||||
|
|
||||||
# Upload file
|
# Upload file
|
||||||
file_url = None
|
file_url = None
|
||||||
@ -250,7 +253,7 @@ async def submit_assignment(
|
|||||||
async def grade_assignment_submission(
|
async def grade_assignment_submission(
|
||||||
submission_id: int,
|
submission_id: int,
|
||||||
data: SubmissionGrade,
|
data: SubmissionGrade,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@ -262,6 +265,10 @@ async def grade_assignment_submission(
|
|||||||
submission = result.scalar_one_or_none()
|
submission = result.scalar_one_or_none()
|
||||||
if submission is None:
|
if submission is None:
|
||||||
raise HTTPException(status_code=404, detail="Submission not found")
|
raise HTTPException(status_code=404, detail="Submission not found")
|
||||||
|
assignment = await get_assignment_by_id(db, submission.assignment_id)
|
||||||
|
if assignment is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||||
|
ensure_class_permission(user, "assignment_manage", assignment.class_id)
|
||||||
|
|
||||||
graded = await grade_submission(db, submission, data)
|
graded = await grade_submission(db, submission, data)
|
||||||
|
|
||||||
|
|||||||
@ -1,90 +1,98 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.core.auth import hash_password, verify_password, create_access_token
|
from app.core.auth import hash_password, verify_password, create_access_token
|
||||||
from app.core.deps import get_current_user
|
from app.core.deps import get_current_user
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.db.models import User
|
from app.db.models import ClassMembership, User
|
||||||
from app.schemas.auth import LoginRequest, RegisterRequest, ChangePasswordRequest
|
from app.schemas.auth import LoginRequest, RegisterRequest, ChangePasswordRequest
|
||||||
from app.schemas.user import TokenResponse, UserOut
|
from app.schemas.user import TokenResponse, UserOut, build_user_out
|
||||||
from app.services.roster_service import validate_registration
|
from app.services.member_activation_service import validate_registration
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register")
|
@router.post("/activate")
|
||||||
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
async def activate_account(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||||
# 1. Check if email is already registered
|
# 1. Check if email is already in use
|
||||||
existing = await db.execute(select(User).where(User.email == req.email))
|
existing = await db.execute(select(User).where(User.email == req.email))
|
||||||
if existing.scalar_one_or_none():
|
if existing.scalar_one_or_none():
|
||||||
raise HTTPException(status_code=400, detail="该邮箱已注册")
|
raise HTTPException(status_code=400, detail="该邮箱已注册")
|
||||||
|
|
||||||
# 2. Validate invite_code + student_id against roster
|
# 2. Validate invite_code + student_id against inactive class member
|
||||||
roster_entry = await validate_registration(db, req.invite_code, req.student_id)
|
activation_target = await validate_registration(db, req.invite_code, req.student_id)
|
||||||
if roster_entry is None:
|
if activation_target is None:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=400, detail="邀请码或学号无效,或账号已激活")
|
||||||
status_code=400, detail="邀请码或学号无效,或该学号已注册"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 3. Create user with approved status directly
|
user, class_id = activation_target
|
||||||
user = User(
|
user.email = req.email
|
||||||
email=req.email,
|
user.password_hash = hash_password(req.password)
|
||||||
password_hash=hash_password(req.password),
|
user.status = "approved"
|
||||||
name=req.name,
|
|
||||||
student_id=req.student_id,
|
|
||||||
role="student",
|
|
||||||
status="approved",
|
|
||||||
class_id=roster_entry.class_id,
|
|
||||||
)
|
|
||||||
db.add(user)
|
|
||||||
await db.flush()
|
|
||||||
|
|
||||||
# 4. Mark roster entry as registered
|
|
||||||
roster_entry.status = "registered"
|
|
||||||
roster_entry.user_id = user.id
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
result = await db.execute(
|
||||||
|
select(User)
|
||||||
|
.options(
|
||||||
|
selectinload(User.memberships),
|
||||||
|
selectinload(User.memberships).selectinload(ClassMembership.class_),
|
||||||
|
)
|
||||||
|
.where(User.id == user.id)
|
||||||
|
)
|
||||||
|
user = result.scalar_one()
|
||||||
|
user.set_active_membership(class_id)
|
||||||
|
|
||||||
# 5. Issue token — register and login in one step
|
# 3. Issue token — activation and login in one step
|
||||||
token = create_access_token({"sub": str(user.id), "role": user.role})
|
token = create_access_token({"sub": str(user.id), "role": user.role})
|
||||||
return {
|
return {
|
||||||
"message": "注册成功",
|
"message": "账号激活成功",
|
||||||
"token": token,
|
"token": token,
|
||||||
"user": UserOut.model_validate(user),
|
"user": build_user_out(user, class_id),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=TokenResponse)
|
@router.post("/login", response_model=TokenResponse)
|
||||||
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
|
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||||
result = await db.execute(select(User).where(User.email == req.email))
|
result = await db.execute(
|
||||||
|
select(User)
|
||||||
|
.options(
|
||||||
|
selectinload(User.memberships),
|
||||||
|
selectinload(User.memberships).selectinload(ClassMembership.class_),
|
||||||
|
)
|
||||||
|
.where(User.email == req.email)
|
||||||
|
)
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=401, detail="邮箱或密码错误")
|
raise HTTPException(status_code=401, detail="邮箱或密码错误")
|
||||||
|
|
||||||
|
if user.status == "inactive":
|
||||||
|
raise HTTPException(status_code=401, detail="账号尚未激活")
|
||||||
|
|
||||||
if user.status == "disabled":
|
if user.status == "disabled":
|
||||||
raise HTTPException(status_code=401, detail="账号已被禁用")
|
raise HTTPException(status_code=401, detail="账号已被禁用")
|
||||||
|
|
||||||
if not verify_password(req.password, user.password_hash):
|
if not user.password_hash or not verify_password(req.password, user.password_hash):
|
||||||
raise HTTPException(status_code=401, detail="邮箱或密码错误")
|
raise HTTPException(status_code=401, detail="邮箱或密码错误")
|
||||||
|
|
||||||
token = create_access_token({"sub": str(user.id), "role": user.role})
|
token = create_access_token({"sub": str(user.id), "role": user.role})
|
||||||
return TokenResponse(
|
return TokenResponse(
|
||||||
token=token,
|
token=token,
|
||||||
user=UserOut.model_validate(user),
|
user=build_user_out(user),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserOut)
|
@router.get("/me", response_model=UserOut)
|
||||||
async def get_me(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
async def get_me(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||||
user_out = UserOut.model_validate(user)
|
default_membership = user.get_default_membership()
|
||||||
|
user_out = build_user_out(user, default_membership.class_id if default_membership else None)
|
||||||
|
|
||||||
# Attach enabled_modules from user's class
|
# Attach enabled_modules from active class
|
||||||
if user.class_id:
|
if default_membership:
|
||||||
from app.db.models import Class_
|
from app.db.models import Class_
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
result = await db.execute(select(Class_).where(Class_.id == user.class_id))
|
result = await db.execute(select(Class_).where(Class_.id == default_membership.class_id))
|
||||||
class_ = result.scalar_one_or_none()
|
class_ = result.scalar_one_or_none()
|
||||||
if class_:
|
if class_:
|
||||||
user_out.enabled_modules = class_.get_enabled_modules()
|
user_out.enabled_modules = class_.get_enabled_modules()
|
||||||
@ -98,7 +106,7 @@ async def change_password(
|
|||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
if not verify_password(req.old_password, user.password_hash):
|
if not user.password_hash or not verify_password(req.old_password, user.password_hash):
|
||||||
raise HTTPException(status_code=400, detail="Old password is incorrect")
|
raise HTTPException(status_code=400, detail="Old password is incorrect")
|
||||||
user.password_hash = hash_password(req.new_password)
|
user.password_hash = hash_password(req.new_password)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
@ -4,12 +4,20 @@ import io
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.deps import require_role
|
from app.core.deps import (
|
||||||
|
ensure_class_permission,
|
||||||
|
get_current_user,
|
||||||
|
require_role,
|
||||||
|
)
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.db.models import User
|
from app.db.models import User
|
||||||
from app.schemas.class_ import ClassCreate, ClassUpdate, ClassOut, ModuleUpdate
|
from app.schemas.class_ import ClassCreate, ClassUpdate, ClassOut, ModuleUpdate
|
||||||
from app.schemas.user import UserListItem
|
from app.schemas.user import UserListItem, build_user_list_item
|
||||||
from app.schemas.roster import RosterOut, RosterImportRequest
|
from app.schemas.inactive_member import (
|
||||||
|
InactiveMemberOut,
|
||||||
|
MemberImportRequest,
|
||||||
|
build_inactive_member_out,
|
||||||
|
)
|
||||||
from app.schemas.common import PageResponse
|
from app.schemas.common import PageResponse
|
||||||
from app.services.class_service import (
|
from app.services.class_service import (
|
||||||
create_class,
|
create_class,
|
||||||
@ -20,13 +28,13 @@ from app.services.class_service import (
|
|||||||
get_member_count,
|
get_member_count,
|
||||||
get_class_members,
|
get_class_members,
|
||||||
)
|
)
|
||||||
from app.services.roster_service import (
|
from app.services.member_activation_service import (
|
||||||
ensure_invite_code,
|
ensure_invite_code,
|
||||||
regenerate_invite_code,
|
regenerate_invite_code,
|
||||||
import_roster,
|
import_members,
|
||||||
get_roster,
|
get_inactive_members,
|
||||||
delete_roster_entry,
|
delete_inactive_member,
|
||||||
clear_unregistered_roster,
|
clear_inactive_members,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/classes", tags=["classes"])
|
router = APIRouter(prefix="/api/classes", tags=["classes"])
|
||||||
@ -36,8 +44,21 @@ router = APIRouter(prefix="/api/classes", tags=["classes"])
|
|||||||
async def get_classes(
|
async def get_classes(
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 50,
|
page_size: int = 50,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
if user.role not in {"super_admin", "teacher"}:
|
||||||
|
membership = user.get_default_membership()
|
||||||
|
if membership is None:
|
||||||
|
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
||||||
|
class_ = await get_class_by_id(db, membership.class_id)
|
||||||
|
if class_ is None:
|
||||||
|
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
||||||
|
count = await get_member_count(db, class_.id)
|
||||||
|
out = ClassOut.model_validate(class_)
|
||||||
|
out.member_count = count
|
||||||
|
return PageResponse(items=[out], total=1, page=1, page_size=page_size, total_pages=1)
|
||||||
|
|
||||||
classes, total = await list_classes(db, page, page_size)
|
classes, total = await list_classes(db, page, page_size)
|
||||||
total_pages = (total + page_size - 1) // page_size
|
total_pages = (total + page_size - 1) // page_size
|
||||||
result = []
|
result = []
|
||||||
@ -98,16 +119,15 @@ async def get_members(
|
|||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 50,
|
page_size: int = 50,
|
||||||
admin: User = Depends(require_role("super_admin", "class_admin")),
|
admin: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
if admin.role == "class_admin" and admin.class_id != class_id:
|
ensure_class_permission(admin, "member_view", class_id)
|
||||||
raise HTTPException(status_code=403, detail="Access denied for this class")
|
|
||||||
|
|
||||||
members, total = await get_class_members(db, class_id, status, page, page_size)
|
members, total = await get_class_members(db, class_id, status, page, page_size)
|
||||||
total_pages = (total + page_size - 1) // page_size
|
total_pages = (total + page_size - 1) // page_size
|
||||||
return PageResponse(
|
return PageResponse(
|
||||||
items=[UserListItem.model_validate(m) for m in members],
|
items=[build_user_list_item(m, class_id) for m in members],
|
||||||
total=total,
|
total=total,
|
||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
@ -115,23 +135,22 @@ async def get_members(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- Roster management ---
|
# --- Inactive member management ---
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{class_id}/roster", response_model=PageResponse[RosterOut])
|
@router.get("/{class_id}/inactive-members", response_model=PageResponse[InactiveMemberOut])
|
||||||
async def get_class_roster(
|
async def get_class_inactive_members(
|
||||||
class_id: int,
|
class_id: int,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 50,
|
page_size: int = 50,
|
||||||
admin: User = Depends(require_role("super_admin", "class_admin")),
|
admin: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
if admin.role == "class_admin" and admin.class_id != class_id:
|
ensure_class_permission(admin, "member_manage", class_id)
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
entries, total = await get_inactive_members(db, class_id, page, page_size)
|
||||||
entries, total = await get_roster(db, class_id, page, page_size)
|
|
||||||
total_pages = (total + page_size - 1) // page_size
|
total_pages = (total + page_size - 1) // page_size
|
||||||
return PageResponse(
|
return PageResponse(
|
||||||
items=[RosterOut.model_validate(e) for e in entries],
|
items=[build_inactive_member_out(entry) for entry in entries],
|
||||||
total=total,
|
total=total,
|
||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
@ -139,28 +158,26 @@ async def get_class_roster(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{class_id}/roster/import")
|
@router.post("/{class_id}/inactive-members/import")
|
||||||
async def import_class_roster(
|
async def import_class_members(
|
||||||
class_id: int,
|
class_id: int,
|
||||||
data: RosterImportRequest,
|
data: MemberImportRequest,
|
||||||
admin: User = Depends(require_role("super_admin", "class_admin")),
|
admin: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
if admin.role == "class_admin" and admin.class_id != class_id:
|
ensure_class_permission(admin, "member_manage", class_id)
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
count = await import_members(db, class_id, data.entries)
|
||||||
count = await import_roster(db, class_id, data.entries)
|
return {"message": f"成功导入 {count} 位成员"}
|
||||||
return {"message": f"成功导入 {count} 条记录"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{class_id}/roster/upload")
|
@router.post("/{class_id}/inactive-members/upload")
|
||||||
async def upload_roster_file(
|
async def upload_member_file(
|
||||||
class_id: int,
|
class_id: int,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
admin: User = Depends(require_role("super_admin", "class_admin")),
|
admin: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
if admin.role == "class_admin" and admin.class_id != class_id:
|
ensure_class_permission(admin, "member_manage", class_id)
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
|
||||||
|
|
||||||
contents = await file.read()
|
contents = await file.read()
|
||||||
filename = file.filename or ""
|
filename = file.filename or ""
|
||||||
@ -214,33 +231,33 @@ async def upload_roster_file(
|
|||||||
if not entries:
|
if not entries:
|
||||||
raise HTTPException(status_code=400, detail="未找到有效数据")
|
raise HTTPException(status_code=400, detail="未找到有效数据")
|
||||||
|
|
||||||
count = await import_roster(db, class_id, entries)
|
count = await import_members(db, class_id, entries)
|
||||||
return {"message": f"成功导入 {count} 条记录"}
|
return {"message": f"成功导入 {count} 位成员"}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{class_id}/roster/{roster_id}")
|
@router.delete("/{class_id}/inactive-members/{user_id}")
|
||||||
async def delete_roster_item(
|
async def delete_inactive_member_item(
|
||||||
class_id: int,
|
class_id: int,
|
||||||
roster_id: int,
|
user_id: int,
|
||||||
admin: User = Depends(require_role("super_admin", "class_admin")),
|
admin: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
success = await delete_roster_entry(db, roster_id)
|
ensure_class_permission(admin, "member_manage", class_id)
|
||||||
|
success = await delete_inactive_member(db, class_id, user_id)
|
||||||
if not success:
|
if not success:
|
||||||
raise HTTPException(status_code=400, detail="无法删除(已注册或不存在)")
|
raise HTTPException(status_code=400, detail="无法删除(已激活、已加入其他班级或不存在)")
|
||||||
return {"message": "已删除"}
|
return {"message": "已删除"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{class_id}/roster/clear")
|
@router.post("/{class_id}/inactive-members/clear")
|
||||||
async def clear_roster(
|
async def clear_class_inactive_members(
|
||||||
class_id: int,
|
class_id: int,
|
||||||
admin: User = Depends(require_role("super_admin", "class_admin")),
|
admin: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
if admin.role == "class_admin" and admin.class_id != class_id:
|
ensure_class_permission(admin, "member_manage", class_id)
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
count = await clear_inactive_members(db, class_id)
|
||||||
count = await clear_unregistered_roster(db, class_id)
|
return {"message": f"已清除 {count} 位未激活成员"}
|
||||||
return {"message": f"已清除 {count} 条未注册记录"}
|
|
||||||
|
|
||||||
|
|
||||||
# --- Invite code management ---
|
# --- Invite code management ---
|
||||||
@ -249,9 +266,10 @@ async def clear_roster(
|
|||||||
@router.get("/{class_id}/invite-code")
|
@router.get("/{class_id}/invite-code")
|
||||||
async def get_invite_code(
|
async def get_invite_code(
|
||||||
class_id: int,
|
class_id: int,
|
||||||
admin: User = Depends(require_role("super_admin", "class_admin")),
|
admin: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
ensure_class_permission(admin, "member_manage", class_id)
|
||||||
code = await ensure_invite_code(db, class_id)
|
code = await ensure_invite_code(db, class_id)
|
||||||
if not code:
|
if not code:
|
||||||
raise HTTPException(status_code=404, detail="Class not found")
|
raise HTTPException(status_code=404, detail="Class not found")
|
||||||
@ -261,9 +279,10 @@ async def get_invite_code(
|
|||||||
@router.post("/{class_id}/invite-code/regenerate")
|
@router.post("/{class_id}/invite-code/regenerate")
|
||||||
async def regenerate_invite(
|
async def regenerate_invite(
|
||||||
class_id: int,
|
class_id: int,
|
||||||
admin: User = Depends(require_role("super_admin", "class_admin")),
|
admin: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
ensure_class_permission(admin, "member_manage", class_id)
|
||||||
code = await regenerate_invite_code(db, class_id)
|
code = await regenerate_invite_code(db, class_id)
|
||||||
if not code:
|
if not code:
|
||||||
raise HTTPException(status_code=404, detail="Class not found")
|
raise HTTPException(status_code=404, detail="Class not found")
|
||||||
@ -276,11 +295,10 @@ async def regenerate_invite(
|
|||||||
@router.get("/{class_id}/modules")
|
@router.get("/{class_id}/modules")
|
||||||
async def get_class_modules(
|
async def get_class_modules(
|
||||||
class_id: int,
|
class_id: int,
|
||||||
admin: User = Depends(require_role("super_admin", "class_admin")),
|
admin: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
if admin.role == "class_admin" and admin.class_id != class_id:
|
ensure_class_permission(admin, "module_manage", class_id)
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
|
||||||
|
|
||||||
class_ = await get_class_by_id(db, class_id)
|
class_ = await get_class_by_id(db, class_id)
|
||||||
if class_ is None:
|
if class_ is None:
|
||||||
@ -297,11 +315,10 @@ async def get_class_modules(
|
|||||||
async def update_class_modules(
|
async def update_class_modules(
|
||||||
class_id: int,
|
class_id: int,
|
||||||
data: ModuleUpdate,
|
data: ModuleUpdate,
|
||||||
admin: User = Depends(require_role("super_admin", "class_admin")),
|
admin: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
if admin.role == "class_admin" and admin.class_id != class_id:
|
ensure_class_permission(admin, "module_manage", class_id)
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
|
||||||
|
|
||||||
class_ = await get_class_by_id(db, class_id)
|
class_ = await get_class_by_id(db, class_id)
|
||||||
if class_ is None:
|
if class_ is None:
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.deps import get_current_user
|
from app.core.deps import ensure_class_permission, get_current_user, resolve_class_id_for_user
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.db.models import User
|
from app.db.models import User
|
||||||
from app.schemas.user import UserPublic
|
from app.schemas.user import UserPublic
|
||||||
@ -23,18 +23,21 @@ async def search_members(
|
|||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
# Determine effective class_id: super_admin can specify one, others use their own
|
effective_class_id = resolve_class_id_for_user(user, class_id)
|
||||||
effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
|
|
||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
||||||
|
ensure_class_permission(user, "class_view", effective_class_id)
|
||||||
|
|
||||||
users, total = await search_directory(
|
users, total = await search_directory(
|
||||||
db, effective_class_id, search, industry, company, page, page_size
|
db, effective_class_id, search, industry, company, page, page_size
|
||||||
)
|
)
|
||||||
total_pages = (total + page_size - 1) // page_size
|
total_pages = (total + page_size - 1) // page_size
|
||||||
include_contact = True # Same class, approved users can see contact
|
include_contact = True # Same class, active members can see contact
|
||||||
return PageResponse(
|
return PageResponse(
|
||||||
items=[user_to_public(u, include_contact=include_contact) for u in users],
|
items=[
|
||||||
|
user_to_public(u, effective_class_id, include_contact=include_contact)
|
||||||
|
for u in users
|
||||||
|
],
|
||||||
total=total,
|
total=total,
|
||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
@ -53,5 +56,11 @@ async def get_member_detail(
|
|||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
# Privacy: only show contact info to same-class members
|
# Privacy: only show contact info to same-class members
|
||||||
include_contact = user.class_id == target.class_id
|
shared_class_ids = {
|
||||||
return user_to_public(target, include_contact=include_contact)
|
membership.class_id for membership in user.memberships
|
||||||
|
} & {
|
||||||
|
membership.class_id for membership in target.memberships
|
||||||
|
}
|
||||||
|
include_contact = bool(shared_class_ids)
|
||||||
|
scoped_class_id = next(iter(shared_class_ids), None)
|
||||||
|
return user_to_public(target, scoped_class_id, include_contact=include_contact)
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.deps import require_role
|
from app.core.deps import ensure_class_permission, require_role, resolve_class_id_for_user
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.db.models import FundRecord, User
|
from app.db.models import FundRecord, User
|
||||||
from app.schemas.fund import FundRecordCreate, FundRecordUpdate, FundRecordOut, FundStatistics
|
from app.schemas.fund import FundRecordCreate, FundRecordUpdate, FundRecordOut, FundStatistics
|
||||||
@ -33,15 +33,16 @@ def record_to_out(record: FundRecord) -> FundRecordOut:
|
|||||||
@router.get("/statistics", response_model=FundStatistics)
|
@router.get("/statistics", response_model=FundStatistics)
|
||||||
async def get_statistics(
|
async def get_statistics(
|
||||||
class_id: int | None = None,
|
class_id: int | None = None,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin", "student")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
|
effective_class_id = resolve_class_id_for_user(user, class_id)
|
||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
return FundStatistics(
|
return FundStatistics(
|
||||||
total_income=0, total_expense=0, balance=0,
|
total_income=0, total_expense=0, balance=0,
|
||||||
income_by_category=[], expense_by_category=[]
|
income_by_category=[], expense_by_category=[]
|
||||||
)
|
)
|
||||||
|
ensure_class_permission(user, "class_view", effective_class_id)
|
||||||
return await get_fund_statistics(db, effective_class_id)
|
return await get_fund_statistics(db, effective_class_id)
|
||||||
|
|
||||||
|
|
||||||
@ -52,12 +53,13 @@ async def get_fund_records(
|
|||||||
type: str | None = None,
|
type: str | None = None,
|
||||||
category: str | None = None,
|
category: str | None = None,
|
||||||
class_id: int | None = None,
|
class_id: int | None = None,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin", "student")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
|
effective_class_id = resolve_class_id_for_user(user, class_id)
|
||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
||||||
|
ensure_class_permission(user, "class_view", effective_class_id)
|
||||||
|
|
||||||
records, total = await list_fund_records(db, effective_class_id, page, page_size, type, category)
|
records, total = await list_fund_records(db, effective_class_id, page, page_size, type, category)
|
||||||
total_pages = (total + page_size - 1) // page_size
|
total_pages = (total + page_size - 1) // page_size
|
||||||
@ -69,12 +71,13 @@ async def get_fund_records(
|
|||||||
async def create_new_record(
|
async def create_new_record(
|
||||||
data: FundRecordCreate,
|
data: FundRecordCreate,
|
||||||
class_id: int | None = None,
|
class_id: int | None = None,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
|
effective_class_id = resolve_class_id_for_user(user, class_id)
|
||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
raise HTTPException(status_code=400, detail="No class specified")
|
raise HTTPException(status_code=400, detail="No class specified")
|
||||||
|
ensure_class_permission(user, "fund_manage", effective_class_id)
|
||||||
|
|
||||||
if data.type not in ("income", "expense"):
|
if data.type not in ("income", "expense"):
|
||||||
raise HTTPException(status_code=400, detail="Type must be 'income' or 'expense'")
|
raise HTTPException(status_code=400, detail="Type must be 'income' or 'expense'")
|
||||||
@ -91,14 +94,13 @@ async def create_new_record(
|
|||||||
async def update_existing_record(
|
async def update_existing_record(
|
||||||
record_id: int,
|
record_id: int,
|
||||||
data: FundRecordUpdate,
|
data: FundRecordUpdate,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
record = await get_fund_record_by_id(db, record_id)
|
record = await get_fund_record_by_id(db, record_id)
|
||||||
if record is None:
|
if record is None:
|
||||||
raise HTTPException(status_code=404, detail="Record not found")
|
raise HTTPException(status_code=404, detail="Record not found")
|
||||||
if user.role != "super_admin" and record.class_id != user.class_id:
|
ensure_class_permission(user, "fund_manage", record.class_id)
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
|
||||||
|
|
||||||
if data.type is not None and data.type not in ("income", "expense"):
|
if data.type is not None and data.type not in ("income", "expense"):
|
||||||
raise HTTPException(status_code=400, detail="Type must be 'income' or 'expense'")
|
raise HTTPException(status_code=400, detail="Type must be 'income' or 'expense'")
|
||||||
@ -114,14 +116,13 @@ async def update_existing_record(
|
|||||||
@router.delete("/{record_id}")
|
@router.delete("/{record_id}")
|
||||||
async def delete_existing_record(
|
async def delete_existing_record(
|
||||||
record_id: int,
|
record_id: int,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
record = await get_fund_record_by_id(db, record_id)
|
record = await get_fund_record_by_id(db, record_id)
|
||||||
if record is None:
|
if record is None:
|
||||||
raise HTTPException(status_code=404, detail="Record not found")
|
raise HTTPException(status_code=404, detail="Record not found")
|
||||||
if user.role != "super_admin" and record.class_id != user.class_id:
|
ensure_class_permission(user, "fund_manage", record.class_id)
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
|
||||||
|
|
||||||
await delete_fund_record(db, record)
|
await delete_fund_record(db, record)
|
||||||
return {"message": "Record deleted"}
|
return {"message": "Record deleted"}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.deps import require_role
|
from app.core.deps import ensure_class_permission, require_role, resolve_class_id_for_user
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.db.models import User
|
from app.db.models import User
|
||||||
from app.schemas.resource import ResourceCreate, ResourceOut
|
from app.schemas.resource import ResourceCreate, ResourceOut
|
||||||
@ -40,12 +40,13 @@ async def get_resources(
|
|||||||
page_size: int = 20,
|
page_size: int = 20,
|
||||||
category: str | None = None,
|
category: str | None = None,
|
||||||
class_id: int | None = None,
|
class_id: int | None = None,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin", "student")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
|
effective_class_id = resolve_class_id_for_user(user, class_id)
|
||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
||||||
|
ensure_class_permission(user, "class_view", effective_class_id)
|
||||||
|
|
||||||
resources, total = await list_resources(db, effective_class_id, category, page, page_size)
|
resources, total = await list_resources(db, effective_class_id, category, page, page_size)
|
||||||
total_pages = (total + page_size - 1) // page_size
|
total_pages = (total + page_size - 1) // page_size
|
||||||
@ -81,12 +82,13 @@ async def upload_new_resource(
|
|||||||
category: str = Form(...),
|
category: str = Form(...),
|
||||||
description: str | None = Form(None),
|
description: str | None = Form(None),
|
||||||
class_id: int | None = Form(None),
|
class_id: int | None = Form(None),
|
||||||
user: User = Depends(require_role("super_admin", "class_admin")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
|
effective_class_id = resolve_class_id_for_user(user, class_id)
|
||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
raise HTTPException(status_code=400, detail="You are not assigned to a class")
|
raise HTTPException(status_code=400, detail="You are not assigned to a class")
|
||||||
|
ensure_class_permission(user, "resource_manage", effective_class_id)
|
||||||
|
|
||||||
contents = await file.read()
|
contents = await file.read()
|
||||||
if len(contents) > 50 * 1024 * 1024: # 50MB limit
|
if len(contents) > 50 * 1024 * 1024: # 50MB limit
|
||||||
@ -126,12 +128,13 @@ async def upload_new_resource(
|
|||||||
@router.post("/{resource_id}/download")
|
@router.post("/{resource_id}/download")
|
||||||
async def download_resource(
|
async def download_resource(
|
||||||
resource_id: int,
|
resource_id: int,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin", "student")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
resource = await get_resource_by_id(db, resource_id)
|
resource = await get_resource_by_id(db, resource_id)
|
||||||
if resource is None:
|
if resource is None:
|
||||||
raise HTTPException(status_code=404, detail="Resource not found")
|
raise HTTPException(status_code=404, detail="Resource not found")
|
||||||
|
ensure_class_permission(user, "class_view", resource.class_id)
|
||||||
|
|
||||||
await increment_download_count(db, resource)
|
await increment_download_count(db, resource)
|
||||||
return {"file_url": resource.file_url}
|
return {"file_url": resource.file_url}
|
||||||
@ -140,14 +143,13 @@ async def download_resource(
|
|||||||
@router.delete("/{resource_id}")
|
@router.delete("/{resource_id}")
|
||||||
async def delete_existing_resource(
|
async def delete_existing_resource(
|
||||||
resource_id: int,
|
resource_id: int,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
resource = await get_resource_by_id(db, resource_id)
|
resource = await get_resource_by_id(db, resource_id)
|
||||||
if resource is None:
|
if resource is None:
|
||||||
raise HTTPException(status_code=404, detail="Resource not found")
|
raise HTTPException(status_code=404, detail="Resource not found")
|
||||||
if user.role != "super_admin" and resource.class_id != user.class_id:
|
ensure_class_permission(user, "resource_manage", resource.class_id)
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
|
||||||
|
|
||||||
await delete_resource(db, resource)
|
await delete_resource(db, resource)
|
||||||
return {"message": "Resource deleted"}
|
return {"message": "Resource deleted"}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.deps import require_role
|
from app.core.deps import ensure_class_permission, require_role, resolve_class_id_for_user
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.db.models import User
|
from app.db.models import User
|
||||||
from app.schemas.schedule import ScheduleCreate, ScheduleUpdate, ScheduleOut
|
from app.schemas.schedule import ScheduleCreate, ScheduleUpdate, ScheduleOut
|
||||||
@ -22,12 +22,13 @@ router = APIRouter(prefix="/api/schedule", tags=["schedule"])
|
|||||||
async def get_upcoming(
|
async def get_upcoming(
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
class_id: int | None = None,
|
class_id: int | None = None,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin", "student")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
|
effective_class_id = resolve_class_id_for_user(user, class_id)
|
||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
return []
|
return []
|
||||||
|
ensure_class_permission(user, "class_view", effective_class_id)
|
||||||
items = await get_upcoming_schedules(db, effective_class_id, limit)
|
items = await get_upcoming_schedules(db, effective_class_id, limit)
|
||||||
return [ScheduleOut.model_validate(i) for i in items]
|
return [ScheduleOut.model_validate(i) for i in items]
|
||||||
|
|
||||||
@ -38,12 +39,13 @@ async def get_schedules(
|
|||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 50,
|
page_size: int = 50,
|
||||||
class_id: int | None = None,
|
class_id: int | None = None,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin", "student")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
|
effective_class_id = resolve_class_id_for_user(user, class_id)
|
||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
||||||
|
ensure_class_permission(user, "class_view", effective_class_id)
|
||||||
|
|
||||||
items, total = await list_schedules(db, effective_class_id, type, page, page_size)
|
items, total = await list_schedules(db, effective_class_id, type, page, page_size)
|
||||||
total_pages = (total + page_size - 1) // page_size
|
total_pages = (total + page_size - 1) // page_size
|
||||||
@ -60,12 +62,13 @@ async def get_schedules(
|
|||||||
async def create_new_schedule(
|
async def create_new_schedule(
|
||||||
data: ScheduleCreate,
|
data: ScheduleCreate,
|
||||||
class_id: int | None = None,
|
class_id: int | None = None,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
|
effective_class_id = resolve_class_id_for_user(user, class_id)
|
||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
raise HTTPException(status_code=400, detail="You are not assigned to a class")
|
raise HTTPException(status_code=400, detail="You are not assigned to a class")
|
||||||
|
ensure_class_permission(user, "schedule_manage", effective_class_id)
|
||||||
|
|
||||||
item = await create_schedule(db, effective_class_id, data)
|
item = await create_schedule(db, effective_class_id, data)
|
||||||
return ScheduleOut.model_validate(item)
|
return ScheduleOut.model_validate(item)
|
||||||
@ -75,14 +78,13 @@ async def create_new_schedule(
|
|||||||
async def update_existing_schedule(
|
async def update_existing_schedule(
|
||||||
schedule_id: int,
|
schedule_id: int,
|
||||||
data: ScheduleUpdate,
|
data: ScheduleUpdate,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
item = await get_schedule_by_id(db, schedule_id)
|
item = await get_schedule_by_id(db, schedule_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail="Schedule not found")
|
raise HTTPException(status_code=404, detail="Schedule not found")
|
||||||
if user.role != "super_admin" and item.class_id != user.class_id:
|
ensure_class_permission(user, "schedule_manage", item.class_id)
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
|
||||||
|
|
||||||
updated = await update_schedule(db, item, data)
|
updated = await update_schedule(db, item, data)
|
||||||
return ScheduleOut.model_validate(updated)
|
return ScheduleOut.model_validate(updated)
|
||||||
@ -91,14 +93,13 @@ async def update_existing_schedule(
|
|||||||
@router.delete("/{schedule_id}")
|
@router.delete("/{schedule_id}")
|
||||||
async def delete_existing_schedule(
|
async def delete_existing_schedule(
|
||||||
schedule_id: int,
|
schedule_id: int,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
item = await get_schedule_by_id(db, schedule_id)
|
item = await get_schedule_by_id(db, schedule_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail="Schedule not found")
|
raise HTTPException(status_code=404, detail="Schedule not found")
|
||||||
if user.role != "super_admin" and item.class_id != user.class_id:
|
ensure_class_permission(user, "schedule_manage", item.class_id)
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
|
||||||
|
|
||||||
await delete_schedule(db, item)
|
await delete_schedule(db, item)
|
||||||
return {"message": "Schedule deleted"}
|
return {"message": "Schedule deleted"}
|
||||||
|
|||||||
@ -2,7 +2,12 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from app.core.deps import require_role
|
from app.core.deps import (
|
||||||
|
ensure_class_permission,
|
||||||
|
get_effective_class_permissions,
|
||||||
|
require_role,
|
||||||
|
resolve_class_id_for_user,
|
||||||
|
)
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.db.models import User
|
from app.db.models import User
|
||||||
from app.schemas.timeline import (
|
from app.schemas.timeline import (
|
||||||
@ -68,12 +73,13 @@ async def get_timelines(
|
|||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 20,
|
page_size: int = 20,
|
||||||
class_id: int | None = None,
|
class_id: int | None = None,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin", "student")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
|
effective_class_id = resolve_class_id_for_user(user, class_id)
|
||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
||||||
|
ensure_class_permission(user, "class_view", effective_class_id)
|
||||||
|
|
||||||
posts, total = await list_timelines(db, effective_class_id, page, page_size)
|
posts, total = await list_timelines(db, effective_class_id, page, page_size)
|
||||||
total_pages = (total + page_size - 1) // page_size
|
total_pages = (total + page_size - 1) // page_size
|
||||||
@ -91,12 +97,13 @@ async def create_new_timeline(
|
|||||||
content: str | None = Form(None),
|
content: str | None = Form(None),
|
||||||
class_id: int | None = Form(None),
|
class_id: int | None = Form(None),
|
||||||
files: list[UploadFile] = File(default=[]),
|
files: list[UploadFile] = File(default=[]),
|
||||||
user: User = Depends(require_role("super_admin", "class_admin", "student")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
|
effective_class_id = resolve_class_id_for_user(user, class_id)
|
||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
raise HTTPException(status_code=400, detail="You are not assigned to a class")
|
raise HTTPException(status_code=400, detail="You are not assigned to a class")
|
||||||
|
ensure_class_permission(user, "class_view", effective_class_id)
|
||||||
|
|
||||||
data = TimelineCreate(title=title, content=content)
|
data = TimelineCreate(title=title, content=content)
|
||||||
post = await create_timeline(db, effective_class_id, user.id, data)
|
post = await create_timeline(db, effective_class_id, user.id, data)
|
||||||
@ -135,7 +142,7 @@ async def create_new_timeline(
|
|||||||
async def upload_timeline_images(
|
async def upload_timeline_images(
|
||||||
post_id: int,
|
post_id: int,
|
||||||
files: list[UploadFile] = File(...),
|
files: list[UploadFile] = File(...),
|
||||||
user: User = Depends(require_role("super_admin", "class_admin", "student")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
post = await get_timeline_by_id(db, post_id)
|
post = await get_timeline_by_id(db, post_id)
|
||||||
@ -143,10 +150,10 @@ async def upload_timeline_images(
|
|||||||
raise HTTPException(status_code=404, detail="Timeline post not found")
|
raise HTTPException(status_code=404, detail="Timeline post not found")
|
||||||
|
|
||||||
# Student can only upload to own post; admin can upload to any in their class
|
# Student can only upload to own post; admin can upload to any in their class
|
||||||
if user.role == "student" and post.author_id != user.id:
|
can_manage = "timeline_manage" in get_effective_class_permissions(user, post.class_id)
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
if not can_manage and post.author_id != user.id:
|
||||||
if user.role != "super_admin" and post.class_id != user.class_id:
|
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
ensure_class_permission(user, "class_view", post.class_id)
|
||||||
|
|
||||||
urls = []
|
urls = []
|
||||||
for f in files:
|
for f in files:
|
||||||
@ -166,16 +173,16 @@ async def upload_timeline_images(
|
|||||||
async def update_existing_timeline(
|
async def update_existing_timeline(
|
||||||
post_id: int,
|
post_id: int,
|
||||||
data: TimelineUpdate,
|
data: TimelineUpdate,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin", "student")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
post = await get_timeline_by_id(db, post_id)
|
post = await get_timeline_by_id(db, post_id)
|
||||||
if post is None:
|
if post is None:
|
||||||
raise HTTPException(status_code=404, detail="Timeline post not found")
|
raise HTTPException(status_code=404, detail="Timeline post not found")
|
||||||
if user.role == "student" and post.author_id != user.id:
|
can_manage = "timeline_manage" in get_effective_class_permissions(user, post.class_id)
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
if not can_manage and post.author_id != user.id:
|
||||||
if user.role != "super_admin" and post.class_id != user.class_id:
|
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
ensure_class_permission(user, "class_view", post.class_id)
|
||||||
|
|
||||||
updated = await update_timeline(db, post, data)
|
updated = await update_timeline(db, post, data)
|
||||||
return _build_timeline_out(updated, user.id)
|
return _build_timeline_out(updated, user.id)
|
||||||
@ -184,16 +191,16 @@ async def update_existing_timeline(
|
|||||||
@router.delete("/{post_id}")
|
@router.delete("/{post_id}")
|
||||||
async def delete_existing_timeline(
|
async def delete_existing_timeline(
|
||||||
post_id: int,
|
post_id: int,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin", "student")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
post = await get_timeline_by_id(db, post_id)
|
post = await get_timeline_by_id(db, post_id)
|
||||||
if post is None:
|
if post is None:
|
||||||
raise HTTPException(status_code=404, detail="Timeline post not found")
|
raise HTTPException(status_code=404, detail="Timeline post not found")
|
||||||
if user.role == "student" and post.author_id != user.id:
|
can_manage = "timeline_manage" in get_effective_class_permissions(user, post.class_id)
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
if not can_manage and post.author_id != user.id:
|
||||||
if user.role != "super_admin" and post.class_id != user.class_id:
|
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
ensure_class_permission(user, "class_view", post.class_id)
|
||||||
|
|
||||||
await delete_timeline(db, post)
|
await delete_timeline(db, post)
|
||||||
return {"message": "Timeline post deleted"}
|
return {"message": "Timeline post deleted"}
|
||||||
@ -204,14 +211,13 @@ async def delete_existing_timeline(
|
|||||||
@router.post("/{post_id}/like")
|
@router.post("/{post_id}/like")
|
||||||
async def like_timeline_post(
|
async def like_timeline_post(
|
||||||
post_id: int,
|
post_id: int,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin", "student")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
post = await get_timeline_by_id(db, post_id)
|
post = await get_timeline_by_id(db, post_id)
|
||||||
if post is None:
|
if post is None:
|
||||||
raise HTTPException(status_code=404, detail="Timeline post not found")
|
raise HTTPException(status_code=404, detail="Timeline post not found")
|
||||||
if user.role != "super_admin" and post.class_id != user.class_id:
|
ensure_class_permission(user, "class_view", post.class_id)
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
|
||||||
return await toggle_like(db, post_id, user.id)
|
return await toggle_like(db, post_id, user.id)
|
||||||
|
|
||||||
|
|
||||||
@ -220,9 +226,13 @@ async def get_post_comments(
|
|||||||
post_id: int,
|
post_id: int,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 50,
|
page_size: int = 50,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin", "student")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
post = await get_timeline_by_id(db, post_id)
|
||||||
|
if post is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Timeline post not found")
|
||||||
|
ensure_class_permission(user, "class_view", post.class_id)
|
||||||
comments, total = await list_comments(db, post_id, page, page_size)
|
comments, total = await list_comments(db, post_id, page, page_size)
|
||||||
total_pages = (total + page_size - 1) // page_size
|
total_pages = (total + page_size - 1) // page_size
|
||||||
items = [
|
items = [
|
||||||
@ -244,14 +254,13 @@ async def get_post_comments(
|
|||||||
async def add_post_comment(
|
async def add_post_comment(
|
||||||
post_id: int,
|
post_id: int,
|
||||||
data: TimelineCommentCreate,
|
data: TimelineCommentCreate,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin", "student")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
post = await get_timeline_by_id(db, post_id)
|
post = await get_timeline_by_id(db, post_id)
|
||||||
if post is None:
|
if post is None:
|
||||||
raise HTTPException(status_code=404, detail="Timeline post not found")
|
raise HTTPException(status_code=404, detail="Timeline post not found")
|
||||||
if user.role != "super_admin" and post.class_id != user.class_id:
|
ensure_class_permission(user, "class_view", post.class_id)
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
|
||||||
|
|
||||||
comment = await create_comment(db, post_id, user.id, data)
|
comment = await create_comment(db, post_id, user.id, data)
|
||||||
return TimelineCommentOut(
|
return TimelineCommentOut(
|
||||||
@ -268,14 +277,19 @@ async def add_post_comment(
|
|||||||
@router.delete("/comments/{comment_id}")
|
@router.delete("/comments/{comment_id}")
|
||||||
async def delete_timeline_comment(
|
async def delete_timeline_comment(
|
||||||
comment_id: int,
|
comment_id: int,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin", "student")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
comment = await get_comment_by_id(db, comment_id)
|
comment = await get_comment_by_id(db, comment_id)
|
||||||
if comment is None:
|
if comment is None:
|
||||||
raise HTTPException(status_code=404, detail="Comment not found")
|
raise HTTPException(status_code=404, detail="Comment not found")
|
||||||
if user.role == "student" and comment.author_id != user.id:
|
can_manage = "timeline_manage" in get_effective_class_permissions(user, post.class_id)
|
||||||
|
if not can_manage and comment.author_id != user.id:
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
post = await get_timeline_by_id(db, comment.post_id)
|
||||||
|
if post is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Timeline post not found")
|
||||||
|
ensure_class_permission(user, "class_view", post.class_id)
|
||||||
|
|
||||||
await delete_comment(db, comment)
|
await delete_comment(db, comment)
|
||||||
return {"message": "Comment deleted"}
|
return {"message": "Comment deleted"}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ router = APIRouter(prefix="/api/upload", tags=["upload"])
|
|||||||
@router.post("/image")
|
@router.post("/image")
|
||||||
async def upload_image_api(
|
async def upload_image_api(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
user: User = Depends(require_role("super_admin", "class_admin")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
):
|
):
|
||||||
"""Upload an image to Tencent COS."""
|
"""Upload an image to Tencent COS."""
|
||||||
contents = await file.read()
|
contents = await file.read()
|
||||||
|
|||||||
@ -1,29 +1,50 @@
|
|||||||
import json
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.deps import get_current_user, require_role
|
from app.core.deps import (
|
||||||
|
CLASS_PERMISSIONS,
|
||||||
|
ensure_class_access,
|
||||||
|
ensure_class_permission,
|
||||||
|
get_current_user,
|
||||||
|
require_role,
|
||||||
|
)
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.db.models import User
|
from app.db.models import User
|
||||||
from app.schemas.user import UserOut, UserUpdate, UserListItem, UserStatusUpdate
|
from app.schemas.user import (
|
||||||
|
TeacherAssignRequest,
|
||||||
|
TeacherAssignResponse,
|
||||||
|
TeacherCreateRequest,
|
||||||
|
TeacherCreateResponse,
|
||||||
|
UserOut,
|
||||||
|
UserUpdate,
|
||||||
|
UserListItem,
|
||||||
|
UserStatusUpdate,
|
||||||
|
UserRoleUpdate,
|
||||||
|
CommitteeRoleUpdate,
|
||||||
|
ClassPermissionsUpdate,
|
||||||
|
build_user_list_item,
|
||||||
|
build_user_out,
|
||||||
|
)
|
||||||
from app.schemas.common import PageResponse
|
from app.schemas.common import PageResponse
|
||||||
from app.services.user_service import (
|
from app.services.user_service import (
|
||||||
update_profile,
|
update_profile,
|
||||||
update_user_status,
|
update_user_status,
|
||||||
|
update_user_role,
|
||||||
|
update_user_committee_role,
|
||||||
|
update_user_class_permissions,
|
||||||
|
assign_existing_teacher_to_class,
|
||||||
|
create_or_assign_teacher,
|
||||||
list_users,
|
list_users,
|
||||||
get_user_by_id,
|
get_user_by_id,
|
||||||
)
|
)
|
||||||
from app.services.cos_service import upload_image
|
from app.services.cos_service import upload_image
|
||||||
from app.services.email_service import send_approval_notification
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserOut)
|
@router.get("/me", response_model=UserOut)
|
||||||
async def get_my_profile(user: User = Depends(get_current_user)):
|
async def get_my_profile(user: User = Depends(get_current_user)):
|
||||||
return UserOut.model_validate(user)
|
return build_user_out(user)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/me", response_model=UserOut)
|
@router.put("/me", response_model=UserOut)
|
||||||
@ -36,7 +57,7 @@ async def update_my_profile(
|
|||||||
updated = await update_profile(db, user, data)
|
updated = await update_profile(db, user, data)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
return UserOut.model_validate(updated)
|
return build_user_out(updated)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/me/avatar")
|
@router.post("/me/avatar")
|
||||||
@ -65,13 +86,18 @@ async def list_all_users(
|
|||||||
class_id: int | None = None,
|
class_id: int | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
role: str | None = None,
|
role: str | None = None,
|
||||||
admin: User = Depends(require_role("super_admin")),
|
admin: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
if class_id is not None:
|
||||||
|
ensure_class_permission(admin, "member_view", class_id)
|
||||||
|
elif admin.role != "super_admin":
|
||||||
|
raise HTTPException(status_code=400, detail="class_id is required")
|
||||||
|
|
||||||
users, total = await list_users(db, page, page_size, class_id, status, role)
|
users, total = await list_users(db, page, page_size, class_id, status, role)
|
||||||
total_pages = (total + page_size - 1) // page_size
|
total_pages = (total + page_size - 1) // page_size
|
||||||
return PageResponse(
|
return PageResponse(
|
||||||
items=[UserListItem.model_validate(u) for u in users],
|
items=[build_user_list_item(u, class_id) for u in users],
|
||||||
total=total,
|
total=total,
|
||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
@ -83,18 +109,15 @@ async def list_all_users(
|
|||||||
async def change_user_status(
|
async def change_user_status(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
data: UserStatusUpdate,
|
data: UserStatusUpdate,
|
||||||
admin: User = Depends(require_role("super_admin", "class_admin")),
|
admin: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
target = await get_user_by_id(db, user_id)
|
target = await get_user_by_id(db, user_id)
|
||||||
if target is None:
|
if target is None:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
# Class admin can only manage users in their own class
|
target_class_id = target.get_default_membership().class_id if target.get_default_membership() else None
|
||||||
if admin.role == "class_admin" and target.class_id != admin.class_id:
|
ensure_class_permission(admin, "member_manage", target_class_id)
|
||||||
raise HTTPException(
|
|
||||||
status_code=403, detail="Cannot manage users outside your class"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Only super_admin can change roles
|
# Only super_admin can change roles
|
||||||
if data.role and admin.role != "super_admin":
|
if data.role and admin.role != "super_admin":
|
||||||
@ -103,53 +126,107 @@ async def change_user_status(
|
|||||||
)
|
)
|
||||||
|
|
||||||
updated = await update_user_status(db, user_id, data.status, data.role)
|
updated = await update_user_status(db, user_id, data.status, data.role)
|
||||||
|
return {"message": f"用户状态已更新为 {data.status}"}
|
||||||
# Send email notification
|
|
||||||
if data.status in ("approved", "rejected"):
|
|
||||||
await send_approval_notification(target.email, data.status == "approved")
|
|
||||||
|
|
||||||
return {"message": f"User status updated to {data.status}"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{user_id}/role")
|
@router.put("/{user_id}/role")
|
||||||
async def change_user_role(
|
async def change_user_role(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
role: str,
|
data: UserRoleUpdate,
|
||||||
admin: User = Depends(require_role("super_admin")),
|
admin: User = Depends(require_role("super_admin")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
if role not in ("super_admin", "class_admin", "student"):
|
if data.role not in ("super_admin", "teacher", "student"):
|
||||||
raise HTTPException(status_code=400, detail="Invalid role")
|
raise HTTPException(status_code=400, detail="Invalid role")
|
||||||
|
|
||||||
target = await get_user_by_id(db, user_id)
|
target = await get_user_by_id(db, user_id)
|
||||||
if target is None:
|
if target is None:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
target.role = role
|
await update_user_role(db, target, data.role)
|
||||||
await db.commit()
|
return {"message": f"User role updated to {data.role}"}
|
||||||
return {"message": f"User role updated to {role}"}
|
|
||||||
|
|
||||||
|
|
||||||
class CommitteeRoleUpdate(BaseModel):
|
|
||||||
committee_role: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{user_id}/committee-role")
|
@router.put("/{user_id}/committee-role")
|
||||||
async def change_committee_role(
|
async def change_committee_role(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
data: CommitteeRoleUpdate,
|
data: CommitteeRoleUpdate,
|
||||||
admin: User = Depends(require_role("super_admin", "class_admin")),
|
admin: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
target = await get_user_by_id(db, user_id)
|
target = await get_user_by_id(db, user_id)
|
||||||
if target is None:
|
if target is None:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
if admin.role == "class_admin" and target.class_id != admin.class_id:
|
ensure_class_permission(admin, "committee_manage", data.class_id)
|
||||||
raise HTTPException(
|
|
||||||
status_code=403, detail="Cannot manage users outside your class"
|
|
||||||
)
|
|
||||||
|
|
||||||
target.committee_role = data.committee_role
|
try:
|
||||||
await db.commit()
|
await update_user_committee_role(db, target, data.class_id, data.committee_role)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
return {"message": "Committee role updated"}
|
return {"message": "Committee role updated"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{user_id}/class-permissions")
|
||||||
|
async def change_class_permissions(
|
||||||
|
user_id: int,
|
||||||
|
data: ClassPermissionsUpdate,
|
||||||
|
admin: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
target = await get_user_by_id(db, user_id)
|
||||||
|
if target is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
ensure_class_permission(admin, "committee_manage", data.class_id)
|
||||||
|
|
||||||
|
invalid = [permission for permission in data.class_permissions if permission not in CLASS_PERMISSIONS]
|
||||||
|
if invalid:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid permissions: {', '.join(invalid)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await update_user_class_permissions(db, target, data.class_id, data.class_permissions)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
return {"message": "Class permissions updated"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/teachers", response_model=TeacherCreateResponse)
|
||||||
|
async def create_teacher_user(
|
||||||
|
data: TeacherCreateRequest,
|
||||||
|
admin: User = Depends(require_role("super_admin")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
user, created, assigned = await create_or_assign_teacher(db, data)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
if created:
|
||||||
|
message = "老师账号已创建并加入当前班级"
|
||||||
|
elif assigned:
|
||||||
|
message = "老师账号已加入当前班级"
|
||||||
|
else:
|
||||||
|
message = "该老师已存在于当前班级"
|
||||||
|
|
||||||
|
return TeacherCreateResponse(
|
||||||
|
message=message,
|
||||||
|
user=build_user_out(user, data.class_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/teachers/assign", response_model=TeacherAssignResponse)
|
||||||
|
async def assign_teacher_to_class(
|
||||||
|
data: TeacherAssignRequest,
|
||||||
|
admin: User = Depends(require_role("super_admin")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
user, assigned = await assign_existing_teacher_to_class(db, data)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
return TeacherAssignResponse(
|
||||||
|
message="老师已加入当前班级" if assigned else "该老师已在当前班级",
|
||||||
|
user=build_user_out(user, data.class_id),
|
||||||
|
)
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.deps import require_role
|
from app.core.deps import (
|
||||||
|
ensure_class_permission,
|
||||||
|
get_effective_class_permissions,
|
||||||
|
require_role,
|
||||||
|
resolve_class_id_for_user,
|
||||||
|
)
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.db.models import User
|
from app.db.models import User
|
||||||
from app.schemas.vote import VoteCreate, VoteUpdate, VoteSubmit, VoteOptionOut, VoteOut
|
from app.schemas.vote import VoteCreate, VoteUpdate, VoteSubmit, VoteOptionOut, VoteOut
|
||||||
@ -70,12 +75,13 @@ async def get_votes(
|
|||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 20,
|
page_size: int = 20,
|
||||||
class_id: int | None = None,
|
class_id: int | None = None,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin", "student")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
|
effective_class_id = resolve_class_id_for_user(user, class_id)
|
||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
|
||||||
|
ensure_class_permission(user, "class_view", effective_class_id)
|
||||||
|
|
||||||
votes, total = await list_votes(db, effective_class_id, page, page_size)
|
votes, total = await list_votes(db, effective_class_id, page, page_size)
|
||||||
total_pages = (total + page_size - 1) // page_size
|
total_pages = (total + page_size - 1) // page_size
|
||||||
@ -87,7 +93,7 @@ async def get_votes(
|
|||||||
async def create_new_vote(
|
async def create_new_vote(
|
||||||
data: VoteCreate,
|
data: VoteCreate,
|
||||||
class_id: int | None = None,
|
class_id: int | None = None,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin", "student")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
if len(data.options) < 2:
|
if len(data.options) < 2:
|
||||||
@ -95,9 +101,10 @@ async def create_new_vote(
|
|||||||
if data.vote_type == "multiple" and data.max_choices < 2:
|
if data.vote_type == "multiple" and data.max_choices < 2:
|
||||||
raise HTTPException(status_code=400, detail="多选投票最多可选数不能小于 2")
|
raise HTTPException(status_code=400, detail="多选投票最多可选数不能小于 2")
|
||||||
|
|
||||||
effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
|
effective_class_id = resolve_class_id_for_user(user, class_id)
|
||||||
if effective_class_id is None:
|
if effective_class_id is None:
|
||||||
raise HTTPException(status_code=400, detail="You are not assigned to a class")
|
raise HTTPException(status_code=400, detail="You are not assigned to a class")
|
||||||
|
ensure_class_permission(user, "class_view", effective_class_id)
|
||||||
|
|
||||||
vote = await create_vote(db, effective_class_id, user.id, data)
|
vote = await create_vote(db, effective_class_id, user.id, data)
|
||||||
# Reload with relationships
|
# Reload with relationships
|
||||||
@ -108,14 +115,13 @@ async def create_new_vote(
|
|||||||
@router.get("/{vote_id}", response_model=VoteOut)
|
@router.get("/{vote_id}", response_model=VoteOut)
|
||||||
async def get_vote_detail(
|
async def get_vote_detail(
|
||||||
vote_id: int,
|
vote_id: int,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin", "student")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
vote = await get_vote_by_id(db, vote_id)
|
vote = await get_vote_by_id(db, vote_id)
|
||||||
if vote is None:
|
if vote is None:
|
||||||
raise HTTPException(status_code=404, detail="Vote not found")
|
raise HTTPException(status_code=404, detail="Vote not found")
|
||||||
if user.role != "super_admin" and vote.class_id != user.class_id:
|
ensure_class_permission(user, "class_view", vote.class_id)
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
|
||||||
return _build_vote_out(vote, user.id)
|
return _build_vote_out(vote, user.id)
|
||||||
|
|
||||||
|
|
||||||
@ -123,14 +129,13 @@ async def get_vote_detail(
|
|||||||
async def submit_vote_response(
|
async def submit_vote_response(
|
||||||
vote_id: int,
|
vote_id: int,
|
||||||
data: VoteSubmit,
|
data: VoteSubmit,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin", "student")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
vote = await get_vote_by_id(db, vote_id)
|
vote = await get_vote_by_id(db, vote_id)
|
||||||
if vote is None:
|
if vote is None:
|
||||||
raise HTTPException(status_code=404, detail="Vote not found")
|
raise HTTPException(status_code=404, detail="Vote not found")
|
||||||
if user.role != "super_admin" and vote.class_id != user.class_id:
|
ensure_class_permission(user, "class_view", vote.class_id)
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await submit_vote(db, vote_id, user.id, data.option_ids)
|
await submit_vote(db, vote_id, user.id, data.option_ids)
|
||||||
@ -143,17 +148,17 @@ async def submit_vote_response(
|
|||||||
@router.put("/{vote_id}/close")
|
@router.put("/{vote_id}/close")
|
||||||
async def close_vote_endpoint(
|
async def close_vote_endpoint(
|
||||||
vote_id: int,
|
vote_id: int,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin", "student")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
vote = await get_vote_by_id(db, vote_id)
|
vote = await get_vote_by_id(db, vote_id)
|
||||||
if vote is None:
|
if vote is None:
|
||||||
raise HTTPException(status_code=404, detail="Vote not found")
|
raise HTTPException(status_code=404, detail="Vote not found")
|
||||||
# Only creator or admin can close
|
# Only creator or admin can close
|
||||||
if user.role == "student" and vote.creator_id != user.id:
|
can_manage = "vote_manage" in get_effective_class_permissions(user, vote.class_id)
|
||||||
|
if not can_manage and user.role == "student" and vote.creator_id != user.id:
|
||||||
raise HTTPException(status_code=403, detail="只有创建者或管理员可以关闭投票")
|
raise HTTPException(status_code=403, detail="只有创建者或管理员可以关闭投票")
|
||||||
if user.role != "super_admin" and vote.class_id != user.class_id:
|
ensure_class_permission(user, "class_view", vote.class_id)
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
|
||||||
|
|
||||||
await close_vote(db, vote)
|
await close_vote(db, vote)
|
||||||
return {"message": "投票已关闭"}
|
return {"message": "投票已关闭"}
|
||||||
@ -162,16 +167,16 @@ async def close_vote_endpoint(
|
|||||||
@router.delete("/{vote_id}")
|
@router.delete("/{vote_id}")
|
||||||
async def delete_vote_endpoint(
|
async def delete_vote_endpoint(
|
||||||
vote_id: int,
|
vote_id: int,
|
||||||
user: User = Depends(require_role("super_admin", "class_admin", "student")),
|
user: User = Depends(require_role("super_admin", "teacher", "student")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
vote = await get_vote_by_id(db, vote_id)
|
vote = await get_vote_by_id(db, vote_id)
|
||||||
if vote is None:
|
if vote is None:
|
||||||
raise HTTPException(status_code=404, detail="Vote not found")
|
raise HTTPException(status_code=404, detail="Vote not found")
|
||||||
if user.role == "student" and vote.creator_id != user.id:
|
can_manage = "vote_manage" in get_effective_class_permissions(user, vote.class_id)
|
||||||
|
if not can_manage and user.role == "student" and vote.creator_id != user.id:
|
||||||
raise HTTPException(status_code=403, detail="只有创建者或管理员可以删除投票")
|
raise HTTPException(status_code=403, detail="只有创建者或管理员可以删除投票")
|
||||||
if user.role != "super_admin" and vote.class_id != user.class_id:
|
ensure_class_permission(user, "class_view", vote.class_id)
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
|
||||||
|
|
||||||
await delete_vote(db, vote)
|
await delete_vote(db, vote)
|
||||||
return {"message": "投票已删除"}
|
return {"message": "投票已删除"}
|
||||||
|
|||||||
@ -1,14 +1,44 @@
|
|||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.auth import decode_access_token
|
from app.core.auth import decode_access_token
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.db.models import User
|
from app.db.models import ClassMembership, User
|
||||||
|
|
||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
CLASS_PERMISSIONS = {
|
||||||
|
"class_view",
|
||||||
|
"member_view",
|
||||||
|
"member_manage",
|
||||||
|
"committee_manage",
|
||||||
|
"announcement_manage",
|
||||||
|
"timeline_manage",
|
||||||
|
"vote_manage",
|
||||||
|
"schedule_manage",
|
||||||
|
"resource_manage",
|
||||||
|
"assignment_manage",
|
||||||
|
"fund_manage",
|
||||||
|
"module_manage",
|
||||||
|
}
|
||||||
|
|
||||||
|
TEACHER_DEFAULT_PERMISSIONS = {
|
||||||
|
"class_view",
|
||||||
|
"member_view",
|
||||||
|
"member_manage",
|
||||||
|
"committee_manage",
|
||||||
|
"announcement_manage",
|
||||||
|
"timeline_manage",
|
||||||
|
"vote_manage",
|
||||||
|
"schedule_manage",
|
||||||
|
"resource_manage",
|
||||||
|
"assignment_manage",
|
||||||
|
"module_manage",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
@ -28,13 +58,24 @@ async def get_current_user(
|
|||||||
detail="Invalid token format",
|
detail="Invalid token format",
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await db.execute(select(User).where(User.id == int(user_id)))
|
result = await db.execute(
|
||||||
|
select(User)
|
||||||
|
.options(
|
||||||
|
selectinload(User.memberships),
|
||||||
|
selectinload(User.memberships).selectinload(ClassMembership.class_),
|
||||||
|
)
|
||||||
|
.where(User.id == int(user_id))
|
||||||
|
)
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found"
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found"
|
||||||
)
|
)
|
||||||
|
if user.status == "inactive":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Account inactive"
|
||||||
|
)
|
||||||
if user.status == "disabled":
|
if user.status == "disabled":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN, detail="Account disabled"
|
status_code=status.HTTP_403_FORBIDDEN, detail="Account disabled"
|
||||||
@ -55,3 +96,68 @@ def require_role(*roles: str):
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
return _check
|
return _check
|
||||||
|
|
||||||
|
|
||||||
|
def get_membership_for_class(user: User, class_id: int | None) -> ClassMembership | None:
|
||||||
|
return user.get_membership(class_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_membership(
|
||||||
|
user: User, class_id: int | None = None
|
||||||
|
) -> ClassMembership | None:
|
||||||
|
membership = get_membership_for_class(user, class_id)
|
||||||
|
if membership is not None:
|
||||||
|
return membership
|
||||||
|
return user.get_default_membership()
|
||||||
|
|
||||||
|
|
||||||
|
def get_effective_class_permissions(user: User, class_id: int | None = None) -> set[str]:
|
||||||
|
if user.role == "super_admin":
|
||||||
|
return set(CLASS_PERMISSIONS)
|
||||||
|
membership = get_active_membership(user, class_id)
|
||||||
|
scoped_permissions = membership.get_class_permissions() if membership else []
|
||||||
|
if user.role == "teacher":
|
||||||
|
return set(TEACHER_DEFAULT_PERMISSIONS) | set(scoped_permissions)
|
||||||
|
return set(scoped_permissions)
|
||||||
|
|
||||||
|
|
||||||
|
def can_access_class(user: User, class_id: int | None) -> bool:
|
||||||
|
if class_id is None:
|
||||||
|
return False
|
||||||
|
if user.role in {"super_admin", "teacher"}:
|
||||||
|
return True
|
||||||
|
return get_membership_for_class(user, class_id) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_class_id_for_user(user: User, requested_class_id: int | None) -> int | None:
|
||||||
|
if user.role in {"super_admin", "teacher"}:
|
||||||
|
return requested_class_id
|
||||||
|
if requested_class_id is not None and can_access_class(user, requested_class_id):
|
||||||
|
return requested_class_id
|
||||||
|
membership = user.get_default_membership()
|
||||||
|
return membership.class_id if membership else None
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_class_access(user: User, class_id: int | None):
|
||||||
|
if not can_access_class(user, class_id):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Access denied for this class",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_class_permission(user: User, permission: str, class_id: int | None = None):
|
||||||
|
if permission not in CLASS_PERMISSIONS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Unknown permission: {permission}",
|
||||||
|
)
|
||||||
|
if class_id is not None:
|
||||||
|
ensure_class_access(user, class_id)
|
||||||
|
if user.role == "super_admin":
|
||||||
|
return
|
||||||
|
if permission not in get_effective_class_permissions(user, class_id):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Insufficient permissions",
|
||||||
|
)
|
||||||
|
|||||||
@ -9,9 +9,3 @@ async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit
|
|||||||
async def get_db():
|
async def get_db():
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
async def create_tables():
|
|
||||||
from app.db.base import Base
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
|
||||||
|
|||||||
@ -35,7 +35,9 @@ class Class_(Base):
|
|||||||
def set_enabled_modules(self, modules: list[str]):
|
def set_enabled_modules(self, modules: list[str]):
|
||||||
self.enabled_modules = json.dumps(modules, ensure_ascii=False) if modules else None
|
self.enabled_modules = json.dumps(modules, ensure_ascii=False) if modules else None
|
||||||
|
|
||||||
members: Mapped[list["User"]] = relationship("User", back_populates="class_")
|
memberships: Mapped[list["ClassMembership"]] = relationship(
|
||||||
|
"ClassMembership", back_populates="class_", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
timelines: Mapped[list["Timeline"]] = relationship(
|
timelines: Mapped[list["Timeline"]] = relationship(
|
||||||
"Timeline", back_populates="class_", cascade="all, delete-orphan"
|
"Timeline", back_populates="class_", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
@ -48,9 +50,6 @@ class Class_(Base):
|
|||||||
resources: Mapped[list["Resource"]] = relationship(
|
resources: Mapped[list["Resource"]] = relationship(
|
||||||
"Resource", back_populates="class_", cascade="all, delete-orphan"
|
"Resource", back_populates="class_", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
roster: Mapped[list["StudentRoster"]] = relationship(
|
|
||||||
"StudentRoster", back_populates="class_", cascade="all, delete-orphan"
|
|
||||||
)
|
|
||||||
assignments: Mapped[list["Assignment"]] = relationship(
|
assignments: Mapped[list["Assignment"]] = relationship(
|
||||||
"Assignment", back_populates="class_", cascade="all, delete-orphan"
|
"Assignment", back_populates="class_", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
@ -67,25 +66,19 @@ class User(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||||
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
password_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
student_id: Mapped[str | None] = mapped_column(String(50), nullable=True, unique=True)
|
student_id: Mapped[str | None] = mapped_column(String(50), nullable=True, unique=True)
|
||||||
|
|
||||||
# role: super_admin | class_admin | student
|
# role: super_admin | teacher | student
|
||||||
role: Mapped[str] = mapped_column(String(20), default="student", nullable=False)
|
role: Mapped[str] = mapped_column(String(20), default="student", nullable=False)
|
||||||
# status: pending | approved | rejected | disabled
|
# status: inactive | approved | disabled
|
||||||
status: Mapped[str] = mapped_column(String(20), default="pending", nullable=False)
|
status: Mapped[str] = mapped_column(String(20), default="inactive", nullable=False)
|
||||||
|
|
||||||
class_id: Mapped[int | None] = mapped_column(
|
|
||||||
Integer, ForeignKey("classes.id"), nullable=True
|
|
||||||
)
|
|
||||||
class_: Mapped["Class_ | None"] = relationship("Class_", back_populates="members")
|
|
||||||
|
|
||||||
# Profile
|
# Profile
|
||||||
industry: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
industry: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
company: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
company: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
position: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
position: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
committee_role: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
|
||||||
skills_tags: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array
|
skills_tags: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array
|
||||||
wechat_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
wechat_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
phone: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
phone: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
@ -97,6 +90,9 @@ class User(Base):
|
|||||||
DateTime, server_default=func.now(), onupdate=func.now()
|
DateTime, server_default=func.now(), onupdate=func.now()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
memberships: Mapped[list["ClassMembership"]] = relationship(
|
||||||
|
"ClassMembership", back_populates="user", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
timeline_posts: Mapped[list["Timeline"]] = relationship(
|
timeline_posts: Mapped[list["Timeline"]] = relationship(
|
||||||
"Timeline", back_populates="author"
|
"Timeline", back_populates="author"
|
||||||
)
|
)
|
||||||
@ -121,6 +117,65 @@ class User(Base):
|
|||||||
def set_skills_list(self, tags: list[str]):
|
def set_skills_list(self, tags: list[str]):
|
||||||
self.skills_tags = json.dumps(tags, ensure_ascii=False) if tags else None
|
self.skills_tags = json.dumps(tags, ensure_ascii=False) if tags else None
|
||||||
|
|
||||||
|
def get_membership(self, class_id: int | None) -> "ClassMembership | None":
|
||||||
|
if class_id is None:
|
||||||
|
return None
|
||||||
|
return next((m for m in self.memberships if m.class_id == class_id), None)
|
||||||
|
|
||||||
|
def get_default_membership(self) -> "ClassMembership | None":
|
||||||
|
active_membership = getattr(self, "_active_membership", None)
|
||||||
|
if active_membership is not None:
|
||||||
|
return active_membership
|
||||||
|
if len(self.memberships) == 1:
|
||||||
|
return self.memberships[0]
|
||||||
|
return self.memberships[0] if self.memberships else None
|
||||||
|
|
||||||
|
def set_active_membership(self, class_id: int | None = None):
|
||||||
|
setattr(self, "_active_membership", self.get_membership(class_id))
|
||||||
|
|
||||||
|
|
||||||
|
class ClassMembership(Base):
|
||||||
|
__tablename__ = "class_memberships"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
class_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("classes.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
membership_role: Mapped[str] = mapped_column(
|
||||||
|
String(20), default="student", nullable=False
|
||||||
|
)
|
||||||
|
committee_role: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
class_permissions: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship("User", back_populates="memberships")
|
||||||
|
class_: Mapped["Class_"] = relationship("Class_", back_populates="memberships")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "class_id", name="uq_class_membership_user_class"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_class_permissions(self) -> list[str]:
|
||||||
|
if not self.class_permissions:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return json.loads(self.class_permissions)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def set_class_permissions(self, permissions: list[str]):
|
||||||
|
self.class_permissions = (
|
||||||
|
json.dumps(sorted(set(permissions)), ensure_ascii=False)
|
||||||
|
if permissions
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Timeline(Base):
|
class Timeline(Base):
|
||||||
__tablename__ = "timelines"
|
__tablename__ = "timelines"
|
||||||
@ -245,27 +300,6 @@ class Notification(Base):
|
|||||||
user: Mapped["User"] = relationship("User")
|
user: Mapped["User"] = relationship("User")
|
||||||
|
|
||||||
|
|
||||||
class StudentRoster(Base):
|
|
||||||
__tablename__ = "student_rosters"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
class_id: Mapped[int] = mapped_column(
|
|
||||||
Integer, ForeignKey("classes.id"), nullable=False, index=True
|
|
||||||
)
|
|
||||||
student_id: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
||||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
||||||
status: Mapped[str] = mapped_column(
|
|
||||||
String(20), default="unregistered", nullable=False
|
|
||||||
)
|
|
||||||
user_id: Mapped[int | None] = mapped_column(
|
|
||||||
Integer, ForeignKey("users.id"), nullable=True
|
|
||||||
)
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
|
||||||
|
|
||||||
class_: Mapped["Class_"] = relationship("Class_", back_populates="roster")
|
|
||||||
user: Mapped["User | None"] = relationship("User")
|
|
||||||
|
|
||||||
|
|
||||||
class TimelineLike(Base):
|
class TimelineLike(Base):
|
||||||
__tablename__ = "timeline_likes"
|
__tablename__ = "timeline_likes"
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,6 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db.database import create_tables
|
|
||||||
from app.api import auth, users, classes, directory, timeline, schedule, upload, announcements, resources, notifications, votes, assignments, fund
|
from app.api import auth, users, classes, directory, timeline, schedule, upload, announcements, resources, notifications, votes, assignments, fund
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@ -37,45 +36,9 @@ async def ensure_super_admin():
|
|||||||
logger.info("Super admin seeded: %s", settings.super_admin_email)
|
logger.info("Super admin seeded: %s", settings.super_admin_email)
|
||||||
|
|
||||||
|
|
||||||
async def ensure_sample_class():
|
|
||||||
"""Seed a sample class if none exists."""
|
|
||||||
from sqlalchemy import select, func, text
|
|
||||||
from app.db.database import async_session
|
|
||||||
from app.db.models import Class_
|
|
||||||
|
|
||||||
async with async_session() as db:
|
|
||||||
result = await db.execute(select(func.count(Class_.id)))
|
|
||||||
count = result.scalar()
|
|
||||||
if count == 0:
|
|
||||||
sample = Class_(
|
|
||||||
name="HKU ICB Sample Class",
|
|
||||||
cohort_year=2025,
|
|
||||||
description="Sample class for testing",
|
|
||||||
)
|
|
||||||
db.add(sample)
|
|
||||||
await db.commit()
|
|
||||||
logger.info("Sample class seeded")
|
|
||||||
|
|
||||||
|
|
||||||
async def migrate_add_enabled_modules():
|
|
||||||
"""Add enabled_modules column to classes table if not exists."""
|
|
||||||
from sqlalchemy import text
|
|
||||||
from app.db.database import engine
|
|
||||||
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
result = await conn.execute(text("PRAGMA table_info(classes)"))
|
|
||||||
columns = [row[1] for row in result.fetchall()]
|
|
||||||
if "enabled_modules" not in columns:
|
|
||||||
await conn.execute(text("ALTER TABLE classes ADD COLUMN enabled_modules TEXT"))
|
|
||||||
logger.info("Migration: added enabled_modules column to classes table")
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
await create_tables()
|
|
||||||
await migrate_add_enabled_modules()
|
|
||||||
await ensure_super_admin()
|
await ensure_super_admin()
|
||||||
await ensure_sample_class()
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,6 @@ class LoginRequest(BaseModel):
|
|||||||
class RegisterRequest(BaseModel):
|
class RegisterRequest(BaseModel):
|
||||||
invite_code: str
|
invite_code: str
|
||||||
student_id: str
|
student_id: str
|
||||||
name: str
|
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|||||||
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 datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, EmailStr, field_validator
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
|
from app.db.models import ClassMembership, User
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
class_id: int
|
||||||
|
class_name: str | None
|
||||||
|
membership_role: str
|
||||||
|
committee_role: str | None = None
|
||||||
|
class_permissions: list[str]
|
||||||
|
|
||||||
|
|
||||||
class UserOut(BaseModel):
|
class UserOut(BaseModel):
|
||||||
@ -11,34 +21,21 @@ class UserOut(BaseModel):
|
|||||||
student_id: str | None
|
student_id: str | None
|
||||||
role: str
|
role: str
|
||||||
status: str
|
status: str
|
||||||
class_id: int | None
|
|
||||||
industry: str | None
|
industry: str | None
|
||||||
company: str | None
|
company: str | None
|
||||||
position: str | None
|
position: str | None
|
||||||
committee_role: str | None
|
|
||||||
skills_tags: list[str] | None
|
skills_tags: list[str] | None
|
||||||
wechat_id: str | None
|
wechat_id: str | None
|
||||||
phone: str | None
|
phone: str | None
|
||||||
avatar_url: str | None
|
avatar_url: str | None
|
||||||
bio: str | None
|
bio: str | None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
memberships: list[MembershipOut]
|
||||||
|
active_membership: MembershipOut | None = None
|
||||||
enabled_modules: list[str] | None = None
|
enabled_modules: list[str] | None = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
|
||||||
|
|
||||||
@field_validator("skills_tags", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def parse_skills_tags(cls, v):
|
|
||||||
if isinstance(v, str):
|
|
||||||
try:
|
|
||||||
return json.loads(v)
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
return []
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class UserPublic(BaseModel):
|
class UserPublic(BaseModel):
|
||||||
"""Shown to same-class approved members (includes contact info)."""
|
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
student_id: str | None
|
student_id: str | None
|
||||||
@ -53,20 +50,19 @@ class UserPublic(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class UserListItem(BaseModel):
|
class UserListItem(BaseModel):
|
||||||
"""For admin user management list."""
|
|
||||||
id: int
|
id: int
|
||||||
email: str
|
email: str
|
||||||
name: str
|
name: str
|
||||||
student_id: str | None
|
student_id: str | None
|
||||||
role: str
|
role: str
|
||||||
status: str
|
status: str
|
||||||
class_id: int | None
|
|
||||||
industry: str | None
|
industry: str | None
|
||||||
company: str | None
|
company: str | None
|
||||||
committee_role: str | None = None
|
committee_role: str | None = None
|
||||||
|
class_permissions: list[str]
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
memberships: list[MembershipOut]
|
||||||
model_config = {"from_attributes": True}
|
active_membership: MembershipOut | None = None
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
class UserUpdate(BaseModel):
|
||||||
@ -81,10 +77,101 @@ class UserUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class UserStatusUpdate(BaseModel):
|
class UserStatusUpdate(BaseModel):
|
||||||
status: str # approved | rejected | disabled
|
status: str
|
||||||
role: str | None = None
|
role: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserRoleUpdate(BaseModel):
|
||||||
|
role: str
|
||||||
|
|
||||||
|
|
||||||
|
class TeacherCreateRequest(BaseModel):
|
||||||
|
class_id: int
|
||||||
|
name: str = Field(min_length=1, max_length=100)
|
||||||
|
email: EmailStr
|
||||||
|
password: str = Field(min_length=8, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class TeacherCreateResponse(BaseModel):
|
||||||
|
message: str
|
||||||
|
user: UserOut
|
||||||
|
|
||||||
|
|
||||||
|
class TeacherAssignRequest(BaseModel):
|
||||||
|
class_id: int
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class TeacherAssignResponse(BaseModel):
|
||||||
|
message: str
|
||||||
|
user: UserOut
|
||||||
|
|
||||||
|
|
||||||
|
class CommitteeRoleUpdate(BaseModel):
|
||||||
|
class_id: int
|
||||||
|
committee_role: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ClassPermissionsUpdate(BaseModel):
|
||||||
|
class_id: int
|
||||||
|
class_permissions: list[str]
|
||||||
|
|
||||||
|
|
||||||
class TokenResponse(BaseModel):
|
class TokenResponse(BaseModel):
|
||||||
token: str
|
token: str
|
||||||
user: UserOut
|
user: UserOut
|
||||||
|
|
||||||
|
|
||||||
|
def build_membership_out(membership: ClassMembership) -> MembershipOut:
|
||||||
|
return MembershipOut(
|
||||||
|
id=membership.id,
|
||||||
|
class_id=membership.class_id,
|
||||||
|
class_name=membership.class_.name if membership.class_ else None,
|
||||||
|
membership_role=membership.membership_role,
|
||||||
|
committee_role=membership.committee_role,
|
||||||
|
class_permissions=membership.get_class_permissions(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_user_out(user: User, class_id: int | None = None) -> UserOut:
|
||||||
|
active_membership = user.get_membership(class_id) if class_id is not None else user.get_default_membership()
|
||||||
|
memberships = [build_membership_out(membership) for membership in user.memberships]
|
||||||
|
return UserOut(
|
||||||
|
id=user.id,
|
||||||
|
email=user.email,
|
||||||
|
name=user.name,
|
||||||
|
student_id=user.student_id,
|
||||||
|
role=user.role,
|
||||||
|
status=user.status,
|
||||||
|
industry=user.industry,
|
||||||
|
company=user.company,
|
||||||
|
position=user.position,
|
||||||
|
skills_tags=user.get_skills_list(),
|
||||||
|
wechat_id=user.wechat_id,
|
||||||
|
phone=user.phone,
|
||||||
|
avatar_url=user.avatar_url,
|
||||||
|
bio=user.bio,
|
||||||
|
created_at=user.created_at,
|
||||||
|
memberships=memberships,
|
||||||
|
active_membership=build_membership_out(active_membership) if active_membership else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_user_list_item(user: User, class_id: int | None = None) -> UserListItem:
|
||||||
|
active_membership = user.get_membership(class_id) if class_id is not None else user.get_default_membership()
|
||||||
|
memberships = [build_membership_out(membership) for membership in user.memberships]
|
||||||
|
return UserListItem(
|
||||||
|
id=user.id,
|
||||||
|
email=user.email,
|
||||||
|
name=user.name,
|
||||||
|
student_id=user.student_id,
|
||||||
|
role=user.role,
|
||||||
|
status=user.status,
|
||||||
|
industry=user.industry,
|
||||||
|
company=user.company,
|
||||||
|
committee_role=active_membership.committee_role if active_membership else None,
|
||||||
|
class_permissions=active_membership.get_class_permissions() if active_membership else [],
|
||||||
|
created_at=user.created_at,
|
||||||
|
memberships=memberships,
|
||||||
|
active_membership=build_membership_out(active_membership) if active_membership else None,
|
||||||
|
)
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db.models import Class_, User
|
from app.db.models import Class_, ClassMembership, User
|
||||||
from app.schemas.class_ import ClassCreate, ClassUpdate
|
from app.schemas.class_ import ClassCreate, ClassUpdate
|
||||||
|
|
||||||
|
|
||||||
@ -49,8 +50,11 @@ async def list_classes(
|
|||||||
|
|
||||||
async def get_member_count(db: AsyncSession, class_id: int) -> int:
|
async def get_member_count(db: AsyncSession, class_id: int) -> int:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(func.count(User.id)).where(
|
select(func.count(ClassMembership.id))
|
||||||
User.class_id == class_id, User.status == "approved"
|
.join(User, User.id == ClassMembership.user_id)
|
||||||
|
.where(
|
||||||
|
ClassMembership.class_id == class_id,
|
||||||
|
User.status == "approved",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result.scalar() or 0
|
return result.scalar() or 0
|
||||||
@ -63,8 +67,18 @@ async def get_class_members(
|
|||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 50,
|
page_size: int = 50,
|
||||||
) -> tuple[list[User], int]:
|
) -> tuple[list[User], int]:
|
||||||
query = select(User).where(User.class_id == class_id)
|
query = (
|
||||||
count_query = select(func.count(User.id)).where(User.class_id == class_id)
|
select(User)
|
||||||
|
.options(
|
||||||
|
selectinload(User.memberships),
|
||||||
|
selectinload(User.memberships).selectinload(ClassMembership.class_),
|
||||||
|
)
|
||||||
|
.join(ClassMembership)
|
||||||
|
.where(ClassMembership.class_id == class_id)
|
||||||
|
)
|
||||||
|
count_query = select(func.count(User.id)).join(ClassMembership).where(
|
||||||
|
ClassMembership.class_id == class_id
|
||||||
|
)
|
||||||
|
|
||||||
if status:
|
if status:
|
||||||
query = query.where(User.status == status)
|
query = query.where(User.status == status)
|
||||||
@ -76,4 +90,4 @@ async def get_class_members(
|
|||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
query.order_by(User.name).offset((page - 1) * page_size).limit(page_size)
|
query.order_by(User.name).offset((page - 1) * page_size).limit(page_size)
|
||||||
)
|
)
|
||||||
return list(result.scalars().all()), total
|
return list(result.scalars().unique().all()), total
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import json
|
import json
|
||||||
from sqlalchemy import select, or_, func, case
|
from sqlalchemy import select, or_, func, case
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db.models import User
|
from app.db.models import ClassMembership, User
|
||||||
from app.schemas.user import UserPublic
|
from app.schemas.user import UserPublic
|
||||||
|
|
||||||
|
|
||||||
@ -15,12 +16,20 @@ async def search_directory(
|
|||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 20,
|
page_size: int = 20,
|
||||||
) -> tuple[list[User], int]:
|
) -> tuple[list[User], int]:
|
||||||
"""Search approved members in a class."""
|
"""Search active members in a class."""
|
||||||
query = select(User).where(
|
query = (
|
||||||
User.class_id == class_id, User.status == "approved"
|
select(User)
|
||||||
|
.options(
|
||||||
|
selectinload(User.memberships),
|
||||||
|
selectinload(User.memberships).selectinload(ClassMembership.class_),
|
||||||
|
)
|
||||||
|
.join(ClassMembership)
|
||||||
|
.where(ClassMembership.class_id == class_id, User.status == "approved")
|
||||||
)
|
)
|
||||||
count_query = select(func.count(User.id)).where(
|
count_query = (
|
||||||
User.class_id == class_id, User.status == "approved"
|
select(func.count(User.id))
|
||||||
|
.join(ClassMembership)
|
||||||
|
.where(ClassMembership.class_id == class_id, User.status == "approved")
|
||||||
)
|
)
|
||||||
|
|
||||||
if search:
|
if search:
|
||||||
@ -54,23 +63,26 @@ async def search_directory(
|
|||||||
|
|
||||||
# Committee role priority: 班长(1) > 副班长(2) > other roles(3) > no role(4)
|
# Committee role priority: 班长(1) > 副班长(2) > other roles(3) > no role(4)
|
||||||
committee_order = case(
|
committee_order = case(
|
||||||
(User.committee_role == None, 4),
|
(ClassMembership.committee_role == None, 4),
|
||||||
(User.committee_role == "班长", 1),
|
(ClassMembership.committee_role == "班长", 1),
|
||||||
(User.committee_role == "副班长", 2),
|
(ClassMembership.committee_role == "副班长", 2),
|
||||||
else_=3,
|
else_=3,
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
query.order_by(committee_order, User.committee_role, User.name)
|
query.order_by(committee_order, ClassMembership.committee_role, User.name)
|
||||||
.offset((page - 1) * page_size)
|
.offset((page - 1) * page_size)
|
||||||
.limit(page_size)
|
.limit(page_size)
|
||||||
)
|
)
|
||||||
users = list(result.scalars().all())
|
users = list(result.scalars().unique().all())
|
||||||
return users, total
|
return users, total
|
||||||
|
|
||||||
|
|
||||||
def user_to_public(user: User, include_contact: bool = True) -> UserPublic:
|
def user_to_public(
|
||||||
|
user: User, class_id: int | None = None, include_contact: bool = True
|
||||||
|
) -> UserPublic:
|
||||||
"""Convert User model to public profile, optionally hiding contact info."""
|
"""Convert User model to public profile, optionally hiding contact info."""
|
||||||
|
membership = user.get_membership(class_id) if class_id is not None else user.get_default_membership()
|
||||||
return UserPublic(
|
return UserPublic(
|
||||||
id=user.id,
|
id=user.id,
|
||||||
name=user.name,
|
name=user.name,
|
||||||
@ -78,7 +90,7 @@ def user_to_public(user: User, include_contact: bool = True) -> UserPublic:
|
|||||||
industry=user.industry,
|
industry=user.industry,
|
||||||
company=user.company,
|
company=user.company,
|
||||||
position=user.position,
|
position=user.position,
|
||||||
committee_role=user.committee_role,
|
committee_role=membership.committee_role if membership else None,
|
||||||
wechat_id=user.wechat_id if include_contact else None,
|
wechat_id=user.wechat_id if include_contact else None,
|
||||||
phone=user.phone if include_contact else None,
|
phone=user.phone if include_contact else None,
|
||||||
avatar_url=user.avatar_url,
|
avatar_url=user.avatar_url,
|
||||||
|
|||||||
@ -36,27 +36,13 @@ async def send_email(to: str, subject: str, html_body: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def send_registration_notification(
|
async def send_account_activated_email(member_email: str):
|
||||||
admin_email: str, student_name: str, class_name: str
|
html = """
|
||||||
):
|
<h2>Account Activated</h2>
|
||||||
html = f"""
|
<p>Your HKU ICB account has been activated successfully.</p>
|
||||||
<h2>New Registration Pending Approval</h2>
|
<p>You can now log in to the platform.</p>
|
||||||
<p><strong>{student_name}</strong> has registered for <strong>{class_name}</strong>.</p>
|
|
||||||
<p>Please log in to HKU ICB to review and approve.</p>
|
|
||||||
"""
|
"""
|
||||||
await send_email(admin_email, "HKU ICB: New Registration", html)
|
await send_email(member_email, "HKU ICB: Account Activated", html)
|
||||||
|
|
||||||
|
|
||||||
async def send_approval_notification(student_email: str, approved: bool):
|
|
||||||
status_text = "approved" if approved else "rejected"
|
|
||||||
html = f"""
|
|
||||||
<h2>Registration {status_text.capitalize()}</h2>
|
|
||||||
<p>Your registration has been <strong>{status_text}</strong>.</p>
|
|
||||||
{"<p>You can now log in to HKU ICB.</p>" if approved else ""}
|
|
||||||
"""
|
|
||||||
await send_email(
|
|
||||||
student_email, f"HKU ICB: Registration {status_text.capitalize()}", html
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def send_class_notification_email(
|
async def send_class_notification_email(
|
||||||
|
|||||||
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 import select, func, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db.models import Notification, User
|
from app.db.models import ClassMembership, Notification, User
|
||||||
from app.services.email_service import send_class_notification_email
|
from app.services.email_service import send_class_notification_email
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -43,10 +43,12 @@ async def create_notifications_for_class(
|
|||||||
email_body: str | None = None,
|
email_body: str | None = None,
|
||||||
email_action_path: str | None = None,
|
email_action_path: str | None = None,
|
||||||
):
|
):
|
||||||
"""Create in-app notifications + send email for all approved users in a class."""
|
"""Create in-app notifications + send email for all active users in a class."""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(User.id, User.email).where(
|
select(User.id, User.email)
|
||||||
User.class_id == class_id,
|
.join(ClassMembership, ClassMembership.user_id == User.id)
|
||||||
|
.where(
|
||||||
|
ClassMembership.class_id == class_id,
|
||||||
User.status == "approved",
|
User.status == "approved",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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 import select, func
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db.models import User
|
from app.db.models import ClassMembership, User
|
||||||
from app.schemas.user import UserOut, UserUpdate
|
from app.core.auth import hash_password
|
||||||
|
from app.schemas.user import TeacherCreateRequest, TeacherAssignRequest, UserUpdate
|
||||||
|
|
||||||
|
|
||||||
async def get_user_by_email(db: AsyncSession, email: str) -> User | None:
|
async def get_user_by_email(db: AsyncSession, email: str) -> User | None:
|
||||||
result = await db.execute(select(User).where(User.email == email))
|
result = await db.execute(
|
||||||
|
select(User)
|
||||||
|
.options(
|
||||||
|
selectinload(User.memberships),
|
||||||
|
selectinload(User.memberships).selectinload(ClassMembership.class_),
|
||||||
|
)
|
||||||
|
.where(User.email == email)
|
||||||
|
)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
async def get_user_by_id(db: AsyncSession, user_id: int) -> User | None:
|
async def get_user_by_id(db: AsyncSession, user_id: int) -> User | None:
|
||||||
result = await db.execute(select(User).where(User.id == user_id))
|
result = await db.execute(
|
||||||
|
select(User)
|
||||||
|
.options(
|
||||||
|
selectinload(User.memberships),
|
||||||
|
selectinload(User.memberships).selectinload(ClassMembership.class_),
|
||||||
|
)
|
||||||
|
.where(User.id == user_id)
|
||||||
|
)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
@ -48,6 +64,39 @@ async def update_user_status(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def update_user_role(
|
||||||
|
db: AsyncSession, user: User, role: str
|
||||||
|
) -> User:
|
||||||
|
user.role = role
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def update_user_committee_role(
|
||||||
|
db: AsyncSession, user: User, class_id: int, committee_role: str | None
|
||||||
|
) -> User:
|
||||||
|
membership = user.get_membership(class_id)
|
||||||
|
if membership is None:
|
||||||
|
raise ValueError("User is not a member of the class")
|
||||||
|
membership.committee_role = committee_role
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def update_user_class_permissions(
|
||||||
|
db: AsyncSession, user: User, class_id: int, class_permissions: list[str]
|
||||||
|
) -> User:
|
||||||
|
membership = user.get_membership(class_id)
|
||||||
|
if membership is None:
|
||||||
|
raise ValueError("User is not a member of the class")
|
||||||
|
membership.set_class_permissions(class_permissions)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
async def list_users(
|
async def list_users(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
@ -56,12 +105,15 @@ async def list_users(
|
|||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
role: str | None = None,
|
role: str | None = None,
|
||||||
) -> tuple[list[User], int]:
|
) -> tuple[list[User], int]:
|
||||||
query = select(User)
|
query = select(User).options(
|
||||||
|
selectinload(User.memberships),
|
||||||
|
selectinload(User.memberships).selectinload(ClassMembership.class_),
|
||||||
|
)
|
||||||
count_query = select(func.count(User.id))
|
count_query = select(func.count(User.id))
|
||||||
|
|
||||||
if class_id is not None:
|
if class_id is not None:
|
||||||
query = query.where(User.class_id == class_id)
|
query = query.join(ClassMembership).where(ClassMembership.class_id == class_id)
|
||||||
count_query = count_query.where(User.class_id == class_id)
|
count_query = count_query.join(ClassMembership).where(ClassMembership.class_id == class_id)
|
||||||
if status is not None:
|
if status is not None:
|
||||||
query = query.where(User.status == status)
|
query = query.where(User.status == status)
|
||||||
count_query = count_query.where(User.status == status)
|
count_query = count_query.where(User.status == status)
|
||||||
@ -75,6 +127,96 @@ async def list_users(
|
|||||||
query = query.order_by(User.created_at.desc())
|
query = query.order_by(User.created_at.desc())
|
||||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
users = list(result.scalars().all())
|
users = list(result.scalars().unique().all())
|
||||||
|
|
||||||
return users, total
|
return users, total
|
||||||
|
|
||||||
|
|
||||||
|
async def create_or_assign_teacher(
|
||||||
|
db: AsyncSession,
|
||||||
|
data: TeacherCreateRequest,
|
||||||
|
) -> tuple[User, bool, bool]:
|
||||||
|
existing_user = await get_user_by_email(db, data.email)
|
||||||
|
created = False
|
||||||
|
assigned = False
|
||||||
|
|
||||||
|
if existing_user is not None:
|
||||||
|
if existing_user.role != "teacher":
|
||||||
|
raise ValueError("该邮箱已存在且不是老师账号")
|
||||||
|
|
||||||
|
membership = existing_user.get_membership(data.class_id)
|
||||||
|
if membership is None:
|
||||||
|
db.add(
|
||||||
|
ClassMembership(
|
||||||
|
user_id=existing_user.id,
|
||||||
|
class_id=data.class_id,
|
||||||
|
membership_role="teacher",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assigned = True
|
||||||
|
await db.commit()
|
||||||
|
refreshed = await get_user_by_id(db, existing_user.id)
|
||||||
|
if refreshed is None:
|
||||||
|
raise ValueError("老师账号创建失败")
|
||||||
|
refreshed.set_active_membership(data.class_id)
|
||||||
|
return refreshed, created, assigned
|
||||||
|
|
||||||
|
existing_user.set_active_membership(data.class_id)
|
||||||
|
return existing_user, created, assigned
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
email=data.email,
|
||||||
|
password_hash=hash_password(data.password),
|
||||||
|
name=data.name.strip(),
|
||||||
|
student_id=None,
|
||||||
|
role="teacher",
|
||||||
|
status="approved",
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.flush()
|
||||||
|
db.add(
|
||||||
|
ClassMembership(
|
||||||
|
user_id=user.id,
|
||||||
|
class_id=data.class_id,
|
||||||
|
membership_role="teacher",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
created = True
|
||||||
|
assigned = True
|
||||||
|
|
||||||
|
refreshed = await get_user_by_id(db, user.id)
|
||||||
|
if refreshed is None:
|
||||||
|
raise ValueError("老师账号创建失败")
|
||||||
|
refreshed.set_active_membership(data.class_id)
|
||||||
|
return refreshed, created, assigned
|
||||||
|
|
||||||
|
|
||||||
|
async def assign_existing_teacher_to_class(
|
||||||
|
db: AsyncSession,
|
||||||
|
data: TeacherAssignRequest,
|
||||||
|
) -> tuple[User, bool]:
|
||||||
|
user = await get_user_by_email(db, data.email)
|
||||||
|
if user is None:
|
||||||
|
raise ValueError("未找到该邮箱对应的老师账号")
|
||||||
|
if user.role != "teacher":
|
||||||
|
raise ValueError("该邮箱对应的账号不是老师")
|
||||||
|
|
||||||
|
membership = user.get_membership(data.class_id)
|
||||||
|
if membership is not None:
|
||||||
|
user.set_active_membership(data.class_id)
|
||||||
|
return user, False
|
||||||
|
|
||||||
|
db.add(
|
||||||
|
ClassMembership(
|
||||||
|
user_id=user.id,
|
||||||
|
class_id=data.class_id,
|
||||||
|
membership_role="teacher",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
refreshed = await get_user_by_id(db, user.id)
|
||||||
|
if refreshed is None:
|
||||||
|
raise ValueError("老师分配失败")
|
||||||
|
refreshed.set_active_membership(data.class_id)
|
||||||
|
return refreshed, True
|
||||||
|
|||||||
@ -18,7 +18,7 @@ from app.db.base import Base
|
|||||||
from app.db.models import (
|
from app.db.models import (
|
||||||
Class_, User, Timeline, TimelineLike, TimelineComment,
|
Class_, User, Timeline, TimelineLike, TimelineComment,
|
||||||
Schedule, Announcement, Resource, Notification,
|
Schedule, Announcement, Resource, Notification,
|
||||||
StudentRoster, Vote, VoteOption, VoteResponse,
|
ClassMembership, Vote, VoteOption, VoteResponse,
|
||||||
Assignment, AssignmentSubmission,
|
Assignment, AssignmentSubmission,
|
||||||
)
|
)
|
||||||
from app.core.auth import hash_password
|
from app.core.auth import hash_password
|
||||||
@ -231,23 +231,29 @@ async def seed():
|
|||||||
await db.flush()
|
await db.flush()
|
||||||
print(f"[+] Class: {cls.name} (invite code: {cls.invite_code})")
|
print(f"[+] Class: {cls.name} (invite code: {cls.invite_code})")
|
||||||
|
|
||||||
# ── 2. Create class admin ────────────────────────────────────────
|
# ── 2. Create teacher ────────────────────────────────────────────
|
||||||
admin = User(
|
teacher = User(
|
||||||
email=CLASS_ADMIN["email"],
|
email=CLASS_ADMIN["email"],
|
||||||
password_hash=pwd_hash,
|
password_hash=pwd_hash,
|
||||||
name=CLASS_ADMIN["name"],
|
name=CLASS_ADMIN["name"],
|
||||||
role="class_admin",
|
role="teacher",
|
||||||
status="approved",
|
status="approved",
|
||||||
class_id=cls.id,
|
|
||||||
industry="教育",
|
industry="教育",
|
||||||
company="香港大学",
|
company="香港大学",
|
||||||
position="教授",
|
position="教授",
|
||||||
bio="香港大学中国商业学院教授,专注于战略管理和企业转型研究。",
|
bio="香港大学中国商业学院教授,专注于战略管理和企业转型研究。",
|
||||||
wechat_id="lin_prof_hku",
|
wechat_id="lin_prof_hku",
|
||||||
)
|
)
|
||||||
db.add(admin)
|
db.add(teacher)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
print(f"[+] Class Admin: {admin.name} ({admin.email})")
|
db.add(
|
||||||
|
ClassMembership(
|
||||||
|
user_id=teacher.id,
|
||||||
|
class_id=cls.id,
|
||||||
|
membership_role="teacher",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
print(f"[+] Teacher: {teacher.name} ({teacher.email})")
|
||||||
|
|
||||||
# ── 3. Create students ───────────────────────────────────────────
|
# ── 3. Create students ───────────────────────────────────────────
|
||||||
COMMITTEE_MAP = {0: "班长", 1: "副班长", 3: "学习委员", 5: "组织委员", 7: "宣传委员", 9: "文体委员"}
|
COMMITTEE_MAP = {0: "班长", 1: "副班长", 3: "学习委员", 5: "组织委员", 7: "宣传委员", 9: "文体委员"}
|
||||||
@ -261,11 +267,9 @@ async def seed():
|
|||||||
student_id=s["student_id"],
|
student_id=s["student_id"],
|
||||||
role="student",
|
role="student",
|
||||||
status="approved",
|
status="approved",
|
||||||
class_id=cls.id,
|
|
||||||
industry=INDUSTRIES[i % len(INDUSTRIES)],
|
industry=INDUSTRIES[i % len(INDUSTRIES)],
|
||||||
company=COMPANIES[i % len(COMPANIES)],
|
company=COMPANIES[i % len(COMPANIES)],
|
||||||
position=POSITIONS[i % len(POSITIONS)],
|
position=POSITIONS[i % len(POSITIONS)],
|
||||||
committee_role=COMMITTEE_MAP.get(i),
|
|
||||||
skills_tags='["' + '", "'.join(skills) + '"]',
|
skills_tags='["' + '", "'.join(skills) + '"]',
|
||||||
bio=f"在{COMPANIES[i % len(COMPANIES)]}担任{POSITIONS[i % len(POSITIONS)]},拥有丰富的{INDUSTRIES[i % len(INDUSTRIES)]}行业经验。",
|
bio=f"在{COMPANIES[i % len(COMPANIES)]}担任{POSITIONS[i % len(POSITIONS)]},拥有丰富的{INDUSTRIES[i % len(INDUSTRIES)]}行业经验。",
|
||||||
wechat_id=f"wx_{s['student_id']}",
|
wechat_id=f"wx_{s['student_id']}",
|
||||||
@ -275,23 +279,19 @@ async def seed():
|
|||||||
students.append(user)
|
students.append(user)
|
||||||
|
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
for user in students:
|
||||||
|
db.add(
|
||||||
|
ClassMembership(
|
||||||
|
user_id=user.id,
|
||||||
|
class_id=cls.id,
|
||||||
|
membership_role="student",
|
||||||
|
committee_role=COMMITTEE_MAP.get(i),
|
||||||
|
)
|
||||||
|
)
|
||||||
print(f"[+] {len(students)} students created (password: demo123)")
|
print(f"[+] {len(students)} students created (password: demo123)")
|
||||||
|
|
||||||
# ── 4. Student roster ────────────────────────────────────────────
|
# ── 4. Timelines with likes and comments ─────────────────────────
|
||||||
for s in students:
|
all_users = [teacher] + students
|
||||||
roster = StudentRoster(
|
|
||||||
class_id=cls.id,
|
|
||||||
student_id=s.student_id,
|
|
||||||
name=s.name,
|
|
||||||
status="registered",
|
|
||||||
user_id=s.id,
|
|
||||||
)
|
|
||||||
db.add(roster)
|
|
||||||
await db.flush()
|
|
||||||
print(f"[+] {len(students)} roster entries created")
|
|
||||||
|
|
||||||
# ── 5. Timelines with likes and comments ─────────────────────────
|
|
||||||
all_users = [admin] + students
|
|
||||||
for i, post_data in enumerate(TIMELINE_POSTS):
|
for i, post_data in enumerate(TIMELINE_POSTS):
|
||||||
author = random.choice(all_users)
|
author = random.choice(all_users)
|
||||||
post = Timeline(
|
post = Timeline(
|
||||||
|
|||||||
@ -3,6 +3,18 @@ import type { NextConfig } from "next";
|
|||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
allowedDevOrigins: ["192.168.31.172"],
|
allowedDevOrigins: ["192.168.31.172"],
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "http",
|
||||||
|
hostname: "**",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "**",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { fetchAPI, postAPI, putAPI, deleteAPI, getErrorMessage } from "@/lib/api";
|
||||||
import { fetchAPI, postAPI, putAPI, deleteAPI } from "@/lib/api";
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@ -21,7 +20,6 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
export default function ClassesPage() {
|
export default function ClassesPage() {
|
||||||
const { user } = useAuth();
|
|
||||||
const [classes, setClasses] = useState<ClassInfo[]>([]);
|
const [classes, setClasses] = useState<ClassInfo[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -35,10 +33,10 @@ export default function ClassesPage() {
|
|||||||
const loadClasses = async () => {
|
const loadClasses = async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetchAPI<any>("/api/classes/");
|
const res = await fetchAPI<{ items: ClassInfo[] }>("/api/classes/");
|
||||||
setClasses(res.items || []);
|
setClasses(res.items || []);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || "加载失败");
|
setError(getErrorMessage(err, "加载失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -90,8 +88,8 @@ export default function ClassesPage() {
|
|||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
loadClasses();
|
loadClasses();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.message || "操作失败");
|
toast.error(getErrorMessage(err, "操作失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@ -105,8 +103,8 @@ export default function ClassesPage() {
|
|||||||
toast.success("已删除");
|
toast.success("已删除");
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
loadClasses();
|
loadClasses();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.message || "删除失败");
|
toast.error(getErrorMessage(err, "删除失败"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -114,8 +112,9 @@ export default function ClassesPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">班级管理</h1>
|
<div className="text-[11px] uppercase tracking-[0.24em] text-[#976746]">Classes</div>
|
||||||
<p className="text-gray-500 mt-1">创建和管理班级</p>
|
<h1 className="mt-2 text-3xl font-semibold text-[#4e1d1a]">班级管理</h1>
|
||||||
|
<p className="mt-2 text-[#765a4d]">创建、调整与维护研究生班级信息</p>
|
||||||
</div>
|
</div>
|
||||||
<RoleGuard roles={["super_admin"]}>
|
<RoleGuard roles={["super_admin"]}>
|
||||||
<Dialog open={dialogOpen} onOpenChange={(open) => { setDialogOpen(open); if (!open) resetForm(); }}>
|
<Dialog open={dialogOpen} onOpenChange={(open) => { setDialogOpen(open); if (!open) resetForm(); }}>
|
||||||
@ -163,7 +162,7 @@ export default function ClassesPage() {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="animate-pulse space-y-4">
|
<div className="animate-pulse space-y-4">
|
||||||
{[1, 2].map((i) => (
|
{[1, 2].map((i) => (
|
||||||
<Card key={i}><CardContent className="p-6"><div className="h-20 bg-gray-200 rounded" /></CardContent></Card>
|
<Card key={i} className="bg-[#fffaf2]"><CardContent className="p-6"><div className="h-20 bg-gray-200 rounded" /></CardContent></Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
@ -171,11 +170,11 @@ export default function ClassesPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{classes.map((cls) => (
|
{classes.map((cls) => (
|
||||||
<Card key={cls.id}>
|
<Card key={cls.id} className="bg-[#fffdf8]">
|
||||||
<CardContent className="p-4 flex items-center justify-between">
|
<CardContent className="p-4 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium">{cls.name}</h3>
|
<h3 className="font-medium text-[#4e1d1a]">{cls.name}</h3>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-[#73594a]">
|
||||||
{cls.cohort_year}届 · {cls.member_count} 名成员
|
{cls.cohort_year}届 · {cls.member_count} 名成员
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,17 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useActiveClass } from "@/hooks/use-active-class";
|
import { useActiveClass } from "@/hooks/use-active-class";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { fetchAPI, putAPI } from "@/lib/api";
|
import { fetchAPI, getErrorMessage, putAPI } from "@/lib/api";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { ErrorState } from "@/components/error-state";
|
import { ErrorState } from "@/components/error-state";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
const ALL_MODULES = [
|
const ALL_MODULES = [
|
||||||
{ key: "announcements", label: "公告", desc: "发布和管理班级公告" },
|
{ key: "announcements", label: "公告", desc: "发布和管理班级公告" },
|
||||||
{ key: "directory", label: "花名册", desc: "查看班级成员花名册" },
|
{ key: "directory", label: "成员名录", desc: "查看班级成员名录" },
|
||||||
{ key: "timeline", label: "班级动态", desc: "分享班级动态和互动" },
|
{ key: "timeline", label: "班级动态", desc: "分享班级动态和互动" },
|
||||||
{ key: "assignments", label: "作业", desc: "发布和提交课程作业" },
|
{ key: "assignments", label: "作业", desc: "发布和提交课程作业" },
|
||||||
{ key: "votes", label: "投票", desc: "发起班级投票活动" },
|
{ key: "votes", label: "投票", desc: "发起班级投票活动" },
|
||||||
@ -33,7 +33,7 @@ export default function ModulesPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadModules = async () => {
|
const loadModules = useCallback(async () => {
|
||||||
if (!activeClassId) {
|
if (!activeClassId) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
@ -43,16 +43,16 @@ export default function ModulesPage() {
|
|||||||
try {
|
try {
|
||||||
const res = await fetchAPI<ModuleConfig>(`/api/classes/${activeClassId}/modules`);
|
const res = await fetchAPI<ModuleConfig>(`/api/classes/${activeClassId}/modules`);
|
||||||
setConfig(res);
|
setConfig(res);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || "加载失败");
|
setError(getErrorMessage(err, "加载失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [activeClassId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadModules();
|
void loadModules();
|
||||||
}, [activeClassId]);
|
}, [loadModules]);
|
||||||
|
|
||||||
const handleToggle = async (moduleKey: string, enabled: boolean) => {
|
const handleToggle = async (moduleKey: string, enabled: boolean) => {
|
||||||
if (!config || !activeClassId) return;
|
if (!config || !activeClassId) return;
|
||||||
@ -76,16 +76,16 @@ export default function ModulesPage() {
|
|||||||
? `已启用「${ALL_MODULES.find((m) => m.key === moduleKey)?.label}」`
|
? `已启用「${ALL_MODULES.find((m) => m.key === moduleKey)?.label}」`
|
||||||
: `已禁用「${ALL_MODULES.find((m) => m.key === moduleKey)?.label}」`
|
: `已禁用「${ALL_MODULES.find((m) => m.key === moduleKey)?.label}」`
|
||||||
);
|
);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
// Rollback
|
// Rollback
|
||||||
setConfig(config);
|
setConfig(config);
|
||||||
toast.error(err.message || "操作失败");
|
toast.error(getErrorMessage(err, "操作失败"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!activeClassId) {
|
if (!activeClassId) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12 text-gray-400">
|
<div className="py-12 text-center text-[#9d806f]">
|
||||||
请先选择一个班级
|
请先选择一个班级
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -94,11 +94,12 @@ export default function ModulesPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">模块管理</h1>
|
<div className="text-[11px] uppercase tracking-[0.24em] text-[#976746]">Modules</div>
|
||||||
<p className="text-gray-500 mt-1">
|
<h1 className="mt-2 text-3xl font-semibold text-[#4e1d1a]">模块管理</h1>
|
||||||
|
<p className="mt-2 text-[#765a4d]">
|
||||||
班级: {activeClassName}
|
班级: {activeClassName}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-400 text-sm mt-1">
|
<p className="mt-1 text-sm text-[#9a7b68]">
|
||||||
控制班级侧边栏中显示的功能模块
|
控制班级侧边栏中显示的功能模块
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -106,7 +107,7 @@ export default function ModulesPage() {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="animate-pulse space-y-4">
|
<div className="animate-pulse space-y-4">
|
||||||
{[1, 2, 3, 4].map((i) => (
|
{[1, 2, 3, 4].map((i) => (
|
||||||
<Card key={i}>
|
<Card key={i} className="bg-[#fffaf2]">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="h-16 bg-gray-200 rounded" />
|
<div className="h-16 bg-gray-200 rounded" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -118,11 +119,11 @@ export default function ModulesPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{ALL_MODULES.map((module) => (
|
{ALL_MODULES.map((module) => (
|
||||||
<Card key={module.key}>
|
<Card key={module.key} className="bg-[#fffdf8]">
|
||||||
<CardContent className="p-4 flex items-center justify-between">
|
<CardContent className="p-4 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{module.label}</p>
|
<p className="font-medium text-[#4e1d1a]">{module.label}</p>
|
||||||
<p className="text-sm text-gray-500">{module.desc}</p>
|
<p className="text-sm text-[#73594a]">{module.desc}</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={config?.enabled_modules.includes(module.key) ?? false}
|
checked={config?.enabled_modules.includes(module.key) ?? false}
|
||||||
|
|||||||
@ -11,33 +11,34 @@ export default function AdminPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">管理后台</h1>
|
<div className="text-[11px] uppercase tracking-[0.24em] text-[#976746]">Administration</div>
|
||||||
<p className="text-gray-500 mt-1">
|
<h1 className="mt-2 text-3xl font-semibold text-[#4e1d1a]">班级管理台</h1>
|
||||||
|
<p className="mt-2 text-[#765a4d]">
|
||||||
当前角色: {user?.role ? ROLES[user.role] : "-"}
|
当前角色: {user?.role ? ROLES[user.role] : "-"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<Link href="/admin/members">
|
<Link href="/admin/members">
|
||||||
<Card className="hover:shadow-md transition-shadow cursor-pointer">
|
<Card className="cursor-pointer bg-[#fffaf2] transition-all hover:-translate-y-0.5 hover:shadow-[0_24px_45px_-32px_rgba(84,29,23,0.38)]">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">成员管理</CardTitle>
|
<CardTitle className="text-lg text-[#4e1d1a]">成员管理</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-gray-500 text-sm">
|
<p className="text-sm text-[#765a4d]">
|
||||||
审核注册申请、管理成员状态
|
导入成员、管理未激活成员和班级权限
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/admin/classes">
|
<Link href="/admin/classes">
|
||||||
<Card className="hover:shadow-md transition-shadow cursor-pointer">
|
<Card className="cursor-pointer bg-[#fffaf2] transition-all hover:-translate-y-0.5 hover:shadow-[0_24px_45px_-32px_rgba(84,29,23,0.38)]">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">班级管理</CardTitle>
|
<CardTitle className="text-lg text-[#4e1d1a]">班级管理</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-gray-500 text-sm">
|
<p className="text-sm text-[#765a4d]">
|
||||||
创建和管理班级信息
|
创建和管理班级信息
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -45,12 +46,12 @@ export default function AdminPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/admin/modules">
|
<Link href="/admin/modules">
|
||||||
<Card className="hover:shadow-md transition-shadow cursor-pointer">
|
<Card className="cursor-pointer bg-[#fffaf2] transition-all hover:-translate-y-0.5 hover:shadow-[0_24px_45px_-32px_rgba(84,29,23,0.38)]">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">模块管理</CardTitle>
|
<CardTitle className="text-lg text-[#4e1d1a]">模块管理</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-gray-500 text-sm">
|
<p className="text-sm text-[#765a4d]">
|
||||||
控制班级功能模块的显示
|
控制班级功能模块的显示
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useActiveClass } from "@/hooks/use-active-class";
|
import { useActiveClass } from "@/hooks/use-active-class";
|
||||||
import { fetchAPI, postAPI, putAPI, deleteAPI } from "@/lib/api";
|
import { fetchAPI, postAPI, putAPI, deleteAPI, getErrorMessage } from "@/lib/api";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@ -20,7 +20,7 @@ import { ConfirmDialog } from "@/components/confirm-dialog";
|
|||||||
import { ErrorState } from "@/components/error-state";
|
import { ErrorState } from "@/components/error-state";
|
||||||
import { Pagination } from "@/components/pagination";
|
import { Pagination } from "@/components/pagination";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { Announcement } from "@/lib/types";
|
import type { Announcement, PageResponse } from "@/lib/types";
|
||||||
|
|
||||||
export default function AnnouncementsPage() {
|
export default function AnnouncementsPage() {
|
||||||
const { activeClassId } = useActiveClass();
|
const { activeClassId } = useActiveClass();
|
||||||
@ -41,27 +41,33 @@ export default function AnnouncementsPage() {
|
|||||||
// Delete state
|
// Delete state
|
||||||
const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
|
||||||
|
|
||||||
const loadAnnouncements = async () => {
|
const loadAnnouncements = useCallback(async () => {
|
||||||
|
if (!activeClassId) {
|
||||||
|
setAnnouncements([]);
|
||||||
|
setError(null);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetchAPI<any>("/api/announcements/", {
|
const res = await fetchAPI<PageResponse<Announcement>>("/api/announcements/", {
|
||||||
page_size: "10",
|
page_size: "10",
|
||||||
page: String(page),
|
page: String(page),
|
||||||
class_id: String(activeClassId),
|
class_id: String(activeClassId),
|
||||||
});
|
});
|
||||||
setAnnouncements(res.items || []);
|
setAnnouncements(res.items ?? []);
|
||||||
setTotalPages(res.total_pages || 1);
|
setTotalPages(res.total_pages ?? 1);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || "加载失败");
|
setError(getErrorMessage(err, "加载失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [activeClassId, page]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeClassId) return;
|
void loadAnnouncements();
|
||||||
loadAnnouncements();
|
}, [loadAnnouncements]);
|
||||||
}, [activeClassId, page]);
|
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
@ -90,19 +96,18 @@ export default function AnnouncementsPage() {
|
|||||||
});
|
});
|
||||||
toast.success("公告已更新");
|
toast.success("公告已更新");
|
||||||
} else {
|
} else {
|
||||||
await postAPI("/api/announcements/", {
|
await postAPI(`/api/announcements/?class_id=${activeClassId}`, {
|
||||||
title: newTitle,
|
title: newTitle,
|
||||||
content: newContent || null,
|
content: newContent || null,
|
||||||
is_pinned: newIsPinned,
|
is_pinned: newIsPinned,
|
||||||
class_id: activeClassId,
|
|
||||||
});
|
});
|
||||||
toast.success("公告已发布");
|
toast.success("公告已发布");
|
||||||
}
|
}
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
loadAnnouncements();
|
await loadAnnouncements();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.message || (editingId ? "更新失败" : "发布失败"));
|
toast.error(getErrorMessage(err, editingId ? "更新失败" : "发布失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@ -113,9 +118,9 @@ export default function AnnouncementsPage() {
|
|||||||
await deleteAPI(`/api/announcements/${id}`);
|
await deleteAPI(`/api/announcements/${id}`);
|
||||||
toast.success("已删除");
|
toast.success("已删除");
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
loadAnnouncements();
|
await loadAnnouncements();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.message || "删除失败");
|
toast.error(getErrorMessage(err, "删除失败"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -123,10 +128,11 @@ export default function AnnouncementsPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">公告</h1>
|
<div className="text-[11px] uppercase tracking-[0.24em] text-[#976746]">Announcements</div>
|
||||||
<p className="text-gray-500 mt-1">班级重要通知与公告</p>
|
<h1 className="mt-2 text-3xl font-semibold text-[#4e1d1a]">班级公告</h1>
|
||||||
|
<p className="mt-2 text-[#765a4d]">发布课程通知、班级说明与重要提醒</p>
|
||||||
</div>
|
</div>
|
||||||
<RoleGuard roles={["class_admin", "super_admin"]}>
|
<RoleGuard permissions={["announcement_manage"]}>
|
||||||
<Dialog open={dialogOpen} onOpenChange={(open) => {
|
<Dialog open={dialogOpen} onOpenChange={(open) => {
|
||||||
setDialogOpen(open);
|
setDialogOpen(open);
|
||||||
if (!open) resetForm();
|
if (!open) resetForm();
|
||||||
@ -173,7 +179,7 @@ export default function AnnouncementsPage() {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{[1, 2, 3].map((i) => (
|
{[1, 2, 3].map((i) => (
|
||||||
<Card key={i} className="animate-pulse">
|
<Card key={i} className="animate-pulse bg-[#fffaf2]">
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="h-24 bg-gray-200 rounded" />
|
<div className="h-24 bg-gray-200 rounded" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -183,20 +189,23 @@ export default function AnnouncementsPage() {
|
|||||||
) : error ? (
|
) : error ? (
|
||||||
<ErrorState message={error} onRetry={loadAnnouncements} />
|
<ErrorState message={error} onRetry={loadAnnouncements} />
|
||||||
) : announcements.length === 0 ? (
|
) : announcements.length === 0 ? (
|
||||||
<div className="text-center py-12 text-gray-400">暂无公告</div>
|
<div className="py-14 text-center text-[#9d806f]">暂无公告</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{announcements.map((item) => (
|
{announcements.map((item) => (
|
||||||
<Card key={item.id} className={item.is_pinned ? "border-blue-200 bg-blue-50/30" : ""}>
|
<Card
|
||||||
|
key={item.id}
|
||||||
|
className={item.is_pinned ? "border-[#e4c37f] bg-[#fff6e6]" : "bg-[#fffdf8]"}
|
||||||
|
>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{item.is_pinned && (
|
{item.is_pinned && (
|
||||||
<Badge className="bg-blue-500 text-white text-xs">置顶</Badge>
|
<Badge className="bg-[#d29a36] text-white text-xs">置顶</Badge>
|
||||||
)}
|
)}
|
||||||
<h3 className="text-lg font-semibold">{item.title}</h3>
|
<h3 className="text-lg font-semibold text-[#4e1d1a]">{item.title}</h3>
|
||||||
</div>
|
</div>
|
||||||
<RoleGuard roles={["class_admin", "super_admin"]}>
|
<RoleGuard permissions={["announcement_manage"]}>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button variant="ghost" size="sm" onClick={() => openEdit(item)}>
|
<Button variant="ghost" size="sm" onClick={() => openEdit(item)}>
|
||||||
编辑
|
编辑
|
||||||
@ -213,9 +222,9 @@ export default function AnnouncementsPage() {
|
|||||||
</RoleGuard>
|
</RoleGuard>
|
||||||
</div>
|
</div>
|
||||||
{item.content && (
|
{item.content && (
|
||||||
<p className="mt-3 text-gray-700 whitespace-pre-wrap">{item.content}</p>
|
<p className="mt-3 whitespace-pre-wrap text-[#5f473b]">{item.content}</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm text-gray-400 mt-3">
|
<p className="mt-3 text-sm text-[#92735f]">
|
||||||
{item.author_name} ·{" "}
|
{item.author_name} ·{" "}
|
||||||
{new Date(item.created_at).toLocaleDateString("zh-CN", {
|
{new Date(item.created_at).toLocaleDateString("zh-CN", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useRef } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { fetchAPI, postAPI, putAPI, uploadAPI } from "@/lib/api";
|
import { fetchAPI, putAPI, uploadAPI, getErrorMessage } from "@/lib/api";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
@ -75,24 +75,22 @@ export default function AssignmentDetailPage() {
|
|||||||
return `${days} 天后截止`;
|
return `${days} 天后截止`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isAdmin =
|
const loadAssignment = useCallback(async () => {
|
||||||
user?.role === "class_admin" || user?.role === "super_admin";
|
setLoading(true);
|
||||||
|
|
||||||
const loadAssignment = async () => {
|
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetchAPI<Assignment>(`/api/assignments/${id}`);
|
const res = await fetchAPI<Assignment>(`/api/assignments/${id}`);
|
||||||
setAssignment(res);
|
setAssignment(res);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || "加载失败");
|
setError(getErrorMessage(err, "加载失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAssignment();
|
void loadAssignment();
|
||||||
}, [id]);
|
}, [loadAssignment]);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!selectedFile) {
|
if (!selectedFile) {
|
||||||
@ -110,9 +108,9 @@ export default function AssignmentDetailPage() {
|
|||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
setNotes("");
|
setNotes("");
|
||||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
loadAssignment();
|
await loadAssignment();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.message || "提交失败");
|
toast.error(getErrorMessage(err, "提交失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@ -123,9 +121,9 @@ export default function AssignmentDetailPage() {
|
|||||||
try {
|
try {
|
||||||
await putAPI(`/api/assignments/${id}`, { status: "closed" });
|
await putAPI(`/api/assignments/${id}`, { status: "closed" });
|
||||||
toast.success("作业已关闭");
|
toast.success("作业已关闭");
|
||||||
loadAssignment();
|
await loadAssignment();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.message || "操作失败");
|
toast.error(getErrorMessage(err, "操作失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setClosing(false);
|
setClosing(false);
|
||||||
}
|
}
|
||||||
@ -145,9 +143,9 @@ export default function AssignmentDetailPage() {
|
|||||||
});
|
});
|
||||||
toast.success("评分已保存");
|
toast.success("评分已保存");
|
||||||
setActiveGradeId(null);
|
setActiveGradeId(null);
|
||||||
loadAssignment();
|
await loadAssignment();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.message || "评分失败");
|
toast.error(getErrorMessage(err, "评分失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setGradingSubmitting(false);
|
setGradingSubmitting(false);
|
||||||
}
|
}
|
||||||
@ -193,12 +191,8 @@ export default function AssignmentDetailPage() {
|
|||||||
(s) => s.student_id === user?.id
|
(s) => s.student_id === user?.id
|
||||||
) ?? null;
|
) ?? null;
|
||||||
|
|
||||||
// Compute unsubmitted students for admin view
|
|
||||||
const submittedStudentIds = new Set(
|
|
||||||
(assignment.submissions || []).map((s) => s.student_id)
|
|
||||||
);
|
|
||||||
// We only know student names from submissions; for unsubmitted we need the
|
// We only know student names from submissions; for unsubmitted we need the
|
||||||
// class roster. Since the API doesn't return the full roster in this endpoint,
|
// full class member list. Since the API doesn't return all members here,
|
||||||
// we display submission info from what's available.
|
// we display submission info from what's available.
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -336,7 +330,7 @@ export default function AssignmentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Admin actions */}
|
{/* Admin actions */}
|
||||||
<RoleGuard roles={["class_admin", "super_admin"]}>
|
<RoleGuard permissions={["assignment_manage"]}>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
{assignment.status === "open" && (
|
{assignment.status === "open" && (
|
||||||
<Button
|
<Button
|
||||||
@ -478,7 +472,7 @@ export default function AssignmentDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Admin: Submissions table */}
|
{/* Admin: Submissions table */}
|
||||||
<RoleGuard roles={["class_admin", "super_admin"]}>
|
<RoleGuard permissions={["assignment_manage"]}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<h2 className="text-lg font-semibold mb-4">
|
<h2 className="text-lg font-semibold mb-4">
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useState, useRef, useCallback } from "react";
|
import { useEffect, useState, useRef, useCallback } from "react";
|
||||||
import { useActiveClass } from "@/hooks/use-active-class";
|
import { useActiveClass } from "@/hooks/use-active-class";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { fetchAPI, postAPI, deleteAPI, uploadAPI } from "@/lib/api";
|
import { fetchAPI, postAPI, deleteAPI, uploadAPI, getErrorMessage } from "@/lib/api";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@ -22,7 +22,8 @@ import { ErrorState } from "@/components/error-state";
|
|||||||
import { Pagination } from "@/components/pagination";
|
import { Pagination } from "@/components/pagination";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { Assignment } from "@/lib/types";
|
import type { Assignment, PageResponse } from "@/lib/types";
|
||||||
|
import { hasClassPermission } from "@/lib/permissions";
|
||||||
|
|
||||||
function formatDeadline(deadline: string | null): {
|
function formatDeadline(deadline: string | null): {
|
||||||
text: string;
|
text: string;
|
||||||
@ -68,15 +69,15 @@ export default function AssignmentsPage() {
|
|||||||
const loadAssignments = useCallback(async () => {
|
const loadAssignments = useCallback(async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetchAPI<any>("/api/assignments/", {
|
const res = await fetchAPI<PageResponse<Assignment>>("/api/assignments/", {
|
||||||
page_size: "10",
|
page_size: "10",
|
||||||
page: String(page),
|
page: String(page),
|
||||||
class_id: String(activeClassId),
|
class_id: String(activeClassId),
|
||||||
});
|
});
|
||||||
setAssignments(res.items || []);
|
setAssignments(res.items || []);
|
||||||
setTotalPages(res.total_pages || 1);
|
setTotalPages(res.total_pages || 1);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || "加载失败");
|
setError(getErrorMessage(err, "加载失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -99,11 +100,10 @@ export default function AssignmentsPage() {
|
|||||||
if (!newTitle.trim()) return;
|
if (!newTitle.trim()) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const assignment = await postAPI<Assignment>("/api/assignments/", {
|
const assignment = await postAPI<Assignment>(`/api/assignments/?class_id=${activeClassId}`, {
|
||||||
title: newTitle,
|
title: newTitle,
|
||||||
description: newDescription || null,
|
description: newDescription || null,
|
||||||
deadline: newDeadline || null,
|
deadline: newDeadline || null,
|
||||||
class_id: activeClassId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Upload attachments if any
|
// Upload attachments if any
|
||||||
@ -119,8 +119,8 @@ export default function AssignmentsPage() {
|
|||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
loadAssignments();
|
loadAssignments();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.message || "发布失败");
|
toast.error(getErrorMessage(err, "发布失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@ -132,13 +132,12 @@ export default function AssignmentsPage() {
|
|||||||
toast.success("已删除");
|
toast.success("已删除");
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
loadAssignments();
|
loadAssignments();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.message || "删除失败");
|
toast.error(getErrorMessage(err, "删除失败"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isAdmin =
|
const isAdmin = hasClassPermission(user, "assignment_manage", activeClassId);
|
||||||
user?.role === "class_admin" || user?.role === "super_admin";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@ -147,7 +146,7 @@ export default function AssignmentsPage() {
|
|||||||
<h1 className="text-2xl font-bold">作业</h1>
|
<h1 className="text-2xl font-bold">作业</h1>
|
||||||
<p className="text-gray-500 mt-1">查看与提交课程作业</p>
|
<p className="text-gray-500 mt-1">查看与提交课程作业</p>
|
||||||
</div>
|
</div>
|
||||||
<RoleGuard roles={["class_admin", "super_admin"]}>
|
<RoleGuard permissions={["assignment_manage"]}>
|
||||||
<Dialog
|
<Dialog
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { fetchAPI } from "@/lib/api";
|
import { fetchAPI, getErrorMessage } from "@/lib/api";
|
||||||
import { useActiveClass } from "@/hooks/use-active-class";
|
import { useActiveClass } from "@/hooks/use-active-class";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -17,61 +18,123 @@ import type { ScheduleItem, TimelinePost, Announcement } from "@/lib/types";
|
|||||||
import { SCHEDULE_TYPES } from "@/lib/constants";
|
import { SCHEDULE_TYPES } from "@/lib/constants";
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { activeClassId } = useActiveClass();
|
const { activeClassId, activeClassName } = useActiveClass();
|
||||||
const [upcoming, setUpcoming] = useState<ScheduleItem[]>([]);
|
const [upcoming, setUpcoming] = useState<ScheduleItem[]>([]);
|
||||||
const [recentTimeline, setRecentTimeline] = useState<TimelinePost[]>([]);
|
const [recentTimeline, setRecentTimeline] = useState<TimelinePost[]>([]);
|
||||||
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
|
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedAnnouncement, setSelectedAnnouncement] = useState<Announcement | null>(null);
|
const [selectedAnnouncement, setSelectedAnnouncement] = useState<Announcement | null>(null);
|
||||||
const [selectedSchedule, setSelectedSchedule] = useState<ScheduleItem | null>(null);
|
const [selectedSchedule, setSelectedSchedule] = useState<ScheduleItem | null>(null);
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
const loadData = async () => {
|
|
||||||
if (!activeClassId) return;
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const [upcomingRes, timelineRes, announcementsRes] = await Promise.all([
|
|
||||||
fetchAPI<ScheduleItem[]>("/api/schedule/upcoming", { limit: "3", class_id: String(activeClassId) }),
|
|
||||||
fetchAPI<any>("/api/timeline/", { page_size: "3", class_id: String(activeClassId) }),
|
|
||||||
fetchAPI<any>("/api/announcements/", { page_size: "3", class_id: String(activeClassId) }),
|
|
||||||
]);
|
|
||||||
setUpcoming(upcomingRes);
|
|
||||||
setRecentTimeline(timelineRes.items || []);
|
|
||||||
setAnnouncements(announcementsRes.items || []);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message || "加载失败");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
if (!activeClassId) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
const [upcomingRes, timelineRes, announcementsRes] = await Promise.all([
|
||||||
|
fetchAPI<ScheduleItem[]>("/api/schedule/upcoming", { limit: "3", class_id: String(activeClassId) }),
|
||||||
|
fetchAPI<{ items: TimelinePost[] }>("/api/timeline/", { page_size: "3", class_id: String(activeClassId) }),
|
||||||
|
fetchAPI<{ items: Announcement[] }>("/api/announcements/", { page_size: "3", class_id: String(activeClassId) }),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
setError(null);
|
||||||
|
setUpcoming(upcomingRes);
|
||||||
|
setRecentTimeline(timelineRes.items || []);
|
||||||
|
setAnnouncements(announcementsRes.items || []);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (cancelled) return;
|
||||||
|
setError(getErrorMessage(err, "加载失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void run();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [activeClassId]);
|
}, [activeClassId]);
|
||||||
|
|
||||||
const getCountdown = (startTime: string) => {
|
useEffect(() => {
|
||||||
const diff = new Date(startTime).getTime() - Date.now();
|
const timer = window.setInterval(() => setNow(Date.now()), 60_000);
|
||||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
return () => window.clearInterval(timer);
|
||||||
if (days <= 0) return "已开始";
|
}, []);
|
||||||
if (days === 1) return "明天";
|
|
||||||
if (days <= 7) return `${days}天后`;
|
const countdownByScheduleId = useMemo(() => {
|
||||||
return `${days}天后`;
|
return Object.fromEntries(
|
||||||
};
|
upcoming.map((item) => {
|
||||||
|
const diff = new Date(item.start_time).getTime() - now;
|
||||||
|
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||||
|
const label =
|
||||||
|
days <= 0 ? "已开始" : days === 1 ? "明天" : `${days}天后`;
|
||||||
|
return [item.id, label];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [upcoming, now]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<section className="relative overflow-hidden rounded-[2rem] border border-[#e7d3ba] bg-[linear-gradient(135deg,rgba(108,26,37,0.96),rgba(145,84,53,0.92)_58%,rgba(233,206,160,0.9)_130%)] px-6 py-8 text-white shadow-[0_30px_80px_-42px_rgba(83,25,24,0.58)] md:px-8 md:py-10">
|
||||||
<h1 className="text-2xl font-bold">HKU ICB 仪表盘</h1>
|
<div className="absolute inset-y-0 right-0 w-1/2 bg-[radial-gradient(circle_at_center,rgba(255,237,207,0.26),transparent_62%)]" />
|
||||||
<p className="text-gray-500 mt-1">欢迎回来</p>
|
<div className="relative max-w-3xl space-y-4">
|
||||||
|
<div className="inline-flex items-center rounded-full border border-white/20 bg-white/10 px-3 py-1 text-[11px] uppercase tracking-[0.24em] text-[#f7e8cb]">
|
||||||
|
HKU ICB
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-3xl font-semibold tracking-tight md:text-4xl">
|
||||||
|
{activeClassName || "香港大学中国商业学院"}
|
||||||
|
</h1>
|
||||||
|
<p className="max-w-2xl text-sm leading-6 text-white/78 md:text-base">
|
||||||
|
班级信息管理平台,集中展示公告、动态、课程安排与协作信息。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Link href="/announcements">
|
||||||
|
<Button className="bg-[#f1d39d] text-[#4c1d17] hover:bg-[#f4ddb2]">查看公告</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/timeline">
|
||||||
|
<Button variant="outline" className="border-white/28 bg-white/8 text-white hover:bg-white/14 hover:text-white">
|
||||||
|
班级动态
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
<Card className="bg-[#fffaf2]">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-[#9a6a49]">Pinned Notes</p>
|
||||||
|
<p className="mt-3 text-3xl font-semibold text-[#5b1f1a]">{announcements.length}</p>
|
||||||
|
<p className="mt-1 text-sm text-[#7a5a48]">当前可见公告</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="bg-[#fffaf2]">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-[#9a6a49]">Upcoming</p>
|
||||||
|
<p className="mt-3 text-3xl font-semibold text-[#5b1f1a]">{upcoming.length}</p>
|
||||||
|
<p className="mt-1 text-sm text-[#7a5a48]">近期待办排期</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="bg-[#fffaf2]">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-[#9a6a49]">Class Feed</p>
|
||||||
|
<p className="mt-3 text-3xl font-semibold text-[#5b1f1a]">{recentTimeline.length}</p>
|
||||||
|
<p className="mt-1 text-sm text-[#7a5a48]">最近班级动态</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<ErrorState message={error} onRetry={loadData} />
|
<ErrorState message={error} onRetry={() => window.location.reload()} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Latest announcements */}
|
{/* Latest announcements */}
|
||||||
{announcements.length > 0 && (
|
{announcements.length > 0 && (
|
||||||
<Card>
|
<Card className="bg-[#fffdf8]">
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<CardTitle className="text-lg">最新公告</CardTitle>
|
<CardTitle className="text-lg text-[#4a1f1a]">最新公告</CardTitle>
|
||||||
<Link href="/announcements" className="text-sm text-gray-500 hover:text-gray-900 transition-colors">
|
<Link href="/announcements" className="text-sm text-[#8a6045] transition-colors hover:text-[#4a1f1a]">
|
||||||
查看全部
|
查看全部
|
||||||
</Link>
|
</Link>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@ -80,17 +143,19 @@ export default function DashboardPage() {
|
|||||||
{announcements.map((a) => (
|
{announcements.map((a) => (
|
||||||
<div
|
<div
|
||||||
key={a.id}
|
key={a.id}
|
||||||
className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100 transition-colors"
|
className="cursor-pointer rounded-2xl border border-[#eadbc8] bg-[#fff8ef] p-4 transition-colors hover:bg-[#fff2df]"
|
||||||
onClick={() => setSelectedAnnouncement(a)}
|
onClick={() => setSelectedAnnouncement(a)}
|
||||||
>
|
>
|
||||||
{a.is_pinned && (
|
<div className="flex items-start gap-3">
|
||||||
<Badge variant="secondary" className="shrink-0 bg-amber-100 text-amber-700 text-xs">置顶</Badge>
|
{a.is_pinned && (
|
||||||
)}
|
<Badge variant="secondary" className="shrink-0 bg-[#f3ddab] text-[#74411f] text-xs">置顶</Badge>
|
||||||
<div className="flex-1 min-w-0">
|
)}
|
||||||
<p className="text-sm font-medium truncate">{a.title}</p>
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-sm font-medium truncate text-[#4a1f1a]">{a.title}</p>
|
||||||
|
<p className="mt-1 text-xs text-[#866556]">
|
||||||
{a.author_name} · {new Date(a.created_at).toLocaleDateString("zh-CN")}
|
{a.author_name} · {new Date(a.created_at).toLocaleDateString("zh-CN")}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -101,19 +166,19 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{/* Upcoming schedules */}
|
{/* Upcoming schedules */}
|
||||||
<Card>
|
<Card className="bg-[#fffdf8]">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">即将到来</CardTitle>
|
<CardTitle className="text-lg text-[#4a1f1a]">即将到来</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{upcoming.length === 0 ? (
|
{upcoming.length === 0 ? (
|
||||||
<p className="text-gray-400 text-sm">暂无排期</p>
|
<p className="text-sm text-[#9a7d69]">暂无排期</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{upcoming.map((item) => (
|
{upcoming.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100 transition-colors"
|
className="flex cursor-pointer items-center justify-between rounded-2xl border border-[#eadbc8] bg-[#fff8ef] p-4 transition-colors hover:bg-[#fff2df]"
|
||||||
onClick={() => setSelectedSchedule(item)}
|
onClick={() => setSelectedSchedule(item)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@ -123,14 +188,14 @@ export default function DashboardPage() {
|
|||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">{item.title}</p>
|
<p className="text-sm font-medium text-[#4a1f1a]">{item.title}</p>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-[#866556]">
|
||||||
{new Date(item.start_time).toLocaleDateString("zh-CN")}
|
{new Date(item.start_time).toLocaleDateString("zh-CN")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="bg-[#efe2c8] text-[#6b4d39] text-xs">
|
||||||
{getCountdown(item.start_time)}
|
{countdownByScheduleId[item.id] ?? "即将开始"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -140,19 +205,19 @@ export default function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Recent timeline */}
|
{/* Recent timeline */}
|
||||||
<Card>
|
<Card className="bg-[#fffdf8]">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">最近动态</CardTitle>
|
<CardTitle className="text-lg text-[#4a1f1a]">最近动态</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{recentTimeline.length === 0 ? (
|
{recentTimeline.length === 0 ? (
|
||||||
<p className="text-gray-400 text-sm">暂无动态</p>
|
<p className="text-sm text-[#9a7d69]">暂无动态</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{recentTimeline.map((post) => (
|
{recentTimeline.map((post) => (
|
||||||
<div key={post.id} className="p-3 bg-gray-50 rounded-lg">
|
<div key={post.id} className="rounded-2xl border border-[#eadbc8] bg-[#fff8ef] p-4">
|
||||||
<p className="text-sm font-medium">{post.title}</p>
|
<p className="text-sm font-medium text-[#4a1f1a]">{post.title}</p>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="mt-1 text-xs text-[#866556]">
|
||||||
{post.author_name} ·{" "}
|
{post.author_name} ·{" "}
|
||||||
{new Date(post.created_at).toLocaleDateString("zh-CN")}
|
{new Date(post.created_at).toLocaleDateString("zh-CN")}
|
||||||
</p>
|
</p>
|
||||||
@ -186,9 +251,9 @@ export default function DashboardPage() {
|
|||||||
})}</span>
|
})}</span>
|
||||||
</div>
|
</div>
|
||||||
{selectedAnnouncement.content ? (
|
{selectedAnnouncement.content ? (
|
||||||
<p className="text-gray-700 whitespace-pre-wrap">{selectedAnnouncement.content}</p>
|
<p className="whitespace-pre-wrap text-[#5b4336]">{selectedAnnouncement.content}</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-400">暂无详细内容</p>
|
<p className="text-[#9a7d69]">暂无详细内容</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@ -211,10 +276,10 @@ export default function DashboardPage() {
|
|||||||
{SCHEDULE_TYPES[selectedSchedule.type]?.label || selectedSchedule.type}
|
{SCHEDULE_TYPES[selectedSchedule.type]?.label || selectedSchedule.type}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{getCountdown(selectedSchedule.start_time)}
|
{countdownByScheduleId[selectedSchedule.id] ?? "即将开始"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500 space-y-1">
|
<div className="space-y-1 text-sm text-[#7f6352]">
|
||||||
<p>开始:{new Date(selectedSchedule.start_time).toLocaleString("zh-CN", { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" })}</p>
|
<p>开始:{new Date(selectedSchedule.start_time).toLocaleString("zh-CN", { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" })}</p>
|
||||||
{selectedSchedule.end_time && (
|
{selectedSchedule.end_time && (
|
||||||
<p>结束:{new Date(selectedSchedule.end_time).toLocaleString("zh-CN", { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" })}</p>
|
<p>结束:{new Date(selectedSchedule.end_time).toLocaleString("zh-CN", { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" })}</p>
|
||||||
@ -224,9 +289,9 @@ export default function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{selectedSchedule.description ? (
|
{selectedSchedule.description ? (
|
||||||
<p className="text-gray-700 whitespace-pre-wrap">{selectedSchedule.description}</p>
|
<p className="whitespace-pre-wrap text-[#5b4336]">{selectedSchedule.description}</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-400">暂无详细说明</p>
|
<p className="text-[#9a7d69]">暂无详细说明</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { fetchAPI, getErrorMessage } from "@/lib/api";
|
||||||
import { fetchAPI } from "@/lib/api";
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@ -13,17 +12,18 @@ import Link from "next/link";
|
|||||||
|
|
||||||
export default function MemberDetailPage() {
|
export default function MemberDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const { user } = useAuth();
|
|
||||||
const [member, setMember] = useState<UserPublic | null>(null);
|
const [member, setMember] = useState<UserPublic | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = params.id as string;
|
const id = params.id as string;
|
||||||
setError(null);
|
|
||||||
fetchAPI<UserPublic>(`/api/directory/${id}`)
|
fetchAPI<UserPublic>(`/api/directory/${id}`)
|
||||||
.then(setMember)
|
.then((data) => {
|
||||||
.catch((err: any) => setError(err.message || "加载失败"))
|
setMember(data);
|
||||||
|
setError(null);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => setError(getErrorMessage(err, "加载失败")))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [params.id]);
|
}, [params.id]);
|
||||||
|
|
||||||
@ -40,7 +40,7 @@ export default function MemberDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-2xl mx-auto">
|
||||||
<Link href="/directory" className="text-sm text-gray-500 hover:text-gray-700 mb-4 inline-block">
|
<Link href="/directory" className="text-sm text-gray-500 hover:text-gray-700 mb-4 inline-block">
|
||||||
← 返回花名册
|
← 返回成员名录
|
||||||
</Link>
|
</Link>
|
||||||
<ErrorState message={error} onRetry={() => window.location.reload()} />
|
<ErrorState message={error} onRetry={() => window.location.reload()} />
|
||||||
</div>
|
</div>
|
||||||
@ -51,35 +51,33 @@ export default function MemberDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const showContact = user?.class_id === member.id; // Privacy: same class check handled by API
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-2xl mx-auto">
|
||||||
<Link href="/directory" className="text-sm text-gray-500 hover:text-gray-700 mb-4 inline-block">
|
<Link href="/directory" className="mb-4 inline-block text-sm text-[#896650] hover:text-[#4e1d1a]">
|
||||||
← 返回花名册
|
← 返回成员名录
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Card>
|
<Card className="bg-[#fffdf8]">
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
{/* Avatar centered at top */}
|
{/* Avatar centered at top */}
|
||||||
<div className="flex flex-col items-center text-center mb-6">
|
<div className="flex flex-col items-center text-center mb-6">
|
||||||
<Avatar className="h-28 w-28 mb-4">
|
<Avatar className="h-28 w-28 mb-4">
|
||||||
<AvatarImage src={member.avatar_url || undefined} alt={member.name} />
|
<AvatarImage src={member.avatar_url || undefined} alt={member.name} />
|
||||||
<AvatarFallback className="bg-gray-900 text-white text-4xl">
|
<AvatarFallback className="bg-gradient-to-br from-[#6f2030] to-[#a4633f] text-white text-4xl">
|
||||||
{member.name[0]}
|
{member.name[0]}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<h1 className="text-2xl font-bold">{member.name}</h1>
|
<h1 className="text-3xl font-semibold text-[#4e1d1a]">{member.name}</h1>
|
||||||
{member.committee_role && (
|
{member.committee_role && (
|
||||||
<Badge className="mt-1 bg-amber-100 text-amber-800 hover:bg-amber-100 border-amber-200">
|
<Badge className="mt-1 bg-[#f3ddab] text-[#74411f] hover:bg-[#f3ddab] border-[#e5c884]">
|
||||||
{member.committee_role}
|
{member.committee_role}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{member.student_id && (
|
{member.student_id && (
|
||||||
<p className="text-sm text-gray-500 mt-1">学号: {member.student_id}</p>
|
<p className="mt-1 text-sm text-[#896c5a]">学号: {member.student_id}</p>
|
||||||
)}
|
)}
|
||||||
{member.company && (
|
{member.company && (
|
||||||
<p className="text-gray-600 mt-1">
|
<p className="mt-1 text-[#6c5245]">
|
||||||
{member.company}
|
{member.company}
|
||||||
{member.position ? ` · ${member.position}` : ""}
|
{member.position ? ` · ${member.position}` : ""}
|
||||||
</p>
|
</p>
|
||||||
@ -91,20 +89,20 @@ export default function MemberDetailPage() {
|
|||||||
|
|
||||||
{member.bio && (
|
{member.bio && (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<h3 className="text-sm font-medium text-gray-500 mb-2">自我介绍</h3>
|
<h3 className="mb-2 text-sm font-medium text-[#8a6d5e]">自我介绍</h3>
|
||||||
<p className="text-gray-700 whitespace-pre-wrap">{member.bio}</p>
|
<p className="whitespace-pre-wrap text-[#5f473b]">{member.bio}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(member.wechat_id || member.phone) && (
|
{(member.wechat_id || member.phone) && (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<h3 className="text-sm font-medium text-gray-500 mb-2">联系方式</h3>
|
<h3 className="mb-2 text-sm font-medium text-[#8a6d5e]">联系方式</h3>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{member.wechat_id && (
|
{member.wechat_id && (
|
||||||
<p className="text-gray-700">微信: {member.wechat_id}</p>
|
<p className="text-[#5f473b]">微信: {member.wechat_id}</p>
|
||||||
)}
|
)}
|
||||||
{member.phone && (
|
{member.phone && (
|
||||||
<p className="text-gray-700">手机: {member.phone}</p>
|
<p className="text-[#5f473b]">手机: {member.phone}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { useActiveClass } from "@/hooks/use-active-class";
|
import { useActiveClass } from "@/hooks/use-active-class";
|
||||||
import { fetchAPI } from "@/lib/api";
|
import { fetchAPI, getErrorMessage } from "@/lib/api";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
@ -16,7 +16,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||||||
import { ErrorState } from "@/components/error-state";
|
import { ErrorState } from "@/components/error-state";
|
||||||
import { Pagination } from "@/components/pagination";
|
import { Pagination } from "@/components/pagination";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import type { UserPublic } from "@/lib/types";
|
import type { PageResponse, UserPublic } from "@/lib/types";
|
||||||
import { INDUSTRY_OPTIONS } from "@/lib/constants";
|
import { INDUSTRY_OPTIONS } from "@/lib/constants";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
@ -46,12 +46,12 @@ export default function DirectoryPage() {
|
|||||||
if (search) params.search = search;
|
if (search) params.search = search;
|
||||||
if (industry) params.industry = industry;
|
if (industry) params.industry = industry;
|
||||||
if (company) params.company = company;
|
if (company) params.company = company;
|
||||||
const res = await fetchAPI<any>("/api/directory/", params);
|
const res = await fetchAPI<PageResponse<UserPublic>>("/api/directory/", params);
|
||||||
setMembers(res.items || []);
|
setMembers(res.items ?? []);
|
||||||
setTotal(res.total || 0);
|
setTotal(res.total ?? 0);
|
||||||
setTotalPages(res.total_pages || 1);
|
setTotalPages(res.total_pages ?? 1);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || "加载失败");
|
setError(getErrorMessage(err, "加载失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -63,20 +63,21 @@ export default function DirectoryPage() {
|
|||||||
}, [search, industry, company]);
|
}, [search, industry, company]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeClassId) return;
|
|
||||||
const timer = setTimeout(loadMembers, 300);
|
const timer = setTimeout(loadMembers, 300);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [loadMembers]);
|
}, [activeClassId, loadMembers]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">花名册</h1>
|
<div className="text-[11px] uppercase tracking-[0.24em] text-[#976746]">Directory</div>
|
||||||
<p className="text-gray-500 mt-1">共 {total} 位同学</p>
|
<h1 className="mt-2 text-3xl font-semibold text-[#4e1d1a]">成员名录</h1>
|
||||||
|
<p className="mt-2 text-[#765a4d]">共 {total} 位成员,按行业、公司与研究兴趣建立连接</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search & Filters */}
|
{/* Search & Filters */}
|
||||||
<div className="flex flex-wrap gap-3">
|
<Card className="bg-[#fffaf2]">
|
||||||
|
<CardContent className="flex flex-wrap gap-3 p-5">
|
||||||
<Input
|
<Input
|
||||||
placeholder="搜索姓名、公司、职位..."
|
placeholder="搜索姓名、公司、职位..."
|
||||||
value={search}
|
value={search}
|
||||||
@ -102,13 +103,14 @@ export default function DirectoryPage() {
|
|||||||
onChange={(e) => setCompany(e.target.value)}
|
onChange={(e) => setCompany(e.target.value)}
|
||||||
className="w-full sm:w-40"
|
className="w-full sm:w-40"
|
||||||
/>
|
/>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Member List */}
|
{/* Member List */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||||
<Card key={i} className="animate-pulse">
|
<Card key={i} className="animate-pulse bg-[#fffaf2]">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="h-20 bg-gray-200 rounded" />
|
<div className="h-20 bg-gray-200 rounded" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -118,24 +120,24 @@ export default function DirectoryPage() {
|
|||||||
) : error ? (
|
) : error ? (
|
||||||
<ErrorState message={error} onRetry={loadMembers} />
|
<ErrorState message={error} onRetry={loadMembers} />
|
||||||
) : members.length === 0 ? (
|
) : members.length === 0 ? (
|
||||||
<div className="text-center py-12 text-gray-400">没有找到匹配的同学</div>
|
<div className="py-12 text-center text-[#9d806f]">没有找到匹配的成员</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
{members.map((member) => (
|
{members.map((member) => (
|
||||||
<Link key={member.id} href={`/directory/${member.id}`}>
|
<Link key={member.id} href={`/directory/${member.id}`}>
|
||||||
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
|
<Card className="h-full cursor-pointer bg-[#fffdf8] transition-all hover:-translate-y-0.5 hover:shadow-[0_24px_45px_-32px_rgba(84,29,23,0.38)]">
|
||||||
<CardContent className="p-4 flex items-start gap-3">
|
<CardContent className="p-4 flex items-start gap-3">
|
||||||
<Avatar className="h-11 w-11 shrink-0">
|
<Avatar className="h-11 w-11 shrink-0">
|
||||||
<AvatarImage src={member.avatar_url || undefined} alt={member.name} />
|
<AvatarImage src={member.avatar_url || undefined} alt={member.name} />
|
||||||
<AvatarFallback className="bg-gray-900 text-white text-base">
|
<AvatarFallback className="bg-gradient-to-br from-[#6f2030] to-[#a4633f] text-white text-base">
|
||||||
{member.name[0]}
|
{member.name[0]}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
<p className="font-medium">{member.name}</p>
|
<p className="font-medium text-[#4e1d1a]">{member.name}</p>
|
||||||
{member.committee_role && (
|
{member.committee_role && (
|
||||||
<Badge className="text-xs bg-amber-100 text-amber-800 hover:bg-amber-100 border-amber-200">
|
<Badge className="text-xs bg-[#f3ddab] text-[#74411f] hover:bg-[#f3ddab] border-[#e5c884]">
|
||||||
{member.committee_role}
|
{member.committee_role}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@ -146,18 +148,18 @@ export default function DirectoryPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{member.company && (
|
{member.company && (
|
||||||
<p className="text-sm text-gray-600 mt-0.5 truncate">
|
<p className="mt-0.5 truncate text-sm text-[#73594a]">
|
||||||
{member.company}
|
{member.company}
|
||||||
{member.position ? ` · ${member.position}` : ""}
|
{member.position ? ` · ${member.position}` : ""}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{member.student_id && (
|
{member.student_id && (
|
||||||
<p className="text-xs text-gray-400 mt-0.5">
|
<p className="mt-0.5 text-xs text-[#9a7b68]">
|
||||||
学号: {member.student_id}
|
学号: {member.student_id}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{member.bio && (
|
{member.bio && (
|
||||||
<p className="text-sm text-gray-500 mt-1 line-clamp-2">
|
<p className="mt-1 line-clamp-2 text-sm text-[#8a6d5e]">
|
||||||
{member.bio}
|
{member.bio}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useActiveClass } from "@/hooks/use-active-class";
|
import { useActiveClass } from "@/hooks/use-active-class";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { fetchAPI, postAPI, putAPI, deleteAPI } from "@/lib/api";
|
import { fetchAPI, postAPI, putAPI, deleteAPI, getErrorMessage } from "@/lib/api";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@ -28,13 +28,14 @@ import { ConfirmDialog } from "@/components/confirm-dialog";
|
|||||||
import { ErrorState } from "@/components/error-state";
|
import { ErrorState } from "@/components/error-state";
|
||||||
import { Pagination } from "@/components/pagination";
|
import { Pagination } from "@/components/pagination";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { FundRecord, FundStatistics } from "@/lib/types";
|
import type { FundRecord, FundStatistics, PageResponse } from "@/lib/types";
|
||||||
import { FUND_TYPES, FUND_INCOME_CATEGORIES, FUND_EXPENSE_CATEGORIES } from "@/lib/constants";
|
import { FUND_TYPES, FUND_INCOME_CATEGORIES, FUND_EXPENSE_CATEGORIES } from "@/lib/constants";
|
||||||
|
import { hasClassPermission } from "@/lib/permissions";
|
||||||
|
|
||||||
export default function FundPage() {
|
export default function FundPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { activeClassId } = useActiveClass();
|
const { activeClassId } = useActiveClass();
|
||||||
const isAdmin = user?.role === "super_admin" || user?.role === "class_admin";
|
const isAdmin = hasClassPermission(user, "fund_manage", activeClassId);
|
||||||
|
|
||||||
// Statistics
|
// Statistics
|
||||||
const [stats, setStats] = useState<FundStatistics | null>(null);
|
const [stats, setStats] = useState<FundStatistics | null>(null);
|
||||||
@ -61,12 +62,16 @@ export default function FundPage() {
|
|||||||
// Delete state
|
// Delete state
|
||||||
const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
|
||||||
|
|
||||||
const loadStats = async () => {
|
const loadStats = useCallback(async () => {
|
||||||
if (!activeClassId) { setStatsLoading(false); return; }
|
if (!activeClassId) {
|
||||||
|
setStats(null);
|
||||||
|
setStatsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setStatsLoading(true);
|
setStatsLoading(true);
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
if (user?.role === "super_admin" && activeClassId) {
|
if (activeClassId) {
|
||||||
params.class_id = String(activeClassId);
|
params.class_id = String(activeClassId);
|
||||||
}
|
}
|
||||||
const res = await fetchAPI<FundStatistics>(
|
const res = await fetchAPI<FundStatistics>(
|
||||||
@ -79,35 +84,39 @@ export default function FundPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setStatsLoading(false);
|
setStatsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [activeClassId]);
|
||||||
|
|
||||||
const loadRecords = async () => {
|
const loadRecords = useCallback(async () => {
|
||||||
if (!activeClassId) { setLoading(false); return; }
|
if (!activeClassId) {
|
||||||
|
setRecords([]);
|
||||||
|
setError(null);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string> = {
|
const params: Record<string, string> = {
|
||||||
page: String(page),
|
page: String(page),
|
||||||
page_size: "20",
|
page_size: "20",
|
||||||
|
class_id: String(activeClassId),
|
||||||
};
|
};
|
||||||
if (typeFilter !== "all") params.type = typeFilter;
|
if (typeFilter !== "all") params.type = typeFilter;
|
||||||
if (user?.role === "super_admin") params.class_id = String(activeClassId);
|
|
||||||
|
|
||||||
const res = await fetchAPI<any>(`/api/fund/`, params);
|
const res = await fetchAPI<PageResponse<FundRecord>>(`/api/fund/`, params);
|
||||||
setRecords(res.items || []);
|
setRecords(res.items ?? []);
|
||||||
setTotalPages(res.total_pages || 1);
|
setTotalPages(res.total_pages ?? 1);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || "加载失败");
|
setError(getErrorMessage(err, "加载失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [activeClassId, page, typeFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeClassId) return;
|
void loadStats();
|
||||||
loadStats();
|
void loadRecords();
|
||||||
loadRecords();
|
}, [loadRecords, loadStats]);
|
||||||
}, [activeClassId, page, typeFilter]);
|
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setFormType("income");
|
setFormType("income");
|
||||||
@ -163,17 +172,15 @@ export default function FundPage() {
|
|||||||
await putAPI(`/api/fund/${editingId}`, payload);
|
await putAPI(`/api/fund/${editingId}`, payload);
|
||||||
toast.success("记录已更新");
|
toast.success("记录已更新");
|
||||||
} else {
|
} else {
|
||||||
const url = user?.role === "super_admin" && activeClassId
|
const url = `/api/fund/?class_id=${activeClassId}`;
|
||||||
? `/api/fund/?class_id=${activeClassId}`
|
|
||||||
: `/api/fund/`;
|
|
||||||
await postAPI(url, payload);
|
await postAPI(url, payload);
|
||||||
toast.success("记录已添加");
|
toast.success("记录已添加");
|
||||||
}
|
}
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
loadStats();
|
await loadStats();
|
||||||
loadRecords();
|
await loadRecords();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.message || "操作失败");
|
toast.error(getErrorMessage(err, "操作失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@ -185,10 +192,10 @@ export default function FundPage() {
|
|||||||
await deleteAPI(`/api/fund/${deleteTarget}`);
|
await deleteAPI(`/api/fund/${deleteTarget}`);
|
||||||
toast.success("记录已删除");
|
toast.success("记录已删除");
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
loadStats();
|
await loadStats();
|
||||||
loadRecords();
|
await loadRecords();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.message || "删除失败");
|
toast.error(getErrorMessage(err, "删除失败"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -205,7 +212,7 @@ export default function FundPage() {
|
|||||||
<h1 className="text-2xl font-bold">班费管理</h1>
|
<h1 className="text-2xl font-bold">班费管理</h1>
|
||||||
<p className="text-gray-500 mt-1">记录和管理班费收支</p>
|
<p className="text-gray-500 mt-1">记录和管理班费收支</p>
|
||||||
</div>
|
</div>
|
||||||
<RoleGuard roles={["super_admin", "class_admin"]}>
|
<RoleGuard permissions={["fund_manage"]}>
|
||||||
<Button onClick={openCreate}>添加记录</Button>
|
<Button onClick={openCreate}>添加记录</Button>
|
||||||
</RoleGuard>
|
</RoleGuard>
|
||||||
</div>
|
</div>
|
||||||
@ -448,4 +455,4 @@ export default function FundPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,11 +11,13 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
|
|||||||
<ActiveClassProvider>
|
<ActiveClassProvider>
|
||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<div className="flex h-screen bg-gray-50">
|
<div className="flex h-screen bg-transparent">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="flex-1 overflow-y-auto p-4 md:p-6">{children}</main>
|
<main className="flex-1 overflow-y-auto px-4 pb-8 pt-4 md:px-8 md:pb-10 md:pt-6">
|
||||||
|
<div className="mx-auto w-full max-w-7xl">{children}</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { putAPI, uploadAPI } from "@/lib/api";
|
import { getErrorMessage, putAPI, uploadAPI } from "@/lib/api";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@ -45,6 +46,38 @@ export default function ProfilePage() {
|
|||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
const normalizedCurrent = {
|
||||||
|
email: email.trim(),
|
||||||
|
name: name.trim(),
|
||||||
|
industry: industry.trim(),
|
||||||
|
company: company.trim(),
|
||||||
|
position: position.trim(),
|
||||||
|
wechatId: wechatId.trim(),
|
||||||
|
phone: phone.trim(),
|
||||||
|
bio: bio.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizedSaved = {
|
||||||
|
email: (user?.email ?? "").trim(),
|
||||||
|
name: (user?.name ?? "").trim(),
|
||||||
|
industry: (user?.industry ?? "").trim(),
|
||||||
|
company: (user?.company ?? "").trim(),
|
||||||
|
position: (user?.position ?? "").trim(),
|
||||||
|
wechatId: (user?.wechat_id ?? "").trim(),
|
||||||
|
phone: (user?.phone ?? "").trim(),
|
||||||
|
bio: (user?.bio ?? "").trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasChanges =
|
||||||
|
normalizedCurrent.email !== normalizedSaved.email ||
|
||||||
|
normalizedCurrent.name !== normalizedSaved.name ||
|
||||||
|
normalizedCurrent.industry !== normalizedSaved.industry ||
|
||||||
|
normalizedCurrent.company !== normalizedSaved.company ||
|
||||||
|
normalizedCurrent.position !== normalizedSaved.position ||
|
||||||
|
normalizedCurrent.wechatId !== normalizedSaved.wechatId ||
|
||||||
|
normalizedCurrent.phone !== normalizedSaved.phone ||
|
||||||
|
normalizedCurrent.bio !== normalizedSaved.bio;
|
||||||
|
|
||||||
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@ -59,8 +92,8 @@ export default function ProfilePage() {
|
|||||||
await uploadAPI("/api/users/me/avatar", formData);
|
await uploadAPI("/api/users/me/avatar", formData);
|
||||||
await refreshUser();
|
await refreshUser();
|
||||||
toast.success("头像已更新");
|
toast.success("头像已更新");
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.message || "头像上传失败");
|
toast.error(getErrorMessage(err, "头像上传失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setAvatarUploading(false);
|
setAvatarUploading(false);
|
||||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
@ -69,22 +102,23 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!hasChanges) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await putAPI("/api/users/me", {
|
await putAPI("/api/users/me", {
|
||||||
email: email || undefined,
|
email: normalizedCurrent.email || undefined,
|
||||||
name: name || undefined,
|
name: normalizedCurrent.name || undefined,
|
||||||
industry: industry || undefined,
|
industry: normalizedCurrent.industry || null,
|
||||||
company: company || undefined,
|
company: normalizedCurrent.company || null,
|
||||||
position: position || undefined,
|
position: normalizedCurrent.position || null,
|
||||||
wechat_id: wechatId || undefined,
|
wechat_id: normalizedCurrent.wechatId || null,
|
||||||
phone: phone || undefined,
|
phone: normalizedCurrent.phone || null,
|
||||||
bio: bio || undefined,
|
bio: normalizedCurrent.bio || null,
|
||||||
});
|
});
|
||||||
await refreshUser();
|
await refreshUser();
|
||||||
toast.success("资料已更新");
|
toast.success("资料已更新");
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.message || "更新失败");
|
toast.error(getErrorMessage(err, "更新失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -92,14 +126,18 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-2xl mx-auto">
|
||||||
<h1 className="text-2xl font-bold mb-6">编辑个人资料</h1>
|
<div className="mb-6">
|
||||||
<Card>
|
<div className="text-[11px] uppercase tracking-[0.24em] text-[#976746]">Profile</div>
|
||||||
|
<h1 className="mt-2 text-3xl font-semibold text-[#4e1d1a]">个人资料</h1>
|
||||||
|
<p className="mt-2 text-[#765a4d]">完善你的职业背景、联系方式与个人介绍</p>
|
||||||
|
</div>
|
||||||
|
<Card className="bg-[#fffdf8]">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex items-center gap-4 mb-6">
|
||||||
<div className="w-20 h-20 rounded-full bg-gray-900 text-white flex items-center justify-center text-2xl font-medium overflow-hidden shrink-0">
|
<div className="flex h-20 w-20 shrink-0 items-center justify-center overflow-hidden rounded-full bg-gradient-to-br from-[#6f2030] to-[#a4633f] text-2xl font-medium text-white">
|
||||||
{user?.avatar_url ? (
|
{user?.avatar_url ? (
|
||||||
<img src={user.avatar_url} alt="" className="w-full h-full object-cover" />
|
<Image src={user.avatar_url} alt="" fill className="object-cover" />
|
||||||
) : (
|
) : (
|
||||||
user?.name?.[0] || "?"
|
user?.name?.[0] || "?"
|
||||||
)}
|
)}
|
||||||
@ -120,7 +158,7 @@ export default function ProfilePage() {
|
|||||||
>
|
>
|
||||||
{avatarUploading ? "上传中..." : "更换头像"}
|
{avatarUploading ? "上传中..." : "更换头像"}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-xs text-gray-400 mt-1">支持 JPG/PNG/GIF/WebP,最大 5MB</p>
|
<p className="mt-1 text-xs text-[#9a7b68]">支持 JPG/PNG/GIF/WebP,最大 5MB</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -144,8 +182,19 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>行业</Label>
|
<div className="flex items-center justify-between gap-3">
|
||||||
<Select value={industry} onValueChange={(v) => v && setIndustry(v)}>
|
<Label>行业</Label>
|
||||||
|
{industry && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-900"
|
||||||
|
onClick={() => setIndustry("")}
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Select value={industry} onValueChange={setIndustry}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue>{industry || "选择行业"}</SelectValue>
|
<SelectValue>{industry || "选择行业"}</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@ -211,8 +260,8 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" disabled={loading}>
|
<Button type="submit" disabled={loading || !hasChanges}>
|
||||||
{loading ? "保存中..." : "保存资料"}
|
{loading ? "保存中..." : hasChanges ? "保存资料" : "资料未修改"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useRef } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useActiveClass } from "@/hooks/use-active-class";
|
import { useActiveClass } from "@/hooks/use-active-class";
|
||||||
import { fetchAPI, postAPI, deleteAPI, uploadAPI } from "@/lib/api";
|
import { fetchAPI, postAPI, deleteAPI, uploadAPI, getErrorMessage } from "@/lib/api";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@ -27,7 +27,11 @@ import { ConfirmDialog } from "@/components/confirm-dialog";
|
|||||||
import { ErrorState } from "@/components/error-state";
|
import { ErrorState } from "@/components/error-state";
|
||||||
import { Pagination } from "@/components/pagination";
|
import { Pagination } from "@/components/pagination";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { Resource } from "@/lib/types";
|
import type { PageResponse, Resource } from "@/lib/types";
|
||||||
|
|
||||||
|
type ResourceDownloadResponse = {
|
||||||
|
file_url: string;
|
||||||
|
};
|
||||||
|
|
||||||
const RESOURCE_CATEGORIES: Record<string, string> = {
|
const RESOURCE_CATEGORIES: Record<string, string> = {
|
||||||
all: "全部",
|
all: "全部",
|
||||||
@ -74,8 +78,15 @@ export default function ResourcesPage() {
|
|||||||
// Delete state
|
// Delete state
|
||||||
const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
|
||||||
|
|
||||||
const loadResources = async () => {
|
const loadResources = useCallback(async () => {
|
||||||
|
if (!activeClassId) {
|
||||||
|
setResources([]);
|
||||||
|
setTotalPages(1);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string> = {
|
const params: Record<string, string> = {
|
||||||
page_size: "20",
|
page_size: "20",
|
||||||
@ -83,20 +94,19 @@ export default function ResourcesPage() {
|
|||||||
class_id: String(activeClassId),
|
class_id: String(activeClassId),
|
||||||
};
|
};
|
||||||
if (category !== "all") params.category = category;
|
if (category !== "all") params.category = category;
|
||||||
const res = await fetchAPI<any>("/api/resources/", params);
|
const res = await fetchAPI<PageResponse<Resource>>("/api/resources/", params);
|
||||||
setResources(res.items || []);
|
setResources(res.items || []);
|
||||||
setTotalPages(res.total_pages || 1);
|
setTotalPages(res.total_pages || 1);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || "加载失败");
|
setError(getErrorMessage(err, "加载失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [activeClassId, category, page]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeClassId) return;
|
void loadResources();
|
||||||
loadResources();
|
}, [loadResources]);
|
||||||
}, [activeClassId, page, category]);
|
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setFormTitle("");
|
setFormTitle("");
|
||||||
@ -122,8 +132,8 @@ export default function ResourcesPage() {
|
|||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
loadResources();
|
loadResources();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.message || "上传失败");
|
toast.error(getErrorMessage(err, "上传失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@ -131,10 +141,10 @@ export default function ResourcesPage() {
|
|||||||
|
|
||||||
const handleDownload = async (resource: Resource) => {
|
const handleDownload = async (resource: Resource) => {
|
||||||
try {
|
try {
|
||||||
const res = await postAPI<any>(`/api/resources/${resource.id}/download`);
|
const res = await postAPI<ResourceDownloadResponse>(`/api/resources/${resource.id}/download`);
|
||||||
window.open(res.file_url, "_blank");
|
window.open(res.file_url, "_blank");
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.message || "下载失败");
|
toast.error(getErrorMessage(err, "下载失败"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -144,8 +154,8 @@ export default function ResourcesPage() {
|
|||||||
toast.success("已删除");
|
toast.success("已删除");
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
loadResources();
|
loadResources();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.message || "删除失败");
|
toast.error(getErrorMessage(err, "删除失败"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -153,10 +163,11 @@ export default function ResourcesPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">资源库</h1>
|
<div className="text-[11px] uppercase tracking-[0.24em] text-[#976746]">Library</div>
|
||||||
<p className="text-gray-500 mt-1">共享课件、文档与学习资料</p>
|
<h1 className="mt-2 text-3xl font-semibold text-[#4e1d1a]">资源库</h1>
|
||||||
|
<p className="mt-2 text-[#765a4d]">共享课件、阅读材料与班级文档档案</p>
|
||||||
</div>
|
</div>
|
||||||
<RoleGuard roles={["class_admin", "super_admin"]}>
|
<RoleGuard permissions={["resource_manage"]}>
|
||||||
<Dialog open={dialogOpen} onOpenChange={(open) => {
|
<Dialog open={dialogOpen} onOpenChange={(open) => {
|
||||||
setDialogOpen(open);
|
setDialogOpen(open);
|
||||||
if (!open) resetForm();
|
if (!open) resetForm();
|
||||||
@ -164,9 +175,9 @@ export default function ResourcesPage() {
|
|||||||
<DialogTrigger>
|
<DialogTrigger>
|
||||||
<Button>上传资源</Button>
|
<Button>上传资源</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent className="border-[#eadbc8] bg-[#fffdf8] sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>上传资源</DialogTitle>
|
<DialogTitle className="text-xl text-[#4e1d1a]">上传资源</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 pt-2">
|
<div className="space-y-4 pt-2">
|
||||||
<Input
|
<Input
|
||||||
@ -195,10 +206,10 @@ export default function ResourcesPage() {
|
|||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-medium file:bg-gray-100 file:text-gray-700 hover:file:bg-gray-200"
|
className="block w-full text-sm text-[#7a5e4f] file:mr-4 file:rounded-xl file:border-0 file:bg-[#f3e4cf] file:px-4 file:py-2 file:text-sm file:font-medium file:text-[#74411f] hover:file:bg-[#ecd6b7]"
|
||||||
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
|
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-400 mt-1">支持 PDF, Word, Excel, PPT, ZIP 等,最大 50MB</p>
|
<p className="mt-1 text-xs text-[#9d806f]">支持 PDF、Word、Excel、PPT、ZIP 等,最大 50MB</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleSubmit} disabled={submitting || !selectedFile} className="w-full">
|
<Button onClick={handleSubmit} disabled={submitting || !selectedFile} className="w-full">
|
||||||
{submitting ? "上传中..." : "上传"}
|
{submitting ? "上传中..." : "上传"}
|
||||||
@ -210,7 +221,7 @@ export default function ResourcesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category tabs */}
|
{/* Category tabs */}
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2 rounded-[1.5rem] border border-[#eadbc8] bg-[#fffaf2] p-2">
|
||||||
{Object.entries(RESOURCE_CATEGORIES).map(([key, label]) => (
|
{Object.entries(RESOURCE_CATEGORIES).map(([key, label]) => (
|
||||||
<Button
|
<Button
|
||||||
key={key}
|
key={key}
|
||||||
@ -226,7 +237,7 @@ export default function ResourcesPage() {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{[1, 2, 3].map((i) => (
|
{[1, 2, 3].map((i) => (
|
||||||
<Card key={i} className="animate-pulse">
|
<Card key={i} className="animate-pulse bg-[#fffaf2]">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="h-16 bg-gray-200 rounded" />
|
<div className="h-16 bg-gray-200 rounded" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -236,23 +247,28 @@ export default function ResourcesPage() {
|
|||||||
) : error ? (
|
) : error ? (
|
||||||
<ErrorState message={error} onRetry={loadResources} />
|
<ErrorState message={error} onRetry={loadResources} />
|
||||||
) : resources.length === 0 ? (
|
) : resources.length === 0 ? (
|
||||||
<div className="text-center py-12 text-gray-400">暂无资源</div>
|
<div className="rounded-[2rem] border border-dashed border-[#dcc6ab] bg-[#fffaf2] py-12 text-center text-[#9d806f]">暂无资源</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
{resources.map((r) => (
|
{resources.map((r) => (
|
||||||
<Card key={r.id}>
|
<Card key={r.id} className="bg-[#fffdf8]">
|
||||||
<CardContent className="p-4 flex items-center justify-between">
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-2xl">{getFileIcon(r.file_type)}</span>
|
<span className="text-2xl">{getFileIcon(r.file_type)}</span>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{r.title}</p>
|
<p className="font-medium text-[#4e1d1a]">{r.title}</p>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-[#7a5e4f]">
|
||||||
{r.uploader_name} · {formatFileSize(r.file_size)} · 下载 {r.download_count} 次
|
{r.uploader_name} · {formatFileSize(r.file_size)} · 下载 {r.download_count} 次
|
||||||
</p>
|
</p>
|
||||||
|
{r.description && (
|
||||||
|
<p className="mt-1 text-xs text-[#9d806f]">{r.description}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline">{RESOURCE_CATEGORIES[r.category] || r.category}</Badge>
|
<Badge variant="outline" className="border-[#dcc6ab] bg-[#fff8ef] text-[#7a5e4f]">
|
||||||
|
{RESOURCE_CATEGORIES[r.category] || r.category}
|
||||||
|
</Badge>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -260,7 +276,7 @@ export default function ResourcesPage() {
|
|||||||
>
|
>
|
||||||
下载
|
下载
|
||||||
</Button>
|
</Button>
|
||||||
<RoleGuard roles={["class_admin", "super_admin"]}>
|
<RoleGuard permissions={["resource_manage"]}>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useActiveClass } from "@/hooks/use-active-class";
|
import { useActiveClass } from "@/hooks/use-active-class";
|
||||||
import { fetchAPI, postAPI, putAPI, deleteAPI } from "@/lib/api";
|
import { fetchAPI, postAPI, putAPI, deleteAPI, getErrorMessage } from "@/lib/api";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@ -28,7 +28,7 @@ import { ErrorState } from "@/components/error-state";
|
|||||||
import { Pagination } from "@/components/pagination";
|
import { Pagination } from "@/components/pagination";
|
||||||
import { CalendarView } from "@/components/calendar-view";
|
import { CalendarView } from "@/components/calendar-view";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { ScheduleItem } from "@/lib/types";
|
import type { PageResponse, ScheduleItem } from "@/lib/types";
|
||||||
import { SCHEDULE_TYPES } from "@/lib/constants";
|
import { SCHEDULE_TYPES } from "@/lib/constants";
|
||||||
|
|
||||||
export default function SchedulePage() {
|
export default function SchedulePage() {
|
||||||
@ -52,28 +52,35 @@ export default function SchedulePage() {
|
|||||||
const [formLocation, setFormLocation] = useState("");
|
const [formLocation, setFormLocation] = useState("");
|
||||||
const [formDesc, setFormDesc] = useState("");
|
const [formDesc, setFormDesc] = useState("");
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = useCallback(async () => {
|
||||||
|
if (!activeClassId) {
|
||||||
|
setItems([]);
|
||||||
|
setUpcoming([]);
|
||||||
|
setError(null);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const params = { class_id: String(activeClassId), page: String(page), page_size: "20" };
|
const params = { class_id: String(activeClassId), page: String(page), page_size: "20" };
|
||||||
const [allRes, upcomingRes] = await Promise.all([
|
const [allRes, upcomingRes] = await Promise.all([
|
||||||
fetchAPI<any>("/api/schedule/", params),
|
fetchAPI<PageResponse<ScheduleItem>>("/api/schedule/", params),
|
||||||
fetchAPI<ScheduleItem[]>("/api/schedule/upcoming", { limit: "10", class_id: String(activeClassId) }),
|
fetchAPI<ScheduleItem[]>("/api/schedule/upcoming", { limit: "10", class_id: String(activeClassId) }),
|
||||||
]);
|
]);
|
||||||
setItems(allRes.items || []);
|
setItems(allRes.items ?? []);
|
||||||
setTotalPages(allRes.total_pages || 1);
|
setTotalPages(allRes.total_pages ?? 1);
|
||||||
setUpcoming(upcomingRes);
|
setUpcoming(upcomingRes);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || "加载失败");
|
setError(getErrorMessage(err, "加载失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [activeClassId, page]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeClassId) return;
|
void loadData();
|
||||||
loadData();
|
}, [loadData]);
|
||||||
}, [activeClassId, page]);
|
|
||||||
|
|
||||||
const getCountdown = (startTime: string) => {
|
const getCountdown = (startTime: string) => {
|
||||||
const diff = new Date(startTime).getTime() - Date.now();
|
const diff = new Date(startTime).getTime() - Date.now();
|
||||||
@ -100,22 +107,21 @@ export default function SchedulePage() {
|
|||||||
});
|
});
|
||||||
toast.success("排期已更新");
|
toast.success("排期已更新");
|
||||||
} else {
|
} else {
|
||||||
await postAPI("/api/schedule/", {
|
await postAPI(`/api/schedule/?class_id=${activeClassId}`, {
|
||||||
type: formType,
|
type: formType,
|
||||||
title: formTitle,
|
title: formTitle,
|
||||||
start_time: formStartTime,
|
start_time: formStartTime,
|
||||||
end_time: formEndTime || null,
|
end_time: formEndTime || null,
|
||||||
location: formLocation || null,
|
location: formLocation || null,
|
||||||
description: formDesc || null,
|
description: formDesc || null,
|
||||||
class_id: activeClassId,
|
|
||||||
});
|
});
|
||||||
toast.success("排期已创建");
|
toast.success("排期已创建");
|
||||||
}
|
}
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
loadData();
|
await loadData();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.message || (editingId ? "更新失败" : "创建失败"));
|
toast.error(getErrorMessage(err, editingId ? "更新失败" : "创建失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@ -145,9 +151,9 @@ export default function SchedulePage() {
|
|||||||
await deleteAPI(`/api/schedule/${id}`);
|
await deleteAPI(`/api/schedule/${id}`);
|
||||||
toast.success("已删除");
|
toast.success("已删除");
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
loadData();
|
await loadData();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.message || "删除失败");
|
toast.error(getErrorMessage(err, "删除失败"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -186,7 +192,7 @@ export default function SchedulePage() {
|
|||||||
日历
|
日历
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<RoleGuard roles={["class_admin", "super_admin"]}>
|
<RoleGuard permissions={["schedule_manage"]}>
|
||||||
<Dialog open={dialogOpen} onOpenChange={(open) => {
|
<Dialog open={dialogOpen} onOpenChange={(open) => {
|
||||||
setDialogOpen(open);
|
setDialogOpen(open);
|
||||||
if (!open) resetForm();
|
if (!open) resetForm();
|
||||||
@ -295,7 +301,7 @@ export default function SchedulePage() {
|
|||||||
{item.location && (
|
{item.location && (
|
||||||
<p className="text-sm text-gray-400 mt-1">{item.location}</p>
|
<p className="text-sm text-gray-400 mt-1">{item.location}</p>
|
||||||
)}
|
)}
|
||||||
<RoleGuard roles={["class_admin", "super_admin"]}>
|
<RoleGuard permissions={["schedule_manage"]}>
|
||||||
<div className="flex gap-2 mt-2">
|
<div className="flex gap-2 mt-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -359,7 +365,7 @@ export default function SchedulePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline">{typeInfo.label}</Badge>
|
<Badge variant="outline">{typeInfo.label}</Badge>
|
||||||
<RoleGuard roles={["class_admin", "super_admin"]}>
|
<RoleGuard permissions={["schedule_manage"]}>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@ -1,9 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
import { useEffect, useState, useRef, useCallback } from "react";
|
import { useEffect, useState, useRef, useCallback } from "react";
|
||||||
import { useActiveClass } from "@/hooks/use-active-class";
|
import { useActiveClass } from "@/hooks/use-active-class";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { fetchAPI, postAPI, putAPI, deleteAPI, uploadAPI, compressImage } from "@/lib/api";
|
import {
|
||||||
|
compressImage,
|
||||||
|
deleteAPI,
|
||||||
|
fetchAPI,
|
||||||
|
getErrorMessage,
|
||||||
|
postAPI,
|
||||||
|
putAPI,
|
||||||
|
uploadAPI,
|
||||||
|
} from "@/lib/api";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@ -19,7 +28,48 @@ import { ConfirmDialog } from "@/components/confirm-dialog";
|
|||||||
import { ErrorState } from "@/components/error-state";
|
import { ErrorState } from "@/components/error-state";
|
||||||
import { Pagination } from "@/components/pagination";
|
import { Pagination } from "@/components/pagination";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { TimelinePost, TimelineComment } from "@/lib/types";
|
import type {
|
||||||
|
PageResponse,
|
||||||
|
TimelineComment,
|
||||||
|
TimelinePost,
|
||||||
|
} from "@/lib/types";
|
||||||
|
import { hasClassPermission } from "@/lib/permissions";
|
||||||
|
|
||||||
|
type LikeResponse = {
|
||||||
|
liked: boolean;
|
||||||
|
like_count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function TimelineImage({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
className,
|
||||||
|
fill = false,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
sizes,
|
||||||
|
}: {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
className?: string;
|
||||||
|
fill?: boolean;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
sizes?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className={className}
|
||||||
|
unoptimized
|
||||||
|
fill={fill}
|
||||||
|
width={fill ? undefined : width}
|
||||||
|
height={fill ? undefined : height}
|
||||||
|
sizes={sizes}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Relative time helper ---------- */
|
/* ---------- Relative time helper ---------- */
|
||||||
function relativeTime(dateStr: string): string {
|
function relativeTime(dateStr: string): string {
|
||||||
@ -71,7 +121,11 @@ function Lightbox({
|
|||||||
const handleTouchEnd = (e: React.TouchEvent) => {
|
const handleTouchEnd = (e: React.TouchEvent) => {
|
||||||
const diff = e.changedTouches[0].clientX - touchStartX.current;
|
const diff = e.changedTouches[0].clientX - touchStartX.current;
|
||||||
if (Math.abs(diff) > 50) {
|
if (Math.abs(diff) > 50) {
|
||||||
diff > 0 ? prev() : next();
|
if (diff > 0) {
|
||||||
|
prev();
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -104,14 +158,20 @@ function Lightbox({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Image */}
|
{/* Image */}
|
||||||
<img
|
<div
|
||||||
src={images[index]}
|
className="relative w-[90vw] h-[85vh]"
|
||||||
alt=""
|
|
||||||
className="max-w-[90vw] max-h-[85vh] object-contain select-none"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={handleTouchEnd}
|
||||||
/>
|
>
|
||||||
|
<TimelineImage
|
||||||
|
src={images[index]}
|
||||||
|
alt=""
|
||||||
|
className="object-contain select-none"
|
||||||
|
fill
|
||||||
|
sizes="90vw"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Next arrow */}
|
{/* Next arrow */}
|
||||||
{images.length > 1 && (
|
{images.length > 1 && (
|
||||||
@ -137,7 +197,9 @@ function Lightbox({
|
|||||||
}`}
|
}`}
|
||||||
onClick={() => setIndex(i)}
|
onClick={() => setIndex(i)}
|
||||||
>
|
>
|
||||||
<img src={url} alt="" className="w-full h-full object-cover" />
|
<div className="relative w-full h-full">
|
||||||
|
<TimelineImage src={url} alt="" className="object-cover" fill sizes="48px" />
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -180,23 +242,33 @@ export default function TimelinePage() {
|
|||||||
};
|
};
|
||||||
const closeLightbox = () => setLightboxImages(null);
|
const closeLightbox = () => setLightboxImages(null);
|
||||||
|
|
||||||
const loadPosts = async () => {
|
const loadPosts = useCallback(async () => {
|
||||||
|
if (!activeClassId) {
|
||||||
|
setPosts([]);
|
||||||
|
setError(null);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetchAPI<any>("/api/timeline/", { page_size: "10", page: String(page), class_id: String(activeClassId) });
|
const res = await fetchAPI<PageResponse<TimelinePost>>("/api/timeline/", {
|
||||||
setPosts(res.items || []);
|
page_size: "10",
|
||||||
setTotalPages(res.total_pages || 1);
|
page: String(page),
|
||||||
} catch (err: any) {
|
class_id: String(activeClassId),
|
||||||
setError(err.message || "加载失败");
|
});
|
||||||
|
setPosts(res.items ?? []);
|
||||||
|
setTotalPages(res.total_pages ?? 1);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(getErrorMessage(err, "加载失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [activeClassId, page]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeClassId) return;
|
void loadPosts();
|
||||||
loadPosts();
|
}, [loadPosts]);
|
||||||
}, [activeClassId, page]);
|
|
||||||
|
|
||||||
/* ---------- File upload helpers ---------- */
|
/* ---------- File upload helpers ---------- */
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@ -254,7 +326,7 @@ export default function TimelinePage() {
|
|||||||
});
|
});
|
||||||
if (selectedFiles.length > 0) {
|
if (selectedFiles.length > 0) {
|
||||||
setUploadProgress(`压缩图片 (0/${selectedFiles.length})...`);
|
setUploadProgress(`压缩图片 (0/${selectedFiles.length})...`);
|
||||||
const compressed = [];
|
const compressed: File[] = [];
|
||||||
for (let i = 0; i < selectedFiles.length; i++) {
|
for (let i = 0; i < selectedFiles.length; i++) {
|
||||||
compressed.push(await compressImage(selectedFiles[i]));
|
compressed.push(await compressImage(selectedFiles[i]));
|
||||||
setUploadProgress(`压缩图片 (${i + 1}/${selectedFiles.length})...`);
|
setUploadProgress(`压缩图片 (${i + 1}/${selectedFiles.length})...`);
|
||||||
@ -270,11 +342,11 @@ export default function TimelinePage() {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("title", newTitle);
|
formData.append("title", newTitle);
|
||||||
if (newContent) formData.append("content", newContent);
|
if (newContent) formData.append("content", newContent);
|
||||||
if (user?.role === "super_admin" && activeClassId) formData.append("class_id", String(activeClassId));
|
if (activeClassId) formData.append("class_id", String(activeClassId));
|
||||||
|
|
||||||
if (selectedFiles.length > 0) {
|
if (selectedFiles.length > 0) {
|
||||||
setUploadProgress(`压缩图片 (0/${selectedFiles.length})...`);
|
setUploadProgress(`压缩图片 (0/${selectedFiles.length})...`);
|
||||||
const compressed = [];
|
const compressed: File[] = [];
|
||||||
for (let i = 0; i < selectedFiles.length; i++) {
|
for (let i = 0; i < selectedFiles.length; i++) {
|
||||||
compressed.push(await compressImage(selectedFiles[i]));
|
compressed.push(await compressImage(selectedFiles[i]));
|
||||||
setUploadProgress(`压缩图片 (${i + 1}/${selectedFiles.length})...`);
|
setUploadProgress(`压缩图片 (${i + 1}/${selectedFiles.length})...`);
|
||||||
@ -289,9 +361,9 @@ export default function TimelinePage() {
|
|||||||
|
|
||||||
resetForm();
|
resetForm();
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
loadPosts();
|
await loadPosts();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.message || (editingId ? "更新失败" : "发布失败"));
|
toast.error(getErrorMessage(err, editingId ? "更新失败" : "发布失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
setUploadProgress("");
|
setUploadProgress("");
|
||||||
@ -306,16 +378,16 @@ export default function TimelinePage() {
|
|||||||
await deleteAPI(`/api/timeline/${id}`);
|
await deleteAPI(`/api/timeline/${id}`);
|
||||||
toast.success("已删除");
|
toast.success("已删除");
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
loadPosts();
|
await loadPosts();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.message || "删除失败");
|
toast.error(getErrorMessage(err, "删除失败"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- Like ---------- */
|
/* ---------- Like ---------- */
|
||||||
const handleLike = async (postId: number) => {
|
const handleLike = async (postId: number) => {
|
||||||
try {
|
try {
|
||||||
const res = await postAPI<{ liked: boolean; like_count: number }>(`/api/timeline/${postId}/like`);
|
const res = await postAPI<LikeResponse>(`/api/timeline/${postId}/like`);
|
||||||
setPosts((prev) =>
|
setPosts((prev) =>
|
||||||
prev.map((p) =>
|
prev.map((p) =>
|
||||||
p.id === postId
|
p.id === postId
|
||||||
@ -323,13 +395,14 @@ export default function TimelinePage() {
|
|||||||
: p
|
: p
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.message || "操作失败");
|
toast.error(getErrorMessage(err, "操作失败"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- Comments ---------- */
|
/* ---------- Comments ---------- */
|
||||||
const toggleComments = async (postId: number) => {
|
const toggleComments = async (postId: number) => {
|
||||||
|
const isExpanded = expandedComments.has(postId);
|
||||||
setExpandedComments((prev) => {
|
setExpandedComments((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(postId)) {
|
if (next.has(postId)) {
|
||||||
@ -340,19 +413,27 @@ export default function TimelinePage() {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch comments when expanding (if not already loaded)
|
if (isExpanded) {
|
||||||
if (!expandedComments.has(postId)) {
|
return;
|
||||||
try {
|
}
|
||||||
const res = await fetchAPI<any>(`/api/timeline/${postId}/comments`);
|
|
||||||
const comments = res.items || [];
|
const targetPost = posts.find((post) => post.id === postId);
|
||||||
setPosts((prev) =>
|
if (targetPost?.comments) {
|
||||||
prev.map((p) =>
|
return;
|
||||||
p.id === postId ? { ...p, comments } : p
|
}
|
||||||
)
|
|
||||||
);
|
try {
|
||||||
} catch {
|
const res = await fetchAPI<PageResponse<TimelineComment>>(
|
||||||
// Silently fail — user can still try to post a new comment
|
`/api/timeline/${postId}/comments`
|
||||||
}
|
);
|
||||||
|
const comments = res.items ?? [];
|
||||||
|
setPosts((prev) =>
|
||||||
|
prev.map((p) =>
|
||||||
|
p.id === postId ? { ...p, comments } : p
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Silently fail; user can still try to post a new comment.
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -370,8 +451,8 @@ export default function TimelinePage() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
setCommentInputs((prev) => ({ ...prev, [postId]: "" }));
|
setCommentInputs((prev) => ({ ...prev, [postId]: "" }));
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.message || "评论失败");
|
toast.error(getErrorMessage(err, "评论失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmittingComment((prev) => ({ ...prev, [postId]: false }));
|
setSubmittingComment((prev) => ({ ...prev, [postId]: false }));
|
||||||
}
|
}
|
||||||
@ -388,21 +469,26 @@ export default function TimelinePage() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
toast.success("已删除评论");
|
toast.success("已删除评论");
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.message || "删除评论失败");
|
toast.error(getErrorMessage(err, "删除评论失败"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- Permission helpers ---------- */
|
/* ---------- Permission helpers ---------- */
|
||||||
const canEditDelete = (post: TimelinePost): boolean => {
|
const canEditDelete = (post: TimelinePost): boolean => {
|
||||||
if (!user) return false;
|
if (!user) return false;
|
||||||
if (user.role === "class_admin" || user.role === "super_admin") return true;
|
if (hasClassPermission(user, "timeline_manage", post.class_id)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return user.id === post.author_id;
|
return user.id === post.author_id;
|
||||||
};
|
};
|
||||||
|
|
||||||
const canDeleteComment = (comment: TimelineComment): boolean => {
|
const canDeleteComment = (comment: TimelineComment): boolean => {
|
||||||
if (!user) return false;
|
if (!user) return false;
|
||||||
if (user.role === "class_admin" || user.role === "super_admin") return true;
|
const post = posts.find((item) => item.id === comment.post_id);
|
||||||
|
if (post && hasClassPermission(user, "timeline_manage", post.class_id)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return user.id === comment.author_id;
|
return user.id === comment.author_id;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -450,13 +536,13 @@ export default function TimelinePage() {
|
|||||||
{/* Existing images (edit mode) */}
|
{/* Existing images (edit mode) */}
|
||||||
{editingImageUrls.map((url, idx) => (
|
{editingImageUrls.map((url, idx) => (
|
||||||
<div key={`existing-${idx}`} className="relative w-20 h-20 rounded-lg overflow-hidden border">
|
<div key={`existing-${idx}`} className="relative w-20 h-20 rounded-lg overflow-hidden border">
|
||||||
<img src={url} alt="" className="w-full h-full object-cover" />
|
<TimelineImage src={url} alt="" className="object-cover" fill sizes="80px" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{/* New image previews */}
|
{/* New image previews */}
|
||||||
{previewUrls.map((url, idx) => (
|
{previewUrls.map((url, idx) => (
|
||||||
<div key={idx} className="relative w-20 h-20 rounded-lg overflow-hidden border">
|
<div key={idx} className="relative w-20 h-20 rounded-lg overflow-hidden border">
|
||||||
<img src={url} alt="" className="w-full h-full object-cover" />
|
<TimelineImage src={url} alt="" className="object-cover" fill sizes="80px" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute top-0 right-0 w-5 h-5 bg-black/60 text-white text-xs flex items-center justify-center rounded-bl"
|
className="absolute top-0 right-0 w-5 h-5 bg-black/60 text-white text-xs flex items-center justify-center rounded-bl"
|
||||||
@ -550,11 +636,15 @@ export default function TimelinePage() {
|
|||||||
className="aspect-video bg-gray-100 rounded-lg overflow-hidden cursor-pointer hover:opacity-90 transition-opacity"
|
className="aspect-video bg-gray-100 rounded-lg overflow-hidden cursor-pointer hover:opacity-90 transition-opacity"
|
||||||
onClick={() => openLightbox(post.image_urls!, idx)}
|
onClick={() => openLightbox(post.image_urls!, idx)}
|
||||||
>
|
>
|
||||||
<img
|
<div className="relative w-full h-full">
|
||||||
src={url}
|
<TimelineImage
|
||||||
alt=""
|
src={url}
|
||||||
className="w-full h-full object-cover"
|
alt=""
|
||||||
/>
|
className="object-cover"
|
||||||
|
fill
|
||||||
|
sizes="(min-width: 768px) 33vw, 50vw"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { useActiveClass } from "@/hooks/use-active-class";
|
import { useActiveClass } from "@/hooks/use-active-class";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { fetchAPI, postAPI, putAPI, deleteAPI } from "@/lib/api";
|
import { fetchAPI, postAPI, putAPI, deleteAPI, getErrorMessage } from "@/lib/api";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@ -21,7 +21,8 @@ import {
|
|||||||
import { ErrorState } from "@/components/error-state";
|
import { ErrorState } from "@/components/error-state";
|
||||||
import { Pagination } from "@/components/pagination";
|
import { Pagination } from "@/components/pagination";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { Vote, VoteOption } from "@/lib/types";
|
import type { Vote, PageResponse } from "@/lib/types";
|
||||||
|
import { hasClassPermission } from "@/lib/permissions";
|
||||||
|
|
||||||
export default function VotesPage() {
|
export default function VotesPage() {
|
||||||
const { activeClassId } = useActiveClass();
|
const { activeClassId } = useActiveClass();
|
||||||
@ -60,18 +61,15 @@ export default function VotesPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetchAPI<{
|
const res = await fetchAPI<PageResponse<Vote>>("/api/votes/", {
|
||||||
items: Vote[];
|
|
||||||
total_pages: number;
|
|
||||||
}>("/api/votes/", {
|
|
||||||
class_id: String(activeClassId),
|
class_id: String(activeClassId),
|
||||||
page: String(page),
|
page: String(page),
|
||||||
page_size: "10",
|
page_size: "10",
|
||||||
});
|
});
|
||||||
setVotes(res.items || []);
|
setVotes(res.items || []);
|
||||||
setTotalPages(res.total_pages || 1);
|
setTotalPages(res.total_pages || 1);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || "加载失败");
|
setError(getErrorMessage(err, "加载失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -118,7 +116,7 @@ export default function VotesPage() {
|
|||||||
}
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await postAPI("/api/votes/", {
|
await postAPI(`/api/votes/?class_id=${activeClassId}`, {
|
||||||
title: formTitle.trim(),
|
title: formTitle.trim(),
|
||||||
description: formDesc.trim() || null,
|
description: formDesc.trim() || null,
|
||||||
vote_type: formVoteType,
|
vote_type: formVoteType,
|
||||||
@ -126,14 +124,13 @@ export default function VotesPage() {
|
|||||||
max_choices: formVoteType === "multiple" ? formMaxChoices : 1,
|
max_choices: formVoteType === "multiple" ? formMaxChoices : 1,
|
||||||
deadline: formDeadline || null,
|
deadline: formDeadline || null,
|
||||||
options: validOptions,
|
options: validOptions,
|
||||||
class_id: activeClassId,
|
|
||||||
});
|
});
|
||||||
toast.success("投票已创建");
|
toast.success("投票已创建");
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
resetCreateForm();
|
resetCreateForm();
|
||||||
loadVotes();
|
loadVotes();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.message || "创建失败");
|
toast.error(getErrorMessage(err, "创建失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@ -161,8 +158,8 @@ export default function VotesPage() {
|
|||||||
try {
|
try {
|
||||||
const data = await fetchAPI<Vote>(`/api/votes/${voteId}`);
|
const data = await fetchAPI<Vote>(`/api/votes/${voteId}`);
|
||||||
setDetailVote(data);
|
setDetailVote(data);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.message || "加载投票详情失败");
|
toast.error(getErrorMessage(err, "加载投票详情失败"));
|
||||||
setDetailOpen(false);
|
setDetailOpen(false);
|
||||||
} finally {
|
} finally {
|
||||||
setDetailLoading(false);
|
setDetailLoading(false);
|
||||||
@ -190,8 +187,8 @@ export default function VotesPage() {
|
|||||||
setDetailVote(data);
|
setDetailVote(data);
|
||||||
setSelectedOptions([]);
|
setSelectedOptions([]);
|
||||||
loadVotes();
|
loadVotes();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.message || "投票失败");
|
toast.error(getErrorMessage(err, "投票失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmittingVote(false);
|
setSubmittingVote(false);
|
||||||
}
|
}
|
||||||
@ -203,8 +200,8 @@ export default function VotesPage() {
|
|||||||
toast.success("投票已关闭");
|
toast.success("投票已关闭");
|
||||||
setDetailOpen(false);
|
setDetailOpen(false);
|
||||||
loadVotes();
|
loadVotes();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.message || "关闭失败");
|
toast.error(getErrorMessage(err, "关闭失败"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -215,13 +212,17 @@ export default function VotesPage() {
|
|||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
setDetailOpen(false);
|
setDetailOpen(false);
|
||||||
loadVotes();
|
loadVotes();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.message || "删除失败");
|
toast.error(getErrorMessage(err, "删除失败"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const canManage = (vote: Vote) =>
|
const canManage = (vote: Vote) =>
|
||||||
user && (user.role === "super_admin" || user.role === "class_admin" || user.id === vote.creator_id);
|
user &&
|
||||||
|
(
|
||||||
|
hasClassPermission(user, "vote_manage", vote.class_id) ||
|
||||||
|
user.id === vote.creator_id
|
||||||
|
);
|
||||||
|
|
||||||
// ---------- Render helpers ----------
|
// ---------- Render helpers ----------
|
||||||
|
|
||||||
|
|||||||
@ -6,13 +6,13 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { postAPI } from "@/lib/api";
|
import { getErrorMessage, postAPI } from "@/lib/api";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import type { LoginResponse } from "@/lib/types";
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function ActivatePage() {
|
||||||
const [inviteCode, setInviteCode] = useState("");
|
const [inviteCode, setInviteCode] = useState("");
|
||||||
const [studentId, setStudentId] = useState("");
|
const [studentId, setStudentId] = useState("");
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
@ -28,42 +28,56 @@ export default function RegisterPage() {
|
|||||||
setError("两次密码输入不一致");
|
setError("两次密码输入不一致");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (password.length < 6) {
|
if (password.length < 8) {
|
||||||
setError("密码至少6位");
|
setError("密码至少8位");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await postAPI<any>("/api/auth/register", {
|
const res = await postAPI<LoginResponse>("/api/auth/activate", {
|
||||||
invite_code: inviteCode,
|
invite_code: inviteCode,
|
||||||
student_id: studentId,
|
student_id: studentId,
|
||||||
name,
|
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Registration returns token — auto-login
|
|
||||||
if (res.token) {
|
if (res.token) {
|
||||||
localStorage.setItem("token", res.token);
|
localStorage.setItem("auth_token", res.token);
|
||||||
localStorage.setItem("user", JSON.stringify(res.user));
|
localStorage.setItem("auth_user", JSON.stringify(res.user));
|
||||||
router.push("/");
|
router.push("/");
|
||||||
} else {
|
} else {
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || "注册失败");
|
setError(getErrorMessage(err, "激活失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
<div className="relative min-h-screen overflow-hidden bg-[linear-gradient(180deg,#f7efe2_0%,#f4ebdf_38%,#f9f5ee_100%)] px-4 py-10">
|
||||||
<Card className="w-full max-w-md">
|
<div className="absolute inset-x-0 top-0 h-72 bg-[radial-gradient(circle_at_top,rgba(115,25,37,0.18),transparent_60%)]" />
|
||||||
|
<div className="relative mx-auto flex min-h-[calc(100vh-5rem)] max-w-6xl items-center justify-center">
|
||||||
|
<div className="grid w-full items-center gap-10 lg:grid-cols-[1.05fr_0.95fr]">
|
||||||
|
<div className="hidden lg:block">
|
||||||
|
<div className="inline-flex items-center rounded-full border border-[#c9ac82] bg-white/55 px-4 py-1.5 text-[11px] uppercase tracking-[0.28em] text-[#84553c]">
|
||||||
|
HKU ICB Cohort Access
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-6 max-w-2xl text-5xl font-semibold leading-tight text-[#4e1d1a]">
|
||||||
|
完成账号激活,进入你的班级空间
|
||||||
|
</h1>
|
||||||
|
<p className="mt-5 max-w-xl text-base leading-8 text-[#775a4a]">
|
||||||
|
成员导入后,只需使用激活码与学号确认身份,再补全邮箱和密码即可启用账号。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mx-auto w-full max-w-md bg-[#fffaf3]">
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<CardTitle className="text-2xl">HKU ICB</CardTitle>
|
<div className="text-[11px] uppercase tracking-[0.24em] text-[#976746]">Activate Account</div>
|
||||||
<CardDescription>班级资源平台 - 注册</CardDescription>
|
<CardTitle className="text-3xl text-[#4e1d1a]">激活账号</CardTitle>
|
||||||
|
<CardDescription className="text-[#7a5e4f]">输入班级激活码与学号,完成账号启用</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
@ -87,16 +101,6 @@ export default function RegisterPage() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name">姓名</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
placeholder="请输入真实姓名"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">邮箱</Label>
|
<Label htmlFor="email">邮箱</Label>
|
||||||
<Input
|
<Input
|
||||||
@ -113,7 +117,7 @@ export default function RegisterPage() {
|
|||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="至少6位"
|
placeholder="至少8位"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
@ -131,22 +135,24 @@ export default function RegisterPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-sm text-red-600 bg-red-50 p-3 rounded-lg">
|
<p className="rounded-2xl bg-red-50 p-3 text-sm text-red-600">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<Button type="submit" className="w-full" disabled={loading}>
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
{loading ? "注册中..." : "注册"}
|
{loading ? "激活中..." : "激活账号"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
<div className="mt-4 text-center text-sm text-gray-600">
|
<div className="mt-5 text-center text-sm text-[#6f5648]">
|
||||||
已有账号?{" "}
|
已有账号?{" "}
|
||||||
<Link href="/login" className="text-blue-600 hover:underline">
|
<Link href="/login" className="text-[#8a4527] hover:underline">
|
||||||
登录
|
登录
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -9,7 +9,7 @@
|
|||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-sans);
|
--font-sans: var(--font-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
--font-heading: var(--font-sans);
|
--font-heading: var(--font-heading);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
@ -49,38 +49,40 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(0.985 0.012 82.5);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.27 0.02 23);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(0.995 0.008 85);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.27 0.02 23);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(0.995 0.008 85);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.27 0.02 23);
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: oklch(0.38 0.14 18);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0.985 0.01 80);
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.952 0.02 75);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.32 0.04 18);
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: oklch(0.958 0.015 82);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: oklch(0.52 0.03 30);
|
||||||
--accent: oklch(0.97 0 0);
|
--accent: oklch(0.935 0.042 55);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.33 0.05 22);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.61 0.22 25);
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.905 0.018 65);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.92 0.018 72);
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: oklch(0.61 0.11 22);
|
||||||
--chart-1: oklch(0.87 0 0);
|
--chart-1: oklch(0.56 0.16 20);
|
||||||
--chart-2: oklch(0.556 0 0);
|
--chart-2: oklch(0.67 0.11 64);
|
||||||
--chart-3: oklch(0.439 0 0);
|
--chart-3: oklch(0.54 0.08 220);
|
||||||
--chart-4: oklch(0.371 0 0);
|
--chart-4: oklch(0.74 0.09 145);
|
||||||
--chart-5: oklch(0.269 0 0);
|
--chart-5: oklch(0.82 0.07 92);
|
||||||
--radius: 0.625rem;
|
--radius: 1rem;
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.27 0.035 18);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar-foreground: oklch(0.95 0.01 80);
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--sidebar-primary: oklch(0.67 0.12 56);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.19 0.01 30);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-accent: oklch(0.33 0.03 18);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.96 0.01 80);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.39 0.03 18 / 45%);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.72 0.1 56);
|
||||||
|
--font-sans: var(--font-inter);
|
||||||
|
--font-heading: var(--font-serif-sc);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@ -123,8 +125,20 @@
|
|||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at top left, color-mix(in oklab, var(--accent) 34%, transparent) 0, transparent 34%),
|
||||||
|
radial-gradient(circle at top right, color-mix(in oklab, var(--primary) 11%, transparent) 0, transparent 30%),
|
||||||
|
linear-gradient(180deg, color-mix(in oklab, var(--background) 92%, white) 0%, var(--background) 100%);
|
||||||
|
background-attachment: fixed;
|
||||||
}
|
}
|
||||||
html {
|
html {
|
||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
}
|
}
|
||||||
}
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4 {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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 type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Noto_Serif_SC, Inter, Geist_Mono } from "next/font/google";
|
||||||
import { AuthProvider } from "@/hooks/use-auth";
|
import { AuthProvider } from "@/hooks/use-auth";
|
||||||
import { AuthGuard } from "@/components/auth-guard";
|
import { AuthGuard } from "@/components/auth-guard";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const bodySans = Inter({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-inter",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -15,9 +15,15 @@ const geistMono = Geist_Mono({
|
|||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const serifHeading = Noto_Serif_SC({
|
||||||
|
variable: "--font-serif-sc",
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "500", "600", "700"],
|
||||||
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "HKU ICB - 班级资源平台",
|
title: "香港大学中国商业学院 - 班级信息管理平台",
|
||||||
description: "研究生班级资源连接平台",
|
description: "香港大学中国商业学院班级信息管理平台",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@ -28,7 +34,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="zh-CN"
|
lang="zh-CN"
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
className={`${bodySans.variable} ${geistMono.variable} ${serifHeading.variable} h-full antialiased`}
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col">
|
<body className="min-h-full flex flex-col">
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { getErrorMessage } from "@/lib/api";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
@ -24,19 +25,40 @@ export default function LoginPage() {
|
|||||||
try {
|
try {
|
||||||
await login(email, password);
|
await login(email, password);
|
||||||
router.push("/dashboard");
|
router.push("/dashboard");
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || "登录失败");
|
setError(getErrorMessage(err, "登录失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
<div className="relative min-h-screen overflow-hidden bg-[linear-gradient(180deg,#f7efe2_0%,#f4ebdf_38%,#f9f5ee_100%)] px-4 py-10">
|
||||||
<Card className="w-full max-w-md">
|
<div className="absolute inset-x-0 top-0 h-72 bg-[radial-gradient(circle_at_top,rgba(115,25,37,0.18),transparent_60%)]" />
|
||||||
|
<div className="relative mx-auto flex min-h-[calc(100vh-5rem)] max-w-6xl items-center justify-center">
|
||||||
|
<div className="grid w-full items-center gap-10 lg:grid-cols-[1.1fr_0.9fr]">
|
||||||
|
<div className="hidden lg:block">
|
||||||
|
<div className="inline-flex items-center rounded-full border border-[#c9ac82] bg-white/55 px-4 py-1.5 text-[11px] uppercase tracking-[0.28em] text-[#84553c]">
|
||||||
|
The University of Hong Kong
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-6 max-w-2xl text-5xl font-semibold leading-tight text-[#4e1d1a]">
|
||||||
|
香港大学中国商业学院
|
||||||
|
</h1>
|
||||||
|
<p className="mt-5 max-w-xl text-base leading-8 text-[#775a4a]">
|
||||||
|
班级信息管理平台
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 flex flex-wrap gap-3 text-sm text-[#6d4d3d]">
|
||||||
|
<span className="rounded-full border border-[#dec8aa] bg-white/60 px-4 py-2">公告与课程通知</span>
|
||||||
|
<span className="rounded-full border border-[#dec8aa] bg-white/60 px-4 py-2">成员名录与班级连接</span>
|
||||||
|
<span className="rounded-full border border-[#dec8aa] bg-white/60 px-4 py-2">活动排期与资源协同</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mx-auto w-full max-w-md bg-[#fffaf3]">
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<CardTitle className="text-2xl">HKU ICB</CardTitle>
|
<div className="text-[11px] uppercase tracking-[0.24em] text-[#976746]">HKU ICB</div>
|
||||||
<CardDescription>班级资源平台 - 登录</CardDescription>
|
<CardTitle className="text-3xl text-[#4e1d1a]">欢迎回来</CardTitle>
|
||||||
|
<CardDescription className="text-[#7a5e4f]">登录班级信息管理平台</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
@ -63,7 +85,7 @@ export default function LoginPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-sm text-red-600 bg-red-50 p-3 rounded-lg">
|
<p className="rounded-2xl bg-red-50 p-3 text-sm text-red-600">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -71,14 +93,16 @@ export default function LoginPage() {
|
|||||||
{loading ? "登录中..." : "登录"}
|
{loading ? "登录中..." : "登录"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
<div className="mt-4 text-center text-sm text-gray-600">
|
<div className="mt-5 text-center text-sm text-[#6f5648]">
|
||||||
还没有账号?{" "}
|
还没有账号?{" "}
|
||||||
<Link href="/register" className="text-blue-600 hover:underline">
|
<Link href="/activate" className="text-[#8a4527] hover:underline">
|
||||||
注册申请
|
激活账号
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,8 +12,8 @@ export default function Home() {
|
|||||||
if (loading) return;
|
if (loading) return;
|
||||||
if (!user) {
|
if (!user) {
|
||||||
router.replace("/login");
|
router.replace("/login");
|
||||||
} else if (user.status === "pending") {
|
} else if (user.status === "inactive") {
|
||||||
router.replace("/pending");
|
router.replace("/inactive-account");
|
||||||
} else {
|
} else {
|
||||||
router.replace("/dashboard");
|
router.replace("/dashboard");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { usePathname, useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
|
||||||
const PUBLIC_PATHS = ["/login", "/register"];
|
const PUBLIC_PATHS = ["/login", "/activate"];
|
||||||
|
|
||||||
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
|
|||||||
@ -53,9 +53,8 @@ export function CalendarView({ events, onEventClick }: CalendarViewProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Navigation */}
|
<div className="flex items-center justify-between rounded-[1.5rem] border border-[#eadbc8] bg-[#fffaf2] px-4 py-3">
|
||||||
<div className="flex items-center justify-between">
|
<h3 className="font-heading text-lg font-medium text-[#4e1d1a]">
|
||||||
<h3 className="text-lg font-medium">
|
|
||||||
{year} 年 {month + 1} 月
|
{year} 年 {month + 1} 月
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
@ -71,24 +70,21 @@ export function CalendarView({ events, onEventClick }: CalendarViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Calendar grid */}
|
<div className="overflow-hidden rounded-[1.75rem] border border-[#eadbc8] bg-[#eadbc8] shadow-[0_24px_60px_-42px_rgba(84,29,23,0.28)]">
|
||||||
<div className="grid grid-cols-7 gap-px bg-gray-200 rounded-lg overflow-hidden">
|
<div className="grid grid-cols-7 gap-px">
|
||||||
{/* Week day headers */}
|
|
||||||
{weekDays.map((d) => (
|
{weekDays.map((d) => (
|
||||||
<div
|
<div
|
||||||
key={d}
|
key={d}
|
||||||
className="bg-gray-50 text-center text-xs font-medium text-gray-500 py-2"
|
className="bg-[#f7eee1] py-2 text-center text-xs font-medium text-[#8a6c59]"
|
||||||
>
|
>
|
||||||
{d}
|
{d}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Leading empty cells */}
|
|
||||||
{Array.from({ length: startDayOfWeek }, (_, i) => (
|
{Array.from({ length: startDayOfWeek }, (_, i) => (
|
||||||
<div key={`empty-${i}`} className="bg-white min-h-[80px] p-1" />
|
<div key={`empty-${i}`} className="min-h-[96px] bg-[#fffdf8] p-1" />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Day cells */}
|
|
||||||
{Array.from({ length: daysInMonth }, (_, i) => {
|
{Array.from({ length: daysInMonth }, (_, i) => {
|
||||||
const day = i + 1;
|
const day = i + 1;
|
||||||
const key = `${year}-${month}-${day}`;
|
const key = `${year}-${month}-${day}`;
|
||||||
@ -102,16 +98,16 @@ export function CalendarView({ events, onEventClick }: CalendarViewProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={day}
|
key={day}
|
||||||
className={`bg-white min-h-[80px] p-1 cursor-pointer hover:bg-gray-50 transition-colors ${
|
className={`min-h-[96px] cursor-pointer bg-[#fffdf8] p-2 transition-colors hover:bg-[#fff6ea] ${
|
||||||
isSelected ? "ring-2 ring-blue-500 ring-inset" : ""
|
isSelected ? "ring-2 ring-inset ring-[#7b2331]" : ""
|
||||||
} ${isToday(day) ? "bg-blue-50" : ""}`}
|
} ${isToday(day) ? "bg-[#fff2df]" : ""}`}
|
||||||
onClick={() => setSelectedDate(new Date(year, month, day))}
|
onClick={() => setSelectedDate(new Date(year, month, day))}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`text-sm ${
|
className={`text-sm ${
|
||||||
isToday(day)
|
isToday(day)
|
||||||
? "font-bold text-blue-600"
|
? "font-bold text-[#7b2331]"
|
||||||
: "text-gray-700"
|
: "text-[#5b463c]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{day}
|
{day}
|
||||||
@ -129,14 +125,14 @@ export function CalendarView({ events, onEventClick }: CalendarViewProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className={`w-1.5 h-1.5 rounded-full ${typeInfo.color} shrink-0`} />
|
<div className={`w-1.5 h-1.5 rounded-full ${typeInfo.color} shrink-0`} />
|
||||||
<span className="text-[10px] text-gray-600 truncate">
|
<span className="truncate text-[10px] text-[#7a5e4f]">
|
||||||
{event.title}
|
{event.title}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{dayEvents.length > 3 && (
|
{dayEvents.length > 3 && (
|
||||||
<span className="text-[10px] text-gray-400">
|
<span className="text-[10px] text-[#aa8b75]">
|
||||||
+{dayEvents.length - 3}
|
+{dayEvents.length - 3}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -145,16 +141,15 @@ export function CalendarView({ events, onEventClick }: CalendarViewProps) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Trailing empty cells */}
|
|
||||||
{Array.from({ length: (7 - (startDayOfWeek + daysInMonth) % 7) % 7 }, (_, i) => (
|
{Array.from({ length: (7 - (startDayOfWeek + daysInMonth) % 7) % 7 }, (_, i) => (
|
||||||
<div key={`trail-${i}`} className="bg-white min-h-[80px] p-1" />
|
<div key={`trail-${i}`} className="min-h-[96px] bg-[#fffdf8] p-1" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Selected date detail */}
|
|
||||||
{selectedDate && selectedEvents.length > 0 && (
|
{selectedDate && selectedEvents.length > 0 && (
|
||||||
<div className="border rounded-lg p-4">
|
<div className="rounded-[1.5rem] border border-[#eadbc8] bg-[#fffaf2] p-4">
|
||||||
<h4 className="text-sm font-medium text-gray-500 mb-2">
|
<h4 className="mb-2 text-sm font-medium text-[#8a6c59]">
|
||||||
{selectedDate.getMonth() + 1} 月 {selectedDate.getDate()} 日
|
{selectedDate.getMonth() + 1} 月 {selectedDate.getDate()} 日
|
||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -163,14 +158,14 @@ export function CalendarView({ events, onEventClick }: CalendarViewProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={event.id}
|
key={event.id}
|
||||||
className="flex items-center justify-between p-2 bg-gray-50 rounded cursor-pointer hover:bg-gray-100"
|
className="flex cursor-pointer items-center justify-between rounded-2xl border border-[#eadbc8] bg-[#fffdf8] p-3 hover:bg-[#fff6ea]"
|
||||||
onClick={() => onEventClick?.(event)}
|
onClick={() => onEventClick?.(event)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className={`w-3 h-3 rounded-full ${typeInfo.color}`} />
|
<div className={`w-3 h-3 rounded-full ${typeInfo.color}`} />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">{event.title}</p>
|
<p className="text-sm font-medium text-[#4e1d1a]">{event.title}</p>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-[#7a5e4f]">
|
||||||
{new Date(event.start_time).toLocaleTimeString("zh-CN", {
|
{new Date(event.start_time).toLocaleTimeString("zh-CN", {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
|
|||||||
@ -9,14 +9,17 @@ interface ErrorStateProps {
|
|||||||
|
|
||||||
export function ErrorState({ message = "加载失败", onRetry }: ErrorStateProps) {
|
export function ErrorState({ message = "加载失败", onRetry }: ErrorStateProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
<div className="rounded-[2rem] border border-[#eadbc8] bg-[#fffaf2]/95 px-6 py-14 text-center shadow-[0_24px_60px_-38px_rgba(84,29,23,0.28)]">
|
||||||
<svg className="w-12 h-12 text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div className="mx-auto flex max-w-md flex-col items-center justify-center">
|
||||||
|
<svg className="mb-4 h-12 w-12 text-[#c5aa8f]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||||
</svg>
|
</svg>
|
||||||
<p className="text-gray-500 mb-4">{message}</p>
|
<p className="font-heading text-lg text-[#4e1d1a]">暂时无法加载内容</p>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-[#7a5e4f]">{message}</p>
|
||||||
{onRetry && (
|
{onRetry && (
|
||||||
<Button variant="outline" onClick={onRetry}>重试</Button>
|
<Button variant="outline" className="mt-5" onClick={onRetry}>重新加载</Button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { useActiveClass } from "@/hooks/use-active-class";
|
import { useActiveClass } from "@/hooks/use-active-class";
|
||||||
import { useSidebar } from "@/hooks/use-sidebar";
|
import { useSidebar } from "@/hooks/use-sidebar";
|
||||||
import { useNotifications } from "@/hooks/use-notifications";
|
import { useNotifications } from "@/hooks/use-notifications";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { putAPI } from "@/lib/api";
|
import { getErrorMessage, putAPI } from "@/lib/api";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@ -51,6 +52,9 @@ export function Header() {
|
|||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [passwordLoading, setPasswordLoading] = useState(false);
|
const [passwordLoading, setPasswordLoading] = useState(false);
|
||||||
|
const classDescriptor = activeClassName
|
||||||
|
? activeClassName.split(" ").slice(0, 2).join(" ")
|
||||||
|
: "香港大学中国商业学院";
|
||||||
|
|
||||||
const handleChangePassword = async (e: React.FormEvent) => {
|
const handleChangePassword = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -73,28 +77,38 @@ export function Header() {
|
|||||||
setConfirmPassword("");
|
setConfirmPassword("");
|
||||||
setPasswordOpen(false);
|
setPasswordOpen(false);
|
||||||
toast.success("密码已修改");
|
toast.success("密码已修改");
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.message || "修改密码失败");
|
toast.error(getErrorMessage(err, "修改密码失败"));
|
||||||
} finally {
|
} finally {
|
||||||
setPasswordLoading(false);
|
setPasswordLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="h-14 md:h-16 bg-white border-b border-gray-200 flex items-center justify-between px-4 md:px-6">
|
<header className="relative border-b border-[#d6c2aa]/65 bg-[linear-gradient(180deg,rgba(255,250,242,0.95),rgba(251,244,232,0.9))] px-4 py-3 backdrop-blur md:px-8">
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[#b7794b]/40 to-transparent" />
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Mobile hamburger */}
|
{/* Mobile hamburger */}
|
||||||
<Button variant="ghost" size="icon" className="md:hidden shrink-0" onClick={toggle}>
|
<Button variant="ghost" size="icon" className="md:hidden shrink-0 rounded-xl" onClick={toggle}>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
|
<div className="hidden min-w-0 md:block">
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.24em] text-[#94613e]">
|
||||||
|
HKU ICB
|
||||||
|
</p>
|
||||||
|
<h2 className="truncate text-lg font-semibold text-[#4a1f1a]">
|
||||||
|
{classDescriptor}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
{canSwitchClass ? (
|
{canSwitchClass ? (
|
||||||
<Select
|
<Select
|
||||||
value={activeClassId ? String(activeClassId) : ""}
|
value={activeClassId ? String(activeClassId) : ""}
|
||||||
onValueChange={(v) => v && setActiveClassId(parseInt(v))}
|
onValueChange={(v) => v && setActiveClassId(parseInt(v))}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-56">
|
<SelectTrigger className="w-56 border-[#d7c0a0] bg-white/75 shadow-none">
|
||||||
<SelectValue>
|
<SelectValue>
|
||||||
{activeClassId
|
{activeClassId
|
||||||
? availableClasses.find((c) => c.id === activeClassId)?.name ||
|
? availableClasses.find((c) => c.id === activeClassId)?.name ||
|
||||||
@ -111,19 +125,19 @@ export function Header() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
) : activeClassName ? (
|
) : activeClassName ? (
|
||||||
<span className="text-sm text-gray-700">
|
<span className="text-sm text-[#6f4b38] md:hidden">
|
||||||
当前班级:<span className="font-medium">{activeClassName}</span>
|
当前班级:<span className="font-medium text-[#4a1f1a]">{activeClassName}</span>
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-3">
|
||||||
{/* Notification bell */}
|
{/* Notification bell */}
|
||||||
{user && (
|
{user && (
|
||||||
<Popover open={notifOpen} onOpenChange={(open) => {
|
<Popover open={notifOpen} onOpenChange={(open) => {
|
||||||
setNotifOpen(open);
|
setNotifOpen(open);
|
||||||
if (open) refresh();
|
if (open) refresh();
|
||||||
}}>
|
}}>
|
||||||
<PopoverTrigger className="relative inline-flex items-center justify-center shrink-0 rounded-md text-sm font-medium transition-colors hover:bg-gray-100 h-9 w-9">
|
<PopoverTrigger className="relative inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-[#dcc5a3] bg-white/70 text-sm font-medium text-[#5a2a22] transition-colors hover:bg-white">
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||||
</svg>
|
</svg>
|
||||||
@ -133,8 +147,8 @@ export function Header() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent align="end" className="w-80 p-0">
|
<PopoverContent align="end" className="w-80 overflow-hidden rounded-3xl border-[#e0ccb0] bg-[#fffaf2] p-0">
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b">
|
<div className="flex items-center justify-between border-b border-[#eedfc8] px-4 py-3">
|
||||||
<span className="font-medium text-sm">通知</span>
|
<span className="font-medium text-sm">通知</span>
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<Button variant="ghost" size="sm" className="text-xs h-auto py-0.5 px-2" onClick={async () => {
|
<Button variant="ghost" size="sm" className="text-xs h-auto py-0.5 px-2" onClick={async () => {
|
||||||
@ -151,7 +165,7 @@ export function Header() {
|
|||||||
notifications.map((n) => (
|
notifications.map((n) => (
|
||||||
<div
|
<div
|
||||||
key={n.id}
|
key={n.id}
|
||||||
className={`px-4 py-3 border-b last:border-b-0 cursor-pointer hover:bg-gray-50 ${!n.is_read ? "bg-blue-50/50" : ""}`}
|
className={`cursor-pointer border-b border-[#f0e2cd] px-4 py-3 last:border-b-0 hover:bg-[#fff5e7] ${!n.is_read ? "bg-[#fff1db]" : ""}`}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!n.is_read) await markRead(n.id);
|
if (!n.is_read) await markRead(n.id);
|
||||||
}}
|
}}
|
||||||
@ -186,15 +200,20 @@ export function Header() {
|
|||||||
{user && (
|
{user && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenuTrigger>
|
||||||
<Button variant="ghost" className="flex items-center gap-2">
|
<Button
|
||||||
<div className="w-8 h-8 rounded-full bg-gray-900 text-white flex items-center justify-center text-sm font-medium overflow-hidden shrink-0">
|
variant="ghost"
|
||||||
|
className="h-11 gap-2 rounded-2xl border border-[#dcc5a3] bg-white/65 px-2 pr-3 hover:bg-white/90"
|
||||||
|
>
|
||||||
|
<div className="relative flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full bg-gradient-to-br from-[#6f2030] to-[#a4633f] text-sm font-medium text-white">
|
||||||
{user.avatar_url ? (
|
{user.avatar_url ? (
|
||||||
<img src={user.avatar_url} alt="" className="w-full h-full object-cover" />
|
<Image src={user.avatar_url} alt="" fill className="object-cover" />
|
||||||
) : (
|
) : (
|
||||||
user.name?.[0] || "?"
|
user.name?.[0] || "?"
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm hidden sm:inline">{user.name || "User"}</span>
|
<span className="hidden max-w-32 truncate text-sm leading-none text-[#4a1f1a] sm:inline">
|
||||||
|
{user.name || "User"}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
@ -238,6 +257,7 @@ export function Header() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,7 +28,7 @@ export function Pagination({ page, totalPages, onPageChange }: PaginationProps)
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center gap-1 pt-4">
|
<div className="flex items-center justify-center gap-1 pt-6">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -39,7 +39,7 @@ export function Pagination({ page, totalPages, onPageChange }: PaginationProps)
|
|||||||
</Button>
|
</Button>
|
||||||
{getPages().map((p, i) =>
|
{getPages().map((p, i) =>
|
||||||
p === "..." ? (
|
p === "..." ? (
|
||||||
<span key={`dots-${i}`} className="px-2 text-gray-400">
|
<span key={`dots-${i}`} className="px-2 text-[#aa8b75]">
|
||||||
...
|
...
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -1,16 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import type { UserRole } from "@/lib/types";
|
import { useActiveClass } from "@/hooks/use-active-class";
|
||||||
|
import type { ClassPermission, UserRole } from "@/lib/types";
|
||||||
|
import { hasClassPermission } from "@/lib/permissions";
|
||||||
|
|
||||||
interface RoleGuardProps {
|
interface RoleGuardProps {
|
||||||
roles: UserRole[];
|
roles?: UserRole[];
|
||||||
|
permissions?: ClassPermission[];
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
fallback?: React.ReactNode;
|
fallback?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RoleGuard({ roles, children, fallback = null }: RoleGuardProps) {
|
export function RoleGuard({
|
||||||
|
roles,
|
||||||
|
permissions,
|
||||||
|
children,
|
||||||
|
fallback = null,
|
||||||
|
}: RoleGuardProps) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
if (!user || !roles.includes(user.role)) return <>{fallback}</>;
|
const { activeClassId } = useActiveClass();
|
||||||
|
if (!user) return <>{fallback}</>;
|
||||||
|
|
||||||
|
const roleMatch = !roles || roles.includes(user.role);
|
||||||
|
const permissionMatch =
|
||||||
|
!permissions ||
|
||||||
|
permissions.every((permission) => hasClassPermission(user, permission, activeClassId));
|
||||||
|
|
||||||
|
if (!roleMatch || !permissionMatch) return <>{fallback}</>;
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,16 +5,14 @@ import { usePathname } from "next/navigation";
|
|||||||
import { useSidebar } from "@/hooks/use-sidebar";
|
import { useSidebar } from "@/hooks/use-sidebar";
|
||||||
import { useActiveClass } from "@/hooks/use-active-class";
|
import { useActiveClass } from "@/hooks/use-active-class";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { UserRole } from "@/lib/types";
|
import type { ClassPermission, UserRole } from "@/lib/types";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { hasClassPermission } from "@/lib/permissions";
|
||||||
// Module keys that can be toggled
|
|
||||||
const TOGGLEABLE_MODULES = ["announcements", "directory", "timeline", "assignments", "votes", "schedule", "resources", "fund"];
|
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/dashboard", label: "首页", icon: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6", moduleKey: undefined },
|
{ href: "/dashboard", label: "首页", icon: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6", moduleKey: undefined },
|
||||||
{ href: "/announcements", label: "公告", icon: "M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z", moduleKey: "announcements" },
|
{ href: "/announcements", label: "公告", icon: "M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z", moduleKey: "announcements" },
|
||||||
{ href: "/directory", label: "花名册", icon: "M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z", moduleKey: "directory" },
|
{ href: "/directory", label: "成员名录", icon: "M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z", moduleKey: "directory" },
|
||||||
{ href: "/timeline", label: "班级动态", icon: "M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z", moduleKey: "timeline" },
|
{ href: "/timeline", label: "班级动态", icon: "M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z", moduleKey: "timeline" },
|
||||||
{ href: "/assignments", label: "作业", icon: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z", moduleKey: "assignments" },
|
{ href: "/assignments", label: "作业", icon: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z", moduleKey: "assignments" },
|
||||||
{ href: "/votes", label: "投票", icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4", moduleKey: "votes" },
|
{ href: "/votes", label: "投票", icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4", moduleKey: "votes" },
|
||||||
@ -25,18 +23,26 @@ const navItems = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const adminItems = [
|
const adminItems = [
|
||||||
{ href: "/admin/members", label: "成员管理", icon: "M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z", roles: ["super_admin", "class_admin"] as UserRole[] },
|
{ href: "/admin/members", label: "成员管理", icon: "M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z", roles: ["super_admin", "teacher", "student"] as UserRole[], permissions: ["member_view"] as ClassPermission[] },
|
||||||
{ href: "/admin/classes", label: "班级管理", icon: "M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4", roles: ["super_admin"] as UserRole[] },
|
{ href: "/admin/classes", label: "班级管理", icon: "M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4", roles: ["super_admin"] as UserRole[] },
|
||||||
{ href: "/admin/modules", label: "模块管理", icon: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z", roles: ["super_admin", "class_admin"] as UserRole[] },
|
{ href: "/admin/modules", label: "模块管理", icon: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z", roles: ["super_admin", "teacher", "student"] as UserRole[], permissions: ["module_manage"] as ClassPermission[] },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { isOpen, close } = useSidebar();
|
const { isOpen, close } = useSidebar();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { enabledModules } = useActiveClass();
|
const { enabledModules, activeClassId } = useActiveClass();
|
||||||
const visibleAdminItems = user
|
const visibleAdminItems = user
|
||||||
? adminItems.filter((item) => item.roles.includes(user.role))
|
? adminItems.filter((item) => {
|
||||||
|
if (!item.roles.includes(user.role)) return false;
|
||||||
|
if (user.role === "super_admin") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (item.permissions ?? []).every((permission) =>
|
||||||
|
hasClassPermission(user, permission, activeClassId)
|
||||||
|
);
|
||||||
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Default to all modules enabled if not loaded yet
|
// Default to all modules enabled if not loaded yet
|
||||||
@ -53,35 +59,35 @@ export function Sidebar() {
|
|||||||
{/* Mobile backdrop */}
|
{/* Mobile backdrop */}
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-40 bg-black/50 md:hidden"
|
className="fixed inset-0 z-40 bg-[#2b1514]/45 backdrop-blur-[2px] md:hidden"
|
||||||
onClick={close}
|
onClick={close}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-64 bg-white border-r border-gray-200 h-screen flex flex-col shrink-0",
|
"w-72 border-r border-sidebar-border bg-sidebar text-sidebar-foreground h-screen flex flex-col shrink-0 shadow-[18px_0_60px_-42px_rgba(29,14,12,0.7)]",
|
||||||
// Mobile: overlay mode
|
// Mobile: overlay mode
|
||||||
"fixed z-50 top-0 left-0 transition-transform duration-200 md:relative md:z-auto",
|
"fixed z-50 top-0 left-0 transition-transform duration-200 md:relative md:z-auto",
|
||||||
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="p-6 border-b border-gray-200">
|
<div className="border-b border-sidebar-border px-6 pb-6 pt-7">
|
||||||
<h1 className="text-xl font-bold text-gray-900">HKU ICB</h1>
|
<h1 className="text-2xl font-semibold tracking-tight text-white">香港大学中国商业学院</h1>
|
||||||
<p className="text-xs text-gray-500 mt-1">班级资源平台</p>
|
<p className="mt-1 text-sm text-white/65">HKU ICB</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 p-4 space-y-1">
|
<nav className="flex-1 space-y-1 overflow-y-auto px-4 py-5">
|
||||||
{visibleNavItems.map((item) => (
|
{visibleNavItems.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
onClick={close}
|
onClick={close}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors",
|
"flex items-center gap-3 rounded-2xl px-3.5 py-2.5 text-sm transition-all",
|
||||||
pathname === item.href || pathname.startsWith(item.href + "/")
|
pathname === item.href || pathname.startsWith(item.href + "/")
|
||||||
? "bg-gray-900 text-white"
|
? "bg-gradient-to-r from-[#e7c98d] to-[#d9ae59] text-[#31130f] shadow-[0_12px_28px_-20px_rgba(217,174,89,0.95)]"
|
||||||
: "text-gray-600 hover:bg-gray-100"
|
: "text-white/72 hover:bg-white/6 hover:text-white"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@ -104,7 +110,7 @@ export function Sidebar() {
|
|||||||
{visibleAdminItems.length > 0 && (
|
{visibleAdminItems.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="pt-4 pb-2">
|
<div className="pt-4 pb-2">
|
||||||
<p className="px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
<p className="px-3 text-[11px] font-semibold uppercase tracking-[0.24em] text-white/35">
|
||||||
管理
|
管理
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -114,10 +120,10 @@ export function Sidebar() {
|
|||||||
href={item.href}
|
href={item.href}
|
||||||
onClick={close}
|
onClick={close}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors",
|
"flex items-center gap-3 rounded-2xl px-3.5 py-2.5 text-sm transition-all",
|
||||||
pathname.startsWith(item.href)
|
pathname.startsWith(item.href)
|
||||||
? "bg-gray-900 text-white"
|
? "bg-gradient-to-r from-[#e7c98d] to-[#d9ae59] text-[#31130f] shadow-[0_12px_28px_-20px_rgba(217,174,89,0.95)]"
|
||||||
: "text-gray-600 hover:bg-gray-100"
|
: "text-white/72 hover:bg-white/6 hover:text-white"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@ -140,9 +146,11 @@ export function Sidebar() {
|
|||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="p-4 border-t border-gray-200">
|
<div className="border-t border-sidebar-border px-5 py-4">
|
||||||
<p className="text-xs text-gray-400">© {new Date().getFullYear()} HKU ICB</p>
|
<div className="rounded-2xl border border-white/8 bg-white/[0.03] px-4 py-3">
|
||||||
<p className="text-xs text-gray-400 mt-0.5">By 周龙 @ FBM05</p>
|
<p className="text-xs text-white/68">© {new Date().getFullYear()} 香港大学中国商业学院</p>
|
||||||
|
<p className="mt-1 text-[11px] text-white/42">班级信息管理平台</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -8,11 +8,11 @@ const buttonVariants = cva(
|
|||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
default: "bg-primary text-primary-foreground shadow-[0_10px_24px_-16px_color-mix(in_oklab,var(--primary)_70%,black)] hover:brightness-[1.04] [a]:hover:bg-primary/80",
|
||||||
outline:
|
outline:
|
||||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
"border-border/80 bg-white/70 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
"bg-secondary text-secondary-foreground hover:bg-secondary/88 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
ghost:
|
ghost:
|
||||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
destructive:
|
destructive:
|
||||||
@ -21,7 +21,7 @@ const buttonVariants = cva(
|
|||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default:
|
default:
|
||||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
"h-9 gap-1.5 rounded-xl px-3.5 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
|
||||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
|||||||
@ -12,7 +12,7 @@ function Card({
|
|||||||
data-slot="card"
|
data-slot="card"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
"group/card flex flex-col gap-4 overflow-hidden rounded-3xl border border-white/60 bg-card/92 py-4 text-sm text-card-foreground shadow-[0_18px_45px_-28px_rgba(82,34,24,0.32)] backdrop-blur-sm ring-1 ring-[#7f1d1d]/6 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-3xl *:[img:last-child]:rounded-b-3xl",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@ -84,7 +84,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
<div
|
<div
|
||||||
data-slot="card-footer"
|
data-slot="card-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
"flex items-center rounded-b-3xl border-t border-border/70 bg-muted/55 p-4 group-data-[size=sm]/card:p-3",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -11,8 +11,30 @@ function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
|||||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
function DialogTrigger({
|
||||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
children,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Trigger.Props) {
|
||||||
|
const renderChild =
|
||||||
|
React.Children.count(children) === 1
|
||||||
|
? React.Children.only(children)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (React.isValidElement(renderChild)) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Trigger
|
||||||
|
data-slot="dialog-trigger"
|
||||||
|
render={renderChild}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Trigger data-slot="dialog-trigger" {...props}>
|
||||||
|
{children}
|
||||||
|
</DialogPrimitive.Trigger>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||||
|
|||||||
@ -14,8 +14,30 @@ function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
|||||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
function DropdownMenuTrigger({
|
||||||
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
children,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Trigger.Props) {
|
||||||
|
const renderChild =
|
||||||
|
React.Children.count(children) === 1
|
||||||
|
? React.Children.only(children)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (React.isValidElement(renderChild)) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Trigger
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
render={renderChild}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props}>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.Trigger>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuContent({
|
function DropdownMenuContent({
|
||||||
|
|||||||
@ -9,8 +9,30 @@ function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
|||||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
function PopoverTrigger({
|
||||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
children,
|
||||||
|
...props
|
||||||
|
}: PopoverPrimitive.Trigger.Props) {
|
||||||
|
const renderChild =
|
||||||
|
React.Children.count(children) === 1
|
||||||
|
? React.Children.only(children)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (React.isValidElement(renderChild)) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Trigger
|
||||||
|
data-slot="popover-trigger"
|
||||||
|
render={renderChild}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Trigger data-slot="popover-trigger" {...props}>
|
||||||
|
{children}
|
||||||
|
</PopoverPrimitive.Trigger>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PopoverContent({
|
function PopoverContent({
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { fetchAPI } from "@/lib/api";
|
import { fetchAPI } from "@/lib/api";
|
||||||
import type { ClassInfo } from "@/lib/types";
|
import type { ClassInfo, PageResponse } from "@/lib/types";
|
||||||
|
|
||||||
interface ActiveClassContextValue {
|
interface ActiveClassContextValue {
|
||||||
/** The class ID to use for all data queries */
|
/** The class ID to use for all data queries */
|
||||||
@ -45,21 +45,24 @@ export function ActiveClassProvider({ children }: { children: ReactNode }) {
|
|||||||
const [selectedClassId, setSelectedClassId] = useState<number | null>(null);
|
const [selectedClassId, setSelectedClassId] = useState<number | null>(null);
|
||||||
const [userClassName, setUserClassName] = useState<string | null>(null);
|
const [userClassName, setUserClassName] = useState<string | null>(null);
|
||||||
|
|
||||||
const isSuperAdmin = user?.role === "super_admin";
|
const canManageMultipleClasses =
|
||||||
// For non-super-admin, use their own class_id
|
user?.role === "super_admin" ||
|
||||||
const activeClassId = isSuperAdmin ? selectedClassId : user?.class_id ?? null;
|
user?.role === "teacher" ||
|
||||||
|
(user?.memberships?.length ?? 0) > 1;
|
||||||
|
const fallbackClassId = user?.active_membership?.class_id ?? user?.memberships?.[0]?.class_id ?? null;
|
||||||
|
const activeClassId = canManageMultipleClasses ? selectedClassId : fallbackClassId;
|
||||||
|
|
||||||
// Super admin: derive class name from availableClasses
|
// Cross-class roles: derive class name from availableClasses
|
||||||
const superAdminClassName = isSuperAdmin && activeClassId
|
const selectedClassName = canManageMultipleClasses && activeClassId
|
||||||
? availableClasses.find((c) => c.id === activeClassId)?.name ?? null
|
? availableClasses.find((c) => c.id === activeClassId)?.name ?? null
|
||||||
: null;
|
: null;
|
||||||
const activeClassName = isSuperAdmin ? superAdminClassName : userClassName;
|
const activeClassName = canManageMultipleClasses ? selectedClassName : userClassName;
|
||||||
|
|
||||||
// Derive enabled modules based on active class
|
// Derive enabled modules based on active class
|
||||||
const enabledModules = (() => {
|
const enabledModules = (() => {
|
||||||
if (!activeClassId) return null;
|
if (!activeClassId) return null;
|
||||||
// For super admin, get from availableClasses
|
// For super admin, get from availableClasses
|
||||||
if (isSuperAdmin) {
|
if (canManageMultipleClasses) {
|
||||||
const cls = availableClasses.find((c) => c.id === activeClassId);
|
const cls = availableClasses.find((c) => c.id === activeClassId);
|
||||||
return cls?.enabled_modules ?? null;
|
return cls?.enabled_modules ?? null;
|
||||||
}
|
}
|
||||||
@ -67,10 +70,10 @@ export function ActiveClassProvider({ children }: { children: ReactNode }) {
|
|||||||
return user?.enabled_modules ?? null;
|
return user?.enabled_modules ?? null;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Super admin: load all classes and auto-select first
|
// Cross-class roles: load all classes and auto-select first
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSuperAdmin) return;
|
if (!canManageMultipleClasses) return;
|
||||||
fetchAPI<any>("/api/classes/").then((res) => {
|
fetchAPI<PageResponse<ClassInfo>>("/api/classes/").then((res) => {
|
||||||
const items = res.items || [];
|
const items = res.items || [];
|
||||||
setAvailableClasses(items);
|
setAvailableClasses(items);
|
||||||
// Restore from localStorage or pick first
|
// Restore from localStorage or pick first
|
||||||
@ -83,17 +86,17 @@ export function ActiveClassProvider({ children }: { children: ReactNode }) {
|
|||||||
setSelectedClassId(items[0].id);
|
setSelectedClassId(items[0].id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [isSuperAdmin]);
|
}, [canManageMultipleClasses]);
|
||||||
|
|
||||||
// Non-super-admin: fetch class name once
|
// Single-class users: fetch class name once
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSuperAdmin || !user?.class_id) return;
|
if (canManageMultipleClasses || !fallbackClassId) return;
|
||||||
fetchAPI<any>("/api/classes/").then((res) => {
|
fetchAPI<PageResponse<ClassInfo>>("/api/classes/").then((res) => {
|
||||||
const items = res.items || [];
|
const items = res.items || [];
|
||||||
const cls = items.find((c: ClassInfo) => c.id === user.class_id);
|
const cls = items.find((c: ClassInfo) => c.id === fallbackClassId);
|
||||||
setUserClassName(cls?.name ?? null);
|
setUserClassName(cls?.name ?? null);
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, [isSuperAdmin, user?.class_id]);
|
}, [canManageMultipleClasses, fallbackClassId]);
|
||||||
|
|
||||||
const setActiveClassId = useCallback((id: number) => {
|
const setActiveClassId = useCallback((id: number) => {
|
||||||
setSelectedClassId(id);
|
setSelectedClassId(id);
|
||||||
@ -102,7 +105,7 @@ export function ActiveClassProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const refreshClasses = useCallback(async () => {
|
const refreshClasses = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetchAPI<any>("/api/classes/");
|
const res = await fetchAPI<PageResponse<ClassInfo>>("/api/classes/");
|
||||||
const items = res.items || [];
|
const items = res.items || [];
|
||||||
setAvailableClasses(items);
|
setAvailableClasses(items);
|
||||||
} catch {
|
} catch {
|
||||||
@ -115,7 +118,7 @@ export function ActiveClassProvider({ children }: { children: ReactNode }) {
|
|||||||
value={{
|
value={{
|
||||||
activeClassId,
|
activeClassId,
|
||||||
activeClassName,
|
activeClassName,
|
||||||
canSwitchClass: isSuperAdmin,
|
canSwitchClass: canManageMultipleClasses,
|
||||||
availableClasses,
|
availableClasses,
|
||||||
setActiveClassId,
|
setActiveClassId,
|
||||||
enabledModules,
|
enabledModules,
|
||||||
|
|||||||
@ -3,9 +3,8 @@
|
|||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
useContext,
|
useContext,
|
||||||
useState,
|
|
||||||
useEffect,
|
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useSyncExternalStore,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { type AuthUser } from "@/lib/types";
|
import { type AuthUser } from "@/lib/types";
|
||||||
@ -28,23 +27,75 @@ const AuthContext = createContext<AuthContextValue>({
|
|||||||
refreshUser: async () => {},
|
refreshUser: async () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
type AuthSnapshot = {
|
||||||
const [user, setUser] = useState<AuthUser | null>(null);
|
user: AuthUser | null;
|
||||||
const [loading, setLoading] = useState(true);
|
loading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const authListeners = new Set<() => void>();
|
||||||
const token = localStorage.getItem("auth_token");
|
const SERVER_SNAPSHOT: AuthSnapshot = { user: null, loading: true };
|
||||||
const storedUser = localStorage.getItem("auth_user");
|
const EMPTY_CLIENT_SNAPSHOT: AuthSnapshot = { user: null, loading: false };
|
||||||
if (token && storedUser) {
|
|
||||||
try {
|
let cachedToken: string | null | undefined;
|
||||||
setUser(JSON.parse(storedUser));
|
let cachedStoredUser: string | null | undefined;
|
||||||
} catch {
|
let cachedSnapshot: AuthSnapshot = SERVER_SNAPSHOT;
|
||||||
localStorage.removeItem("auth_token");
|
|
||||||
localStorage.removeItem("auth_user");
|
function emitAuthChange() {
|
||||||
}
|
authListeners.forEach((listener) => listener());
|
||||||
}
|
}
|
||||||
setLoading(false);
|
|
||||||
}, []);
|
function subscribeAuth(listener: () => void) {
|
||||||
|
authListeners.add(listener);
|
||||||
|
return () => authListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearStoredAuth() {
|
||||||
|
localStorage.removeItem("auth_token");
|
||||||
|
localStorage.removeItem("auth_user");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAuthSnapshot(): AuthSnapshot {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return SERVER_SNAPSHOT;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = localStorage.getItem("auth_token");
|
||||||
|
const storedUser = localStorage.getItem("auth_user");
|
||||||
|
|
||||||
|
if (token === cachedToken && storedUser === cachedStoredUser) {
|
||||||
|
return cachedSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token || !storedUser) {
|
||||||
|
cachedToken = token;
|
||||||
|
cachedStoredUser = storedUser;
|
||||||
|
cachedSnapshot = EMPTY_CLIENT_SNAPSHOT;
|
||||||
|
return cachedSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
cachedToken = token;
|
||||||
|
cachedStoredUser = storedUser;
|
||||||
|
cachedSnapshot = {
|
||||||
|
user: JSON.parse(storedUser) as AuthUser,
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
|
return cachedSnapshot;
|
||||||
|
} catch {
|
||||||
|
clearStoredAuth();
|
||||||
|
cachedToken = null;
|
||||||
|
cachedStoredUser = null;
|
||||||
|
cachedSnapshot = EMPTY_CLIENT_SNAPSHOT;
|
||||||
|
return cachedSnapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { user, loading } = useSyncExternalStore(
|
||||||
|
subscribeAuth,
|
||||||
|
readAuthSnapshot,
|
||||||
|
() => SERVER_SNAPSHOT
|
||||||
|
);
|
||||||
|
|
||||||
const login = useCallback(async (email: string, password: string) => {
|
const login = useCallback(async (email: string, password: string) => {
|
||||||
const res = await postAPI<LoginResponse>("/api/auth/login", {
|
const res = await postAPI<LoginResponse>("/api/auth/login", {
|
||||||
@ -52,19 +103,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
localStorage.setItem("auth_token", res.token);
|
localStorage.setItem("auth_token", res.token);
|
||||||
// Temporarily set user from login response, then refresh to get full data (enabled_modules etc.)
|
|
||||||
setUser(res.user);
|
|
||||||
localStorage.setItem("auth_user", JSON.stringify(res.user));
|
localStorage.setItem("auth_user", JSON.stringify(res.user));
|
||||||
// Refresh to get complete user data including enabled_modules
|
emitAuthChange();
|
||||||
const userData = await fetchAPI<AuthUser>("/api/auth/me");
|
const userData = await fetchAPI<AuthUser>("/api/auth/me");
|
||||||
localStorage.setItem("auth_user", JSON.stringify(userData));
|
localStorage.setItem("auth_user", JSON.stringify(userData));
|
||||||
setUser(userData);
|
emitAuthChange();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
localStorage.removeItem("auth_token");
|
clearStoredAuth();
|
||||||
localStorage.removeItem("auth_user");
|
emitAuthChange();
|
||||||
setUser(null);
|
|
||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -72,9 +120,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
const userData = await fetchAPI<AuthUser>("/api/auth/me");
|
const userData = await fetchAPI<AuthUser>("/api/auth/me");
|
||||||
localStorage.setItem("auth_user", JSON.stringify(userData));
|
localStorage.setItem("auth_user", JSON.stringify(userData));
|
||||||
setUser(userData);
|
emitAuthChange();
|
||||||
} catch {
|
} catch {
|
||||||
// Token might be invalid
|
clearStoredAuth();
|
||||||
|
emitAuthChange();
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
|
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { fetchAPI, putAPI } from "@/lib/api";
|
import { fetchAPI, putAPI } from "@/lib/api";
|
||||||
import type { NotificationItem } from "@/lib/types";
|
import type { NotificationItem, PageResponse } from "@/lib/types";
|
||||||
|
|
||||||
interface NotificationContextType {
|
interface NotificationContextType {
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
@ -39,7 +39,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
|
|||||||
const fetchNotifications = useCallback(async () => {
|
const fetchNotifications = useCallback(async () => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetchAPI<any>("/api/notifications/", { page_size: "10" });
|
const res = await fetchAPI<PageResponse<NotificationItem>>("/api/notifications/", { page_size: "10" });
|
||||||
setNotifications(res.items || []);
|
setNotifications(res.items || []);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
@ -47,15 +47,21 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
|
|||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
fetchUnreadCount();
|
void fetchUnreadCount();
|
||||||
fetchNotifications();
|
void fetchNotifications();
|
||||||
}, [fetchUnreadCount, fetchNotifications]);
|
}, [fetchUnreadCount, fetchNotifications]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
fetchUnreadCount();
|
|
||||||
|
const initialRefresh = window.setTimeout(() => {
|
||||||
|
void fetchUnreadCount();
|
||||||
|
}, 0);
|
||||||
const interval = setInterval(fetchUnreadCount, 30000);
|
const interval = setInterval(fetchUnreadCount, 30000);
|
||||||
return () => clearInterval(interval);
|
return () => {
|
||||||
|
window.clearTimeout(initialRefresh);
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
}, [user, fetchUnreadCount]);
|
}, [user, fetchUnreadCount]);
|
||||||
|
|
||||||
const markRead = useCallback(async (id: number) => {
|
const markRead = useCallback(async (id: number) => {
|
||||||
|
|||||||
@ -1,5 +1,20 @@
|
|||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
|
||||||
|
|
||||||
|
export function getErrorMessage(error: unknown, fallback = "操作失败"): string {
|
||||||
|
if (error instanceof Error && error.message) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof error === "object" &&
|
||||||
|
error !== null &&
|
||||||
|
"message" in error &&
|
||||||
|
typeof (error as { message?: unknown }).message === "string"
|
||||||
|
) {
|
||||||
|
return (error as { message: string }).message;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
function getToken(): string | null {
|
function getToken(): string | null {
|
||||||
if (typeof window === "undefined") return null;
|
if (typeof window === "undefined") return null;
|
||||||
return localStorage.getItem("auth_token");
|
return localStorage.getItem("auth_token");
|
||||||
|
|||||||
@ -1,13 +1,41 @@
|
|||||||
export const ROLES = {
|
export const ROLES = {
|
||||||
super_admin: "超级管理员",
|
super_admin: "超级管理员",
|
||||||
class_admin: "班级管理员",
|
teacher: "老师",
|
||||||
student: "同学",
|
student: "同学",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const CLASS_PERMISSIONS = {
|
||||||
|
class_view: "班级查看",
|
||||||
|
member_view: "成员查看",
|
||||||
|
member_manage: "成员管理",
|
||||||
|
committee_manage: "班委设置",
|
||||||
|
announcement_manage: "公告管理",
|
||||||
|
timeline_manage: "动态管理",
|
||||||
|
vote_manage: "投票管理",
|
||||||
|
schedule_manage: "排期管理",
|
||||||
|
resource_manage: "资源库管理",
|
||||||
|
assignment_manage: "作业管理",
|
||||||
|
fund_manage: "班费管理",
|
||||||
|
module_manage: "模块管理",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const TEACHER_DEFAULT_PERMISSIONS = [
|
||||||
|
"class_view",
|
||||||
|
"member_view",
|
||||||
|
"member_manage",
|
||||||
|
"committee_manage",
|
||||||
|
"announcement_manage",
|
||||||
|
"timeline_manage",
|
||||||
|
"vote_manage",
|
||||||
|
"schedule_manage",
|
||||||
|
"resource_manage",
|
||||||
|
"assignment_manage",
|
||||||
|
"module_manage",
|
||||||
|
] as const;
|
||||||
|
|
||||||
export const USER_STATUS = {
|
export const USER_STATUS = {
|
||||||
pending: "待审核",
|
inactive: "未激活",
|
||||||
approved: "已通过",
|
approved: "已激活",
|
||||||
rejected: "已拒绝",
|
|
||||||
disabled: "已禁用",
|
disabled: "已禁用",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
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 UserRole = "super_admin" | "teacher" | "student";
|
||||||
export type UserStatus = "pending" | "approved" | "rejected" | "disabled";
|
export type UserStatus = "inactive" | "approved" | "disabled";
|
||||||
export type ScheduleType = "course" | "deadline" | "activity";
|
export type ScheduleType = "course" | "deadline" | "activity";
|
||||||
|
export type ClassPermission =
|
||||||
|
| "class_view"
|
||||||
|
| "member_view"
|
||||||
|
| "member_manage"
|
||||||
|
| "committee_manage"
|
||||||
|
| "announcement_manage"
|
||||||
|
| "timeline_manage"
|
||||||
|
| "vote_manage"
|
||||||
|
| "schedule_manage"
|
||||||
|
| "resource_manage"
|
||||||
|
| "assignment_manage"
|
||||||
|
| "fund_manage"
|
||||||
|
| "module_manage";
|
||||||
|
|
||||||
|
export interface ClassMembership {
|
||||||
|
id: number;
|
||||||
|
class_id: number;
|
||||||
|
class_name: string | null;
|
||||||
|
membership_role: "teacher" | "student";
|
||||||
|
committee_role: string | null;
|
||||||
|
class_permissions: ClassPermission[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
id: number;
|
id: number;
|
||||||
@ -9,17 +31,17 @@ export interface AuthUser {
|
|||||||
student_id: string | null;
|
student_id: string | null;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
status: UserStatus;
|
status: UserStatus;
|
||||||
class_id: number | null;
|
|
||||||
industry: string | null;
|
industry: string | null;
|
||||||
company: string | null;
|
company: string | null;
|
||||||
position: string | null;
|
position: string | null;
|
||||||
committee_role: string | null;
|
|
||||||
skills_tags: string[] | null;
|
skills_tags: string[] | null;
|
||||||
wechat_id: string | null;
|
wechat_id: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
bio: string | null;
|
bio: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
memberships: ClassMembership[];
|
||||||
|
active_membership: ClassMembership | null;
|
||||||
enabled_modules: string[] | null;
|
enabled_modules: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,11 +82,13 @@ export interface UserListItem {
|
|||||||
student_id: string | null;
|
student_id: string | null;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
status: UserStatus;
|
status: UserStatus;
|
||||||
class_id: number | null;
|
|
||||||
industry: string | null;
|
industry: string | null;
|
||||||
company: string | null;
|
company: string | null;
|
||||||
committee_role: string | null;
|
committee_role: string | null;
|
||||||
|
class_permissions: ClassPermission[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
memberships: ClassMembership[];
|
||||||
|
active_membership: ClassMembership | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TimelineComment {
|
export interface TimelineComment {
|
||||||
@ -150,11 +174,11 @@ export interface NotificationItem {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RosterEntry {
|
export interface InactiveMemberEntry {
|
||||||
id: number;
|
id: number;
|
||||||
student_id: string;
|
student_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
status: "unregistered" | "registered";
|
status: "inactive";
|
||||||
user_id: number | null;
|
user_id: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -239,6 +263,6 @@ export interface FundStatistics {
|
|||||||
total_income: number;
|
total_income: number;
|
||||||
total_expense: number;
|
total_expense: number;
|
||||||
balance: number;
|
balance: number;
|
||||||
income_by_category: { category: string; amount: number }[];
|
income_by_category: Record<string, number>;
|
||||||
expense_by_category: { category: string; amount: number }[];
|
expense_by_category: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user