commit f4aae08b836fb2a3d190684e38a028dd70ba7884
Author: aaron <>
Date: Sun Apr 12 18:15:38 2026 +0800
first commit
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..bf5e79e
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,21 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(# Kill all existing uvicorn processes and restart fresh pkill -f \"\"uvicorn app.main\"\" ; sleep 1 rm -f /Users/aaron/source_code/hku-icb-class/backend/classhub.db source .venv/bin/activate && uvicorn app.main:app --host 127.0.0.1 --port 8001 & sleep 3 echo \"\"=== 1. Health ===\"\" curl -s http://127.0.0.1:8001/api/health)",
+ "Bash(__NEW_LINE_aa4d9e75e4b994e8__ echo \"=== 2. Get Classes ===\")",
+ "Bash(__NEW_LINE_43deffae9fb2cfb0__ echo \"=== 3. Register ===\")",
+ "Bash(__NEW_LINE_efb029665c5ac199__ echo \"=== 4. Login as Admin ===\")",
+ "Bash(pkill:*)",
+ "Bash(npx shadcn@latest init:*)",
+ "Bash(npx shadcn@latest add:*)",
+ "Bash(npm --prefix /Users/aaron/source_code/hku-icb-class/frontend run build)",
+ "Bash(/Users/aaron/source_code/hku-icb-class/frontend/node_modules/.bin/tsc --noEmit --project /Users/aaron/source_code/hku-icb-class/frontend/tsconfig.json)",
+ "Bash(test:*)",
+ "Bash(/Users/aaron/source_code/hku-icb-class/backend/.venv/bin/python:*)",
+ "Bash(.venv/bin/python:*)",
+ "Bash(backend/.venv/bin/pip show:*)",
+ "Bash(uv run python:*)",
+ "Bash(grep:*)"
+ ]
+ }
+}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..47d36d0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,41 @@
+# Python
+__pycache__/
+*.py[cod]
+*.egg-info/
+dist/
+build/
+*.egg
+.venv/
+venv/
+env/
+
+# Backend
+backend/.env
+backend/*.db
+
+# Node
+frontend/node_modules/
+frontend/.next/
+frontend/out/
+
+# Frontend env
+frontend/.env
+frontend/.env.local
+frontend/.env.*.local
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# OS
+.DS_Store
+Thumbs.db
+
+# TypeScript build cache
+frontend/tsconfig.tsbuildinfo
+
+# Logs
+*.log
diff --git a/backend/.dockerignore b/backend/.dockerignore
new file mode 100644
index 0000000..b6795e5
--- /dev/null
+++ b/backend/.dockerignore
@@ -0,0 +1,10 @@
+.venv/
+__pycache__/
+*.pyc
+*.pyo
+.env
+*.db
+.git
+.gitignore
+*.md
+alembic/versions/
diff --git a/backend/.env.example b/backend/.env.example
new file mode 100644
index 0000000..367fb5c
--- /dev/null
+++ b/backend/.env.example
@@ -0,0 +1,33 @@
+# Service
+CH_HOST=0.0.0.0
+CH_PORT=8000
+CH_DEBUG=false
+
+# Database (Docker 部署时使用 volume 路径)
+CH_DATABASE_URL=sqlite+aiosqlite:///./data/classhub.db
+
+# JWT (务必修改 secret)
+CH_JWT_SECRET=change-me-in-production
+CH_JWT_EXPIRY_HOURS=72
+
+# Tencent COS (对象存储)
+CH_COS_SECRET_ID=your-cos-secret-id
+CH_COS_SECRET_KEY=your-cos-secret-key
+CH_COS_REGION=ap-guangzhou
+CH_COS_BUCKET=your-bucket-name
+CH_COS_BASE_URL=https://your-bucket.cos.ap-guangzhou.myqcloud.com
+
+# SMTP Email (邮件通知)
+CH_SMTP_HOST=smtp.example.com
+CH_SMTP_PORT=465
+CH_SMTP_USER=noreply@example.com
+CH_SMTP_PASSWORD=your-smtp-password
+CH_SMTP_FROM_EMAIL=noreply@example.com
+CH_SMTP_FROM_NAME=HKU ICB Class Hub
+
+# Frontend URL (CORS 用)
+CH_FRONTEND_URL=http://your-server-ip
+
+# Super Admin Seed (首次启动自动创建)
+CH_SUPER_ADMIN_EMAIL=admin@example.com
+CH_SUPER_ADMIN_PASSWORD=change-me-please
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..613e19f
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,17 @@
+FROM python:3.13-slim
+
+WORKDIR /app
+
+# Install dependencies first (layer caching)
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+# Copy application code
+COPY . .
+
+# Create data directory for SQLite
+RUN mkdir -p /app/data
+
+EXPOSE 8000
+
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/backend/alembic.ini b/backend/alembic.ini
new file mode 100644
index 0000000..14d27ab
--- /dev/null
+++ b/backend/alembic.ini
@@ -0,0 +1,36 @@
+[alembic]
+script_location = alembic
+sqlalchemy.url = sqlite+aiosqlite:///./classhub.db
+
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/backend/alembic/env.py b/backend/alembic/env.py
new file mode 100644
index 0000000..21a77bf
--- /dev/null
+++ b/backend/alembic/env.py
@@ -0,0 +1,57 @@
+import asyncio
+from logging.config import fileConfig
+
+from alembic import context
+from sqlalchemy import pool
+from sqlalchemy.ext.asyncio import async_engine_from_config
+
+from app.config import settings
+from app.db.base import Base
+from app.db.models import Class_, User, Timeline, Schedule, Announcement, Resource, Notification # noqa: ensure models registered
+
+config = context.config
+config.set_main_option("sqlalchemy.url", settings.database_url)
+
+if config.config_file_name is not None:
+ fileConfig(config.config_file_name)
+
+target_metadata = Base.metadata
+
+
+def run_migrations_offline() -> None:
+ url = config.get_main_option("sqlalchemy.url")
+ context.configure(
+ url=url,
+ target_metadata=target_metadata,
+ literal_binds=True,
+ dialect_opts={"paramstyle": "named"},
+ )
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def do_run_migrations(connection):
+ context.configure(connection=connection, target_metadata=target_metadata)
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+async def run_async_migrations() -> None:
+ connectable = async_engine_from_config(
+ config.get_section(config.config_ini_section, {}),
+ prefix="sqlalchemy.",
+ poolclass=pool.NullPool,
+ )
+ async with connectable.connect() as connection:
+ await connection.run_sync(do_run_migrations)
+ await connectable.dispose()
+
+
+def run_migrations_online() -> None:
+ asyncio.run(run_async_migrations())
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako
new file mode 100644
index 0000000..fbc4b07
--- /dev/null
+++ b/backend/alembic/script.py.mako
@@ -0,0 +1,26 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision: str = ${repr(up_revision)}
+down_revision: Union[str, None] = ${repr(down_revision)}
+branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
+depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+ ${downgrades if downgrades else "pass"}
diff --git a/backend/app/__init__.py b/backend/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/api/announcements.py b/backend/app/api/announcements.py
new file mode 100644
index 0000000..e8a2671
--- /dev/null
+++ b/backend/app/api/announcements.py
@@ -0,0 +1,121 @@
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.deps import require_role
+from app.db.database import get_db
+from app.db.models import User
+from app.schemas.announcement import AnnouncementCreate, AnnouncementUpdate, AnnouncementOut
+from app.schemas.common import PageResponse
+from app.services.announcement_service import (
+ create_announcement,
+ update_announcement,
+ delete_announcement,
+ get_announcement_by_id,
+ list_announcements,
+)
+
+router = APIRouter(prefix="/api/announcements", tags=["announcements"])
+
+
+@router.get("/", response_model=PageResponse[AnnouncementOut])
+async def get_announcements(
+ page: int = 1,
+ page_size: int = 20,
+ class_id: int | None = None,
+ user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ db: AsyncSession = Depends(get_db),
+):
+ effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
+ if effective_class_id is None:
+ return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
+
+ announcements, total = await list_announcements(db, effective_class_id, page, page_size)
+ total_pages = (total + page_size - 1) // page_size
+
+ items = []
+ for a in announcements:
+ items.append(
+ AnnouncementOut(
+ id=a.id,
+ class_id=a.class_id,
+ author_id=a.author_id,
+ author_name=a.author.name if a.author else "Unknown",
+ title=a.title,
+ content=a.content,
+ is_pinned=a.is_pinned,
+ created_at=a.created_at,
+ updated_at=a.updated_at,
+ )
+ )
+
+ return PageResponse(
+ items=items, total=total, page=page, page_size=page_size, total_pages=total_pages
+ )
+
+
+@router.post("/", response_model=AnnouncementOut)
+async def create_new_announcement(
+ data: AnnouncementCreate,
+ class_id: int | None = None,
+ user: User = Depends(require_role("super_admin", "class_admin")),
+ db: AsyncSession = Depends(get_db),
+):
+ effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
+ if effective_class_id is None:
+ raise HTTPException(status_code=400, detail="You are not assigned to a class")
+
+ announcement = await create_announcement(db, effective_class_id, user.id, data)
+ return AnnouncementOut(
+ id=announcement.id,
+ class_id=announcement.class_id,
+ author_id=announcement.author_id,
+ author_name=user.name,
+ title=announcement.title,
+ content=announcement.content,
+ is_pinned=announcement.is_pinned,
+ created_at=announcement.created_at,
+ updated_at=announcement.updated_at,
+ )
+
+
+@router.put("/{announcement_id}", response_model=AnnouncementOut)
+async def update_existing_announcement(
+ announcement_id: int,
+ data: AnnouncementUpdate,
+ user: User = Depends(require_role("super_admin", "class_admin")),
+ db: AsyncSession = Depends(get_db),
+):
+ announcement = await get_announcement_by_id(db, announcement_id)
+ if announcement is None:
+ raise HTTPException(status_code=404, detail="Announcement not found")
+ if user.role != "super_admin" and announcement.class_id != user.class_id:
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ updated = await update_announcement(db, announcement, data)
+ return AnnouncementOut(
+ id=updated.id,
+ class_id=updated.class_id,
+ author_id=updated.author_id,
+ author_name=updated.author.name if updated.author else user.name,
+ title=updated.title,
+ content=updated.content,
+ is_pinned=updated.is_pinned,
+ created_at=updated.created_at,
+ updated_at=updated.updated_at,
+ )
+
+
+@router.delete("/{announcement_id}")
+async def delete_existing_announcement(
+ announcement_id: int,
+ user: User = Depends(require_role("super_admin", "class_admin")),
+ db: AsyncSession = Depends(get_db),
+):
+ announcement = await get_announcement_by_id(db, announcement_id)
+ if announcement is None:
+ raise HTTPException(status_code=404, detail="Announcement not found")
+ if user.role != "super_admin" and announcement.class_id != user.class_id:
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ await delete_announcement(db, announcement)
+ return {"message": "Announcement deleted"}
diff --git a/backend/app/api/assignments.py b/backend/app/api/assignments.py
new file mode 100644
index 0000000..a7ec865
--- /dev/null
+++ b/backend/app/api/assignments.py
@@ -0,0 +1,276 @@
+from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
+from sqlalchemy import select, func
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from app.core.deps import require_role
+from app.db.database import get_db
+from app.db.models import User, Class_
+from app.schemas.assignment import (
+ AssignmentCreate, AssignmentUpdate, AssignmentOut,
+ AssignmentDetailOut, SubmissionGrade, SubmissionOut,
+)
+from app.schemas.common import PageResponse
+from app.services.assignment_service import (
+ create_assignment,
+ update_assignment,
+ delete_assignment,
+ get_assignment_by_id,
+ list_assignments,
+ add_attachments,
+ create_submission,
+ get_submission_by_student,
+ grade_submission,
+ list_submissions,
+)
+from app.services.cos_service import upload_file
+
+router = APIRouter(prefix="/api/assignments", tags=["assignments"])
+
+
+async def _get_roster_count(db: AsyncSession, class_id: int) -> int:
+ from app.db.models import StudentRoster
+ result = await db.execute(
+ select(func.count(StudentRoster.id)).where(StudentRoster.class_id == class_id)
+ )
+ return result.scalar() or 0
+
+
+def _build_assignment_out(a: any, user_id: int, total_members: int = 0) -> AssignmentOut:
+ submission_count = len(a.submissions) if a.submissions else 0
+ my_submitted = any(s.student_id == user_id for s in (a.submissions or []))
+ return AssignmentOut(
+ id=a.id,
+ class_id=a.class_id,
+ creator_id=a.creator_id,
+ creator_name=a.creator.name if a.creator else "Unknown",
+ title=a.title,
+ description=a.description,
+ deadline=a.deadline,
+ attachment_urls=a.get_attachment_urls_list(),
+ status=a.status,
+ submission_count=submission_count,
+ total_members=total_members,
+ my_submitted=my_submitted,
+ created_at=a.created_at,
+ updated_at=a.updated_at,
+ )
+
+
+def _build_submission_out(s: any) -> SubmissionOut:
+ return SubmissionOut(
+ id=s.id,
+ assignment_id=s.assignment_id,
+ student_id=s.student_id,
+ student_name=s.student.name if s.student else "Unknown",
+ notes=s.notes,
+ file_url=s.file_url,
+ file_name=s.file_name,
+ file_type=s.file_type,
+ file_size=s.file_size,
+ grade=s.grade,
+ feedback=s.feedback,
+ graded_at=s.graded_at,
+ created_at=s.created_at,
+ updated_at=s.updated_at,
+ )
+
+
+@router.get("/", response_model=PageResponse[AssignmentOut])
+async def get_assignments(
+ page: int = 1,
+ page_size: int = 20,
+ class_id: int | None = None,
+ user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ db: AsyncSession = Depends(get_db),
+):
+ effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
+ if effective_class_id is None:
+ return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
+
+ assignments, total = await list_assignments(db, effective_class_id, page, page_size)
+ total_pages = (total + page_size - 1) // page_size
+ roster_count = await _get_roster_count(db, effective_class_id)
+ items = [_build_assignment_out(a, user.id, roster_count) for a in assignments]
+ return PageResponse(items=items, total=total, page=page, page_size=page_size, total_pages=total_pages)
+
+
+@router.post("/", response_model=AssignmentOut)
+async def create_new_assignment(
+ data: AssignmentCreate,
+ class_id: int | None = None,
+ user: User = Depends(require_role("super_admin", "class_admin")),
+ db: AsyncSession = Depends(get_db),
+):
+ effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
+ if effective_class_id is None:
+ raise HTTPException(status_code=400, detail="You are not assigned to a class")
+
+ assignment = await create_assignment(db, effective_class_id, user.id, data)
+ roster_count = await _get_roster_count(db, effective_class_id)
+ return _build_assignment_out(assignment, user.id, roster_count)
+
+
+@router.post("/{assignment_id}/attachments")
+async def upload_assignment_attachments(
+ assignment_id: int,
+ files: list[UploadFile] = File(...),
+ user: User = Depends(require_role("super_admin", "class_admin")),
+ db: AsyncSession = Depends(get_db),
+):
+ assignment = await get_assignment_by_id(db, assignment_id)
+ if assignment is None:
+ raise HTTPException(status_code=404, detail="Assignment not found")
+ if user.role != "super_admin" and assignment.class_id != user.class_id:
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ urls = []
+ for f in files:
+ contents = await f.read()
+ if len(contents) > 20 * 1024 * 1024: # 20MB limit
+ raise HTTPException(status_code=400, detail=f"File {f.filename} too large (max 20MB)")
+ url = upload_file(
+ f"assignments/{assignment_id}", f.filename or "file", contents,
+ f.content_type or "application/octet-stream",
+ )
+ urls.append(url)
+
+ await add_attachments(db, assignment, urls)
+ return {"attachment_urls": urls}
+
+
+@router.get("/{assignment_id}", response_model=AssignmentDetailOut)
+async def get_assignment_detail(
+ assignment_id: int,
+ user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ db: AsyncSession = Depends(get_db),
+):
+ assignment = await get_assignment_by_id(db, assignment_id)
+ if assignment is None:
+ raise HTTPException(status_code=404, detail="Assignment not found")
+ if user.role != "super_admin" and assignment.class_id != user.class_id:
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ base = _build_assignment_out(assignment, user.id, await _get_roster_count(db, assignment.class_id))
+
+ # Student only sees their own submission
+ if user.role == "student":
+ my_submission = None
+ for s in (assignment.submissions or []):
+ if s.student_id == user.id:
+ my_submission = _build_submission_out(s)
+ break
+ return AssignmentDetailOut(**base.model_dump(), submissions=[my_submission] if my_submission else [])
+
+ # Admin sees all submissions
+ submissions = [_build_submission_out(s) for s in (assignment.submissions or [])]
+ return AssignmentDetailOut(**base.model_dump(), submissions=submissions)
+
+
+@router.put("/{assignment_id}", response_model=AssignmentOut)
+async def update_existing_assignment(
+ assignment_id: int,
+ data: AssignmentUpdate,
+ user: User = Depends(require_role("super_admin", "class_admin")),
+ db: AsyncSession = Depends(get_db),
+):
+ assignment = await get_assignment_by_id(db, assignment_id)
+ if assignment is None:
+ raise HTTPException(status_code=404, detail="Assignment not found")
+ if user.role != "super_admin" and assignment.class_id != user.class_id:
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ updated = await update_assignment(db, assignment, data)
+ return _build_assignment_out(updated, user.id, await _get_roster_count(db, updated.class_id))
+
+
+@router.delete("/{assignment_id}")
+async def delete_existing_assignment(
+ assignment_id: int,
+ user: User = Depends(require_role("super_admin", "class_admin")),
+ db: AsyncSession = Depends(get_db),
+):
+ assignment = await get_assignment_by_id(db, assignment_id)
+ if assignment is None:
+ raise HTTPException(status_code=404, detail="Assignment not found")
+ if user.role != "super_admin" and assignment.class_id != user.class_id:
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ await delete_assignment(db, assignment)
+ return {"message": "Assignment deleted"}
+
+
+@router.post("/{assignment_id}/submit", response_model=SubmissionOut)
+async def submit_assignment(
+ assignment_id: int,
+ notes: str = "",
+ file: UploadFile = File(...),
+ user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ db: AsyncSession = Depends(get_db),
+):
+ assignment = await get_assignment_by_id(db, assignment_id)
+ if assignment is None:
+ raise HTTPException(status_code=404, detail="Assignment not found")
+ if user.role != "super_admin" and assignment.class_id != user.class_id:
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ # Upload file
+ file_url = None
+ file_name = None
+ file_type = None
+ file_size = None
+ if file.filename:
+ contents = await file.read()
+ if len(contents) > 50 * 1024 * 1024: # 50MB limit
+ raise HTTPException(status_code=400, detail="File too large (max 50MB)")
+ file_url = upload_file(
+ f"submissions/{assignment_id}/{user.id}", file.filename, contents,
+ file.content_type or "application/octet-stream",
+ )
+ file_name = file.filename
+ file_type = file.content_type
+ file_size = len(contents)
+
+ try:
+ submission = await create_submission(
+ db, assignment_id, user.id,
+ notes=notes or None,
+ file_url=file_url,
+ file_name=file_name,
+ file_type=file_type,
+ file_size=file_size,
+ )
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+ return _build_submission_out(submission)
+
+
+@router.put("/submissions/{submission_id}/grade", response_model=SubmissionOut)
+async def grade_assignment_submission(
+ submission_id: int,
+ data: SubmissionGrade,
+ user: User = Depends(require_role("super_admin", "class_admin")),
+ db: AsyncSession = Depends(get_db),
+):
+ from sqlalchemy import select
+ from app.db.models import AssignmentSubmission
+ result = await db.execute(
+ select(AssignmentSubmission)
+ .where(AssignmentSubmission.id == submission_id)
+ )
+ submission = result.scalar_one_or_none()
+ if submission is None:
+ raise HTTPException(status_code=404, detail="Submission not found")
+
+ graded = await grade_submission(db, submission, data)
+
+ # Reload with student relationship
+ from sqlalchemy.orm import selectinload
+ result = await db.execute(
+ select(AssignmentSubmission)
+ .options(selectinload(AssignmentSubmission.student))
+ .where(AssignmentSubmission.id == graded.id)
+ )
+ graded = result.scalar_one()
+ return _build_submission_out(graded)
diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py
new file mode 100644
index 0000000..72547c0
--- /dev/null
+++ b/backend/app/api/auth.py
@@ -0,0 +1,93 @@
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.auth import hash_password, verify_password, create_access_token
+from app.core.deps import get_current_user
+from app.db.database import get_db
+from app.db.models import User
+from app.schemas.auth import LoginRequest, RegisterRequest, ChangePasswordRequest
+from app.schemas.user import TokenResponse, UserOut
+from app.services.roster_service import validate_registration
+
+router = APIRouter(prefix="/api/auth", tags=["auth"])
+
+
+@router.post("/register")
+async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
+ # 1. Check if email is already registered
+ existing = await db.execute(select(User).where(User.email == req.email))
+ if existing.scalar_one_or_none():
+ raise HTTPException(status_code=400, detail="该邮箱已注册")
+
+ # 2. Validate invite_code + student_id against roster
+ roster_entry = await validate_registration(db, req.invite_code, req.student_id)
+ if roster_entry is None:
+ raise HTTPException(
+ status_code=400, detail="邀请码或学号无效,或该学号已注册"
+ )
+
+ # 3. Create user with approved status directly
+ user = User(
+ email=req.email,
+ password_hash=hash_password(req.password),
+ name=req.name,
+ student_id=req.student_id,
+ role="student",
+ status="approved",
+ class_id=roster_entry.class_id,
+ )
+ db.add(user)
+ await db.flush()
+
+ # 4. Mark roster entry as registered
+ roster_entry.status = "registered"
+ roster_entry.user_id = user.id
+ await db.commit()
+
+ # 5. Issue token — register and login in one step
+ token = create_access_token({"sub": str(user.id), "role": user.role})
+ return {
+ "message": "注册成功",
+ "token": token,
+ "user": UserOut.model_validate(user),
+ }
+
+
+@router.post("/login", response_model=TokenResponse)
+async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
+ result = await db.execute(select(User).where(User.email == req.email))
+ user = result.scalar_one_or_none()
+
+ if user is None:
+ raise HTTPException(status_code=401, detail="邮箱或密码错误")
+
+ if user.status == "disabled":
+ raise HTTPException(status_code=401, detail="账号已被禁用")
+
+ if not verify_password(req.password, user.password_hash):
+ raise HTTPException(status_code=401, detail="邮箱或密码错误")
+
+ token = create_access_token({"sub": str(user.id), "role": user.role})
+ return TokenResponse(
+ token=token,
+ user=UserOut.model_validate(user),
+ )
+
+
+@router.get("/me", response_model=UserOut)
+async def get_me(user: User = Depends(get_current_user)):
+ return UserOut.model_validate(user)
+
+
+@router.put("/change-password")
+async def change_password(
+ req: ChangePasswordRequest,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ if not verify_password(req.old_password, user.password_hash):
+ raise HTTPException(status_code=400, detail="Old password is incorrect")
+ user.password_hash = hash_password(req.new_password)
+ await db.commit()
+ return {"message": "Password changed successfully"}
diff --git a/backend/app/api/classes.py b/backend/app/api/classes.py
new file mode 100644
index 0000000..cbdca3e
--- /dev/null
+++ b/backend/app/api/classes.py
@@ -0,0 +1,270 @@
+import csv
+import io
+
+from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.deps import require_role
+from app.db.database import get_db
+from app.db.models import User
+from app.schemas.class_ import ClassCreate, ClassUpdate, ClassOut
+from app.schemas.user import UserListItem
+from app.schemas.roster import RosterOut, RosterImportRequest
+from app.schemas.common import PageResponse
+from app.services.class_service import (
+ create_class,
+ update_class,
+ delete_class,
+ get_class_by_id,
+ list_classes,
+ get_member_count,
+ get_class_members,
+)
+from app.services.roster_service import (
+ ensure_invite_code,
+ regenerate_invite_code,
+ import_roster,
+ get_roster,
+ delete_roster_entry,
+ clear_unregistered_roster,
+)
+
+router = APIRouter(prefix="/api/classes", tags=["classes"])
+
+
+@router.get("/", response_model=PageResponse[ClassOut])
+async def get_classes(
+ page: int = 1,
+ page_size: int = 50,
+ db: AsyncSession = Depends(get_db),
+):
+ classes, total = await list_classes(db, page, page_size)
+ total_pages = (total + page_size - 1) // page_size
+ result = []
+ for c in classes:
+ count = await get_member_count(db, c.id)
+ out = ClassOut.model_validate(c)
+ out.member_count = count
+ result.append(out)
+ return PageResponse(
+ items=result, total=total, page=page, page_size=page_size, total_pages=total_pages
+ )
+
+
+@router.post("/", response_model=ClassOut)
+async def create_new_class(
+ data: ClassCreate,
+ admin: User = Depends(require_role("super_admin")),
+ db: AsyncSession = Depends(get_db),
+):
+ class_ = await create_class(db, data)
+ out = ClassOut.model_validate(class_)
+ out.member_count = 0
+ return out
+
+
+@router.put("/{class_id}", response_model=ClassOut)
+async def update_existing_class(
+ class_id: int,
+ data: ClassUpdate,
+ admin: User = Depends(require_role("super_admin")),
+ db: AsyncSession = Depends(get_db),
+):
+ class_ = await get_class_by_id(db, class_id)
+ if class_ is None:
+ raise HTTPException(status_code=404, detail="Class not found")
+ updated = await update_class(db, class_, data)
+ out = ClassOut.model_validate(updated)
+ out.member_count = await get_member_count(db, class_id)
+ return out
+
+
+@router.delete("/{class_id}")
+async def delete_existing_class(
+ class_id: int,
+ admin: User = Depends(require_role("super_admin")),
+ db: AsyncSession = Depends(get_db),
+):
+ class_ = await get_class_by_id(db, class_id)
+ if class_ is None:
+ raise HTTPException(status_code=404, detail="Class not found")
+ await delete_class(db, class_)
+ return {"message": "Class deleted"}
+
+
+@router.get("/{class_id}/members", response_model=PageResponse[UserListItem])
+async def get_members(
+ class_id: int,
+ status: str | None = None,
+ page: int = 1,
+ page_size: int = 50,
+ admin: User = Depends(require_role("super_admin", "class_admin")),
+ db: AsyncSession = Depends(get_db),
+):
+ if admin.role == "class_admin" and admin.class_id != class_id:
+ raise HTTPException(status_code=403, detail="Access denied for this class")
+
+ members, total = await get_class_members(db, class_id, status, page, page_size)
+ total_pages = (total + page_size - 1) // page_size
+ return PageResponse(
+ items=[UserListItem.model_validate(m) for m in members],
+ total=total,
+ page=page,
+ page_size=page_size,
+ total_pages=total_pages,
+ )
+
+
+# --- Roster management ---
+
+
+@router.get("/{class_id}/roster", response_model=PageResponse[RosterOut])
+async def get_class_roster(
+ class_id: int,
+ page: int = 1,
+ page_size: int = 50,
+ admin: User = Depends(require_role("super_admin", "class_admin")),
+ db: AsyncSession = Depends(get_db),
+):
+ if admin.role == "class_admin" and admin.class_id != class_id:
+ raise HTTPException(status_code=403, detail="Access denied")
+ entries, total = await get_roster(db, class_id, page, page_size)
+ total_pages = (total + page_size - 1) // page_size
+ return PageResponse(
+ items=[RosterOut.model_validate(e) for e in entries],
+ total=total,
+ page=page,
+ page_size=page_size,
+ total_pages=total_pages,
+ )
+
+
+@router.post("/{class_id}/roster/import")
+async def import_class_roster(
+ class_id: int,
+ data: RosterImportRequest,
+ admin: User = Depends(require_role("super_admin", "class_admin")),
+ db: AsyncSession = Depends(get_db),
+):
+ if admin.role == "class_admin" and admin.class_id != class_id:
+ raise HTTPException(status_code=403, detail="Access denied")
+ count = await import_roster(db, class_id, data.entries)
+ return {"message": f"成功导入 {count} 条记录"}
+
+
+@router.post("/{class_id}/roster/upload")
+async def upload_roster_file(
+ class_id: int,
+ file: UploadFile = File(...),
+ admin: User = Depends(require_role("super_admin", "class_admin")),
+ db: AsyncSession = Depends(get_db),
+):
+ if admin.role == "class_admin" and admin.class_id != class_id:
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ contents = await file.read()
+ filename = file.filename or ""
+
+ entries: list[dict] = []
+
+ if filename.endswith(".csv"):
+ text = contents.decode("utf-8-sig")
+ reader = csv.DictReader(io.StringIO(text))
+ for row in reader:
+ sid = row.get("student_id") or row.get("学号") or ""
+ name = row.get("name") or row.get("姓名") or ""
+ if sid and name:
+ entries.append({"student_id": sid.strip(), "name": name.strip()})
+ elif filename.endswith((".xlsx", ".xls")):
+ try:
+ import openpyxl
+
+ wb = openpyxl.load_workbook(io.BytesIO(contents), read_only=True)
+ ws = wb.active
+ rows = list(ws.iter_rows(values_only=True))
+ if len(rows) < 2:
+ raise HTTPException(status_code=400, detail="Excel 文件为空")
+ header = [str(h).strip() if h else "" for h in rows[0]]
+ # Find student_id and name columns
+ sid_col = None
+ name_col = None
+ for i, h in enumerate(header):
+ if h in ("student_id", "学号"):
+ sid_col = i
+ elif h in ("name", "姓名"):
+ name_col = i
+ if sid_col is None or name_col is None:
+ raise HTTPException(
+ status_code=400,
+ detail="Excel 需包含 '学号'(student_id) 和 '姓名'(name) 列",
+ )
+ for row in rows[1:]:
+ sid = str(row[sid_col]).strip() if row[sid_col] else ""
+ name = str(row[name_col]).strip() if row[name_col] else ""
+ if sid and name and sid != "None":
+ entries.append({"student_id": sid, "name": name})
+ wb.close()
+ except ImportError:
+ raise HTTPException(
+ status_code=400, detail="服务器未安装 openpyxl,请使用 CSV 格式"
+ )
+ else:
+ raise HTTPException(status_code=400, detail="仅支持 CSV 或 Excel (.xlsx) 文件")
+
+ if not entries:
+ raise HTTPException(status_code=400, detail="未找到有效数据")
+
+ count = await import_roster(db, class_id, entries)
+ return {"message": f"成功导入 {count} 条记录"}
+
+
+@router.delete("/{class_id}/roster/{roster_id}")
+async def delete_roster_item(
+ class_id: int,
+ roster_id: int,
+ admin: User = Depends(require_role("super_admin", "class_admin")),
+ db: AsyncSession = Depends(get_db),
+):
+ success = await delete_roster_entry(db, roster_id)
+ if not success:
+ raise HTTPException(status_code=400, detail="无法删除(已注册或不存在)")
+ return {"message": "已删除"}
+
+
+@router.post("/{class_id}/roster/clear")
+async def clear_roster(
+ class_id: int,
+ admin: User = Depends(require_role("super_admin", "class_admin")),
+ db: AsyncSession = Depends(get_db),
+):
+ if admin.role == "class_admin" and admin.class_id != class_id:
+ raise HTTPException(status_code=403, detail="Access denied")
+ count = await clear_unregistered_roster(db, class_id)
+ return {"message": f"已清除 {count} 条未注册记录"}
+
+
+# --- Invite code management ---
+
+
+@router.get("/{class_id}/invite-code")
+async def get_invite_code(
+ class_id: int,
+ admin: User = Depends(require_role("super_admin", "class_admin")),
+ db: AsyncSession = Depends(get_db),
+):
+ code = await ensure_invite_code(db, class_id)
+ if not code:
+ raise HTTPException(status_code=404, detail="Class not found")
+ return {"invite_code": code}
+
+
+@router.post("/{class_id}/invite-code/regenerate")
+async def regenerate_invite(
+ class_id: int,
+ admin: User = Depends(require_role("super_admin", "class_admin")),
+ db: AsyncSession = Depends(get_db),
+):
+ code = await regenerate_invite_code(db, class_id)
+ if not code:
+ raise HTTPException(status_code=404, detail="Class not found")
+ return {"invite_code": code}
diff --git a/backend/app/api/directory.py b/backend/app/api/directory.py
new file mode 100644
index 0000000..4c604a3
--- /dev/null
+++ b/backend/app/api/directory.py
@@ -0,0 +1,57 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.deps import get_current_user
+from app.db.database import get_db
+from app.db.models import User
+from app.schemas.user import UserPublic
+from app.schemas.common import PageResponse
+from app.services.directory_service import search_directory, user_to_public
+from app.services.user_service import get_user_by_id
+
+router = APIRouter(prefix="/api/directory", tags=["directory"])
+
+
+@router.get("/", response_model=PageResponse[UserPublic])
+async def search_members(
+ search: str | None = None,
+ industry: str | None = None,
+ company: str | None = None,
+ class_id: int | None = None,
+ page: int = 1,
+ page_size: int = 20,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ # Determine effective class_id: super_admin can specify one, others use their own
+ effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
+ if effective_class_id is None:
+ return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
+
+ users, total = await search_directory(
+ db, effective_class_id, search, industry, company, page, page_size
+ )
+ total_pages = (total + page_size - 1) // page_size
+ include_contact = True # Same class, approved users can see contact
+ return PageResponse(
+ items=[user_to_public(u, include_contact=include_contact) for u in users],
+ total=total,
+ page=page,
+ page_size=page_size,
+ total_pages=total_pages,
+ )
+
+
+@router.get("/{user_id}", response_model=UserPublic)
+async def get_member_detail(
+ user_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ target = await get_user_by_id(db, user_id)
+ if target is None or target.status != "approved":
+ raise HTTPException(status_code=404, detail="User not found")
+
+ # Privacy: only show contact info to same-class members
+ include_contact = user.class_id == target.class_id
+ return user_to_public(target, include_contact=include_contact)
diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py
new file mode 100644
index 0000000..2d6e64a
--- /dev/null
+++ b/backend/app/api/notifications.py
@@ -0,0 +1,74 @@
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.deps import get_current_user
+from app.db.database import get_db
+from app.db.models import User
+from app.schemas.notification import NotificationOut, UnreadCount
+from app.schemas.common import PageResponse
+from app.services.notification_service import (
+ list_notifications,
+ get_unread_count,
+ mark_as_read,
+ mark_all_as_read,
+)
+
+router = APIRouter(prefix="/api/notifications", tags=["notifications"])
+
+
+@router.get("/", response_model=PageResponse[NotificationOut])
+async def get_notifications(
+ page: int = 1,
+ page_size: int = 20,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ notifications, total = await list_notifications(db, user.id, page, page_size)
+ total_pages = (total + page_size - 1) // page_size
+
+ items = [
+ NotificationOut(
+ id=n.id,
+ type=n.type,
+ title=n.title,
+ content=n.content,
+ related_id=n.related_id,
+ is_read=n.is_read,
+ created_at=n.created_at,
+ )
+ for n in notifications
+ ]
+
+ return PageResponse(
+ items=items, total=total, page=page, page_size=page_size, total_pages=total_pages
+ )
+
+
+@router.get("/unread-count", response_model=UnreadCount)
+async def get_unread_count_api(
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ count = await get_unread_count(db, user.id)
+ return UnreadCount(count=count)
+
+
+@router.put("/{notification_id}/read")
+async def mark_notification_read(
+ notification_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ success = await mark_as_read(db, notification_id, user.id)
+ if not success:
+ raise HTTPException(status_code=404, detail="Notification not found")
+ return {"message": "Marked as read"}
+
+
+@router.put("/read-all")
+async def mark_all_notifications_read(
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ count = await mark_all_as_read(db, user.id)
+ return {"message": f"Marked {count} notifications as read"}
diff --git a/backend/app/api/resources.py b/backend/app/api/resources.py
new file mode 100644
index 0000000..6d8f83d
--- /dev/null
+++ b/backend/app/api/resources.py
@@ -0,0 +1,153 @@
+from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.deps import require_role
+from app.db.database import get_db
+from app.db.models import User
+from app.schemas.resource import ResourceCreate, ResourceOut
+from app.schemas.common import PageResponse
+from app.services.resource_service import (
+ create_resource,
+ list_resources,
+ get_resource_by_id,
+ increment_download_count,
+ delete_resource,
+)
+from app.services.cos_service import upload_file
+
+router = APIRouter(prefix="/api/resources", tags=["resources"])
+
+ALLOWED_FILE_TYPES = {
+ "application/pdf",
+ "application/msword",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ "application/vnd.ms-excel",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ "application/vnd.ms-powerpoint",
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ "application/zip",
+ "text/plain",
+ "image/jpeg",
+ "image/png",
+ "image/gif",
+ "image/webp",
+}
+
+
+@router.get("/", response_model=PageResponse[ResourceOut])
+async def get_resources(
+ page: int = 1,
+ page_size: int = 20,
+ category: str | None = None,
+ class_id: int | None = None,
+ user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ db: AsyncSession = Depends(get_db),
+):
+ effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
+ if effective_class_id is None:
+ return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
+
+ resources, total = await list_resources(db, effective_class_id, category, page, page_size)
+ total_pages = (total + page_size - 1) // page_size
+
+ items = []
+ for r in resources:
+ items.append(
+ ResourceOut(
+ id=r.id,
+ class_id=r.class_id,
+ uploader_id=r.uploader_id,
+ uploader_name=r.uploader.name if r.uploader else "Unknown",
+ title=r.title,
+ description=r.description,
+ file_url=r.file_url,
+ file_type=r.file_type,
+ file_size=r.file_size,
+ category=r.category,
+ download_count=r.download_count,
+ created_at=r.created_at,
+ )
+ )
+
+ return PageResponse(
+ items=items, total=total, page=page, page_size=page_size, total_pages=total_pages
+ )
+
+
+@router.post("/", response_model=ResourceOut)
+async def upload_new_resource(
+ file: UploadFile = File(...),
+ title: str = Form(...),
+ category: str = Form(...),
+ description: str | None = Form(None),
+ class_id: int | None = Form(None),
+ user: User = Depends(require_role("super_admin", "class_admin")),
+ db: AsyncSession = Depends(get_db),
+):
+ effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
+ if effective_class_id is None:
+ raise HTTPException(status_code=400, detail="You are not assigned to a class")
+
+ contents = await file.read()
+ if len(contents) > 50 * 1024 * 1024: # 50MB limit
+ raise HTTPException(status_code=400, detail="File too large (max 50MB)")
+
+ if file.content_type not in ALLOWED_FILE_TYPES:
+ raise HTTPException(status_code=400, detail=f"File type {file.content_type} not allowed")
+
+ file_url = upload_file(
+ f"resources/{effective_class_id}",
+ file.filename or "file",
+ contents,
+ file.content_type,
+ )
+
+ data = ResourceCreate(title=title, description=description, category=category)
+ resource = await create_resource(
+ db, effective_class_id, user.id, data, file_url, file.content_type, len(contents)
+ )
+
+ return ResourceOut(
+ id=resource.id,
+ class_id=resource.class_id,
+ uploader_id=resource.uploader_id,
+ uploader_name=user.name,
+ title=resource.title,
+ description=resource.description,
+ file_url=resource.file_url,
+ file_type=resource.file_type,
+ file_size=resource.file_size,
+ category=resource.category,
+ download_count=resource.download_count,
+ created_at=resource.created_at,
+ )
+
+
+@router.post("/{resource_id}/download")
+async def download_resource(
+ resource_id: int,
+ user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ db: AsyncSession = Depends(get_db),
+):
+ resource = await get_resource_by_id(db, resource_id)
+ if resource is None:
+ raise HTTPException(status_code=404, detail="Resource not found")
+
+ await increment_download_count(db, resource)
+ return {"file_url": resource.file_url}
+
+
+@router.delete("/{resource_id}")
+async def delete_existing_resource(
+ resource_id: int,
+ user: User = Depends(require_role("super_admin", "class_admin")),
+ db: AsyncSession = Depends(get_db),
+):
+ resource = await get_resource_by_id(db, resource_id)
+ if resource is None:
+ raise HTTPException(status_code=404, detail="Resource not found")
+ if user.role != "super_admin" and resource.class_id != user.class_id:
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ await delete_resource(db, resource)
+ return {"message": "Resource deleted"}
diff --git a/backend/app/api/schedule.py b/backend/app/api/schedule.py
new file mode 100644
index 0000000..5174539
--- /dev/null
+++ b/backend/app/api/schedule.py
@@ -0,0 +1,104 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.deps import require_role
+from app.db.database import get_db
+from app.db.models import User
+from app.schemas.schedule import ScheduleCreate, ScheduleUpdate, ScheduleOut
+from app.schemas.common import PageResponse
+from app.services.schedule_service import (
+ create_schedule,
+ update_schedule,
+ delete_schedule,
+ get_schedule_by_id,
+ list_schedules,
+ get_upcoming_schedules,
+)
+
+router = APIRouter(prefix="/api/schedule", tags=["schedule"])
+
+
+@router.get("/upcoming", response_model=list[ScheduleOut])
+async def get_upcoming(
+ limit: int = 10,
+ class_id: int | None = None,
+ user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ db: AsyncSession = Depends(get_db),
+):
+ effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
+ if effective_class_id is None:
+ return []
+ items = await get_upcoming_schedules(db, effective_class_id, limit)
+ return [ScheduleOut.model_validate(i) for i in items]
+
+
+@router.get("/", response_model=PageResponse[ScheduleOut])
+async def get_schedules(
+ type: str | None = None,
+ page: int = 1,
+ page_size: int = 50,
+ class_id: int | None = None,
+ user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ db: AsyncSession = Depends(get_db),
+):
+ effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
+ if effective_class_id is None:
+ return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
+
+ items, total = await list_schedules(db, effective_class_id, type, page, page_size)
+ total_pages = (total + page_size - 1) // page_size
+ return PageResponse(
+ items=[ScheduleOut.model_validate(i) for i in items],
+ total=total,
+ page=page,
+ page_size=page_size,
+ total_pages=total_pages,
+ )
+
+
+@router.post("/", response_model=ScheduleOut)
+async def create_new_schedule(
+ data: ScheduleCreate,
+ class_id: int | None = None,
+ user: User = Depends(require_role("super_admin", "class_admin")),
+ db: AsyncSession = Depends(get_db),
+):
+ effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
+ if effective_class_id is None:
+ raise HTTPException(status_code=400, detail="You are not assigned to a class")
+
+ item = await create_schedule(db, effective_class_id, data)
+ return ScheduleOut.model_validate(item)
+
+
+@router.put("/{schedule_id}", response_model=ScheduleOut)
+async def update_existing_schedule(
+ schedule_id: int,
+ data: ScheduleUpdate,
+ user: User = Depends(require_role("super_admin", "class_admin")),
+ db: AsyncSession = Depends(get_db),
+):
+ item = await get_schedule_by_id(db, schedule_id)
+ if item is None:
+ raise HTTPException(status_code=404, detail="Schedule not found")
+ if user.role != "super_admin" and item.class_id != user.class_id:
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ updated = await update_schedule(db, item, data)
+ return ScheduleOut.model_validate(updated)
+
+
+@router.delete("/{schedule_id}")
+async def delete_existing_schedule(
+ schedule_id: int,
+ user: User = Depends(require_role("super_admin", "class_admin")),
+ db: AsyncSession = Depends(get_db),
+):
+ item = await get_schedule_by_id(db, schedule_id)
+ if item is None:
+ raise HTTPException(status_code=404, detail="Schedule not found")
+ if user.role != "super_admin" and item.class_id != user.class_id:
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ await delete_schedule(db, item)
+ return {"message": "Schedule deleted"}
diff --git a/backend/app/api/timeline.py b/backend/app/api/timeline.py
new file mode 100644
index 0000000..1bbda36
--- /dev/null
+++ b/backend/app/api/timeline.py
@@ -0,0 +1,262 @@
+from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.deps import require_role
+from app.db.database import get_db
+from app.db.models import User
+from app.schemas.timeline import (
+ TimelineCreate, TimelineUpdate, TimelineOut,
+ TimelineCommentCreate, TimelineCommentOut,
+)
+from app.schemas.common import PageResponse
+from app.services.timeline_service import (
+ create_timeline,
+ update_timeline,
+ delete_timeline,
+ get_timeline_by_id,
+ list_timelines,
+ add_images_to_timeline,
+ toggle_like,
+ create_comment,
+ delete_comment,
+ get_comment_by_id,
+ list_comments,
+)
+from app.services.cos_service import upload_image
+
+router = APIRouter(prefix="/api/timeline", tags=["timeline"])
+
+
+def _build_timeline_out(p, user_id: int, include_comments: bool = False) -> TimelineOut:
+ like_count = len(p.likes) if p.likes else 0
+ has_liked = any(l.user_id == user_id for l in (p.likes or []))
+ comment_count = len(p.comments) if p.comments else 0
+ comments_out = None
+ if include_comments and p.comments:
+ comments_out = [
+ TimelineCommentOut(
+ id=c.id,
+ post_id=c.post_id,
+ author_id=c.author_id,
+ author_name=c.author.name if c.author else "Unknown",
+ content=c.content,
+ created_at=c.created_at,
+ updated_at=c.updated_at,
+ )
+ for c in p.comments
+ ]
+ return TimelineOut(
+ id=p.id,
+ class_id=p.class_id,
+ author_id=p.author_id,
+ author_name=p.author.name if p.author else "Unknown",
+ title=p.title,
+ content=p.content,
+ image_urls=p.get_image_urls_list(),
+ like_count=like_count,
+ has_liked=has_liked,
+ comment_count=comment_count,
+ comments=comments_out,
+ created_at=p.created_at,
+ updated_at=p.updated_at,
+ )
+
+
+@router.get("/", response_model=PageResponse[TimelineOut])
+async def get_timelines(
+ page: int = 1,
+ page_size: int = 20,
+ class_id: int | None = None,
+ user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ db: AsyncSession = Depends(get_db),
+):
+ effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
+ if effective_class_id is None:
+ return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
+
+ posts, total = await list_timelines(db, effective_class_id, page, page_size)
+ total_pages = (total + page_size - 1) // page_size
+
+ items = [_build_timeline_out(p, user.id) for p in posts]
+
+ return PageResponse(
+ items=items, total=total, page=page, page_size=page_size, total_pages=total_pages
+ )
+
+
+@router.post("/", response_model=TimelineOut)
+async def create_new_timeline(
+ data: TimelineCreate,
+ class_id: int | None = None,
+ user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ db: AsyncSession = Depends(get_db),
+):
+ effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
+ if effective_class_id is None:
+ raise HTTPException(status_code=400, detail="You are not assigned to a class")
+
+ post = await create_timeline(db, effective_class_id, user.id, data)
+ return TimelineOut(
+ id=post.id,
+ class_id=post.class_id,
+ author_id=post.author_id,
+ author_name=user.name,
+ title=post.title,
+ content=post.content,
+ image_urls=[],
+ like_count=0,
+ has_liked=False,
+ comment_count=0,
+ created_at=post.created_at,
+ updated_at=post.updated_at,
+ )
+
+
+@router.post("/{post_id}/images")
+async def upload_timeline_images(
+ post_id: int,
+ files: list[UploadFile] = File(...),
+ user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ db: AsyncSession = Depends(get_db),
+):
+ post = await get_timeline_by_id(db, post_id)
+ if post is None:
+ raise HTTPException(status_code=404, detail="Timeline post not found")
+
+ # Student can only upload to own post; admin can upload to any in their class
+ if user.role == "student" and post.author_id != user.id:
+ raise HTTPException(status_code=403, detail="Access denied")
+ if user.role != "super_admin" and post.class_id != user.class_id:
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ urls = []
+ for f in files:
+ contents = await f.read()
+ if len(contents) > 10 * 1024 * 1024:
+ raise HTTPException(status_code=400, detail=f"File {f.filename} too large (max 10MB)")
+ if f.content_type not in {"image/jpeg", "image/png", "image/gif", "image/webp"}:
+ raise HTTPException(status_code=400, detail=f"File {f.filename} has invalid type")
+ url = upload_image(f"timeline/{post_id}", f.filename or "image.jpg", contents, f.content_type)
+ urls.append(url)
+
+ await add_images_to_timeline(db, post, urls)
+ return {"image_urls": urls}
+
+
+@router.put("/{post_id}", response_model=TimelineOut)
+async def update_existing_timeline(
+ post_id: int,
+ data: TimelineUpdate,
+ user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ db: AsyncSession = Depends(get_db),
+):
+ post = await get_timeline_by_id(db, post_id)
+ if post is None:
+ raise HTTPException(status_code=404, detail="Timeline post not found")
+ if user.role == "student" and post.author_id != user.id:
+ raise HTTPException(status_code=403, detail="Access denied")
+ if user.role != "super_admin" and post.class_id != user.class_id:
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ updated = await update_timeline(db, post, data)
+ return _build_timeline_out(updated, user.id)
+
+
+@router.delete("/{post_id}")
+async def delete_existing_timeline(
+ post_id: int,
+ user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ db: AsyncSession = Depends(get_db),
+):
+ post = await get_timeline_by_id(db, post_id)
+ if post is None:
+ raise HTTPException(status_code=404, detail="Timeline post not found")
+ if user.role == "student" and post.author_id != user.id:
+ raise HTTPException(status_code=403, detail="Access denied")
+ if user.role != "super_admin" and post.class_id != user.class_id:
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ await delete_timeline(db, post)
+ return {"message": "Timeline post deleted"}
+
+
+# --- Like & Comment endpoints ---
+
+@router.post("/{post_id}/like")
+async def like_timeline_post(
+ post_id: int,
+ user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ db: AsyncSession = Depends(get_db),
+):
+ post = await get_timeline_by_id(db, post_id)
+ if post is None:
+ raise HTTPException(status_code=404, detail="Timeline post not found")
+ if user.role != "super_admin" and post.class_id != user.class_id:
+ raise HTTPException(status_code=403, detail="Access denied")
+ return await toggle_like(db, post_id, user.id)
+
+
+@router.get("/{post_id}/comments", response_model=PageResponse[TimelineCommentOut])
+async def get_post_comments(
+ post_id: int,
+ page: int = 1,
+ page_size: int = 50,
+ user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ db: AsyncSession = Depends(get_db),
+):
+ comments, total = await list_comments(db, post_id, page, page_size)
+ total_pages = (total + page_size - 1) // page_size
+ items = [
+ TimelineCommentOut(
+ id=c.id,
+ post_id=c.post_id,
+ author_id=c.author_id,
+ author_name=c.author.name if c.author else "Unknown",
+ content=c.content,
+ created_at=c.created_at,
+ updated_at=c.updated_at,
+ )
+ for c in comments
+ ]
+ return PageResponse(items=items, total=total, page=page, page_size=page_size, total_pages=total_pages)
+
+
+@router.post("/{post_id}/comments", response_model=TimelineCommentOut)
+async def add_post_comment(
+ post_id: int,
+ data: TimelineCommentCreate,
+ user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ db: AsyncSession = Depends(get_db),
+):
+ post = await get_timeline_by_id(db, post_id)
+ if post is None:
+ raise HTTPException(status_code=404, detail="Timeline post not found")
+ if user.role != "super_admin" and post.class_id != user.class_id:
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ comment = await create_comment(db, post_id, user.id, data)
+ return TimelineCommentOut(
+ id=comment.id,
+ post_id=comment.post_id,
+ author_id=comment.author_id,
+ author_name=comment.author.name if comment.author else "Unknown",
+ content=comment.content,
+ created_at=comment.created_at,
+ updated_at=comment.updated_at,
+ )
+
+
+@router.delete("/comments/{comment_id}")
+async def delete_timeline_comment(
+ comment_id: int,
+ user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ db: AsyncSession = Depends(get_db),
+):
+ comment = await get_comment_by_id(db, comment_id)
+ if comment is None:
+ raise HTTPException(status_code=404, detail="Comment not found")
+ if user.role == "student" and comment.author_id != user.id:
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ await delete_comment(db, comment)
+ return {"message": "Comment deleted"}
diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py
new file mode 100644
index 0000000..bb9e687
--- /dev/null
+++ b/backend/app/api/upload.py
@@ -0,0 +1,24 @@
+from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
+
+from app.core.deps import require_role
+from app.db.models import User
+from app.services.cos_service import upload_image
+
+router = APIRouter(prefix="/api/upload", tags=["upload"])
+
+
+@router.post("/image")
+async def upload_image_api(
+ file: UploadFile = File(...),
+ user: User = Depends(require_role("super_admin", "class_admin")),
+):
+ """Upload an image to Tencent COS."""
+ contents = await file.read()
+ if len(contents) > 10 * 1024 * 1024: # 10MB
+ raise HTTPException(status_code=400, detail="File too large (max 10MB)")
+
+ if file.content_type not in {"image/jpeg", "image/png", "image/gif", "image/webp"}:
+ raise HTTPException(status_code=400, detail="Invalid file type, only JPEG/PNG/GIF/WebP allowed")
+
+ url = upload_image("images", file.filename or "upload.jpg", contents, file.content_type)
+ return {"url": url}
diff --git a/backend/app/api/users.py b/backend/app/api/users.py
new file mode 100644
index 0000000..57f594d
--- /dev/null
+++ b/backend/app/api/users.py
@@ -0,0 +1,123 @@
+import json
+
+from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.deps import get_current_user, require_role
+from app.db.database import get_db
+from app.db.models import User
+from app.schemas.user import UserOut, UserUpdate, UserListItem, UserStatusUpdate
+from app.schemas.common import PageResponse
+from app.services.user_service import (
+ update_profile,
+ update_user_status,
+ list_users,
+ get_user_by_id,
+)
+from app.services.cos_service import upload_image
+from app.services.email_service import send_approval_notification
+
+router = APIRouter(prefix="/api/users", tags=["users"])
+
+
+@router.get("/me", response_model=UserOut)
+async def get_my_profile(user: User = Depends(get_current_user)):
+ return UserOut.model_validate(user)
+
+
+@router.put("/me", response_model=UserOut)
+async def update_my_profile(
+ data: UserUpdate,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ try:
+ updated = await update_profile(db, user, data)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ return UserOut.model_validate(updated)
+
+
+@router.post("/me/avatar")
+async def upload_avatar(
+ file: UploadFile = File(...),
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ contents = await file.read()
+ if len(contents) > 5 * 1024 * 1024: # 5MB limit
+ raise HTTPException(status_code=400, detail="File too large (max 5MB)")
+
+ if file.content_type not in {"image/jpeg", "image/png", "image/gif", "image/webp"}:
+ raise HTTPException(status_code=400, detail="Invalid file type")
+
+ url = upload_image(f"avatars/{user.id}", file.filename or "avatar.jpg", contents, file.content_type)
+ user.avatar_url = url
+ await db.commit()
+ return {"avatar_url": url}
+
+
+@router.get("/", response_model=PageResponse[UserListItem])
+async def list_all_users(
+ page: int = 1,
+ page_size: int = 20,
+ class_id: int | None = None,
+ status: str | None = None,
+ role: str | None = None,
+ admin: User = Depends(require_role("super_admin")),
+ db: AsyncSession = Depends(get_db),
+):
+ users, total = await list_users(db, page, page_size, class_id, status, role)
+ total_pages = (total + page_size - 1) // page_size
+ return PageResponse(
+ items=[UserListItem.model_validate(u) for u in users],
+ total=total,
+ page=page,
+ page_size=page_size,
+ total_pages=total_pages,
+ )
+
+
+@router.put("/{user_id}/status")
+async def change_user_status(
+ user_id: int,
+ data: UserStatusUpdate,
+ admin: User = Depends(require_role("super_admin", "class_admin")),
+ db: AsyncSession = Depends(get_db),
+):
+ target = await get_user_by_id(db, user_id)
+ if target is None:
+ raise HTTPException(status_code=404, detail="User not found")
+
+ # Class admin can only manage users in their own class
+ if admin.role == "class_admin" and target.class_id != admin.class_id:
+ raise HTTPException(
+ status_code=403, detail="Cannot manage users outside your class"
+ )
+
+ updated = await update_user_status(db, user_id, data.status, data.role)
+
+ # Send email notification
+ if data.status in ("approved", "rejected"):
+ await send_approval_notification(target.email, data.status == "approved")
+
+ return {"message": f"User status updated to {data.status}"}
+
+
+@router.put("/{user_id}/role")
+async def change_user_role(
+ user_id: int,
+ role: str,
+ admin: User = Depends(require_role("super_admin")),
+ db: AsyncSession = Depends(get_db),
+):
+ if role not in ("super_admin", "class_admin", "student"):
+ raise HTTPException(status_code=400, detail="Invalid role")
+
+ target = await get_user_by_id(db, user_id)
+ if target is None:
+ raise HTTPException(status_code=404, detail="User not found")
+
+ target.role = role
+ await db.commit()
+ return {"message": f"User role updated to {role}"}
diff --git a/backend/app/api/votes.py b/backend/app/api/votes.py
new file mode 100644
index 0000000..59137ab
--- /dev/null
+++ b/backend/app/api/votes.py
@@ -0,0 +1,177 @@
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.deps import require_role
+from app.db.database import get_db
+from app.db.models import User
+from app.schemas.vote import VoteCreate, VoteUpdate, VoteSubmit, VoteOptionOut, VoteOut
+from app.schemas.common import PageResponse
+from app.services.vote_service import (
+ create_vote,
+ get_vote_by_id,
+ list_votes,
+ submit_vote,
+ close_vote,
+ delete_vote,
+)
+
+router = APIRouter(prefix="/api/votes", tags=["votes"])
+
+
+def _build_vote_out(vote: any, user_id: int) -> VoteOut:
+ # Compute per-option stats
+ options_out = []
+ for opt in (vote.options or []):
+ responses = opt.responses or []
+ vote_count = len(responses)
+ voter_names = None
+ if not vote.is_anonymous:
+ voter_names = [r.voter.name for r in responses if r.voter]
+ options_out.append(VoteOptionOut(
+ id=opt.id,
+ content=opt.content,
+ sort_order=opt.sort_order,
+ vote_count=vote_count,
+ voter_names=voter_names,
+ ))
+
+ # Total unique voters
+ all_voter_ids = set()
+ my_option_ids = []
+ for opt in (vote.options or []):
+ for r in (opt.responses or []):
+ all_voter_ids.add(r.voter_id)
+ if r.voter_id == user_id:
+ my_option_ids.append(opt.id)
+
+ return VoteOut(
+ id=vote.id,
+ class_id=vote.class_id,
+ creator_id=vote.creator_id,
+ creator_name=vote.creator.name if vote.creator else "Unknown",
+ title=vote.title,
+ description=vote.description,
+ vote_type=vote.vote_type,
+ is_anonymous=vote.is_anonymous,
+ max_choices=vote.max_choices,
+ deadline=vote.deadline,
+ status=vote.status,
+ total_voters=len(all_voter_ids),
+ has_voted=user_id in all_voter_ids,
+ my_option_ids=my_option_ids if my_option_ids else None,
+ options=options_out,
+ created_at=vote.created_at,
+ updated_at=vote.updated_at,
+ )
+
+
+@router.get("/", response_model=PageResponse[VoteOut])
+async def get_votes(
+ page: int = 1,
+ page_size: int = 20,
+ class_id: int | None = None,
+ user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ db: AsyncSession = Depends(get_db),
+):
+ effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
+ if effective_class_id is None:
+ return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0)
+
+ votes, total = await list_votes(db, effective_class_id, page, page_size)
+ total_pages = (total + page_size - 1) // page_size
+ items = [_build_vote_out(v, user.id) for v in votes]
+ return PageResponse(items=items, total=total, page=page, page_size=page_size, total_pages=total_pages)
+
+
+@router.post("/", response_model=VoteOut)
+async def create_new_vote(
+ data: VoteCreate,
+ class_id: int | None = None,
+ user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ db: AsyncSession = Depends(get_db),
+):
+ if len(data.options) < 2:
+ raise HTTPException(status_code=400, detail="至少需要 2 个选项")
+ if data.vote_type == "multiple" and data.max_choices < 2:
+ raise HTTPException(status_code=400, detail="多选投票最多可选数不能小于 2")
+
+ effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id
+ if effective_class_id is None:
+ raise HTTPException(status_code=400, detail="You are not assigned to a class")
+
+ vote = await create_vote(db, effective_class_id, user.id, data)
+ # Reload with relationships
+ vote = await get_vote_by_id(db, vote.id)
+ return _build_vote_out(vote, user.id)
+
+
+@router.get("/{vote_id}", response_model=VoteOut)
+async def get_vote_detail(
+ vote_id: int,
+ user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ db: AsyncSession = Depends(get_db),
+):
+ vote = await get_vote_by_id(db, vote_id)
+ if vote is None:
+ raise HTTPException(status_code=404, detail="Vote not found")
+ if user.role != "super_admin" and vote.class_id != user.class_id:
+ raise HTTPException(status_code=403, detail="Access denied")
+ return _build_vote_out(vote, user.id)
+
+
+@router.post("/{vote_id}/submit")
+async def submit_vote_response(
+ vote_id: int,
+ data: VoteSubmit,
+ user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ db: AsyncSession = Depends(get_db),
+):
+ vote = await get_vote_by_id(db, vote_id)
+ if vote is None:
+ raise HTTPException(status_code=404, detail="Vote not found")
+ if user.role != "super_admin" and vote.class_id != user.class_id:
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ try:
+ await submit_vote(db, vote_id, user.id, data.option_ids)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+ return {"message": "投票成功"}
+
+
+@router.put("/{vote_id}/close")
+async def close_vote_endpoint(
+ vote_id: int,
+ user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ db: AsyncSession = Depends(get_db),
+):
+ vote = await get_vote_by_id(db, vote_id)
+ if vote is None:
+ raise HTTPException(status_code=404, detail="Vote not found")
+ # Only creator or admin can close
+ if user.role == "student" and vote.creator_id != user.id:
+ raise HTTPException(status_code=403, detail="只有创建者或管理员可以关闭投票")
+ if user.role != "super_admin" and vote.class_id != user.class_id:
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ await close_vote(db, vote)
+ return {"message": "投票已关闭"}
+
+
+@router.delete("/{vote_id}")
+async def delete_vote_endpoint(
+ vote_id: int,
+ user: User = Depends(require_role("super_admin", "class_admin", "student")),
+ db: AsyncSession = Depends(get_db),
+):
+ vote = await get_vote_by_id(db, vote_id)
+ if vote is None:
+ raise HTTPException(status_code=404, detail="Vote not found")
+ if user.role == "student" and vote.creator_id != user.id:
+ raise HTTPException(status_code=403, detail="只有创建者或管理员可以删除投票")
+ if user.role != "super_admin" and vote.class_id != user.class_id:
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ await delete_vote(db, vote)
+ return {"message": "投票已删除"}
diff --git a/backend/app/config.py b/backend/app/config.py
new file mode 100644
index 0000000..dca2318
--- /dev/null
+++ b/backend/app/config.py
@@ -0,0 +1,43 @@
+from pydantic_settings import BaseSettings
+
+
+class Settings(BaseSettings):
+ # Service
+ host: str = "0.0.0.0"
+ port: int = 8000
+ debug: bool = False
+
+ # Database
+ database_url: str = "sqlite+aiosqlite:///./classhub.db"
+
+ # JWT
+ jwt_secret: str = "change-me-in-production"
+ jwt_expiry_hours: int = 72
+ jwt_algorithm: str = "HS256"
+
+ # Tencent COS
+ cos_secret_id: str = ""
+ cos_secret_key: str = ""
+ cos_region: str = "ap-hongkong"
+ cos_bucket: str = ""
+ cos_base_url: str = ""
+
+ # SMTP Email
+ smtp_host: str = ""
+ smtp_port: int = 465
+ smtp_user: str = ""
+ smtp_password: str = ""
+ smtp_from_email: str = ""
+ smtp_from_name: str = "HKU ICB"
+
+ # Frontend URL
+ frontend_url: str = "http://localhost:3000"
+
+ # Super Admin seed
+ super_admin_email: str = "admin@classhub.com"
+ super_admin_password: str = "admin123"
+
+ model_config = {"env_file": ".env", "env_prefix": "CH_"}
+
+
+settings = Settings()
diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py
new file mode 100644
index 0000000..156ed75
--- /dev/null
+++ b/backend/app/core/auth.py
@@ -0,0 +1,31 @@
+from datetime import datetime, timedelta, timezone
+
+import bcrypt
+from jose import JWTError, jwt
+
+from app.config import settings
+
+
+def hash_password(password: str) -> str:
+ return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
+
+
+def verify_password(plain: str, hashed: str) -> bool:
+ return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
+
+
+def create_access_token(data: dict) -> str:
+ to_encode = data.copy()
+ expire = datetime.now(timezone.utc) + timedelta(hours=settings.jwt_expiry_hours)
+ to_encode.update({"exp": expire})
+ return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm)
+
+
+def decode_access_token(token: str) -> dict | None:
+ try:
+ payload = jwt.decode(
+ token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]
+ )
+ return payload
+ except JWTError:
+ return None
diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py
new file mode 100644
index 0000000..e1a8bba
--- /dev/null
+++ b/backend/app/core/deps.py
@@ -0,0 +1,57 @@
+from fastapi import Depends, HTTPException, status
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.auth import decode_access_token
+from app.db.database import get_db
+from app.db.models import User
+
+security = HTTPBearer()
+
+
+async def get_current_user(
+ credentials: HTTPAuthorizationCredentials = Depends(security),
+ db: AsyncSession = Depends(get_db),
+) -> User:
+ payload = decode_access_token(credentials.credentials)
+ if payload is None:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Token invalid or expired",
+ )
+
+ user_id = payload.get("sub")
+ if user_id is None:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid token format",
+ )
+
+ result = await db.execute(select(User).where(User.id == int(user_id)))
+ user = result.scalar_one_or_none()
+
+ if user is None:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found"
+ )
+ if user.status == "disabled":
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail="Account disabled"
+ )
+
+ return user
+
+
+def require_role(*roles: str):
+ """Factory: returns a dependency that checks user role."""
+
+ async def _check(user: User = Depends(get_current_user)) -> User:
+ if user.role not in roles:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Insufficient permissions",
+ )
+ return user
+
+ return _check
diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/db/base.py b/backend/app/db/base.py
new file mode 100644
index 0000000..fa2b68a
--- /dev/null
+++ b/backend/app/db/base.py
@@ -0,0 +1,5 @@
+from sqlalchemy.orm import DeclarativeBase
+
+
+class Base(DeclarativeBase):
+ pass
diff --git a/backend/app/db/database.py b/backend/app/db/database.py
new file mode 100644
index 0000000..3982c31
--- /dev/null
+++ b/backend/app/db/database.py
@@ -0,0 +1,17 @@
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+
+from app.config import settings
+
+engine = create_async_engine(settings.database_url, echo=settings.debug)
+async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+
+
+async def get_db():
+ async with async_session() as session:
+ yield session
+
+
+async def create_tables():
+ from app.db.base import Base
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
diff --git a/backend/app/db/models.py b/backend/app/db/models.py
new file mode 100644
index 0000000..932b734
--- /dev/null
+++ b/backend/app/db/models.py
@@ -0,0 +1,416 @@
+import json
+from datetime import datetime
+
+from sqlalchemy import String, Text, Integer, DateTime, Boolean, ForeignKey, func, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.db.base import Base
+
+
+class Class_(Base):
+ __tablename__ = "classes"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ name: Mapped[str] = mapped_column(String(100), nullable=False)
+ cohort_year: Mapped[int] = mapped_column(Integer, nullable=False)
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ invite_code: Mapped[str | None] = mapped_column(String(20), unique=True, 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()
+ )
+
+ members: Mapped[list["User"]] = relationship("User", back_populates="class_")
+ timelines: Mapped[list["Timeline"]] = relationship(
+ "Timeline", back_populates="class_", cascade="all, delete-orphan"
+ )
+ schedules: Mapped[list["Schedule"]] = relationship(
+ "Schedule", back_populates="class_", cascade="all, delete-orphan"
+ )
+ announcements: Mapped[list["Announcement"]] = relationship(
+ "Announcement", back_populates="class_", cascade="all, delete-orphan"
+ )
+ resources: Mapped[list["Resource"]] = relationship(
+ "Resource", back_populates="class_", cascade="all, delete-orphan"
+ )
+ roster: Mapped[list["StudentRoster"]] = relationship(
+ "StudentRoster", back_populates="class_", cascade="all, delete-orphan"
+ )
+ assignments: Mapped[list["Assignment"]] = relationship(
+ "Assignment", back_populates="class_", cascade="all, delete-orphan"
+ )
+ votes: Mapped[list["Vote"]] = relationship(
+ "Vote", back_populates="class_", cascade="all, delete-orphan"
+ )
+
+
+class User(Base):
+ __tablename__ = "users"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
+ password_hash: Mapped[str] = mapped_column(Text, nullable=False)
+ name: Mapped[str] = mapped_column(String(100), nullable=False)
+ student_id: Mapped[str | None] = mapped_column(String(50), nullable=True, unique=True)
+
+ # role: super_admin | class_admin | student
+ role: Mapped[str] = mapped_column(String(20), default="student", nullable=False)
+ # status: pending | approved | rejected | disabled
+ status: Mapped[str] = mapped_column(String(20), default="pending", nullable=False)
+
+ class_id: Mapped[int | None] = mapped_column(
+ Integer, ForeignKey("classes.id"), nullable=True
+ )
+ class_: Mapped["Class_ | None"] = relationship("Class_", back_populates="members")
+
+ # Profile
+ industry: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ company: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ position: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ skills_tags: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array
+ wechat_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ phone: Mapped[str | None] = mapped_column(String(20), nullable=True)
+ avatar_url: Mapped[str | None] = mapped_column(Text, nullable=True)
+ bio: 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()
+ )
+
+ timeline_posts: Mapped[list["Timeline"]] = relationship(
+ "Timeline", back_populates="author"
+ )
+ created_assignments: Mapped[list["Assignment"]] = relationship(
+ "Assignment", back_populates="creator"
+ )
+ assignment_submissions: Mapped[list["AssignmentSubmission"]] = relationship(
+ "AssignmentSubmission", back_populates="student"
+ )
+ created_votes: Mapped[list["Vote"]] = relationship(
+ "Vote", back_populates="creator"
+ )
+
+ def get_skills_list(self) -> list[str]:
+ if not self.skills_tags:
+ return []
+ try:
+ return json.loads(self.skills_tags)
+ except (json.JSONDecodeError, TypeError):
+ return []
+
+ def set_skills_list(self, tags: list[str]):
+ self.skills_tags = json.dumps(tags, ensure_ascii=False) if tags else None
+
+
+class Timeline(Base):
+ __tablename__ = "timelines"
+
+ 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
+ )
+ author_id: Mapped[int] = mapped_column(
+ Integer, ForeignKey("users.id"), nullable=False
+ )
+ title: Mapped[str] = mapped_column(String(200), nullable=False)
+ content: Mapped[str | None] = mapped_column(Text, nullable=True)
+ image_urls: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array
+ 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()
+ )
+
+ class_: Mapped["Class_"] = relationship("Class_", back_populates="timelines")
+ author: Mapped["User"] = relationship("User", back_populates="timeline_posts")
+ likes: Mapped[list["TimelineLike"]] = relationship(
+ "TimelineLike", back_populates="post", cascade="all, delete-orphan"
+ )
+ comments: Mapped[list["TimelineComment"]] = relationship(
+ "TimelineComment", back_populates="post", cascade="all, delete-orphan"
+ )
+
+ def get_image_urls_list(self) -> list[str]:
+ if not self.image_urls:
+ return []
+ try:
+ return json.loads(self.image_urls)
+ except (json.JSONDecodeError, TypeError):
+ return []
+
+ def set_image_urls_list(self, urls: list[str]):
+ self.image_urls = json.dumps(urls) if urls else None
+
+
+class Schedule(Base):
+ __tablename__ = "schedules"
+
+ 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
+ )
+ # type: course | deadline | activity
+ type: Mapped[str] = mapped_column(String(20), nullable=False)
+ title: Mapped[str] = mapped_column(String(200), nullable=False)
+ start_time: Mapped[datetime] = mapped_column(DateTime, nullable=False)
+ end_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+ location: Mapped[str | None] = mapped_column(String(200), nullable=True)
+ description: 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()
+ )
+
+ class_: Mapped["Class_"] = relationship("Class_", back_populates="schedules")
+
+
+class Announcement(Base):
+ __tablename__ = "announcements"
+
+ 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
+ )
+ author_id: Mapped[int] = mapped_column(
+ Integer, ForeignKey("users.id"), nullable=False
+ )
+ title: Mapped[str] = mapped_column(String(200), nullable=False)
+ content: Mapped[str | None] = mapped_column(Text, nullable=True)
+ is_pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+ 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()
+ )
+
+ class_: Mapped["Class_"] = relationship("Class_", back_populates="announcements")
+ author: Mapped["User"] = relationship("User")
+
+
+class Resource(Base):
+ __tablename__ = "resources"
+
+ 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
+ )
+ uploader_id: Mapped[int] = mapped_column(
+ Integer, ForeignKey("users.id"), nullable=False
+ )
+ title: Mapped[str] = mapped_column(String(200), nullable=False)
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ file_url: Mapped[str] = mapped_column(Text, nullable=False)
+ file_type: Mapped[str] = mapped_column(String(50), nullable=False)
+ file_size: Mapped[int] = mapped_column(Integer, nullable=False)
+ category: Mapped[str] = mapped_column(String(50), nullable=False)
+ download_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+ class_: Mapped["Class_"] = relationship("Class_", back_populates="resources")
+ uploader: Mapped["User"] = relationship("User")
+
+
+class Notification(Base):
+ __tablename__ = "notifications"
+
+ 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
+ )
+ type: Mapped[str] = mapped_column(String(50), nullable=False)
+ title: Mapped[str] = mapped_column(String(200), nullable=False)
+ content: Mapped[str | None] = mapped_column(Text, nullable=True)
+ related_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ is_read: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+ user: Mapped["User"] = relationship("User")
+
+
+class StudentRoster(Base):
+ __tablename__ = "student_rosters"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ class_id: Mapped[int] = mapped_column(
+ Integer, ForeignKey("classes.id"), nullable=False, index=True
+ )
+ student_id: Mapped[str] = mapped_column(String(50), nullable=False)
+ name: Mapped[str] = mapped_column(String(100), nullable=False)
+ status: Mapped[str] = mapped_column(
+ String(20), default="unregistered", nullable=False
+ )
+ user_id: Mapped[int | None] = mapped_column(
+ Integer, ForeignKey("users.id"), nullable=True
+ )
+ created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+ class_: Mapped["Class_"] = relationship("Class_", back_populates="roster")
+ user: Mapped["User | None"] = relationship("User")
+
+
+class TimelineLike(Base):
+ __tablename__ = "timeline_likes"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ post_id: Mapped[int] = mapped_column(
+ Integer, ForeignKey("timelines.id"), nullable=False, index=True
+ )
+ user_id: Mapped[int] = mapped_column(
+ Integer, ForeignKey("users.id"), nullable=False
+ )
+ created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+ post: Mapped["Timeline"] = relationship("Timeline", back_populates="likes")
+ user: Mapped["User"] = relationship("User")
+
+ __table_args__ = (
+ UniqueConstraint("post_id", "user_id", name="uq_timeline_like"),
+ )
+
+
+class TimelineComment(Base):
+ __tablename__ = "timeline_comments"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ post_id: Mapped[int] = mapped_column(
+ Integer, ForeignKey("timelines.id"), nullable=False, index=True
+ )
+ author_id: Mapped[int] = mapped_column(
+ Integer, ForeignKey("users.id"), nullable=False
+ )
+ content: Mapped[str] = mapped_column(Text, nullable=False)
+ 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()
+ )
+
+ post: Mapped["Timeline"] = relationship("Timeline", back_populates="comments")
+ author: Mapped["User"] = relationship("User")
+
+
+class Vote(Base):
+ __tablename__ = "votes"
+
+ 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
+ )
+ creator_id: Mapped[int] = mapped_column(
+ Integer, ForeignKey("users.id"), nullable=False
+ )
+ title: Mapped[str] = mapped_column(String(200), nullable=False)
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ vote_type: Mapped[str] = mapped_column(String(20), default="single", nullable=False) # single | multiple
+ is_anonymous: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+ max_choices: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
+ deadline: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+ status: Mapped[str] = mapped_column(String(20), default="open", nullable=False) # open | closed
+ 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()
+ )
+
+ class_: Mapped["Class_"] = relationship("Class_", back_populates="votes")
+ creator: Mapped["User"] = relationship("User", back_populates="created_votes")
+ options: Mapped[list["VoteOption"]] = relationship(
+ "VoteOption", back_populates="vote", cascade="all, delete-orphan"
+ )
+
+
+class VoteOption(Base):
+ __tablename__ = "vote_options"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ vote_id: Mapped[int] = mapped_column(
+ Integer, ForeignKey("votes.id"), nullable=False, index=True
+ )
+ content: Mapped[str] = mapped_column(String(500), nullable=False)
+ sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
+
+ vote: Mapped["Vote"] = relationship("Vote", back_populates="options")
+ responses: Mapped[list["VoteResponse"]] = relationship(
+ "VoteResponse", back_populates="option", cascade="all, delete-orphan"
+ )
+
+
+class VoteResponse(Base):
+ __tablename__ = "vote_responses"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ vote_id: Mapped[int] = mapped_column(
+ Integer, ForeignKey("votes.id"), nullable=False, index=True
+ )
+ option_id: Mapped[int] = mapped_column(
+ Integer, ForeignKey("vote_options.id"), nullable=False, index=True
+ )
+ voter_id: Mapped[int] = mapped_column(
+ Integer, ForeignKey("users.id"), nullable=False
+ )
+ created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+ option: Mapped["VoteOption"] = relationship("VoteOption", back_populates="responses")
+ voter: Mapped["User"] = relationship("User")
+
+
+class Assignment(Base):
+ __tablename__ = "assignments"
+
+ 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
+ )
+ creator_id: Mapped[int] = mapped_column(
+ Integer, ForeignKey("users.id"), nullable=False
+ )
+ title: Mapped[str] = mapped_column(String(200), nullable=False)
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ deadline: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+ attachment_urls: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array
+ status: Mapped[str] = mapped_column(String(20), default="open", nullable=False) # open | closed
+ 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()
+ )
+
+ class_: Mapped["Class_"] = relationship("Class_", back_populates="assignments")
+ creator: Mapped["User"] = relationship("User", back_populates="created_assignments")
+ submissions: Mapped[list["AssignmentSubmission"]] = relationship(
+ "AssignmentSubmission", back_populates="assignment", cascade="all, delete-orphan"
+ )
+
+ def get_attachment_urls_list(self) -> list[str]:
+ if not self.attachment_urls:
+ return []
+ try:
+ return json.loads(self.attachment_urls)
+ except (json.JSONDecodeError, TypeError):
+ return []
+
+ def set_attachment_urls_list(self, urls: list[str]):
+ self.attachment_urls = json.dumps(urls) if urls else None
+
+
+class AssignmentSubmission(Base):
+ __tablename__ = "assignment_submissions"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ assignment_id: Mapped[int] = mapped_column(
+ Integer, ForeignKey("assignments.id"), nullable=False, index=True
+ )
+ student_id: Mapped[int] = mapped_column(
+ Integer, ForeignKey("users.id"), nullable=False
+ )
+ notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+ file_url: Mapped[str | None] = mapped_column(Text, nullable=True)
+ file_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ file_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
+ file_size: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ grade: Mapped[str | None] = mapped_column(String(50), nullable=True)
+ feedback: Mapped[str | None] = mapped_column(Text, nullable=True)
+ graded_at: Mapped[datetime | None] = mapped_column(DateTime, 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()
+ )
+
+ assignment: Mapped["Assignment"] = relationship("Assignment", back_populates="submissions")
+ student: Mapped["User"] = relationship("User", back_populates="assignment_submissions")
diff --git a/backend/app/main.py b/backend/app/main.py
new file mode 100644
index 0000000..dc431d2
--- /dev/null
+++ b/backend/app/main.py
@@ -0,0 +1,99 @@
+import logging
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+
+from app.config import settings
+from app.db.database import create_tables
+from app.api import auth, users, classes, directory, timeline, schedule, upload, announcements, resources, notifications, votes, assignments
+
+logging.basicConfig(
+ level=logging.DEBUG if settings.debug else logging.INFO,
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+)
+logger = logging.getLogger(__name__)
+
+
+async def ensure_super_admin():
+ """Seed super admin on first run."""
+ from sqlalchemy import select
+ from app.db.database import async_session
+ from app.db.models import User
+ from app.core.auth import hash_password
+
+ async with async_session() as db:
+ result = await db.execute(select(User).where(User.role == "super_admin"))
+ if result.scalar_one_or_none() is None:
+ admin = User(
+ email=settings.super_admin_email,
+ password_hash=hash_password(settings.super_admin_password),
+ name="Super Admin",
+ role="super_admin",
+ status="approved",
+ )
+ db.add(admin)
+ await db.commit()
+ 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
+ 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")
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ await create_tables()
+ await ensure_super_admin()
+ await ensure_sample_class()
+ yield
+
+
+app = FastAPI(
+ title="HKU ICB",
+ description="HKU ICB Graduate Class Resource Platform",
+ version="1.0.0",
+ lifespan=lifespan,
+)
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=[settings.frontend_url, "http://localhost:3000", "http://192.168.31.172:3000"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+app.include_router(auth.router)
+app.include_router(users.router)
+app.include_router(classes.router)
+app.include_router(directory.router)
+app.include_router(timeline.router)
+app.include_router(schedule.router)
+app.include_router(upload.router)
+app.include_router(announcements.router)
+app.include_router(resources.router)
+app.include_router(notifications.router)
+app.include_router(votes.router)
+app.include_router(assignments.router)
+
+
+@app.get("/api/health")
+async def health():
+ return {"status": "ok", "service": "classhub"}
diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/schemas/announcement.py b/backend/app/schemas/announcement.py
new file mode 100644
index 0000000..6a846ea
--- /dev/null
+++ b/backend/app/schemas/announcement.py
@@ -0,0 +1,27 @@
+from datetime import datetime
+
+from pydantic import BaseModel
+
+
+class AnnouncementCreate(BaseModel):
+ title: str
+ content: str | None = None
+ is_pinned: bool = False
+
+
+class AnnouncementUpdate(BaseModel):
+ title: str | None = None
+ content: str | None = None
+ is_pinned: bool | None = None
+
+
+class AnnouncementOut(BaseModel):
+ id: int
+ class_id: int
+ author_id: int
+ author_name: str
+ title: str
+ content: str | None
+ is_pinned: bool
+ created_at: datetime
+ updated_at: datetime
diff --git a/backend/app/schemas/assignment.py b/backend/app/schemas/assignment.py
new file mode 100644
index 0000000..be8645d
--- /dev/null
+++ b/backend/app/schemas/assignment.py
@@ -0,0 +1,63 @@
+from datetime import datetime
+
+from pydantic import BaseModel
+
+
+class AssignmentCreate(BaseModel):
+ title: str
+ description: str | None = None
+ deadline: datetime | None = None
+
+
+class AssignmentUpdate(BaseModel):
+ title: str | None = None
+ description: str | None = None
+ deadline: datetime | None = None
+ status: str | None = None # open | closed
+
+
+class SubmissionCreate(BaseModel):
+ notes: str | None = None
+
+
+class SubmissionGrade(BaseModel):
+ grade: str
+ feedback: str | None = None
+
+
+class SubmissionOut(BaseModel):
+ id: int
+ assignment_id: int
+ student_id: int
+ student_name: str
+ notes: str | None
+ file_url: str | None
+ file_name: str | None
+ file_type: str | None
+ file_size: int | None
+ grade: str | None
+ feedback: str | None
+ graded_at: datetime | None
+ created_at: datetime
+ updated_at: datetime
+
+
+class AssignmentOut(BaseModel):
+ id: int
+ class_id: int
+ creator_id: int
+ creator_name: str
+ title: str
+ description: str | None
+ deadline: datetime | None
+ attachment_urls: list[str] | None
+ status: str
+ submission_count: int
+ total_members: int
+ my_submitted: bool
+ created_at: datetime
+ updated_at: datetime
+
+
+class AssignmentDetailOut(AssignmentOut):
+ submissions: list[SubmissionOut] | None = None
diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py
new file mode 100644
index 0000000..851f3f7
--- /dev/null
+++ b/backend/app/schemas/auth.py
@@ -0,0 +1,19 @@
+from pydantic import BaseModel, EmailStr
+
+
+class LoginRequest(BaseModel):
+ email: EmailStr
+ password: str
+
+
+class RegisterRequest(BaseModel):
+ invite_code: str
+ student_id: str
+ name: str
+ email: EmailStr
+ password: str
+
+
+class ChangePasswordRequest(BaseModel):
+ old_password: str
+ new_password: str
diff --git a/backend/app/schemas/class_.py b/backend/app/schemas/class_.py
new file mode 100644
index 0000000..5615b24
--- /dev/null
+++ b/backend/app/schemas/class_.py
@@ -0,0 +1,27 @@
+from datetime import datetime
+
+from pydantic import BaseModel
+
+
+class ClassCreate(BaseModel):
+ name: str
+ cohort_year: int
+ description: str | None = None
+
+
+class ClassUpdate(BaseModel):
+ name: str | None = None
+ cohort_year: int | None = None
+ description: str | None = None
+
+
+class ClassOut(BaseModel):
+ id: int
+ name: str
+ cohort_year: int
+ description: str | None
+ invite_code: str | None = None
+ member_count: int = 0
+ created_at: datetime
+
+ model_config = {"from_attributes": True}
diff --git a/backend/app/schemas/common.py b/backend/app/schemas/common.py
new file mode 100644
index 0000000..c536567
--- /dev/null
+++ b/backend/app/schemas/common.py
@@ -0,0 +1,24 @@
+from typing import Generic, TypeVar
+
+from pydantic import BaseModel
+
+T = TypeVar("T")
+
+
+class APIResponse(BaseModel, Generic[T]):
+ success: bool = True
+ data: T | None = None
+ message: str = ""
+
+
+class PageParams(BaseModel):
+ page: int = 1
+ page_size: int = 20
+
+
+class PageResponse(BaseModel, Generic[T]):
+ items: list[T]
+ total: int
+ page: int
+ page_size: int
+ total_pages: int
diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py
new file mode 100644
index 0000000..46eec08
--- /dev/null
+++ b/backend/app/schemas/notification.py
@@ -0,0 +1,17 @@
+from datetime import datetime
+
+from pydantic import BaseModel
+
+
+class NotificationOut(BaseModel):
+ id: int
+ type: str
+ title: str
+ content: str | None
+ related_id: int | None
+ is_read: bool
+ created_at: datetime
+
+
+class UnreadCount(BaseModel):
+ count: int
diff --git a/backend/app/schemas/resource.py b/backend/app/schemas/resource.py
new file mode 100644
index 0000000..fd5405e
--- /dev/null
+++ b/backend/app/schemas/resource.py
@@ -0,0 +1,24 @@
+from datetime import datetime
+
+from pydantic import BaseModel
+
+
+class ResourceCreate(BaseModel):
+ title: str
+ description: str | None = None
+ category: str # "course_material" | "assignment" | "reading" | "other"
+
+
+class ResourceOut(BaseModel):
+ id: int
+ class_id: int
+ uploader_id: int
+ uploader_name: str
+ title: str
+ description: str | None
+ file_url: str
+ file_type: str
+ file_size: int
+ category: str
+ download_count: int
+ created_at: datetime
diff --git a/backend/app/schemas/roster.py b/backend/app/schemas/roster.py
new file mode 100644
index 0000000..75640e7
--- /dev/null
+++ b/backend/app/schemas/roster.py
@@ -0,0 +1,15 @@
+from pydantic import BaseModel
+
+
+class RosterOut(BaseModel):
+ id: int
+ student_id: str
+ name: str
+ status: str # "unregistered" | "registered"
+ user_id: int | None
+
+ model_config = {"from_attributes": True}
+
+
+class RosterImportRequest(BaseModel):
+ entries: list[dict] # [{"student_id": "...", "name": "..."}, ...]
diff --git a/backend/app/schemas/schedule.py b/backend/app/schemas/schedule.py
new file mode 100644
index 0000000..fa6a25a
--- /dev/null
+++ b/backend/app/schemas/schedule.py
@@ -0,0 +1,35 @@
+from datetime import datetime
+
+from pydantic import BaseModel
+
+
+class ScheduleCreate(BaseModel):
+ type: str # course | deadline | activity
+ title: str
+ start_time: datetime
+ end_time: datetime | None = None
+ location: str | None = None
+ description: str | None = None
+
+
+class ScheduleUpdate(BaseModel):
+ type: str | None = None
+ title: str | None = None
+ start_time: datetime | None = None
+ end_time: datetime | None = None
+ location: str | None = None
+ description: str | None = None
+
+
+class ScheduleOut(BaseModel):
+ id: int
+ class_id: int
+ type: str
+ title: str
+ start_time: datetime
+ end_time: datetime | None
+ location: str | None
+ description: str | None
+ created_at: datetime
+
+ model_config = {"from_attributes": True}
diff --git a/backend/app/schemas/timeline.py b/backend/app/schemas/timeline.py
new file mode 100644
index 0000000..d8278e1
--- /dev/null
+++ b/backend/app/schemas/timeline.py
@@ -0,0 +1,43 @@
+from datetime import datetime
+
+from pydantic import BaseModel
+
+
+class TimelineCreate(BaseModel):
+ title: str
+ content: str | None = None
+
+
+class TimelineUpdate(BaseModel):
+ title: str | None = None
+ content: str | None = None
+
+
+class TimelineCommentCreate(BaseModel):
+ content: str
+
+
+class TimelineCommentOut(BaseModel):
+ id: int
+ post_id: int
+ author_id: int
+ author_name: str
+ content: str
+ created_at: datetime
+ updated_at: datetime
+
+
+class TimelineOut(BaseModel):
+ id: int
+ class_id: int
+ author_id: int
+ author_name: str
+ title: str
+ content: str | None
+ image_urls: list[str] | None
+ like_count: int
+ has_liked: bool
+ comment_count: int
+ comments: list[TimelineCommentOut] | None = None
+ created_at: datetime
+ updated_at: datetime
diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py
new file mode 100644
index 0000000..eaf4184
--- /dev/null
+++ b/backend/app/schemas/user.py
@@ -0,0 +1,86 @@
+import json
+from datetime import datetime
+
+from pydantic import BaseModel, EmailStr, field_validator
+
+
+class UserOut(BaseModel):
+ id: int
+ email: str
+ name: str
+ student_id: str | None
+ role: str
+ status: str
+ class_id: int | None
+ industry: str | None
+ company: str | None
+ position: str | None
+ skills_tags: list[str] | None
+ wechat_id: str | None
+ phone: str | None
+ avatar_url: str | None
+ bio: str | None
+ created_at: datetime
+
+ model_config = {"from_attributes": True}
+
+ @field_validator("skills_tags", mode="before")
+ @classmethod
+ def parse_skills_tags(cls, v):
+ if isinstance(v, str):
+ try:
+ return json.loads(v)
+ except (json.JSONDecodeError, TypeError):
+ return []
+ return v
+
+
+class UserPublic(BaseModel):
+ """Shown to same-class approved members (includes contact info)."""
+ id: int
+ name: str
+ student_id: str | None
+ industry: str | None
+ company: str | None
+ position: str | None
+ wechat_id: str | None
+ phone: str | None
+ avatar_url: str | None
+ bio: str | None
+
+
+class UserListItem(BaseModel):
+ """For admin user management list."""
+ id: int
+ email: str
+ name: str
+ student_id: str | None
+ role: str
+ status: str
+ class_id: int | None
+ industry: str | None
+ company: str | None
+ created_at: datetime
+
+ model_config = {"from_attributes": True}
+
+
+class UserUpdate(BaseModel):
+ email: EmailStr | None = None
+ name: str | None = None
+ industry: str | None = None
+ company: str | None = None
+ position: str | None = None
+ wechat_id: str | None = None
+ phone: str | None = None
+ bio: str | None = None
+
+
+class UserStatusUpdate(BaseModel):
+ status: str # approved | rejected | disabled
+ role: str | None = None
+
+
+class TokenResponse(BaseModel):
+ token: str
+ user: UserOut
diff --git a/backend/app/schemas/vote.py b/backend/app/schemas/vote.py
new file mode 100644
index 0000000..059a692
--- /dev/null
+++ b/backend/app/schemas/vote.py
@@ -0,0 +1,51 @@
+from datetime import datetime
+
+from pydantic import BaseModel
+
+
+class VoteCreate(BaseModel):
+ title: str
+ description: str | None = None
+ vote_type: str = "single" # single | multiple
+ is_anonymous: bool = False
+ max_choices: int = 1
+ deadline: datetime | None = None
+ options: list[str] # option text list, min 2
+
+
+class VoteUpdate(BaseModel):
+ title: str | None = None
+ description: str | None = None
+ status: str | None = None # to close a vote
+
+
+class VoteSubmit(BaseModel):
+ option_ids: list[int] # selected option IDs
+
+
+class VoteOptionOut(BaseModel):
+ id: int
+ content: str
+ sort_order: int
+ vote_count: int
+ voter_names: list[str] | None = None # None if anonymous
+
+
+class VoteOut(BaseModel):
+ id: int
+ class_id: int
+ creator_id: int
+ creator_name: str
+ title: str
+ description: str | None
+ vote_type: str
+ is_anonymous: bool
+ max_choices: int
+ deadline: datetime | None
+ status: str
+ total_voters: int
+ has_voted: bool
+ my_option_ids: list[int] | None = None
+ options: list[VoteOptionOut]
+ created_at: datetime
+ updated_at: datetime
diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/services/announcement_service.py b/backend/app/services/announcement_service.py
new file mode 100644
index 0000000..2e831d2
--- /dev/null
+++ b/backend/app/services/announcement_service.py
@@ -0,0 +1,79 @@
+from sqlalchemy import select, func
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from app.db.models import Announcement, User
+from app.schemas.announcement import AnnouncementCreate, AnnouncementUpdate
+from app.services.notification_service import create_notifications_for_class
+
+
+async def create_announcement(
+ db: AsyncSession, class_id: int, author_id: int, data: AnnouncementCreate
+) -> Announcement:
+ announcement = Announcement(
+ class_id=class_id,
+ author_id=author_id,
+ title=data.title,
+ content=data.content,
+ is_pinned=data.is_pinned,
+ )
+ db.add(announcement)
+ await db.commit()
+ await db.refresh(announcement)
+
+ # Send notifications + email to class members
+ content_preview = (data.content[:100] + "...") if data.content and len(data.content) > 100 else (data.content or "")
+ await create_notifications_for_class(
+ db, class_id, "announcement", f"新公告: {data.title}",
+ content=content_preview,
+ related_id=announcement.id,
+ email_subject=f"HKU ICB - 新公告: {data.title}",
+ email_body=f"
{content_preview}
" if content_preview else None,
+ email_action_path="/announcements",
+ )
+
+ return announcement
+
+
+async def update_announcement(
+ db: AsyncSession, announcement: Announcement, data: AnnouncementUpdate
+) -> Announcement:
+ for field, value in data.model_dump(exclude_unset=True).items():
+ setattr(announcement, field, value)
+ await db.commit()
+ await db.refresh(announcement)
+ return announcement
+
+
+async def delete_announcement(db: AsyncSession, announcement: Announcement):
+ await db.delete(announcement)
+ await db.commit()
+
+
+async def get_announcement_by_id(db: AsyncSession, announcement_id: int) -> Announcement | None:
+ result = await db.execute(
+ select(Announcement)
+ .options(selectinload(Announcement.author))
+ .where(Announcement.id == announcement_id)
+ )
+ return result.scalar_one_or_none()
+
+
+async def list_announcements(
+ db: AsyncSession, class_id: int, page: int = 1, page_size: int = 20
+) -> tuple[list[Announcement], int]:
+ total_result = await db.execute(
+ select(func.count(Announcement.id)).where(Announcement.class_id == class_id)
+ )
+ total = total_result.scalar() or 0
+
+ result = await db.execute(
+ select(Announcement)
+ .options(selectinload(Announcement.author))
+ .where(Announcement.class_id == class_id)
+ .order_by(Announcement.is_pinned.desc(), Announcement.created_at.desc())
+ .offset((page - 1) * page_size)
+ .limit(page_size)
+ )
+ announcements = list(result.scalars().all())
+ return announcements, total
diff --git a/backend/app/services/assignment_service.py b/backend/app/services/assignment_service.py
new file mode 100644
index 0000000..a0807ae
--- /dev/null
+++ b/backend/app/services/assignment_service.py
@@ -0,0 +1,179 @@
+from datetime import datetime
+
+from sqlalchemy import select, func
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from app.db.models import Assignment, AssignmentSubmission, User
+from app.schemas.assignment import AssignmentCreate, AssignmentUpdate, SubmissionCreate, SubmissionGrade
+from app.services.notification_service import create_notifications_for_class
+
+
+async def create_assignment(
+ db: AsyncSession, class_id: int, creator_id: int, data: AssignmentCreate
+) -> Assignment:
+ assignment = Assignment(
+ class_id=class_id,
+ creator_id=creator_id,
+ title=data.title,
+ description=data.description,
+ deadline=data.deadline,
+ )
+ db.add(assignment)
+ await db.commit()
+ await db.refresh(assignment)
+
+ await create_notifications_for_class(
+ db, class_id, "assignment", f"新作业: {data.title}",
+ content=data.description,
+ related_id=assignment.id,
+ email_subject=f"HKU ICB - 新作业: {data.title}",
+ email_body=f"{data.description or data.title}
",
+ email_action_path="/assignments",
+ )
+
+ return assignment
+
+
+async def update_assignment(
+ db: AsyncSession, assignment: Assignment, data: AssignmentUpdate
+) -> Assignment:
+ for field, value in data.model_dump(exclude_unset=True).items():
+ setattr(assignment, field, value)
+ await db.commit()
+ await db.refresh(assignment)
+ return assignment
+
+
+async def delete_assignment(db: AsyncSession, assignment: Assignment):
+ await db.delete(assignment)
+ await db.commit()
+
+
+async def get_assignment_by_id(db: AsyncSession, assignment_id: int) -> Assignment | None:
+ result = await db.execute(
+ select(Assignment)
+ .options(
+ selectinload(Assignment.creator),
+ selectinload(Assignment.submissions).selectinload(AssignmentSubmission.student),
+ )
+ .where(Assignment.id == assignment_id)
+ )
+ return result.scalar_one_or_none()
+
+
+async def list_assignments(
+ db: AsyncSession, class_id: int, page: int = 1, page_size: int = 20
+) -> tuple[list[Assignment], int]:
+ total_result = await db.execute(
+ select(func.count(Assignment.id)).where(Assignment.class_id == class_id)
+ )
+ total = total_result.scalar() or 0
+
+ result = await db.execute(
+ select(Assignment)
+ .options(
+ selectinload(Assignment.creator),
+ selectinload(Assignment.submissions),
+ )
+ .where(Assignment.class_id == class_id)
+ .order_by(Assignment.created_at.desc())
+ .offset((page - 1) * page_size)
+ .limit(page_size)
+ )
+ return list(result.scalars().all()), total
+
+
+async def add_attachments(db: AsyncSession, assignment: Assignment, urls: list[str]):
+ existing = assignment.get_attachment_urls_list()
+ existing.extend(urls)
+ assignment.set_attachment_urls_list(existing)
+ await db.commit()
+ await db.refresh(assignment)
+
+
+async def create_submission(
+ db: AsyncSession,
+ assignment_id: int,
+ student_id: int,
+ notes: str | None,
+ file_url: str | None = None,
+ file_name: str | None = None,
+ file_type: str | None = None,
+ file_size: int | None = None,
+) -> AssignmentSubmission:
+ # Check assignment exists and is open
+ a_result = await db.execute(select(Assignment).where(Assignment.id == assignment_id))
+ assignment = a_result.scalar_one_or_none()
+ if assignment is None:
+ raise ValueError("作业不存在")
+ if assignment.status != "open":
+ raise ValueError("作业已关闭提交")
+ if assignment.deadline and datetime.now() > assignment.deadline:
+ raise ValueError("作业已过截止日期")
+
+ # Check no existing submission
+ existing = await db.execute(
+ select(AssignmentSubmission).where(
+ AssignmentSubmission.assignment_id == assignment_id,
+ AssignmentSubmission.student_id == student_id,
+ )
+ )
+ if existing.scalar_one_or_none():
+ raise ValueError("你已经提交过作业了")
+
+ submission = AssignmentSubmission(
+ assignment_id=assignment_id,
+ student_id=student_id,
+ notes=notes,
+ file_url=file_url,
+ file_name=file_name,
+ file_type=file_type,
+ file_size=file_size,
+ )
+ db.add(submission)
+ await db.commit()
+ await db.refresh(submission)
+
+ # Reload with student relationship
+ result = await db.execute(
+ select(AssignmentSubmission)
+ .options(selectinload(AssignmentSubmission.student))
+ .where(AssignmentSubmission.id == submission.id)
+ )
+ return result.scalar_one()
+
+
+async def get_submission_by_student(
+ db: AsyncSession, assignment_id: int, student_id: int
+) -> AssignmentSubmission | None:
+ result = await db.execute(
+ select(AssignmentSubmission)
+ .options(selectinload(AssignmentSubmission.student))
+ .where(
+ AssignmentSubmission.assignment_id == assignment_id,
+ AssignmentSubmission.student_id == student_id,
+ )
+ )
+ return result.scalar_one_or_none()
+
+
+async def grade_submission(
+ db: AsyncSession, submission: AssignmentSubmission, data: SubmissionGrade
+) -> AssignmentSubmission:
+ submission.grade = data.grade
+ submission.feedback = data.feedback
+ submission.graded_at = datetime.now()
+ await db.commit()
+ await db.refresh(submission)
+ return submission
+
+
+async def list_submissions(db: AsyncSession, assignment_id: int) -> list[AssignmentSubmission]:
+ result = await db.execute(
+ select(AssignmentSubmission)
+ .options(selectinload(AssignmentSubmission.student))
+ .where(AssignmentSubmission.assignment_id == assignment_id)
+ .order_by(AssignmentSubmission.created_at.desc())
+ )
+ return list(result.scalars().all())
diff --git a/backend/app/services/class_service.py b/backend/app/services/class_service.py
new file mode 100644
index 0000000..003efd1
--- /dev/null
+++ b/backend/app/services/class_service.py
@@ -0,0 +1,79 @@
+from sqlalchemy import select, func
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db.models import Class_, User
+from app.schemas.class_ import ClassCreate, ClassUpdate
+
+
+async def create_class(db: AsyncSession, data: ClassCreate) -> Class_:
+ class_ = Class_(**data.model_dump())
+ db.add(class_)
+ await db.commit()
+ await db.refresh(class_)
+ return class_
+
+
+async def update_class(db: AsyncSession, class_: Class_, data: ClassUpdate) -> Class_:
+ for field, value in data.model_dump(exclude_unset=True).items():
+ setattr(class_, field, value)
+ await db.commit()
+ await db.refresh(class_)
+ return class_
+
+
+async def delete_class(db: AsyncSession, class_: Class_):
+ await db.delete(class_)
+ await db.commit()
+
+
+async def get_class_by_id(db: AsyncSession, class_id: int) -> Class_ | None:
+ result = await db.execute(select(Class_).where(Class_.id == class_id))
+ return result.scalar_one_or_none()
+
+
+async def list_classes(
+ db: AsyncSession, page: int = 1, page_size: int = 50
+) -> tuple[list[Class_], int]:
+ total_result = await db.execute(select(func.count(Class_.id)))
+ total = total_result.scalar() or 0
+
+ result = await db.execute(
+ select(Class_)
+ .order_by(Class_.cohort_year.desc())
+ .offset((page - 1) * page_size)
+ .limit(page_size)
+ )
+ classes = list(result.scalars().all())
+ return classes, total
+
+
+async def get_member_count(db: AsyncSession, class_id: int) -> int:
+ result = await db.execute(
+ select(func.count(User.id)).where(
+ User.class_id == class_id, User.status == "approved"
+ )
+ )
+ return result.scalar() or 0
+
+
+async def get_class_members(
+ db: AsyncSession,
+ class_id: int,
+ status: str | None = None,
+ page: int = 1,
+ page_size: int = 50,
+) -> tuple[list[User], int]:
+ query = select(User).where(User.class_id == class_id)
+ count_query = select(func.count(User.id)).where(User.class_id == class_id)
+
+ if status:
+ query = query.where(User.status == status)
+ count_query = count_query.where(User.status == status)
+
+ total_result = await db.execute(count_query)
+ total = total_result.scalar() or 0
+
+ result = await db.execute(
+ query.order_by(User.name).offset((page - 1) * page_size).limit(page_size)
+ )
+ return list(result.scalars().all()), total
diff --git a/backend/app/services/cos_service.py b/backend/app/services/cos_service.py
new file mode 100644
index 0000000..2fbd850
--- /dev/null
+++ b/backend/app/services/cos_service.py
@@ -0,0 +1,75 @@
+import uuid
+from datetime import datetime
+from io import BytesIO
+
+from qcloud_cos import CosConfig, CosS3Client
+
+from app.config import settings
+
+config = CosConfig(
+ Region=settings.cos_region,
+ SecretId=settings.cos_secret_id,
+ SecretKey=settings.cos_secret_key,
+)
+client = CosS3Client(config)
+
+ALLOWED_IMAGE_TYPES = {
+ "image/jpeg": ".jpg",
+ "image/png": ".png",
+ "image/gif": ".gif",
+ "image/webp": ".webp",
+}
+
+
+def upload_bytes(key: str, data: bytes, content_type: str) -> str:
+ """Upload raw bytes to COS, return the public URL."""
+ client.put_object(
+ Bucket=settings.cos_bucket,
+ Key=key,
+ Body=BytesIO(data),
+ ContentType=content_type,
+ )
+ return f"{settings.cos_base_url}/{key}"
+
+
+def upload_image(prefix: str, filename: str, data: bytes, content_type: str) -> str:
+ """
+ Upload an image to COS under the given prefix.
+ Returns the public URL.
+ """
+ if content_type not in ALLOWED_IMAGE_TYPES:
+ raise ValueError(f"Unsupported image type: {content_type}")
+
+ ext = ALLOWED_IMAGE_TYPES[content_type]
+ date_path = datetime.now().strftime("%Y/%m/%d")
+ unique_name = uuid.uuid4().hex[:12]
+ key = f"{prefix}/{date_path}/{unique_name}{ext}"
+
+ return upload_bytes(key, data, content_type)
+
+
+FILE_TYPE_EXTENSIONS = {
+ "application/pdf": ".pdf",
+ "application/msword": ".doc",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
+ "application/vnd.ms-excel": ".xls",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
+ "application/vnd.ms-powerpoint": ".ppt",
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
+ "application/zip": ".zip",
+ "text/plain": ".txt",
+ "image/jpeg": ".jpg",
+ "image/png": ".png",
+ "image/gif": ".gif",
+ "image/webp": ".webp",
+}
+
+
+def upload_file(prefix: str, filename: str, data: bytes, content_type: str) -> str:
+ """Upload any allowed file type to COS. Returns the public URL."""
+ ext = FILE_TYPE_EXTENSIONS.get(content_type, "")
+ date_path = datetime.now().strftime("%Y/%m/%d")
+ unique_name = uuid.uuid4().hex[:12]
+ key = f"{prefix}/{date_path}/{unique_name}{ext}"
+
+ return upload_bytes(key, data, content_type)
diff --git a/backend/app/services/directory_service.py b/backend/app/services/directory_service.py
new file mode 100644
index 0000000..72b56a5
--- /dev/null
+++ b/backend/app/services/directory_service.py
@@ -0,0 +1,77 @@
+import json
+from sqlalchemy import select, or_, func
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db.models import User
+from app.schemas.user import UserPublic
+
+
+async def search_directory(
+ db: AsyncSession,
+ class_id: int,
+ search: str | None = None,
+ industry: str | None = None,
+ company: str | None = None,
+ page: int = 1,
+ page_size: int = 20,
+) -> tuple[list[User], int]:
+ """Search approved members in a class."""
+ query = select(User).where(
+ User.class_id == class_id, User.status == "approved"
+ )
+ count_query = select(func.count(User.id)).where(
+ User.class_id == class_id, User.status == "approved"
+ )
+
+ if search:
+ search_term = f"%{search}%"
+ query = query.where(
+ or_(
+ User.name.ilike(search_term),
+ User.company.ilike(search_term),
+ User.position.ilike(search_term),
+ )
+ )
+ count_query = count_query.where(
+ or_(
+ User.name.ilike(search_term),
+ User.company.ilike(search_term),
+ User.position.ilike(search_term),
+ )
+ )
+
+ if industry:
+ query = query.where(User.industry == industry)
+ count_query = count_query.where(User.industry == industry)
+
+ if company:
+ company_term = f"%{company}%"
+ query = query.where(User.company.ilike(company_term))
+ count_query = count_query.where(User.company.ilike(company_term))
+
+ total_result = await db.execute(count_query)
+ total = total_result.scalar() or 0
+
+ result = await db.execute(
+ query.order_by(User.name)
+ .offset((page - 1) * page_size)
+ .limit(page_size)
+ )
+ users = list(result.scalars().all())
+ return users, total
+
+
+def user_to_public(user: User, include_contact: bool = True) -> UserPublic:
+ """Convert User model to public profile, optionally hiding contact info."""
+ return UserPublic(
+ id=user.id,
+ name=user.name,
+ student_id=user.student_id,
+ industry=user.industry,
+ company=user.company,
+ position=user.position,
+ wechat_id=user.wechat_id if include_contact else None,
+ phone=user.phone if include_contact else None,
+ avatar_url=user.avatar_url,
+ bio=user.bio,
+ )
diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py
new file mode 100644
index 0000000..28f0b20
--- /dev/null
+++ b/backend/app/services/email_service.py
@@ -0,0 +1,95 @@
+import logging
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+
+import aiosmtplib
+
+from app.config import settings
+
+logger = logging.getLogger(__name__)
+
+
+async def send_email(to: str, subject: str, html_body: str) -> bool:
+ """Send HTML email via SMTP. Returns True on success."""
+ if not settings.smtp_host:
+ logger.info(f"SMTP not configured, skipping email to {to}: {subject}")
+ return False
+
+ msg = MIMEMultipart("alternative")
+ msg["From"] = f"{settings.smtp_from_name} <{settings.smtp_from_email}>"
+ msg["To"] = to
+ msg["Subject"] = subject
+ msg.attach(MIMEText(html_body, "html"))
+
+ try:
+ await aiosmtplib.send(
+ msg,
+ hostname=settings.smtp_host,
+ port=settings.smtp_port,
+ username=settings.smtp_user,
+ password=settings.smtp_password,
+ use_tls=True,
+ )
+ return True
+ except Exception as e:
+ logger.error(f"Failed to send email to {to}: {e}")
+ return False
+
+
+async def send_registration_notification(
+ admin_email: str, student_name: str, class_name: str
+):
+ html = f"""
+ New Registration Pending Approval
+ {student_name} has registered for {class_name}.
+ Please log in to HKU ICB to review and approve.
+ """
+ await send_email(admin_email, "HKU ICB: New Registration", html)
+
+
+async def send_approval_notification(student_email: str, approved: bool):
+ status_text = "approved" if approved else "rejected"
+ html = f"""
+ Registration {status_text.capitalize()}
+ Your registration has been {status_text}.
+ {"You can now log in to HKU ICB.
" if approved else ""}
+ """
+ await send_email(
+ student_email, f"HKU ICB: Registration {status_text.capitalize()}", html
+ )
+
+
+async def send_class_notification_email(
+ emails: list[str],
+ subject: str,
+ title: str,
+ body: str,
+ action_url: str | None = None,
+):
+ """Send a styled notification email to class members."""
+ action_html = ""
+ if action_url:
+ action_html = f"""
+
+ """
+ html = f"""
+
+
+
HKU ICB
+
+
+
{title}
+
{body}
+ {action_html}
+
+
+ 此邮件由系统自动发送,请勿直接回复。
+
+
+ """
+ for email in emails:
+ await send_email(email, subject, html)
diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py
new file mode 100644
index 0000000..f379b8a
--- /dev/null
+++ b/backend/app/services/notification_service.py
@@ -0,0 +1,132 @@
+import asyncio
+import logging
+
+from sqlalchemy import select, func, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db.models import Notification, User
+from app.services.email_service import send_class_notification_email
+
+logger = logging.getLogger(__name__)
+
+
+async def create_notification(
+ db: AsyncSession,
+ user_id: int,
+ type: str,
+ title: str,
+ content: str | None = None,
+ related_id: int | None = None,
+) -> Notification:
+ notification = Notification(
+ user_id=user_id,
+ type=type,
+ title=title,
+ content=content,
+ related_id=related_id,
+ )
+ db.add(notification)
+ await db.commit()
+ await db.refresh(notification)
+ return notification
+
+
+async def create_notifications_for_class(
+ db: AsyncSession,
+ class_id: int,
+ type: str,
+ title: str,
+ content: str | None = None,
+ related_id: int | None = None,
+ exclude_user_id: int | None = None,
+ email_subject: str | None = None,
+ email_body: str | None = None,
+ email_action_path: str | None = None,
+):
+ """Create in-app notifications + send email for all approved users in a class."""
+ result = await db.execute(
+ select(User.id, User.email).where(
+ User.class_id == class_id,
+ User.status == "approved",
+ )
+ )
+ rows = result.all()
+
+ emails: list[str] = []
+ for uid, email in rows:
+ notification = Notification(
+ user_id=uid,
+ type=type,
+ title=title,
+ content=content,
+ related_id=related_id,
+ )
+ db.add(notification)
+ emails.append(email)
+
+ await db.commit()
+
+ # Send email notification in background (fire-and-forget)
+ if email_subject and emails:
+ from app.config import settings
+ action_url = f"{settings.frontend_url}{email_action_path}" if email_action_path else None
+ asyncio.create_task(_safe_send_emails(emails, email_subject, title, email_body or content or "", action_url))
+
+
+async def _safe_send_emails(
+ emails: list[str], subject: str, title: str, body: str, action_url: str | None
+):
+ """Fire-and-forget email sending with error logging."""
+ try:
+ await send_class_notification_email(emails, subject, title, body, action_url)
+ except Exception as e:
+ logger.error(f"Failed to send class notification emails: {e}")
+
+
+async def list_notifications(
+ db: AsyncSession, user_id: int, page: int = 1, page_size: int = 20
+) -> tuple[list[Notification], int]:
+ total_result = await db.execute(
+ select(func.count(Notification.id)).where(Notification.user_id == user_id)
+ )
+ total = total_result.scalar() or 0
+
+ result = await db.execute(
+ select(Notification)
+ .where(Notification.user_id == user_id)
+ .order_by(Notification.created_at.desc())
+ .offset((page - 1) * page_size)
+ .limit(page_size)
+ )
+ notifications = list(result.scalars().all())
+ return notifications, total
+
+
+async def get_unread_count(db: AsyncSession, user_id: int) -> int:
+ result = await db.execute(
+ select(func.count(Notification.id)).where(
+ Notification.user_id == user_id,
+ Notification.is_read == False,
+ )
+ )
+ return result.scalar() or 0
+
+
+async def mark_as_read(db: AsyncSession, notification_id: int, user_id: int) -> bool:
+ result = await db.execute(
+ update(Notification)
+ .where(Notification.id == notification_id, Notification.user_id == user_id)
+ .values(is_read=True)
+ )
+ await db.commit()
+ return result.rowcount > 0
+
+
+async def mark_all_as_read(db: AsyncSession, user_id: int) -> int:
+ result = await db.execute(
+ update(Notification)
+ .where(Notification.user_id == user_id, Notification.is_read == False)
+ .values(is_read=True)
+ )
+ await db.commit()
+ return result.rowcount
diff --git a/backend/app/services/resource_service.py b/backend/app/services/resource_service.py
new file mode 100644
index 0000000..747a32f
--- /dev/null
+++ b/backend/app/services/resource_service.py
@@ -0,0 +1,75 @@
+from sqlalchemy import select, func
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from app.db.models import Resource, User
+from app.schemas.resource import ResourceCreate
+
+
+async def create_resource(
+ db: AsyncSession,
+ class_id: int,
+ uploader_id: int,
+ data: ResourceCreate,
+ file_url: str,
+ file_type: str,
+ file_size: int,
+) -> Resource:
+ resource = Resource(
+ class_id=class_id,
+ uploader_id=uploader_id,
+ title=data.title,
+ description=data.description,
+ file_url=file_url,
+ file_type=file_type,
+ file_size=file_size,
+ category=data.category,
+ )
+ db.add(resource)
+ await db.commit()
+ await db.refresh(resource)
+ return resource
+
+
+async def list_resources(
+ db: AsyncSession, class_id: int, category: str | None = None, page: int = 1, page_size: int = 20
+) -> tuple[list[Resource], int]:
+ query = select(Resource).where(Resource.class_id == class_id)
+ count_query = select(func.count(Resource.id)).where(Resource.class_id == class_id)
+
+ if category:
+ query = query.where(Resource.category == category)
+ count_query = count_query.where(Resource.category == category)
+
+ total_result = await db.execute(count_query)
+ total = total_result.scalar() or 0
+
+ result = await db.execute(
+ query.options(selectinload(Resource.uploader))
+ .order_by(Resource.created_at.desc())
+ .offset((page - 1) * page_size)
+ .limit(page_size)
+ )
+ resources = list(result.scalars().all())
+ return resources, total
+
+
+async def get_resource_by_id(db: AsyncSession, resource_id: int) -> Resource | None:
+ result = await db.execute(
+ select(Resource)
+ .options(selectinload(Resource.uploader))
+ .where(Resource.id == resource_id)
+ )
+ return result.scalar_one_or_none()
+
+
+async def increment_download_count(db: AsyncSession, resource: Resource) -> Resource:
+ resource.download_count += 1
+ await db.commit()
+ await db.refresh(resource)
+ return resource
+
+
+async def delete_resource(db: AsyncSession, resource: Resource):
+ await db.delete(resource)
+ await db.commit()
diff --git a/backend/app/services/roster_service.py b/backend/app/services/roster_service.py
new file mode 100644
index 0000000..b0b2fc0
--- /dev/null
+++ b/backend/app/services/roster_service.py
@@ -0,0 +1,125 @@
+import secrets
+
+from sqlalchemy import select, func
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db.models import StudentRoster, Class_
+
+
+def generate_invite_code(length: int = 8) -> str:
+ chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
+ return "".join(secrets.choice(chars) for _ in range(length))
+
+
+async def ensure_invite_code(db: AsyncSession, class_id: int) -> str:
+ result = await db.execute(select(Class_).where(Class_.id == class_id))
+ class_ = result.scalar_one_or_none()
+ if class_ is None:
+ return ""
+ if not class_.invite_code:
+ class_.invite_code = generate_invite_code()
+ await db.commit()
+ await db.refresh(class_)
+ return class_.invite_code
+
+
+async def regenerate_invite_code(db: AsyncSession, class_id: int) -> str:
+ result = await db.execute(select(Class_).where(Class_.id == class_id))
+ class_ = result.scalar_one_or_none()
+ if class_ is None:
+ return ""
+ class_.invite_code = generate_invite_code()
+ await db.commit()
+ await db.refresh(class_)
+ return class_.invite_code
+
+
+async def import_roster(
+ db: AsyncSession, class_id: int, entries: list[dict]
+) -> int:
+ existing_ids: set[str] = set()
+ result = await db.execute(
+ select(StudentRoster.student_id).where(StudentRoster.class_id == class_id)
+ )
+ for row in result.all():
+ existing_ids.add(row[0])
+
+ count = 0
+ for entry in entries:
+ sid = entry.get("student_id", "").strip()
+ name = entry.get("name", "").strip()
+ if not sid or not name or sid in existing_ids:
+ continue
+ roster = StudentRoster(class_id=class_id, student_id=sid, name=name)
+ db.add(roster)
+ existing_ids.add(sid)
+ count += 1
+ await db.commit()
+ return count
+
+
+async def get_roster(
+ db: AsyncSession, class_id: int, page: int = 1, page_size: int = 50
+) -> tuple[list[StudentRoster], int]:
+ query = select(StudentRoster).where(StudentRoster.class_id == class_id)
+ count_query = select(func.count(StudentRoster.id)).where(
+ StudentRoster.class_id == class_id
+ )
+
+ total_result = await db.execute(count_query)
+ total = total_result.scalar() or 0
+
+ result = await db.execute(
+ query.order_by(StudentRoster.student_id)
+ .offset((page - 1) * page_size)
+ .limit(page_size)
+ )
+ return list(result.scalars().all()), total
+
+
+async def validate_registration(
+ db: AsyncSession, invite_code: str, student_id: str
+) -> StudentRoster | None:
+ class_result = await db.execute(
+ select(Class_).where(Class_.invite_code == invite_code)
+ )
+ class_ = class_result.scalar_one_or_none()
+ if class_ is None:
+ return None
+
+ roster_result = await db.execute(
+ select(StudentRoster).where(
+ StudentRoster.class_id == class_.id,
+ StudentRoster.student_id == student_id,
+ StudentRoster.status == "unregistered",
+ )
+ )
+ return roster_result.scalar_one_or_none()
+
+
+async def delete_roster_entry(db: AsyncSession, roster_id: int) -> bool:
+ result = await db.execute(
+ select(StudentRoster).where(StudentRoster.id == roster_id)
+ )
+ entry = result.scalar_one_or_none()
+ if entry is None:
+ return False
+ if entry.status == "registered":
+ return False
+ await db.delete(entry)
+ await db.commit()
+ return True
+
+
+async def clear_unregistered_roster(db: AsyncSession, class_id: int) -> int:
+ result = await db.execute(
+ select(StudentRoster).where(
+ StudentRoster.class_id == class_id,
+ StudentRoster.status == "unregistered",
+ )
+ )
+ entries = list(result.scalars().all())
+ for entry in entries:
+ await db.delete(entry)
+ await db.commit()
+ return len(entries)
diff --git a/backend/app/services/schedule_service.py b/backend/app/services/schedule_service.py
new file mode 100644
index 0000000..e7f4a60
--- /dev/null
+++ b/backend/app/services/schedule_service.py
@@ -0,0 +1,93 @@
+from datetime import datetime, timezone
+
+from sqlalchemy import select, func
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db.models import Schedule
+from app.schemas.schedule import ScheduleCreate, ScheduleUpdate
+from app.services.notification_service import create_notifications_for_class
+
+
+async def create_schedule(
+ db: AsyncSession, class_id: int, data: ScheduleCreate
+) -> Schedule:
+ item = Schedule(
+ class_id=class_id,
+ **data.model_dump(),
+ )
+ db.add(item)
+ await db.commit()
+ await db.refresh(item)
+
+ # Send notifications + email to class members
+ time_str = data.start_time.strftime("%Y-%m-%d %H:%M")
+ location_info = f" · {data.location}" if data.location else ""
+ await create_notifications_for_class(
+ db, class_id, "schedule", f"新排期: {data.title}",
+ content=f"{time_str}{location_info}",
+ related_id=item.id,
+ email_subject=f"HKU ICB - 新排期: {data.title}",
+ email_body=f"{data.title}
时间: {time_str}{location_info}
",
+ email_action_path="/schedule",
+ )
+
+ return item
+
+
+async def update_schedule(
+ db: AsyncSession, item: Schedule, data: ScheduleUpdate
+) -> Schedule:
+ for field, value in data.model_dump(exclude_unset=True).items():
+ setattr(item, field, value)
+ await db.commit()
+ await db.refresh(item)
+ return item
+
+
+async def delete_schedule(db: AsyncSession, item: Schedule):
+ await db.delete(item)
+ await db.commit()
+
+
+async def get_schedule_by_id(db: AsyncSession, schedule_id: int) -> Schedule | None:
+ result = await db.execute(select(Schedule).where(Schedule.id == schedule_id))
+ return result.scalar_one_or_none()
+
+
+async def list_schedules(
+ db: AsyncSession,
+ class_id: int,
+ schedule_type: str | None = None,
+ page: int = 1,
+ page_size: int = 50,
+) -> tuple[list[Schedule], int]:
+ query = select(Schedule).where(Schedule.class_id == class_id)
+ count_query = select(func.count(Schedule.id)).where(Schedule.class_id == class_id)
+
+ if schedule_type:
+ query = query.where(Schedule.type == schedule_type)
+ count_query = count_query.where(Schedule.type == schedule_type)
+
+ total_result = await db.execute(count_query)
+ total = total_result.scalar() or 0
+
+ result = await db.execute(
+ query.order_by(Schedule.start_time.desc())
+ .offset((page - 1) * page_size)
+ .limit(page_size)
+ )
+ items = list(result.scalars().all())
+ return items, total
+
+
+async def get_upcoming_schedules(
+ db: AsyncSession, class_id: int, limit: int = 10
+) -> list[Schedule]:
+ now = datetime.now(timezone.utc)
+ result = await db.execute(
+ select(Schedule)
+ .where(Schedule.class_id == class_id, Schedule.start_time >= now)
+ .order_by(Schedule.start_time.asc())
+ .limit(limit)
+ )
+ return list(result.scalars().all())
diff --git a/backend/app/services/timeline_service.py b/backend/app/services/timeline_service.py
new file mode 100644
index 0000000..dc29b3a
--- /dev/null
+++ b/backend/app/services/timeline_service.py
@@ -0,0 +1,177 @@
+import json
+from datetime import datetime
+
+from sqlalchemy import select, func
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from app.db.models import Timeline, TimelineLike, TimelineComment, User
+from app.schemas.timeline import TimelineCreate, TimelineUpdate, TimelineCommentCreate
+from app.services.notification_service import create_notifications_for_class
+
+
+async def create_timeline(
+ db: AsyncSession, class_id: int, author_id: int, data: TimelineCreate
+) -> Timeline:
+ post = Timeline(
+ class_id=class_id,
+ author_id=author_id,
+ title=data.title,
+ content=data.content,
+ )
+ db.add(post)
+ await db.commit()
+ await db.refresh(post)
+
+ content_preview = (data.content[:100] + "...") if data.content and len(data.content) > 100 else (data.content or "")
+ await create_notifications_for_class(
+ db, class_id, "timeline", f"新动态: {data.title}",
+ content=content_preview,
+ related_id=post.id,
+ email_subject=f"HKU ICB - 新动态: {data.title}",
+ email_body=f"{content_preview}
" if content_preview else None,
+ email_action_path="/timeline",
+ )
+
+ return post
+
+
+async def update_timeline(
+ db: AsyncSession, post: Timeline, data: TimelineUpdate
+) -> Timeline:
+ for field, value in data.model_dump(exclude_unset=True).items():
+ setattr(post, field, value)
+ await db.commit()
+ await db.refresh(post)
+ return post
+
+
+async def delete_timeline(db: AsyncSession, post: Timeline):
+ await db.delete(post)
+ await db.commit()
+
+
+async def get_timeline_by_id(db: AsyncSession, post_id: int) -> Timeline | None:
+ result = await db.execute(
+ select(Timeline)
+ .options(
+ selectinload(Timeline.author),
+ selectinload(Timeline.likes),
+ selectinload(Timeline.comments).selectinload(TimelineComment.author),
+ )
+ .where(Timeline.id == post_id)
+ )
+ return result.scalar_one_or_none()
+
+
+async def list_timelines(
+ db: AsyncSession, class_id: int, page: int = 1, page_size: int = 20
+) -> tuple[list[Timeline], int]:
+ total_result = await db.execute(
+ select(func.count(Timeline.id)).where(Timeline.class_id == class_id)
+ )
+ total = total_result.scalar() or 0
+
+ result = await db.execute(
+ select(Timeline)
+ .options(
+ selectinload(Timeline.author),
+ selectinload(Timeline.likes),
+ selectinload(Timeline.comments),
+ )
+ .where(Timeline.class_id == class_id)
+ .order_by(Timeline.created_at.desc())
+ .offset((page - 1) * page_size)
+ .limit(page_size)
+ )
+ posts = list(result.scalars().all())
+ return posts, total
+
+
+async def add_images_to_timeline(db: AsyncSession, post: Timeline, urls: list[str]):
+ existing = post.get_image_urls_list()
+ existing.extend(urls)
+ post.set_image_urls_list(existing)
+ await db.commit()
+ await db.refresh(post)
+
+
+async def toggle_like(db: AsyncSession, post_id: int, user_id: int) -> dict:
+ result = await db.execute(
+ select(TimelineLike).where(
+ TimelineLike.post_id == post_id,
+ TimelineLike.user_id == user_id,
+ )
+ )
+ existing = result.scalar_one_or_none()
+
+ if existing:
+ await db.delete(existing)
+ await db.commit()
+ # Count remaining likes
+ count_result = await db.execute(
+ select(func.count(TimelineLike.id)).where(TimelineLike.post_id == post_id)
+ )
+ return {"liked": False, "like_count": count_result.scalar() or 0}
+ else:
+ like = TimelineLike(post_id=post_id, user_id=user_id)
+ db.add(like)
+ await db.commit()
+ count_result = await db.execute(
+ select(func.count(TimelineLike.id)).where(TimelineLike.post_id == post_id)
+ )
+ return {"liked": True, "like_count": count_result.scalar() or 0}
+
+
+async def create_comment(
+ db: AsyncSession, post_id: int, author_id: int, data: TimelineCommentCreate
+) -> TimelineComment:
+ comment = TimelineComment(
+ post_id=post_id,
+ author_id=author_id,
+ content=data.content,
+ )
+ db.add(comment)
+ await db.commit()
+ await db.refresh(comment)
+
+ # Load author relationship for response
+ result = await db.execute(
+ select(TimelineComment)
+ .options(selectinload(TimelineComment.author))
+ .where(TimelineComment.id == comment.id)
+ )
+ return result.scalar_one()
+
+
+async def delete_comment(db: AsyncSession, comment: TimelineComment):
+ await db.delete(comment)
+ await db.commit()
+
+
+async def get_comment_by_id(db: AsyncSession, comment_id: int) -> TimelineComment | None:
+ result = await db.execute(
+ select(TimelineComment)
+ .options(selectinload(TimelineComment.author))
+ .where(TimelineComment.id == comment_id)
+ )
+ return result.scalar_one_or_none()
+
+
+async def list_comments(
+ db: AsyncSession, post_id: int, page: int = 1, page_size: int = 50
+) -> tuple[list[TimelineComment], int]:
+ total_result = await db.execute(
+ select(func.count(TimelineComment.id)).where(TimelineComment.post_id == post_id)
+ )
+ total = total_result.scalar() or 0
+
+ result = await db.execute(
+ select(TimelineComment)
+ .options(selectinload(TimelineComment.author))
+ .where(TimelineComment.post_id == post_id)
+ .order_by(TimelineComment.created_at.asc())
+ .offset((page - 1) * page_size)
+ .limit(page_size)
+ )
+ return list(result.scalars().all()), total
diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py
new file mode 100644
index 0000000..a951866
--- /dev/null
+++ b/backend/app/services/user_service.py
@@ -0,0 +1,80 @@
+from sqlalchemy import select, func
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db.models import User
+from app.schemas.user import UserOut, UserUpdate
+
+
+async def get_user_by_email(db: AsyncSession, email: str) -> User | None:
+ result = await db.execute(select(User).where(User.email == email))
+ return result.scalar_one_or_none()
+
+
+async def get_user_by_id(db: AsyncSession, user_id: int) -> User | None:
+ result = await db.execute(select(User).where(User.id == user_id))
+ return result.scalar_one_or_none()
+
+
+async def update_profile(db: AsyncSession, user: User, data: UserUpdate) -> User:
+ update_data = data.model_dump(exclude_unset=True)
+
+ # Handle email change with uniqueness check
+ if "email" in update_data and update_data["email"] != user.email:
+ existing = await db.execute(
+ select(User).where(User.email == update_data["email"])
+ )
+ if existing.scalar_one_or_none():
+ raise ValueError("该邮箱已被使用")
+ user.email = update_data.pop("email")
+
+ for field, value in update_data.items():
+ setattr(user, field, value)
+ await db.commit()
+ await db.refresh(user)
+ return user
+
+
+async def update_user_status(
+ db: AsyncSession, user_id: int, status: str, role: str | None = None
+) -> User | None:
+ user = await get_user_by_id(db, user_id)
+ if user is None:
+ return None
+ user.status = status
+ if role is not None:
+ user.role = role
+ await db.commit()
+ await db.refresh(user)
+ return user
+
+
+async def list_users(
+ db: AsyncSession,
+ page: int = 1,
+ page_size: int = 20,
+ class_id: int | None = None,
+ status: str | None = None,
+ role: str | None = None,
+) -> tuple[list[User], int]:
+ query = select(User)
+ count_query = select(func.count(User.id))
+
+ if class_id is not None:
+ query = query.where(User.class_id == class_id)
+ count_query = count_query.where(User.class_id == class_id)
+ if status is not None:
+ query = query.where(User.status == status)
+ count_query = count_query.where(User.status == status)
+ if role is not None:
+ query = query.where(User.role == role)
+ count_query = count_query.where(User.role == role)
+
+ total_result = await db.execute(count_query)
+ total = total_result.scalar() or 0
+
+ query = query.order_by(User.created_at.desc())
+ query = query.offset((page - 1) * page_size).limit(page_size)
+ result = await db.execute(query)
+ users = list(result.scalars().all())
+
+ return users, total
diff --git a/backend/app/services/vote_service.py b/backend/app/services/vote_service.py
new file mode 100644
index 0000000..8173ebe
--- /dev/null
+++ b/backend/app/services/vote_service.py
@@ -0,0 +1,141 @@
+from datetime import datetime
+
+from sqlalchemy import select, func
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from app.db.models import Vote, VoteOption, VoteResponse, User
+from app.schemas.vote import VoteCreate
+from app.services.notification_service import create_notifications_for_class
+
+
+async def create_vote(
+ db: AsyncSession, class_id: int, creator_id: int, data: VoteCreate
+) -> Vote:
+ vote = Vote(
+ class_id=class_id,
+ creator_id=creator_id,
+ title=data.title,
+ description=data.description,
+ vote_type=data.vote_type,
+ is_anonymous=data.is_anonymous,
+ max_choices=data.max_choices,
+ deadline=data.deadline,
+ )
+ db.add(vote)
+ await db.flush()
+
+ for i, opt_text in enumerate(data.options):
+ option = VoteOption(vote_id=vote.id, content=opt_text, sort_order=i)
+ db.add(option)
+
+ await db.commit()
+ await db.refresh(vote)
+
+ # Load options for the returned object
+ result = await db.execute(
+ select(Vote)
+ .options(selectinload(Vote.options))
+ .where(Vote.id == vote.id)
+ )
+ vote = result.scalar_one()
+
+ await create_notifications_for_class(
+ db, class_id, "vote", f"新投票: {data.title}",
+ content=data.description,
+ related_id=vote.id,
+ email_subject=f"HKU ICB - 新投票: {data.title}",
+ email_body=f"{data.description or data.title}
",
+ email_action_path="/votes",
+ )
+
+ return vote
+
+
+async def get_vote_by_id(db: AsyncSession, vote_id: int) -> Vote | None:
+ result = await db.execute(
+ select(Vote)
+ .options(
+ selectinload(Vote.creator),
+ selectinload(Vote.options).selectinload(VoteOption.responses).selectinload(VoteResponse.voter),
+ )
+ .where(Vote.id == vote_id)
+ )
+ return result.scalar_one_or_none()
+
+
+async def list_votes(
+ db: AsyncSession, class_id: int, page: int = 1, page_size: int = 20
+) -> tuple[list[Vote], int]:
+ total_result = await db.execute(
+ select(func.count(Vote.id)).where(Vote.class_id == class_id)
+ )
+ total = total_result.scalar() or 0
+
+ result = await db.execute(
+ select(Vote)
+ .options(
+ selectinload(Vote.creator),
+ selectinload(Vote.options).selectinload(VoteOption.responses),
+ )
+ .where(Vote.class_id == class_id)
+ .order_by(Vote.created_at.desc())
+ .offset((page - 1) * page_size)
+ .limit(page_size)
+ )
+ return list(result.scalars().all()), total
+
+
+async def submit_vote(
+ db: AsyncSession, vote_id: int, voter_id: int, option_ids: list[int]
+) -> None:
+ vote_result = await db.execute(select(Vote).where(Vote.id == vote_id))
+ vote = vote_result.scalar_one_or_none()
+ if vote is None:
+ raise ValueError("投票不存在")
+ if vote.status != "open":
+ raise ValueError("投票已关闭")
+ if vote.deadline and datetime.now() > vote.deadline:
+ raise ValueError("投票已过截止日期")
+
+ # Check if already voted
+ existing = await db.execute(
+ select(VoteResponse).where(
+ VoteResponse.vote_id == vote_id,
+ VoteResponse.voter_id == voter_id,
+ )
+ )
+ if existing.scalar_one_or_none():
+ raise ValueError("你已经投过票了")
+
+ # Validate option count
+ if vote.vote_type == "single" and len(option_ids) != 1:
+ raise ValueError("单选投票只能选择一个选项")
+ if vote.vote_type == "multiple" and len(option_ids) > vote.max_choices:
+ raise ValueError(f"最多选择 {vote.max_choices} 个选项")
+
+ # Validate option_ids belong to this vote
+ for oid in option_ids:
+ opt_result = await db.execute(
+ select(VoteOption).where(VoteOption.id == oid, VoteOption.vote_id == vote_id)
+ )
+ if opt_result.scalar_one_or_none() is None:
+ raise ValueError(f"选项 {oid} 不属于此投票")
+
+ for oid in option_ids:
+ response = VoteResponse(vote_id=vote_id, option_id=oid, voter_id=voter_id)
+ db.add(response)
+
+ await db.commit()
+
+
+async def close_vote(db: AsyncSession, vote: Vote) -> Vote:
+ vote.status = "closed"
+ await db.commit()
+ await db.refresh(vote)
+ return vote
+
+
+async def delete_vote(db: AsyncSession, vote: Vote):
+ await db.delete(vote)
+ await db.commit()
diff --git a/backend/requirements.txt b/backend/requirements.txt
new file mode 100644
index 0000000..b8033a9
--- /dev/null
+++ b/backend/requirements.txt
@@ -0,0 +1,13 @@
+fastapi==0.115.6
+uvicorn[standard]==0.34.0
+sqlalchemy[asyncio]==2.0.36
+aiosqlite==0.20.0
+pydantic[email]==2.10.3
+pydantic-settings==2.7.1
+python-jose[cryptography]==3.3.0
+passlib[bcrypt]==1.7.4
+alembic==1.14.0
+python-multipart==0.0.20
+aiosmtplib==3.0.2
+cos-python-sdk-v5==1.9.30
+httpx==0.28.1
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..901d45f
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,42 @@
+services:
+ backend:
+ build: ./backend
+ restart: unless-stopped
+ env_file: ./backend/.env
+ environment:
+ - CH_DATABASE_URL=sqlite+aiosqlite:///./data/classhub.db
+ - CH_FRONTEND_URL=http://localhost
+ volumes:
+ - classhub-data:/app/data
+ expose:
+ - "8000"
+ healthcheck:
+ test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+
+ frontend:
+ build:
+ context: ./frontend
+ args:
+ - NEXT_PUBLIC_API_URL=
+ restart: unless-stopped
+ expose:
+ - "3000"
+ depends_on:
+ - backend
+
+ nginx:
+ image: nginx:alpine
+ restart: unless-stopped
+ ports:
+ - "80:80"
+ volumes:
+ - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
+ depends_on:
+ - backend
+ - frontend
+
+volumes:
+ classhub-data:
diff --git a/frontend/.dockerignore b/frontend/.dockerignore
new file mode 100644
index 0000000..5522d9f
--- /dev/null
+++ b/frontend/.dockerignore
@@ -0,0 +1,7 @@
+node_modules/
+.next/
+out/
+.env.local
+.env*.local
+*.tsbuildinfo
+.git/
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000..5ef6a52
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,41 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files (can opt-in for committing if needed)
+.env*
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md
new file mode 100644
index 0000000..8bd0e39
--- /dev/null
+++ b/frontend/AGENTS.md
@@ -0,0 +1,5 @@
+
+# This is NOT the Next.js you know
+
+This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
+
diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md
new file mode 100644
index 0000000..43c994c
--- /dev/null
+++ b/frontend/CLAUDE.md
@@ -0,0 +1 @@
+@AGENTS.md
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 0000000..af6d9b4
--- /dev/null
+++ b/frontend/Dockerfile
@@ -0,0 +1,34 @@
+FROM node:22-alpine AS deps
+WORKDIR /app
+COPY package.json package-lock.json* ./
+RUN npm ci --omit=dev
+
+FROM node:22-alpine AS builder
+WORKDIR /app
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+
+ARG NEXT_PUBLIC_API_URL=""
+ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
+
+RUN npm run build
+
+FROM node:22-alpine AS runner
+WORKDIR /app
+
+ENV NODE_ENV=production
+
+RUN addgroup --system --gid 1001 nodejs && \
+ adduser --system --uid 1001 nextjs
+
+COPY --from=builder /app/public ./public
+COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
+COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
+
+USER nextjs
+
+EXPOSE 3000
+ENV PORT=3000
+ENV HOSTNAME="0.0.0.0"
+
+CMD ["node", "server.js"]
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 0000000..e215bc4
--- /dev/null
+++ b/frontend/README.md
@@ -0,0 +1,36 @@
+This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
+
+## Getting Started
+
+First, run the development server:
+
+```bash
+npm run dev
+# or
+yarn dev
+# or
+pnpm dev
+# or
+bun dev
+```
+
+Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+
+You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
+
+This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
+
+## Learn More
+
+To learn more about Next.js, take a look at the following resources:
+
+- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
+- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
+
+You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
+
+## Deploy on Vercel
+
+The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
+
+Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
diff --git a/frontend/components.json b/frontend/components.json
new file mode 100644
index 0000000..8d886db
--- /dev/null
+++ b/frontend/components.json
@@ -0,0 +1,25 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "base-nova",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/app/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "rtl": false,
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "menuColor": "default",
+ "menuAccent": "subtle",
+ "registries": {}
+}
diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs
new file mode 100644
index 0000000..05e726d
--- /dev/null
+++ b/frontend/eslint.config.mjs
@@ -0,0 +1,18 @@
+import { defineConfig, globalIgnores } from "eslint/config";
+import nextVitals from "eslint-config-next/core-web-vitals";
+import nextTs from "eslint-config-next/typescript";
+
+const eslintConfig = defineConfig([
+ ...nextVitals,
+ ...nextTs,
+ // Override default ignores of eslint-config-next.
+ globalIgnores([
+ // Default ignores of eslint-config-next:
+ ".next/**",
+ "out/**",
+ "build/**",
+ "next-env.d.ts",
+ ]),
+]);
+
+export default eslintConfig;
diff --git a/frontend/next.config.ts b/frontend/next.config.ts
new file mode 100644
index 0000000..bf85763
--- /dev/null
+++ b/frontend/next.config.ts
@@ -0,0 +1,8 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+ output: "standalone",
+ allowedDevOrigins: ["192.168.31.172"],
+};
+
+export default nextConfig;
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..e1336b1
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "frontend",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "eslint"
+ },
+ "dependencies": {
+ "@base-ui/react": "^1.3.0",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "lucide-react": "^1.8.0",
+ "next": "16.2.3",
+ "next-themes": "^0.4.6",
+ "react": "19.2.4",
+ "react-dom": "19.2.4",
+ "shadcn": "^4.2.0",
+ "sonner": "^2.0.7",
+ "tailwind-merge": "^3.5.0",
+ "tw-animate-css": "^1.4.0"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4",
+ "@types/node": "^20",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "eslint": "^9",
+ "eslint-config-next": "16.2.3",
+ "tailwindcss": "^4",
+ "typescript": "^5"
+ }
+}
diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs
new file mode 100644
index 0000000..61e3684
--- /dev/null
+++ b/frontend/postcss.config.mjs
@@ -0,0 +1,7 @@
+const config = {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
+};
+
+export default config;
diff --git a/frontend/public/file.svg b/frontend/public/file.svg
new file mode 100644
index 0000000..004145c
--- /dev/null
+++ b/frontend/public/file.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/globe.svg b/frontend/public/globe.svg
new file mode 100644
index 0000000..567f17b
--- /dev/null
+++ b/frontend/public/globe.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/next.svg b/frontend/public/next.svg
new file mode 100644
index 0000000..5174b28
--- /dev/null
+++ b/frontend/public/next.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/vercel.svg b/frontend/public/vercel.svg
new file mode 100644
index 0000000..7705396
--- /dev/null
+++ b/frontend/public/vercel.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/window.svg b/frontend/public/window.svg
new file mode 100644
index 0000000..b2b2a44
--- /dev/null
+++ b/frontend/public/window.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/src/app/(app)/admin/classes/page.tsx b/frontend/src/app/(app)/admin/classes/page.tsx
new file mode 100644
index 0000000..df728cc
--- /dev/null
+++ b/frontend/src/app/(app)/admin/classes/page.tsx
@@ -0,0 +1,218 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useAuth } from "@/hooks/use-auth";
+import { fetchAPI, postAPI, putAPI, deleteAPI } from "@/lib/api";
+import { Card, CardContent } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { RoleGuard } from "@/components/role-guard";
+import { ConfirmDialog } from "@/components/confirm-dialog";
+import { ErrorState } from "@/components/error-state";
+import { toast } from "sonner";
+import type { ClassInfo } from "@/lib/types";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+
+export default function ClassesPage() {
+ const { user } = useAuth();
+ const [classes, setClasses] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+ const [formName, setFormName] = useState("");
+ const [formYear, setFormYear] = useState(new Date().getFullYear().toString());
+ const [formDesc, setFormDesc] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+
+ const loadClasses = async () => {
+ setError(null);
+ try {
+ const res = await fetchAPI("/api/classes/");
+ setClasses(res.items || []);
+ } catch (err: any) {
+ setError(err.message || "加载失败");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ loadClasses();
+ }, []);
+
+ const resetForm = () => {
+ setFormName("");
+ setFormYear(new Date().getFullYear().toString());
+ setFormDesc("");
+ setEditingId(null);
+ };
+
+ const openCreate = () => {
+ resetForm();
+ setDialogOpen(true);
+ };
+
+ const openEdit = (cls: ClassInfo) => {
+ setEditingId(cls.id);
+ setFormName(cls.name);
+ setFormYear(String(cls.cohort_year));
+ setFormDesc(cls.description || "");
+ setDialogOpen(true);
+ };
+
+ const handleSubmit = async () => {
+ if (!formName.trim()) return;
+ setSubmitting(true);
+ try {
+ if (editingId) {
+ await putAPI(`/api/classes/${editingId}`, {
+ name: formName,
+ cohort_year: parseInt(formYear),
+ description: formDesc || null,
+ });
+ toast.success("班级已更新");
+ } else {
+ await postAPI("/api/classes/", {
+ name: formName,
+ cohort_year: parseInt(formYear),
+ description: formDesc || null,
+ });
+ toast.success("班级已创建");
+ }
+ setDialogOpen(false);
+ resetForm();
+ loadClasses();
+ } catch (err: any) {
+ toast.error(err.message || "操作失败");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const [deleteTarget, setDeleteTarget] = useState(null);
+
+ const handleDelete = async (id: number) => {
+ try {
+ await deleteAPI(`/api/classes/${id}`);
+ toast.success("已删除");
+ setDeleteTarget(null);
+ loadClasses();
+ } catch (err: any) {
+ toast.error(err.message || "删除失败");
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ {loading ? (
+
+ {[1, 2].map((i) => (
+
+ ))}
+
+ ) : error ? (
+
+ ) : (
+
+ {classes.map((cls) => (
+
+
+
+
{cls.name}
+
+ {cls.cohort_year}届 · {cls.member_count} 名成员
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+ {/* Delete confirmation */}
+
{ if (!open) setDeleteTarget(null); }}
+ title="删除班级"
+ description="确定删除该班级?相关成员数据也将清除,此操作不可恢复。"
+ confirmText="删除"
+ variant="destructive"
+ onConfirm={() => deleteTarget && handleDelete(deleteTarget)}
+ />
+
+ );
+}
diff --git a/frontend/src/app/(app)/admin/members/page.tsx b/frontend/src/app/(app)/admin/members/page.tsx
new file mode 100644
index 0000000..43baa29
--- /dev/null
+++ b/frontend/src/app/(app)/admin/members/page.tsx
@@ -0,0 +1,549 @@
+"use client";
+
+import { useEffect, useState, useRef } from "react";
+import { useAuth } from "@/hooks/use-auth";
+import { useActiveClass } from "@/hooks/use-active-class";
+import { fetchAPI, putAPI, postAPI, deleteAPI, uploadAPI } from "@/lib/api";
+import { Card, CardContent } from "@/components/ui/card";
+import { ErrorState } from "@/components/error-state";
+import { Pagination } from "@/components/pagination";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { ConfirmDialog } from "@/components/confirm-dialog";
+import { toast } from "sonner";
+import type { UserListItem, RosterEntry } from "@/lib/types";
+import { USER_STATUS, ROLES } from "@/lib/constants";
+
+export default function MembersPage() {
+ const { user } = useAuth();
+ const { activeClassId } = useActiveClass();
+
+ // Tab state
+ const [activeTab, setActiveTab] = useState<"members" | "roster">("roster");
+
+ // Members state
+ const [members, setMembers] = useState([]);
+ const [membersLoading, setMembersLoading] = useState(true);
+ const [membersError, setMembersError] = useState(null);
+ const [membersPage, setMembersPage] = useState(1);
+ const [membersTotalPages, setMembersTotalPages] = useState(1);
+ const [filter, setFilter] = useState("all");
+
+ // Roster state
+ const [roster, setRoster] = useState([]);
+ const [rosterLoading, setRosterLoading] = useState(true);
+ const [rosterError, setRosterError] = useState(null);
+ const [rosterPage, setRosterPage] = useState(1);
+ const [rosterTotalPages, setRosterTotalPages] = useState(1);
+ const [inviteCode, setInviteCode] = useState("");
+ const [importOpen, setImportOpen] = useState(false);
+ const [importText, setImportText] = useState("");
+ const [importing, setImporting] = useState(false);
+ const [clearTarget, setClearTarget] = useState(false);
+ const fileInputRef = useRef(null);
+
+ const isSuperAdmin = user?.role === "super_admin";
+
+ // Load members
+ const loadMembers = async () => {
+ if (!activeClassId) {
+ setMembersLoading(false);
+ return;
+ }
+ setMembersLoading(true);
+ setMembersError(null);
+ try {
+ const res = await fetchAPI(`/api/classes/${activeClassId}/members`, {
+ page: String(membersPage),
+ page_size: "20",
+ });
+ setMembers(res.items || []);
+ setMembersTotalPages(res.total_pages || 1);
+ } catch (err: any) {
+ setMembersError(err.message || "加载失败");
+ } finally {
+ setMembersLoading(false);
+ }
+ };
+
+ // Load roster
+ const loadRoster = async () => {
+ if (!activeClassId) {
+ setRosterLoading(false);
+ return;
+ }
+ setRosterLoading(true);
+ setRosterError(null);
+ try {
+ const [rosterRes, codeRes] = await Promise.all([
+ fetchAPI(`/api/classes/${activeClassId}/roster`, {
+ page: String(rosterPage),
+ page_size: "50",
+ }),
+ fetchAPI(`/api/classes/${activeClassId}/invite-code`),
+ ]);
+ setRoster(rosterRes.items || []);
+ setRosterTotalPages(rosterRes.total_pages || 1);
+ setInviteCode(codeRes.invite_code || "");
+ } catch (err: any) {
+ setRosterError(err.message || "加载失败");
+ } finally {
+ setRosterLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ if (!activeClassId) return;
+ if (activeTab === "members") loadMembers();
+ else loadRoster();
+ }, [activeClassId, activeTab, membersPage, rosterPage]);
+
+ // Member actions
+ const handleStatusChange = async (
+ userId: number,
+ newStatus: string,
+ role?: string
+ ) => {
+ try {
+ await putAPI(`/api/users/${userId}/status`, {
+ status: newStatus,
+ role: role || undefined,
+ });
+ toast.success(
+ `已更新状态为 ${USER_STATUS[newStatus as keyof typeof USER_STATUS] || newStatus}`
+ );
+ loadMembers();
+ } catch (err: any) {
+ toast.error(err.message || "操作失败");
+ }
+ };
+
+ const handleRoleChange = async (userId: number, newRole: string) => {
+ try {
+ await putAPI(`/api/users/${userId}/status`, {
+ status: "approved",
+ role: newRole,
+ });
+ toast.success(
+ `角色已更新为 ${ROLES[newRole as keyof typeof ROLES] || newRole}`
+ );
+ loadMembers();
+ } catch (err: any) {
+ toast.error(err.message || "操作失败");
+ }
+ };
+
+ const getStatusBadge = (status: string) => {
+ const variants: Record = {
+ approved: "default",
+ disabled: "secondary",
+ };
+ return (
+
+ {USER_STATUS[status as keyof typeof USER_STATUS] || status}
+
+ );
+ };
+
+ // Roster actions
+ const handleCopyCode = () => {
+ navigator.clipboard.writeText(inviteCode);
+ toast.success("邀请码已复制");
+ };
+
+ const handleRegenerateCode = async () => {
+ try {
+ const res = await postAPI(`/api/classes/${activeClassId}/invite-code/regenerate`);
+ setInviteCode(res.invite_code);
+ toast.success("邀请码已重新生成");
+ } catch (err: any) {
+ toast.error(err.message || "操作失败");
+ }
+ };
+
+ const handleTextImport = async () => {
+ if (!importText.trim()) return;
+ setImporting(true);
+ try {
+ // Parse text: each line is "学号 姓名" or "学号,姓名" or "学号\t姓名"
+ const entries: { student_id: string; name: string }[] = [];
+ const lines = importText.trim().split("\n");
+ for (const line of lines) {
+ const parts = line.trim().split(/[,\t,\s]+/);
+ if (parts.length >= 2) {
+ entries.push({ student_id: parts[0], name: parts.slice(1).join("") });
+ }
+ }
+ if (entries.length === 0) {
+ toast.error("未解析到有效数据,每行格式:学号 姓名");
+ return;
+ }
+ const res = await postAPI(`/api/classes/${activeClassId}/roster/import`, {
+ entries,
+ });
+ toast.success(res.message);
+ setImportOpen(false);
+ setImportText("");
+ loadRoster();
+ } catch (err: any) {
+ toast.error(err.message || "导入失败");
+ } finally {
+ setImporting(false);
+ }
+ };
+
+ const handleFileUpload = async () => {
+ const fileInput = fileInputRef.current;
+ if (!fileInput?.files?.length) return;
+ setImporting(true);
+ try {
+ const formData = new FormData();
+ formData.append("file", fileInput.files[0]);
+ const res = await uploadAPI(`/api/classes/${activeClassId}/roster/upload`, formData);
+ toast.success((res as any).message);
+ setImportOpen(false);
+ if (fileInput) fileInput.value = "";
+ loadRoster();
+ } catch (err: any) {
+ toast.error(err.message || "上传失败");
+ } finally {
+ setImporting(false);
+ }
+ };
+
+ const handleDeleteRoster = async (rosterId: number) => {
+ try {
+ await deleteAPI(`/api/classes/${activeClassId}/roster/${rosterId}`);
+ toast.success("已删除");
+ loadRoster();
+ } catch (err: any) {
+ toast.error(err.message || "删除失败");
+ }
+ };
+
+ const handleClearRoster = async () => {
+ try {
+ const res = await postAPI(`/api/classes/${activeClassId}/roster/clear`);
+ toast.success(res.message);
+ setClearTarget(false);
+ loadRoster();
+ } catch (err: any) {
+ toast.error(err.message || "操作失败");
+ }
+ };
+
+ const filteredMembers =
+ filter === "all" ? members : members.filter((m) => m.status === filter);
+
+ return (
+
+
+
成员与花名册
+
管理班级花名册、邀请码和已注册成员
+
+
+ {!activeClassId ? (
+
+ {isSuperAdmin ? "请在顶部选择一个班级" : "您尚未分配班级"}
+
+ ) : (
+ <>
+ {/* Tab switcher */}
+
+
+
+
+
+ {activeTab === "roster" && (
+
+ {/* Invite code section */}
+
+
+
+
+
班级邀请码
+
+ {inviteCode || "—"}
+
+
+
+
+
+
+
+
+ 将此邀请码分享给学生,学生注册时输入邀请码+学号即可加入班级
+
+
+
+
+ {/* Import actions */}
+
+
+
+
+
+ {/* Roster list */}
+ {rosterLoading ? (
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+
+ ))}
+
+ ) : rosterError ? (
+
+ ) : roster.length === 0 ? (
+
+ 暂无花名册数据,点击「导入花名册」添加
+
+ ) : (
+
+ {roster.map((r) => (
+
+
+
+
{r.name}
+
{r.student_id}
+
+
+
+ {r.status === "registered" ? "已注册" : "未注册"}
+
+ {r.status === "unregistered" && (
+
+ )}
+
+
+
+ ))}
+
+ )}
+
+
+
+ )}
+
+ {activeTab === "members" && (
+
+
+
已注册成员
+
+
+
+ {membersLoading ? (
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+
+ ))}
+
+ ) : membersError ? (
+
+ ) : filteredMembers.length === 0 ? (
+
没有符合条件的成员
+ ) : (
+
+ {filteredMembers.map((m) => (
+
+
+
+
{m.name}
+
+ {m.email}
+ {m.student_id ? ` · ${m.student_id}` : ""}
+ {m.company ? ` · ${m.company}` : ""}
+
+
+
+
+ {getStatusBadge(m.status)}
+ {m.status === "approved" && (
+
+ )}
+ {m.status === "disabled" && (
+
+ )}
+
+
+
+ ))}
+
+ )}
+
+
+
+ )}
+ >
+ )}
+
+
{ if (!open) setClearTarget(false); }}
+ title="清除未注册花名册"
+ description="确定清除所有未注册的花名册条目?此操作不可恢复。"
+ confirmText="清除"
+ variant="destructive"
+ onConfirm={handleClearRoster}
+ />
+
+ );
+}
diff --git a/frontend/src/app/(app)/admin/page.tsx b/frontend/src/app/(app)/admin/page.tsx
new file mode 100644
index 0000000..c6558d8
--- /dev/null
+++ b/frontend/src/app/(app)/admin/page.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import { useAuth } from "@/hooks/use-auth";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { ROLES } from "@/lib/constants";
+import Link from "next/link";
+
+export default function AdminPage() {
+ const { user } = useAuth();
+
+ return (
+
+
+
管理后台
+
+ 当前角色: {user?.role ? ROLES[user.role] : "-"}
+
+
+
+
+
+
+
+ 成员管理
+
+
+
+ 审核注册申请、管理成员状态
+
+
+
+
+
+
+
+
+ 班级管理
+
+
+
+ 创建和管理班级信息
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/(app)/announcements/page.tsx b/frontend/src/app/(app)/announcements/page.tsx
new file mode 100644
index 0000000..b727e10
--- /dev/null
+++ b/frontend/src/app/(app)/announcements/page.tsx
@@ -0,0 +1,245 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useActiveClass } from "@/hooks/use-active-class";
+import { fetchAPI, postAPI, putAPI, deleteAPI } from "@/lib/api";
+import { Card, CardContent } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Badge } from "@/components/ui/badge";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { RoleGuard } from "@/components/role-guard";
+import { ConfirmDialog } from "@/components/confirm-dialog";
+import { ErrorState } from "@/components/error-state";
+import { Pagination } from "@/components/pagination";
+import { toast } from "sonner";
+import type { Announcement } from "@/lib/types";
+
+export default function AnnouncementsPage() {
+ const { activeClassId } = useActiveClass();
+ const [announcements, setAnnouncements] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [page, setPage] = useState(1);
+ const [totalPages, setTotalPages] = useState(1);
+
+ // Dialog state
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+ const [newTitle, setNewTitle] = useState("");
+ const [newContent, setNewContent] = useState("");
+ const [newIsPinned, setNewIsPinned] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+
+ // Delete state
+ const [deleteTarget, setDeleteTarget] = useState(null);
+
+ const loadAnnouncements = async () => {
+ setError(null);
+ try {
+ const res = await fetchAPI("/api/announcements/", {
+ page_size: "10",
+ page: String(page),
+ class_id: String(activeClassId),
+ });
+ setAnnouncements(res.items || []);
+ setTotalPages(res.total_pages || 1);
+ } catch (err: any) {
+ setError(err.message || "加载失败");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ if (!activeClassId) return;
+ loadAnnouncements();
+ }, [activeClassId, page]);
+
+ const resetForm = () => {
+ setEditingId(null);
+ setNewTitle("");
+ setNewContent("");
+ setNewIsPinned(false);
+ };
+
+ const openEdit = (item: Announcement) => {
+ setEditingId(item.id);
+ setNewTitle(item.title);
+ setNewContent(item.content || "");
+ setNewIsPinned(item.is_pinned);
+ setDialogOpen(true);
+ };
+
+ const handleSubmit = async () => {
+ if (!newTitle.trim()) return;
+ setSubmitting(true);
+ try {
+ if (editingId) {
+ await putAPI(`/api/announcements/${editingId}`, {
+ title: newTitle,
+ content: newContent || null,
+ is_pinned: newIsPinned,
+ });
+ toast.success("公告已更新");
+ } else {
+ await postAPI("/api/announcements/", {
+ title: newTitle,
+ content: newContent || null,
+ is_pinned: newIsPinned,
+ class_id: activeClassId,
+ });
+ toast.success("公告已发布");
+ }
+ setDialogOpen(false);
+ resetForm();
+ loadAnnouncements();
+ } catch (err: any) {
+ toast.error(err.message || (editingId ? "更新失败" : "发布失败"));
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const handleDelete = async (id: number) => {
+ try {
+ await deleteAPI(`/api/announcements/${id}`);
+ toast.success("已删除");
+ setDeleteTarget(null);
+ loadAnnouncements();
+ } catch (err: any) {
+ toast.error(err.message || "删除失败");
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ {loading ? (
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+
+ ))}
+
+ ) : error ? (
+
+ ) : announcements.length === 0 ? (
+
暂无公告
+ ) : (
+
+ {announcements.map((item) => (
+
+
+
+
+ {item.is_pinned && (
+ 置顶
+ )}
+
{item.title}
+
+
+
+
+
+
+
+
+ {item.content && (
+ {item.content}
+ )}
+
+ {item.author_name} ·{" "}
+ {new Date(item.created_at).toLocaleDateString("zh-CN", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ })}
+
+
+
+ ))}
+
+ )}
+
+
+
+
{ if (!open) setDeleteTarget(null); }}
+ title="删除公告"
+ description="确定删除这条公告?此操作不可恢复。"
+ confirmText="删除"
+ variant="destructive"
+ onConfirm={() => deleteTarget && handleDelete(deleteTarget)}
+ />
+
+ );
+}
diff --git a/frontend/src/app/(app)/assignments/[id]/page.tsx b/frontend/src/app/(app)/assignments/[id]/page.tsx
new file mode 100644
index 0000000..c9ce2a5
--- /dev/null
+++ b/frontend/src/app/(app)/assignments/[id]/page.tsx
@@ -0,0 +1,586 @@
+"use client";
+
+import { useEffect, useState, useRef } from "react";
+import { useParams } from "next/navigation";
+import { useAuth } from "@/hooks/use-auth";
+import { fetchAPI, postAPI, putAPI, uploadAPI } from "@/lib/api";
+import { Card, CardContent } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { Input } from "@/components/ui/input";
+import { Badge } from "@/components/ui/badge";
+import { ErrorState } from "@/components/error-state";
+import { RoleGuard } from "@/components/role-guard";
+import { toast } from "sonner";
+import type { Assignment, AssignmentSubmission } from "@/lib/types";
+import Link from "next/link";
+
+function formatFileSize(bytes: number | null): string {
+ if (!bytes) return "-";
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+function formatDate(dateStr: string): string {
+ return new Date(dateStr).toLocaleDateString("zh-CN", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+function getFileNameFromUrl(url: string): string {
+ try {
+ const parts = url.split("/");
+ return decodeURIComponent(parts[parts.length - 1]) || "下载文件";
+ } catch {
+ return "下载文件";
+ }
+}
+
+export default function AssignmentDetailPage() {
+ const params = useParams();
+ const id = params.id as string;
+ const { user } = useAuth();
+
+ const [assignment, setAssignment] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // Submission form state
+ const [selectedFile, setSelectedFile] = useState(null);
+ const [notes, setNotes] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+ const fileInputRef = useRef(null);
+
+ // Grading state: submission id -> { grade, feedback }
+ const [gradingMap, setGradingMap] = useState<
+ Record
+ >({});
+ const [activeGradeId, setActiveGradeId] = useState(null);
+ const [gradingSubmitting, setGradingSubmitting] = useState(false);
+
+ // Close assignment state
+ const [closing, setClosing] = useState(false);
+
+ const getCountdown = (dateStr: string): string => {
+ const diff = new Date(dateStr).getTime() - Date.now();
+ if (diff <= 0) return "已截止";
+ const hours = Math.floor(diff / (1000 * 60 * 60));
+ if (hours < 24) return `${hours} 小时后截止`;
+ const days = Math.ceil(hours / 24);
+ return `${days} 天后截止`;
+ };
+
+ const isAdmin =
+ user?.role === "class_admin" || user?.role === "super_admin";
+
+ const loadAssignment = async () => {
+ setError(null);
+ try {
+ const res = await fetchAPI(`/api/assignments/${id}`);
+ setAssignment(res);
+ } catch (err: any) {
+ setError(err.message || "加载失败");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ loadAssignment();
+ }, [id]);
+
+ const handleSubmit = async () => {
+ if (!selectedFile) {
+ toast.error("请选择要提交的文件");
+ return;
+ }
+ setSubmitting(true);
+ try {
+ const formData = new FormData();
+ formData.append("file", selectedFile);
+ if (notes.trim()) formData.append("notes", notes);
+
+ await uploadAPI(`/api/assignments/${id}/submit`, formData);
+ toast.success("提交成功");
+ setSelectedFile(null);
+ setNotes("");
+ if (fileInputRef.current) fileInputRef.current.value = "";
+ loadAssignment();
+ } catch (err: any) {
+ toast.error(err.message || "提交失败");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const handleClose = async () => {
+ setClosing(true);
+ try {
+ await putAPI(`/api/assignments/${id}`, { status: "closed" });
+ toast.success("作业已关闭");
+ loadAssignment();
+ } catch (err: any) {
+ toast.error(err.message || "操作失败");
+ } finally {
+ setClosing(false);
+ }
+ };
+
+ const handleGrade = async (submissionId: number) => {
+ const g = gradingMap[submissionId];
+ if (!g || !g.grade.trim()) {
+ toast.error("请输入成绩");
+ return;
+ }
+ setGradingSubmitting(true);
+ try {
+ await putAPI(`/api/assignments/submissions/${submissionId}/grade`, {
+ grade: g.grade,
+ feedback: g.feedback || null,
+ });
+ toast.success("评分已保存");
+ setActiveGradeId(null);
+ loadAssignment();
+ } catch (err: any) {
+ toast.error(err.message || "评分失败");
+ } finally {
+ setGradingSubmitting(false);
+ }
+ };
+
+ const openGrading = (submission: AssignmentSubmission) => {
+ setGradingMap((prev) => ({
+ ...prev,
+ [submission.id]: {
+ grade: submission.grade || "",
+ feedback: submission.feedback || "",
+ },
+ }));
+ setActiveGradeId(submission.id);
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return ;
+ }
+
+ if (!assignment) {
+ return ;
+ }
+
+ const deadline = assignment.deadline
+ ? new Date(assignment.deadline)
+ : null;
+ const isPastDeadline = deadline ? deadline < new Date() : false;
+ const mySubmission = (assignment.submissions || []).find(
+ (s) => s.student_id === user?.id
+ ) ?? null;
+
+ // Compute unsubmitted students for admin view
+ const submittedStudentIds = new Set(
+ (assignment.submissions || []).map((s) => s.student_id)
+ );
+ // We only know student names from submissions; for unsubmitted we need the
+ // class roster. Since the API doesn't return the full roster in this endpoint,
+ // we display submission info from what's available.
+
+ return (
+
+ {/* Back link */}
+
+
+ 返回作业列表
+
+
+ {/* Assignment info */}
+
+
+
+
+
+
{assignment.title}
+
+ {assignment.status === "open" ? "进行中" : "已关闭"}
+
+ {isPastDeadline && assignment.status === "open" && (
+
+ 已截止
+
+ )}
+
+
+ {assignment.description && (
+
+ {assignment.description}
+
+ )}
+
+
+
+ 发布者:{assignment.creator_name} · 发布时间:
+ {formatDate(assignment.created_at)}
+
+ {deadline && (
+
+ 截止时间:
+ {formatDate(assignment.deadline!)}
+ {isPastDeadline ? (
+ (已过期)
+ ) : (
+ ({getCountdown(assignment.deadline!)})
+ )}
+
+ )}
+
+
+ {/* Stats bar */}
+ {assignment.total_members > 0 && (
+
+
+
+ 提交进度:{assignment.submission_count} / {assignment.total_members} 人
+
+
+ {Math.round((assignment.submission_count / assignment.total_members) * 100)}%
+
+
+
+
+ 已提交 {assignment.submission_count} 人
+ 未提交 {assignment.total_members - assignment.submission_count} 人
+
+
+ )}
+
+ {/* Attachments */}
+ {assignment.attachment_urls &&
+ assignment.attachment_urls.length > 0 && (
+
+ )}
+
+
+ {/* Admin actions */}
+
+
+ {assignment.status === "open" && (
+
+ )}
+
+
+
+
+
+
+ {/* My submission section — all roles can submit */}
+ {(
+
+
+ 我的提交
+ {mySubmission ? (
+
+
+ 已提交
+
+ 提交时间:{formatDate(mySubmission.created_at)}
+
+
+ {mySubmission.file_url && (
+
+ )}
+ {mySubmission.notes && (
+
+
备注:
+
+ {mySubmission.notes}
+
+
+ )}
+
+ {/* Grade & feedback */}
+ {mySubmission.grade && (
+
+
+ 已评分
+
+ {mySubmission.grade}
+
+
+ {mySubmission.feedback && (
+
+
教师反馈:
+
+ {mySubmission.feedback}
+
+
+ )}
+ {mySubmission.graded_at && (
+
+ 评分时间:{formatDate(mySubmission.graded_at)}
+
+ )}
+
+ )}
+
+ ) : (
+
+ {assignment.status === "closed" || isPastDeadline ? (
+
+ ) : (
+
+
+
+
+ setSelectedFile(e.target.files?.[0] || null)
+ }
+ />
+
+
+
+
+
+
+ )}
+
+ )}
+
+
+ )}
+
+ {/* Admin: Submissions table */}
+
+
+
+
+ 提交记录({assignment.submissions?.length || 0} /
+ {assignment.submission_count})
+
+ {(!assignment.submissions || assignment.submissions.length === 0) ? (
+
+ 暂无提交记录
+
+ ) : (
+ <>
+ {/* Desktop table */}
+
+ {/* Mobile cards */}
+
+ {assignment.submissions!.map((sub) => (
+
+
+ {sub.student_name}
+ {sub.grade ? {sub.grade} : 未评分}
+
+
{formatDate(sub.created_at)}
+ {sub.file_url && (
+
+
+ {sub.file_name || "下载"}
+
+ )}
+ {sub.feedback &&
反馈:{sub.feedback}
}
+ {activeGradeId === sub.id ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/(app)/assignments/page.tsx b/frontend/src/app/(app)/assignments/page.tsx
new file mode 100644
index 0000000..a4262e3
--- /dev/null
+++ b/frontend/src/app/(app)/assignments/page.tsx
@@ -0,0 +1,334 @@
+"use client";
+
+import { useEffect, useState, useRef, useCallback } from "react";
+import { useActiveClass } from "@/hooks/use-active-class";
+import { useAuth } from "@/hooks/use-auth";
+import { fetchAPI, postAPI, deleteAPI, uploadAPI } from "@/lib/api";
+import { Card, CardContent } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Badge } from "@/components/ui/badge";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { RoleGuard } from "@/components/role-guard";
+import { ConfirmDialog } from "@/components/confirm-dialog";
+import { ErrorState } from "@/components/error-state";
+import { Pagination } from "@/components/pagination";
+import { toast } from "sonner";
+import Link from "next/link";
+import type { Assignment } from "@/lib/types";
+
+function formatDeadline(deadline: string | null): {
+ text: string;
+ isPast: boolean;
+} {
+ if (!deadline) return { text: "无截止时间", isPast: false };
+ const date = new Date(deadline);
+ const now = new Date();
+ const isPast = date < now;
+ return {
+ text: date.toLocaleDateString("zh-CN", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ }),
+ isPast,
+ };
+}
+
+export default function AssignmentsPage() {
+ const { activeClassId } = useActiveClass();
+ const { user } = useAuth();
+ const [assignments, setAssignments] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [page, setPage] = useState(1);
+ const [totalPages, setTotalPages] = useState(1);
+
+ // Create dialog state
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [newTitle, setNewTitle] = useState("");
+ const [newDescription, setNewDescription] = useState("");
+ const [newDeadline, setNewDeadline] = useState("");
+ const [selectedFiles, setSelectedFiles] = useState([]);
+ const [submitting, setSubmitting] = useState(false);
+ const fileInputRef = useRef(null);
+
+ // Delete state
+ const [deleteTarget, setDeleteTarget] = useState(null);
+
+ const loadAssignments = useCallback(async () => {
+ setError(null);
+ try {
+ const res = await fetchAPI("/api/assignments/", {
+ page_size: "10",
+ page: String(page),
+ class_id: String(activeClassId),
+ });
+ setAssignments(res.items || []);
+ setTotalPages(res.total_pages || 1);
+ } catch (err: any) {
+ setError(err.message || "加载失败");
+ } finally {
+ setLoading(false);
+ }
+ }, [activeClassId, page]);
+
+ useEffect(() => {
+ if (!activeClassId) return;
+ loadAssignments();
+ }, [activeClassId, page, loadAssignments]);
+
+ const resetForm = () => {
+ setNewTitle("");
+ setNewDescription("");
+ setNewDeadline("");
+ setSelectedFiles([]);
+ if (fileInputRef.current) fileInputRef.current.value = "";
+ };
+
+ const handleCreate = async () => {
+ if (!newTitle.trim()) return;
+ setSubmitting(true);
+ try {
+ const assignment = await postAPI("/api/assignments/", {
+ title: newTitle,
+ description: newDescription || null,
+ deadline: newDeadline || null,
+ class_id: activeClassId,
+ });
+
+ // Upload attachments if any
+ if (selectedFiles.length > 0) {
+ const formData = new FormData();
+ selectedFiles.forEach((file) => {
+ formData.append("files", file);
+ });
+ await uploadAPI(`/api/assignments/${assignment.id}/attachments`, formData);
+ }
+
+ toast.success("作业已发布");
+ setDialogOpen(false);
+ resetForm();
+ loadAssignments();
+ } catch (err: any) {
+ toast.error(err.message || "发布失败");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const handleDelete = async (id: number) => {
+ try {
+ await deleteAPI(`/api/assignments/${id}`);
+ toast.success("已删除");
+ setDeleteTarget(null);
+ loadAssignments();
+ } catch (err: any) {
+ toast.error(err.message || "删除失败");
+ }
+ };
+
+ const isAdmin =
+ user?.role === "class_admin" || user?.role === "super_admin";
+
+ return (
+
+
+
+
+
+
+
+
+ {loading ? (
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+
+ ))}
+
+ ) : error ? (
+
+ ) : assignments.length === 0 ? (
+
暂无作业
+ ) : (
+
+ {assignments.map((assignment) => {
+ const { text: deadlineText, isPast } = formatDeadline(
+ assignment.deadline
+ );
+ return (
+
+
+
+
+
+
+
+ {assignment.title}
+
+
+ {assignment.status === "open" ? "进行中" : "已关闭"}
+
+ {isPast && assignment.status === "open" && (
+
+ 已截止
+
+ )}
+ {assignment.status === "closed" && (
+
+ 已截止
+
+ )}
+
+ {assignment.description && (
+
+ {assignment.description}
+
+ )}
+
+ 截止时间:{deadlineText}
+ 已提交 {assignment.submission_count}/{assignment.total_members} 人
+ {assignment.total_members > 0 && (
+
+ {Math.round((assignment.submission_count / assignment.total_members) * 100)}%
+
+ )}
+
+
+
+ {assignment.my_submitted ? (
+ 已提交
+ ) : assignment.status === "open" ? (
+
+ 去提交
+
+ ) : null}
+ {isAdmin && (
+
+ )}
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+
{
+ if (!open) setDeleteTarget(null);
+ }}
+ title="删除作业"
+ description="确定删除该作业?此操作不可恢复。"
+ confirmText="删除"
+ variant="destructive"
+ onConfirm={() => deleteTarget && handleDelete(deleteTarget)}
+ />
+
+ );
+}
diff --git a/frontend/src/app/(app)/dashboard/page.tsx b/frontend/src/app/(app)/dashboard/page.tsx
new file mode 100644
index 0000000..920764e
--- /dev/null
+++ b/frontend/src/app/(app)/dashboard/page.tsx
@@ -0,0 +1,238 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { fetchAPI } from "@/lib/api";
+import { useActiveClass } from "@/hooks/use-active-class";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { ErrorState } from "@/components/error-state";
+import type { ScheduleItem, TimelinePost, Announcement } from "@/lib/types";
+import { SCHEDULE_TYPES } from "@/lib/constants";
+
+export default function DashboardPage() {
+ const { activeClassId } = useActiveClass();
+ const [upcoming, setUpcoming] = useState([]);
+ const [recentTimeline, setRecentTimeline] = useState([]);
+ const [announcements, setAnnouncements] = useState([]);
+ const [error, setError] = useState(null);
+ const [selectedAnnouncement, setSelectedAnnouncement] = useState(null);
+ const [selectedSchedule, setSelectedSchedule] = useState(null);
+
+ const loadData = async () => {
+ if (!activeClassId) return;
+ setError(null);
+ try {
+ const [upcomingRes, timelineRes, announcementsRes] = await Promise.all([
+ fetchAPI("/api/schedule/upcoming", { limit: "3", class_id: String(activeClassId) }),
+ fetchAPI("/api/timeline/", { page_size: "3", class_id: String(activeClassId) }),
+ fetchAPI("/api/announcements/", { page_size: "3", class_id: String(activeClassId) }),
+ ]);
+ setUpcoming(upcomingRes);
+ setRecentTimeline(timelineRes.items || []);
+ setAnnouncements(announcementsRes.items || []);
+ } catch (err: any) {
+ setError(err.message || "加载失败");
+ }
+ };
+
+ useEffect(() => {
+ loadData();
+ }, [activeClassId]);
+
+ const getCountdown = (startTime: string) => {
+ const diff = new Date(startTime).getTime() - Date.now();
+ const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
+ if (days <= 0) return "已开始";
+ if (days === 1) return "明天";
+ if (days <= 7) return `${days}天后`;
+ return `${days}天后`;
+ };
+
+ return (
+
+
+
+ {error ? (
+
+ ) : (
+ <>
+ {/* Latest announcements */}
+ {announcements.length > 0 && (
+
+
+ 最新公告
+
+ 查看全部
+
+
+
+
+ {announcements.map((a) => (
+
setSelectedAnnouncement(a)}
+ >
+ {a.is_pinned && (
+
置顶
+ )}
+
+
{a.title}
+
+ {a.author_name} · {new Date(a.created_at).toLocaleDateString("zh-CN")}
+
+
+
+ ))}
+
+
+
+ )}
+
+
+ {/* Upcoming schedules */}
+
+
+ 即将到来
+
+
+ {upcoming.length === 0 ? (
+ 暂无排期
+ ) : (
+
+ {upcoming.map((item) => (
+
setSelectedSchedule(item)}
+ >
+
+
+
+
{item.title}
+
+ {new Date(item.start_time).toLocaleDateString("zh-CN")}
+
+
+
+
+ {getCountdown(item.start_time)}
+
+
+ ))}
+
+ )}
+
+
+
+ {/* Recent timeline */}
+
+
+ 最近动态
+
+
+ {recentTimeline.length === 0 ? (
+ 暂无动态
+ ) : (
+
+ {recentTimeline.map((post) => (
+
+
{post.title}
+
+ {post.author_name} ·{" "}
+ {new Date(post.created_at).toLocaleDateString("zh-CN")}
+
+
+ ))}
+
+ )}
+
+
+
+ >
+ )}
+
+ {/* Announcement detail dialog */}
+
+
+ {/* Schedule detail dialog */}
+
+
+ );
+}
diff --git a/frontend/src/app/(app)/directory/[id]/page.tsx b/frontend/src/app/(app)/directory/[id]/page.tsx
new file mode 100644
index 0000000..c2de16e
--- /dev/null
+++ b/frontend/src/app/(app)/directory/[id]/page.tsx
@@ -0,0 +1,111 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useParams } from "next/navigation";
+import { useAuth } from "@/hooks/use-auth";
+import { fetchAPI } from "@/lib/api";
+import { Card, CardContent } from "@/components/ui/card";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Badge } from "@/components/ui/badge";
+import { ErrorState } from "@/components/error-state";
+import type { UserPublic } from "@/lib/types";
+import Link from "next/link";
+
+export default function MemberDetailPage() {
+ const params = useParams();
+ const { user } = useAuth();
+ const [member, setMember] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const id = params.id as string;
+ setError(null);
+ fetchAPI(`/api/directory/${id}`)
+ .then(setMember)
+ .catch((err: any) => setError(err.message || "加载失败"))
+ .finally(() => setLoading(false));
+ }, [params.id]);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (!member) {
+ if (error) {
+ return (
+
+
+ ← 返回花名册
+
+ window.location.reload()} />
+
+ );
+ }
+ return (
+ 成员不存在
+ );
+ }
+
+ const showContact = user?.class_id === member.id; // Privacy: same class check handled by API
+
+ return (
+
+
+ ← 返回花名册
+
+
+
+
+ {/* Avatar centered at top */}
+
+
+
+
+ {member.name[0]}
+
+
+
{member.name}
+ {member.student_id && (
+
学号: {member.student_id}
+ )}
+ {member.company && (
+
+ {member.company}
+ {member.position ? ` · ${member.position}` : ""}
+
+ )}
+ {member.industry && (
+
{member.industry}
+ )}
+
+
+ {member.bio && (
+
+ )}
+
+ {(member.wechat_id || member.phone) && (
+
+
联系方式
+
+ {member.wechat_id && (
+
微信: {member.wechat_id}
+ )}
+ {member.phone && (
+
手机: {member.phone}
+ )}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/app/(app)/directory/page.tsx b/frontend/src/app/(app)/directory/page.tsx
new file mode 100644
index 0000000..68ae1ad
--- /dev/null
+++ b/frontend/src/app/(app)/directory/page.tsx
@@ -0,0 +1,156 @@
+"use client";
+
+import { useEffect, useState, useCallback } from "react";
+import { useActiveClass } from "@/hooks/use-active-class";
+import { fetchAPI } from "@/lib/api";
+import { Input } from "@/components/ui/input";
+import { Badge } from "@/components/ui/badge";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Card, CardContent } from "@/components/ui/card";
+import { ErrorState } from "@/components/error-state";
+import { Pagination } from "@/components/pagination";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import type { UserPublic } from "@/lib/types";
+import { INDUSTRY_OPTIONS } from "@/lib/constants";
+import Link from "next/link";
+
+export default function DirectoryPage() {
+ const { activeClassId } = useActiveClass();
+ const [members, setMembers] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [search, setSearch] = useState("");
+ const [industry, setIndustry] = useState("");
+ const [company, setCompany] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [page, setPage] = useState(1);
+ const [totalPages, setTotalPages] = useState(1);
+
+ const loadMembers = useCallback(async () => {
+ if (!activeClassId) {
+ setMembers([]);
+ setTotal(0);
+ setLoading(false);
+ return;
+ }
+ setLoading(true);
+ setError(null);
+ try {
+ const params: Record = { class_id: String(activeClassId), page: String(page), page_size: "20" };
+ if (search) params.search = search;
+ if (industry) params.industry = industry;
+ if (company) params.company = company;
+ const res = await fetchAPI("/api/directory/", params);
+ setMembers(res.items || []);
+ setTotal(res.total || 0);
+ setTotalPages(res.total_pages || 1);
+ } catch (err: any) {
+ setError(err.message || "加载失败");
+ } finally {
+ setLoading(false);
+ }
+ }, [search, industry, company, activeClassId, page]);
+
+ // Reset page when filters change
+ useEffect(() => {
+ setPage(1);
+ }, [search, industry, company]);
+
+ useEffect(() => {
+ if (!activeClassId) return;
+ const timer = setTimeout(loadMembers, 300);
+ return () => clearTimeout(timer);
+ }, [loadMembers]);
+
+ return (
+
+
+
+ {/* Search & Filters */}
+
+ setSearch(e.target.value)}
+ className="max-w-sm w-full sm:w-auto"
+ />
+
+ setCompany(e.target.value)}
+ className="w-full sm:w-40"
+ />
+
+
+ {/* Member Grid */}
+ {loading ? (
+
+ {[1, 2, 3, 4, 5, 6].map((i) => (
+
+
+
+
+
+ ))}
+
+ ) : error ? (
+
+ ) : members.length === 0 ? (
+
没有找到匹配的同学
+ ) : (
+
+ {members.map((member) => (
+
+
+
+
+
+
+ {member.name[0]}
+
+
+ {member.name}
+ {member.company && (
+
+ {member.company}
+ {member.position ? ` · ${member.position}` : ""}
+
+ )}
+ {member.industry && (
+
+ {member.industry}
+
+ )}
+
+
+
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx
new file mode 100644
index 0000000..206e1cc
--- /dev/null
+++ b/frontend/src/app/(app)/layout.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { Sidebar } from "@/components/sidebar";
+import { Header } from "@/components/header";
+import { ActiveClassProvider } from "@/hooks/use-active-class";
+import { SidebarProvider } from "@/hooks/use-sidebar";
+import { NotificationProvider } from "@/hooks/use-notifications";
+
+export default function AppLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/(app)/profile/page.tsx b/frontend/src/app/(app)/profile/page.tsx
new file mode 100644
index 0000000..4226b9a
--- /dev/null
+++ b/frontend/src/app/(app)/profile/page.tsx
@@ -0,0 +1,222 @@
+"use client";
+
+import { useEffect, useState, useRef } from "react";
+import { useAuth } from "@/hooks/use-auth";
+import { putAPI, uploadAPI } from "@/lib/api";
+import { Card, CardContent } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { toast } from "sonner";
+import { INDUSTRY_OPTIONS } from "@/lib/constants";
+
+export default function ProfilePage() {
+ const { user, refreshUser } = useAuth();
+ const [email, setEmail] = useState("");
+ const [name, setName] = useState("");
+ const [industry, setIndustry] = useState("");
+ const [company, setCompany] = useState("");
+ const [position, setPosition] = useState("");
+ const [wechatId, setWechatId] = useState("");
+ const [phone, setPhone] = useState("");
+ const [bio, setBio] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [avatarUploading, setAvatarUploading] = useState(false);
+ const fileInputRef = useRef(null);
+
+ useEffect(() => {
+ if (user) {
+ setEmail(user.email || "");
+ setName(user.name || "");
+ setIndustry(user.industry || "");
+ setCompany(user.company || "");
+ setPosition(user.position || "");
+ setWechatId(user.wechat_id || "");
+ setPhone(user.phone || "");
+ setBio(user.bio || "");
+ }
+ }, [user]);
+
+ const handleAvatarChange = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ if (file.size > 5 * 1024 * 1024) {
+ toast.error("图片大小不能超过 5MB");
+ return;
+ }
+ setAvatarUploading(true);
+ try {
+ const formData = new FormData();
+ formData.append("file", file);
+ await uploadAPI("/api/users/me/avatar", formData);
+ await refreshUser();
+ toast.success("头像已更新");
+ } catch (err: any) {
+ toast.error(err.message || "头像上传失败");
+ } finally {
+ setAvatarUploading(false);
+ if (fileInputRef.current) fileInputRef.current.value = "";
+ }
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setLoading(true);
+ try {
+ await putAPI("/api/users/me", {
+ email: email || undefined,
+ name: name || undefined,
+ industry: industry || undefined,
+ company: company || undefined,
+ position: position || undefined,
+ wechat_id: wechatId || undefined,
+ phone: phone || undefined,
+ bio: bio || undefined,
+ });
+ await refreshUser();
+ toast.success("资料已更新");
+ } catch (err: any) {
+ toast.error(err.message || "更新失败");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
编辑个人资料
+
+
+ {/* Avatar */}
+
+
+ {user?.avatar_url ? (
+

+ ) : (
+ user?.name?.[0] || "?"
+ )}
+
+
+
+
+
支持 JPG/PNG/GIF/WebP,最大 5MB
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/(app)/resources/page.tsx b/frontend/src/app/(app)/resources/page.tsx
new file mode 100644
index 0000000..40cf3a4
--- /dev/null
+++ b/frontend/src/app/(app)/resources/page.tsx
@@ -0,0 +1,293 @@
+"use client";
+
+import { useEffect, useState, useRef } from "react";
+import { useActiveClass } from "@/hooks/use-active-class";
+import { fetchAPI, postAPI, deleteAPI, uploadAPI } from "@/lib/api";
+import { Card, CardContent } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Badge } from "@/components/ui/badge";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { RoleGuard } from "@/components/role-guard";
+import { ConfirmDialog } from "@/components/confirm-dialog";
+import { ErrorState } from "@/components/error-state";
+import { Pagination } from "@/components/pagination";
+import { toast } from "sonner";
+import type { Resource } from "@/lib/types";
+
+const RESOURCE_CATEGORIES: Record = {
+ all: "全部",
+ course_material: "课件资料",
+ assignment: "作业",
+ reading: "阅读材料",
+ other: "其他",
+};
+
+function formatFileSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+function getFileIcon(fileType: string): string {
+ if (fileType.startsWith("image/")) return "🖼️";
+ if (fileType.includes("pdf")) return "📄";
+ if (fileType.includes("word") || fileType.includes("document")) return "📝";
+ if (fileType.includes("sheet") || fileType.includes("excel")) return "📊";
+ if (fileType.includes("presentation") || fileType.includes("powerpoint")) return "📽️";
+ if (fileType.includes("zip")) return "📦";
+ return "📎";
+}
+
+export default function ResourcesPage() {
+ const { activeClassId } = useActiveClass();
+ const [resources, setResources] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [category, setCategory] = useState("all");
+ const [page, setPage] = useState(1);
+ const [totalPages, setTotalPages] = useState(1);
+
+ // Upload dialog state
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [formTitle, setFormTitle] = useState("");
+ const [formDesc, setFormDesc] = useState("");
+ const [formCategory, setFormCategory] = useState("course_material");
+ const [selectedFile, setSelectedFile] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+ const fileInputRef = useRef(null);
+
+ // Delete state
+ const [deleteTarget, setDeleteTarget] = useState(null);
+
+ const loadResources = async () => {
+ setError(null);
+ try {
+ const params: Record = {
+ page_size: "20",
+ page: String(page),
+ class_id: String(activeClassId),
+ };
+ if (category !== "all") params.category = category;
+ const res = await fetchAPI("/api/resources/", params);
+ setResources(res.items || []);
+ setTotalPages(res.total_pages || 1);
+ } catch (err: any) {
+ setError(err.message || "加载失败");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ if (!activeClassId) return;
+ loadResources();
+ }, [activeClassId, page, category]);
+
+ const resetForm = () => {
+ setFormTitle("");
+ setFormDesc("");
+ setFormCategory("course_material");
+ setSelectedFile(null);
+ if (fileInputRef.current) fileInputRef.current.value = "";
+ };
+
+ const handleSubmit = async () => {
+ if (!formTitle.trim() || !selectedFile) return;
+ setSubmitting(true);
+ try {
+ const formData = new FormData();
+ formData.append("title", formTitle);
+ formData.append("category", formCategory);
+ formData.append("file", selectedFile);
+ if (formDesc.trim()) formData.append("description", formDesc);
+ if (activeClassId) formData.append("class_id", String(activeClassId));
+
+ await uploadAPI("/api/resources/", formData);
+ toast.success("资源已上传");
+ setDialogOpen(false);
+ resetForm();
+ loadResources();
+ } catch (err: any) {
+ toast.error(err.message || "上传失败");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const handleDownload = async (resource: Resource) => {
+ try {
+ const res = await postAPI(`/api/resources/${resource.id}/download`);
+ window.open(res.file_url, "_blank");
+ } catch (err: any) {
+ toast.error(err.message || "下载失败");
+ }
+ };
+
+ const handleDelete = async (id: number) => {
+ try {
+ await deleteAPI(`/api/resources/${id}`);
+ toast.success("已删除");
+ setDeleteTarget(null);
+ loadResources();
+ } catch (err: any) {
+ toast.error(err.message || "删除失败");
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ {/* Category tabs */}
+
+ {Object.entries(RESOURCE_CATEGORIES).map(([key, label]) => (
+
+ ))}
+
+
+ {loading ? (
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+
+ ))}
+
+ ) : error ? (
+
+ ) : resources.length === 0 ? (
+
暂无资源
+ ) : (
+
+ {resources.map((r) => (
+
+
+
+
{getFileIcon(r.file_type)}
+
+
{r.title}
+
+ {r.uploader_name} · {formatFileSize(r.file_size)} · 下载 {r.download_count} 次
+
+
+
+
+ {RESOURCE_CATEGORIES[r.category] || r.category}
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
{ if (!open) setDeleteTarget(null); }}
+ title="删除资源"
+ description="确定删除该资源?此操作不可恢复。"
+ confirmText="删除"
+ variant="destructive"
+ onConfirm={() => deleteTarget && handleDelete(deleteTarget)}
+ />
+
+ );
+}
diff --git a/frontend/src/app/(app)/schedule/page.tsx b/frontend/src/app/(app)/schedule/page.tsx
new file mode 100644
index 0000000..0521de4
--- /dev/null
+++ b/frontend/src/app/(app)/schedule/page.tsx
@@ -0,0 +1,405 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useActiveClass } from "@/hooks/use-active-class";
+import { fetchAPI, postAPI, putAPI, deleteAPI } from "@/lib/api";
+import { Card, CardContent } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Badge } from "@/components/ui/badge";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { RoleGuard } from "@/components/role-guard";
+import { ConfirmDialog } from "@/components/confirm-dialog";
+import { ErrorState } from "@/components/error-state";
+import { Pagination } from "@/components/pagination";
+import { CalendarView } from "@/components/calendar-view";
+import { toast } from "sonner";
+import type { ScheduleItem } from "@/lib/types";
+import { SCHEDULE_TYPES } from "@/lib/constants";
+
+export default function SchedulePage() {
+ const { activeClassId } = useActiveClass();
+ const [items, setItems] = useState([]);
+ const [upcoming, setUpcoming] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [page, setPage] = useState(1);
+ const [totalPages, setTotalPages] = useState(1);
+ const [viewMode, setViewMode] = useState<"list" | "calendar">("list");
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+
+ // Form state
+ const [formType, setFormType] = useState("course");
+ const [formTitle, setFormTitle] = useState("");
+ const [formStartTime, setFormStartTime] = useState("");
+ const [formEndTime, setFormEndTime] = useState("");
+ const [formLocation, setFormLocation] = useState("");
+ const [formDesc, setFormDesc] = useState("");
+
+ const loadData = async () => {
+ setError(null);
+ try {
+ const params = { class_id: String(activeClassId), page: String(page), page_size: "20" };
+ const [allRes, upcomingRes] = await Promise.all([
+ fetchAPI("/api/schedule/", params),
+ fetchAPI("/api/schedule/upcoming", { limit: "10", class_id: String(activeClassId) }),
+ ]);
+ setItems(allRes.items || []);
+ setTotalPages(allRes.total_pages || 1);
+ setUpcoming(upcomingRes);
+ } catch (err: any) {
+ setError(err.message || "加载失败");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ if (!activeClassId) return;
+ loadData();
+ }, [activeClassId, page]);
+
+ const getCountdown = (startTime: string) => {
+ const diff = new Date(startTime).getTime() - Date.now();
+ const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
+ if (days <= 0) return { text: "已开始", urgent: false };
+ if (days === 1) return { text: "明天", urgent: true };
+ if (days <= 3) return { text: `${days}天后`, urgent: true };
+ if (days <= 7) return { text: `${days}天后`, urgent: false };
+ return { text: `${days}天后`, urgent: false };
+ };
+
+ const handleSubmit = async () => {
+ if (!formTitle.trim() || !formStartTime) return;
+ setSubmitting(true);
+ try {
+ if (editingId) {
+ await putAPI(`/api/schedule/${editingId}`, {
+ type: formType,
+ title: formTitle,
+ start_time: formStartTime,
+ end_time: formEndTime || null,
+ location: formLocation || null,
+ description: formDesc || null,
+ });
+ toast.success("排期已更新");
+ } else {
+ await postAPI("/api/schedule/", {
+ type: formType,
+ title: formTitle,
+ start_time: formStartTime,
+ end_time: formEndTime || null,
+ location: formLocation || null,
+ description: formDesc || null,
+ class_id: activeClassId,
+ });
+ toast.success("排期已创建");
+ }
+ setDialogOpen(false);
+ resetForm();
+ loadData();
+ } catch (err: any) {
+ toast.error(err.message || (editingId ? "更新失败" : "创建失败"));
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const openEdit = (item: ScheduleItem) => {
+ setEditingId(item.id);
+ setFormType(item.type);
+ setFormTitle(item.title);
+ // Format datetime for datetime-local input (YYYY-MM-DDTHH:mm)
+ const pad = (n: number) => String(n).padStart(2, "0");
+ const fmt = (d: string) => {
+ const dt = new Date(d);
+ return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())}T${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
+ };
+ setFormStartTime(fmt(item.start_time));
+ setFormEndTime(item.end_time ? fmt(item.end_time) : "");
+ setFormLocation(item.location || "");
+ setFormDesc(item.description || "");
+ setDialogOpen(true);
+ };
+
+ const [deleteTarget, setDeleteTarget] = useState(null);
+
+ const handleDelete = async (id: number) => {
+ try {
+ await deleteAPI(`/api/schedule/${id}`);
+ toast.success("已删除");
+ setDeleteTarget(null);
+ loadData();
+ } catch (err: any) {
+ toast.error(err.message || "删除失败");
+ }
+ };
+
+ const resetForm = () => {
+ setEditingId(null);
+ setFormType("course");
+ setFormTitle("");
+ setFormStartTime("");
+ setFormEndTime("");
+ setFormLocation("");
+ setFormDesc("");
+ };
+
+ return (
+
+
+
+
+ {/* View mode toggle */}
+
+
+
+
+
+
+
+
+
+
+ {/* Calendar view or list view */}
+ {viewMode === "calendar" ? (
+
+ ) : (
+ <>
+ {/* Upcoming with countdown */}
+ {upcoming.length > 0 && (
+
+
即将到来
+
+ {upcoming.map((item) => {
+ const countdown = getCountdown(item.start_time);
+ const typeInfo = SCHEDULE_TYPES[item.type] || { label: item.type, color: "bg-gray-400" };
+ return (
+
+
+
+
+
+
+ {typeInfo.label}
+
+
+
+ {countdown.text}
+
+
+ {item.title}
+
+ {new Date(item.start_time).toLocaleString("zh-CN", {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ })}
+
+ {item.location && (
+ {item.location}
+ )}
+
+
+
+
+
+
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* All schedules */}
+
+
全部排期
+ {loading ? (
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+
+ ))}
+
+ ) : error ? (
+
+ ) : items.length === 0 ? (
+
暂无排期
+ ) : (
+
+ {items.map((item) => {
+ const typeInfo = SCHEDULE_TYPES[item.type] || { label: item.type, color: "bg-gray-400" };
+ return (
+
+
+
+
+
+
{item.title}
+
+ {new Date(item.start_time).toLocaleString("zh-CN")}
+ {item.location ? ` · ${item.location}` : ""}
+
+
+
+
+ {typeInfo.label}
+
+
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+ >
+ )}
+
+ {/* Delete confirmation */}
+
{ if (!open) setDeleteTarget(null); }}
+ title="删除排期"
+ description="确定删除这条排期?此操作不可恢复。"
+ confirmText="删除"
+ variant="destructive"
+ onConfirm={() => deleteTarget && handleDelete(deleteTarget)}
+ />
+
+ );
+}
diff --git a/frontend/src/app/(app)/timeline/page.tsx b/frontend/src/app/(app)/timeline/page.tsx
new file mode 100644
index 0000000..505c6b3
--- /dev/null
+++ b/frontend/src/app/(app)/timeline/page.tsx
@@ -0,0 +1,688 @@
+"use client";
+
+import { useEffect, useState, useRef, useCallback } from "react";
+import { useActiveClass } from "@/hooks/use-active-class";
+import { useAuth } from "@/hooks/use-auth";
+import { fetchAPI, postAPI, putAPI, deleteAPI, uploadAPI } from "@/lib/api";
+import { Card, CardContent } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { ConfirmDialog } from "@/components/confirm-dialog";
+import { ErrorState } from "@/components/error-state";
+import { Pagination } from "@/components/pagination";
+import { toast } from "sonner";
+import type { TimelinePost, TimelineComment } from "@/lib/types";
+
+/* ---------- Relative time helper ---------- */
+function relativeTime(dateStr: string): string {
+ const now = Date.now();
+ const then = new Date(dateStr).getTime();
+ const diff = Math.floor((now - then) / 1000);
+ if (diff < 60) return "刚刚";
+ if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`;
+ if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`;
+ if (diff < 2592000) return `${Math.floor(diff / 86400)} 天前`;
+ return new Date(dateStr).toLocaleDateString("zh-CN", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
+}
+
+/* ---------- Image Lightbox ---------- */
+function Lightbox({
+ images,
+ initialIndex,
+ onClose,
+}: {
+ images: string[];
+ initialIndex: number;
+ onClose: () => void;
+}) {
+ const [index, setIndex] = useState(initialIndex);
+
+ const prev = useCallback(() => setIndex((i) => (i - 1 + images.length) % images.length), [images.length]);
+ const next = useCallback(() => setIndex((i) => (i + 1) % images.length), [images.length]);
+
+ // Keyboard & touch handling
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onClose();
+ if (e.key === "ArrowLeft") prev();
+ if (e.key === "ArrowRight") next();
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [onClose, prev, next]);
+
+ // Touch swipe
+ const touchStartX = useRef(0);
+ const handleTouchStart = (e: React.TouchEvent) => {
+ touchStartX.current = e.touches[0].clientX;
+ };
+ const handleTouchEnd = (e: React.TouchEvent) => {
+ const diff = e.changedTouches[0].clientX - touchStartX.current;
+ if (Math.abs(diff) > 50) {
+ diff > 0 ? prev() : next();
+ }
+ };
+
+ return (
+
+ {/* Close button */}
+
+
+ {/* Counter */}
+
+ {index + 1} / {images.length}
+
+
+ {/* Prev arrow */}
+ {images.length > 1 && (
+
+ )}
+
+ {/* Image */}
+

e.stopPropagation()}
+ onTouchStart={handleTouchStart}
+ onTouchEnd={handleTouchEnd}
+ />
+
+ {/* Next arrow */}
+ {images.length > 1 && (
+
+ )}
+
+ {/* Thumbnails */}
+ {images.length > 1 && (
+
e.stopPropagation()}
+ >
+ {images.map((url, i) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+export default function TimelinePage() {
+ const { activeClassId } = useActiveClass();
+ const { user } = useAuth();
+ const [posts, setPosts] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [page, setPage] = useState(1);
+ const [totalPages, setTotalPages] = useState(1);
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [newTitle, setNewTitle] = useState("");
+ const [newContent, setNewContent] = useState("");
+ const [selectedFiles, setSelectedFiles] = useState([]);
+ const [previewUrls, setPreviewUrls] = useState([]);
+ const [submitting, setSubmitting] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+ const [editingImageUrls, setEditingImageUrls] = useState([]);
+ const fileInputRef = useRef(null);
+
+ // Lightbox state
+ const [lightboxImages, setLightboxImages] = useState(null);
+ const [lightboxIndex, setLightboxIndex] = useState(0);
+
+ // Comment state
+ const [expandedComments, setExpandedComments] = useState>(new Set());
+ const [commentInputs, setCommentInputs] = useState>({});
+ const [submittingComment, setSubmittingComment] = useState>({});
+
+ const openLightbox = (images: string[], index: number) => {
+ setLightboxImages(images);
+ setLightboxIndex(index);
+ };
+ const closeLightbox = () => setLightboxImages(null);
+
+ const loadPosts = async () => {
+ setError(null);
+ try {
+ const res = await fetchAPI("/api/timeline/", { page_size: "10", page: String(page), class_id: String(activeClassId) });
+ setPosts(res.items || []);
+ setTotalPages(res.total_pages || 1);
+ } catch (err: any) {
+ setError(err.message || "加载失败");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ if (!activeClassId) return;
+ loadPosts();
+ }, [activeClassId, page]);
+
+ /* ---------- File upload helpers ---------- */
+ const handleFileChange = (e: React.ChangeEvent) => {
+ const files = Array.from(e.target.files || []);
+ // Max 9 images
+ const toAdd = files.slice(0, 9 - selectedFiles.length - editingImageUrls.length);
+ if (toAdd.length === 0) {
+ toast.error("最多上传 9 张图片");
+ return;
+ }
+ const newFiles = [...selectedFiles, ...toAdd];
+ setSelectedFiles(newFiles);
+ // Generate preview URLs
+ const newUrls = toAdd.map((f) => URL.createObjectURL(f));
+ setPreviewUrls((prev) => [...prev, ...newUrls]);
+ };
+
+ const removeFile = (index: number) => {
+ URL.revokeObjectURL(previewUrls[index]);
+ setSelectedFiles((prev) => prev.filter((_, i) => i !== index));
+ setPreviewUrls((prev) => prev.filter((_, i) => i !== index));
+ };
+
+ const resetForm = () => {
+ setEditingId(null);
+ setEditingImageUrls([]);
+ setNewTitle("");
+ setNewContent("");
+ previewUrls.forEach((url) => URL.revokeObjectURL(url));
+ setSelectedFiles([]);
+ setPreviewUrls([]);
+ if (fileInputRef.current) fileInputRef.current.value = "";
+ };
+
+ const openEdit = (post: TimelinePost) => {
+ setEditingId(post.id);
+ setEditingImageUrls(post.image_urls || []);
+ setNewTitle(post.title);
+ setNewContent(post.content || "");
+ setSelectedFiles([]);
+ setPreviewUrls([]);
+ setDialogOpen(true);
+ };
+
+ const handleCreate = async () => {
+ if (!newTitle.trim()) return;
+ setSubmitting(true);
+ try {
+ if (editingId) {
+ // Edit mode
+ await putAPI(`/api/timeline/${editingId}`, {
+ title: newTitle,
+ content: newContent || null,
+ });
+ // Upload new images if any
+ if (selectedFiles.length > 0) {
+ const formData = new FormData();
+ for (const f of selectedFiles) formData.append("files", f);
+ await uploadAPI(`/api/timeline/${editingId}/images`, formData);
+ }
+ toast.success("已更新");
+ } else {
+ // Create mode
+ const post: any = await postAPI("/api/timeline/", {
+ title: newTitle,
+ content: newContent || null,
+ class_id: activeClassId,
+ });
+ if (selectedFiles.length > 0) {
+ const formData = new FormData();
+ for (const f of selectedFiles) formData.append("files", f);
+ await uploadAPI(`/api/timeline/${post.id}/images`, formData);
+ }
+ toast.success("发布成功");
+ }
+
+ resetForm();
+ setDialogOpen(false);
+ loadPosts();
+ } catch (err: any) {
+ toast.error(err.message || (editingId ? "更新失败" : "发布失败"));
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ /* ---------- Delete post ---------- */
+ const [deleteTarget, setDeleteTarget] = useState(null);
+
+ const handleDelete = async (id: number) => {
+ try {
+ await deleteAPI(`/api/timeline/${id}`);
+ toast.success("已删除");
+ setDeleteTarget(null);
+ loadPosts();
+ } catch (err: any) {
+ toast.error(err.message || "删除失败");
+ }
+ };
+
+ /* ---------- Like ---------- */
+ const handleLike = async (postId: number) => {
+ try {
+ const res = await postAPI<{ liked: boolean; like_count: number }>(`/api/timeline/${postId}/like`);
+ setPosts((prev) =>
+ prev.map((p) =>
+ p.id === postId
+ ? { ...p, has_liked: res.liked, like_count: res.like_count }
+ : p
+ )
+ );
+ } catch (err: any) {
+ toast.error(err.message || "操作失败");
+ }
+ };
+
+ /* ---------- Comments ---------- */
+ const toggleComments = async (postId: number) => {
+ setExpandedComments((prev) => {
+ const next = new Set(prev);
+ if (next.has(postId)) {
+ next.delete(postId);
+ return next;
+ }
+ next.add(postId);
+ return next;
+ });
+
+ // Fetch comments when expanding (if not already loaded)
+ if (!expandedComments.has(postId)) {
+ try {
+ const res = await fetchAPI(`/api/timeline/${postId}/comments`);
+ const comments = res.items || [];
+ setPosts((prev) =>
+ prev.map((p) =>
+ p.id === postId ? { ...p, comments } : p
+ )
+ );
+ } catch {
+ // Silently fail — user can still try to post a new comment
+ }
+ }
+ };
+
+ const handleAddComment = async (postId: number) => {
+ const content = (commentInputs[postId] || "").trim();
+ if (!content) return;
+ setSubmittingComment((prev) => ({ ...prev, [postId]: true }));
+ try {
+ const newComment = await postAPI(`/api/timeline/${postId}/comments`, { content });
+ setPosts((prev) =>
+ prev.map((p) => {
+ if (p.id !== postId) return p;
+ const comments = [...(p.comments || []), newComment];
+ return { ...p, comments, comment_count: comments.length };
+ })
+ );
+ setCommentInputs((prev) => ({ ...prev, [postId]: "" }));
+ } catch (err: any) {
+ toast.error(err.message || "评论失败");
+ } finally {
+ setSubmittingComment((prev) => ({ ...prev, [postId]: false }));
+ }
+ };
+
+ const handleDeleteComment = async (postId: number, commentId: number) => {
+ try {
+ await deleteAPI(`/api/timeline/comments/${commentId}`);
+ setPosts((prev) =>
+ prev.map((p) => {
+ if (p.id !== postId) return p;
+ const comments = (p.comments || []).filter((c) => c.id !== commentId);
+ return { ...p, comments, comment_count: comments.length };
+ })
+ );
+ toast.success("已删除评论");
+ } catch (err: any) {
+ toast.error(err.message || "删除评论失败");
+ }
+ };
+
+ /* ---------- Permission helpers ---------- */
+ const canEditDelete = (post: TimelinePost): boolean => {
+ if (!user) return false;
+ if (user.role === "class_admin" || user.role === "super_admin") return true;
+ return user.id === post.author_id;
+ };
+
+ const canDeleteComment = (comment: TimelineComment): boolean => {
+ if (!user) return false;
+ if (user.role === "class_admin" || user.role === "super_admin") return true;
+ return user.id === comment.author_id;
+ };
+
+ return (
+
+
+
+
+
+
+ {loading ? (
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+
+ ))}
+
+ ) : error ? (
+
+ ) : posts.length === 0 ? (
+
暂无动态,快来发布第一条吧
+ ) : (
+
+ {posts.map((post) => (
+
+
+
+
+
{post.title}
+
+ {post.author_name} ·{" "}
+ {new Date(post.created_at).toLocaleDateString("zh-CN", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ })}
+
+
+ {canEditDelete(post) && (
+
+
+
+
+ )}
+
+ {post.content && (
+
+ {post.content}
+
+ )}
+ {post.image_urls && post.image_urls.length > 0 && (
+
+ {post.image_urls.map((url, idx) => (
+
openLightbox(post.image_urls!, idx)}
+ >
+

+
+ ))}
+
+ )}
+
+ {/* Action bar */}
+
+ {/* Like button */}
+
+
+ {/* Comment button */}
+
+
+
+ {/* Comment section (expandable) */}
+ {expandedComments.has(post.id) && (
+
+ {/* Existing comments */}
+ {post.comments && post.comments.length > 0 && (
+
+ {post.comments.map((comment) => (
+
+
+
+ {comment.author_name}
+ {relativeTime(comment.created_at)}
+
+
{comment.content}
+
+ {canDeleteComment(comment) && (
+
+ )}
+
+ ))}
+
+ )}
+
+ {/* New comment input */}
+
+
+ setCommentInputs((prev) => ({ ...prev, [post.id]: e.target.value }))
+ }
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleAddComment(post.id);
+ }
+ }}
+ disabled={submittingComment[post.id]}
+ className="flex-1"
+ />
+
+
+
+ )}
+
+
+ ))}
+
+ )}
+
+
+
+ {/* Delete confirmation */}
+
{ if (!open) setDeleteTarget(null); }}
+ title="删除动态"
+ description="确定删除这条动态?此操作不可恢复。"
+ confirmText="删除"
+ variant="destructive"
+ onConfirm={() => deleteTarget && handleDelete(deleteTarget)}
+ />
+
+ {/* Lightbox */}
+ {lightboxImages && (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/app/(app)/votes/page.tsx b/frontend/src/app/(app)/votes/page.tsx
new file mode 100644
index 0000000..8809ad3
--- /dev/null
+++ b/frontend/src/app/(app)/votes/page.tsx
@@ -0,0 +1,623 @@
+"use client";
+
+import { useEffect, useState, useCallback } from "react";
+import { useActiveClass } from "@/hooks/use-active-class";
+import { useAuth } from "@/hooks/use-auth";
+import { fetchAPI, postAPI, putAPI, deleteAPI } from "@/lib/api";
+import { Card, CardContent } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Badge } from "@/components/ui/badge";
+import { Switch } from "@/components/ui/switch";
+import { Label } from "@/components/ui/label";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { ErrorState } from "@/components/error-state";
+import { Pagination } from "@/components/pagination";
+import { toast } from "sonner";
+import type { Vote, VoteOption } from "@/lib/types";
+
+export default function VotesPage() {
+ const { activeClassId } = useActiveClass();
+ const { user } = useAuth();
+
+ // List state
+ const [votes, setVotes] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [page, setPage] = useState(1);
+ const [totalPages, setTotalPages] = useState(1);
+
+ // Create dialog state
+ const [createOpen, setCreateOpen] = useState(false);
+ const [formTitle, setFormTitle] = useState("");
+ const [formDesc, setFormDesc] = useState("");
+ const [formVoteType, setFormVoteType] = useState<"single" | "multiple">("single");
+ const [formAnonymous, setFormAnonymous] = useState(false);
+ const [formMaxChoices, setFormMaxChoices] = useState(2);
+ const [formDeadline, setFormDeadline] = useState("");
+ const [formOptions, setFormOptions] = useState(["", ""]);
+ const [submitting, setSubmitting] = useState(false);
+
+ // Detail dialog state
+ const [detailOpen, setDetailOpen] = useState(false);
+ const [detailVote, setDetailVote] = useState(null);
+ const [detailLoading, setDetailLoading] = useState(false);
+ const [selectedOptions, setSelectedOptions] = useState([]);
+ const [submittingVote, setSubmittingVote] = useState(false);
+
+ // Delete confirm state
+ const [deleteTarget, setDeleteTarget] = useState(null);
+
+ const loadVotes = useCallback(async () => {
+ if (!activeClassId) return;
+ setError(null);
+ setLoading(true);
+ try {
+ const res = await fetchAPI<{
+ items: Vote[];
+ total_pages: number;
+ }>("/api/votes/", {
+ class_id: String(activeClassId),
+ page: String(page),
+ page_size: "10",
+ });
+ setVotes(res.items || []);
+ setTotalPages(res.total_pages || 1);
+ } catch (err: any) {
+ setError(err.message || "加载失败");
+ } finally {
+ setLoading(false);
+ }
+ }, [activeClassId, page]);
+
+ useEffect(() => {
+ loadVotes();
+ }, [loadVotes]);
+
+ // Reset page when class changes
+ useEffect(() => {
+ setPage(1);
+ }, [activeClassId]);
+
+ // ---------- Create ----------
+
+ const resetCreateForm = () => {
+ setFormTitle("");
+ setFormDesc("");
+ setFormVoteType("single");
+ setFormAnonymous(false);
+ setFormMaxChoices(2);
+ setFormDeadline("");
+ setFormOptions(["", ""]);
+ };
+
+ const handleCreate = async () => {
+ if (!formTitle.trim()) {
+ toast.error("请输入投票标题");
+ return;
+ }
+ const validOptions = formOptions.filter((o) => o.trim());
+ if (validOptions.length < 2) {
+ toast.error("至少需要两个选项");
+ return;
+ }
+ if (formVoteType === "multiple" && formMaxChoices < 2) {
+ toast.error("多选投票至少允许选择2项");
+ return;
+ }
+ if (formVoteType === "multiple" && formMaxChoices > validOptions.length) {
+ toast.error("最多可选择数不能超过选项总数");
+ return;
+ }
+ setSubmitting(true);
+ try {
+ await postAPI("/api/votes/", {
+ title: formTitle.trim(),
+ description: formDesc.trim() || null,
+ vote_type: formVoteType,
+ is_anonymous: formAnonymous,
+ max_choices: formVoteType === "multiple" ? formMaxChoices : 1,
+ deadline: formDeadline || null,
+ options: validOptions,
+ class_id: activeClassId,
+ });
+ toast.success("投票已创建");
+ setCreateOpen(false);
+ resetCreateForm();
+ loadVotes();
+ } catch (err: any) {
+ toast.error(err.message || "创建失败");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const addOption = () => setFormOptions([...formOptions, ""]);
+
+ const removeOption = (index: number) => {
+ if (formOptions.length <= 2) return;
+ setFormOptions(formOptions.filter((_, i) => i !== index));
+ };
+
+ const updateOption = (index: number, value: string) => {
+ const updated = [...formOptions];
+ updated[index] = value;
+ setFormOptions(updated);
+ };
+
+ // ---------- Detail ----------
+
+ const openDetail = async (voteId: number) => {
+ setDetailOpen(true);
+ setDetailLoading(true);
+ setSelectedOptions([]);
+ try {
+ const data = await fetchAPI(`/api/votes/${voteId}`);
+ setDetailVote(data);
+ } catch (err: any) {
+ toast.error(err.message || "加载投票详情失败");
+ setDetailOpen(false);
+ } finally {
+ setDetailLoading(false);
+ }
+ };
+
+ const handleSubmitVote = async () => {
+ if (!detailVote) return;
+ if (selectedOptions.length === 0) {
+ toast.error("请至少选择一个选项");
+ return;
+ }
+ if (detailVote.vote_type === "multiple" && selectedOptions.length > detailVote.max_choices) {
+ toast.error(`最多只能选择${detailVote.max_choices}项`);
+ return;
+ }
+ setSubmittingVote(true);
+ try {
+ await postAPI(`/api/votes/${detailVote.id}/submit`, {
+ option_ids: selectedOptions,
+ });
+ toast.success("投票成功");
+ // Refresh detail
+ const data = await fetchAPI(`/api/votes/${detailVote.id}`);
+ setDetailVote(data);
+ setSelectedOptions([]);
+ loadVotes();
+ } catch (err: any) {
+ toast.error(err.message || "投票失败");
+ } finally {
+ setSubmittingVote(false);
+ }
+ };
+
+ const handleCloseVote = async (voteId: number) => {
+ try {
+ await putAPI(`/api/votes/${voteId}/close`);
+ toast.success("投票已关闭");
+ setDetailOpen(false);
+ loadVotes();
+ } catch (err: any) {
+ toast.error(err.message || "关闭失败");
+ }
+ };
+
+ const handleDelete = async (id: number) => {
+ try {
+ await deleteAPI(`/api/votes/${id}`);
+ toast.success("已删除");
+ setDeleteTarget(null);
+ setDetailOpen(false);
+ loadVotes();
+ } catch (err: any) {
+ toast.error(err.message || "删除失败");
+ }
+ };
+
+ const canManage = (vote: Vote) =>
+ user && (user.role === "super_admin" || user.role === "class_admin" || user.id === vote.creator_id);
+
+ // ---------- Render helpers ----------
+
+ const renderStatusBadge = (status: Vote["status"]) => {
+ if (status === "open") {
+ return 进行中;
+ }
+ return 已结束;
+ };
+
+ const formatDeadline = (deadline: string | null) => {
+ if (!deadline) return "无截止时间";
+ return new Date(deadline).toLocaleString("zh-CN", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ };
+
+ const getPercentage = (count: number, total: number) => {
+ if (total === 0) return 0;
+ return Math.round((count / total) * 100);
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+ {/* Loading */}
+ {loading ? (
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+
+ ))}
+
+ ) : error ? (
+
+ ) : votes.length === 0 ? (
+
暂无投票
+ ) : (
+
+ {votes.map((vote) => (
+
openDetail(vote.id)}
+ >
+
+
+
+
+ {renderStatusBadge(vote.status)}
+ {vote.is_anonymous && (
+ 匿名
+ )}
+
+ {vote.vote_type === "single" ? "单选" : "多选"}
+
+
{vote.title}
+
+ {vote.description && (
+
{vote.description}
+ )}
+
+ {vote.creator_name}
+ {formatDeadline(vote.deadline)}
+ {vote.total_voters} 人参与
+
+
+
+ {vote.has_voted ? (
+ 已投票
+ ) : vote.status === "open" ? (
+ 投票
+ ) : null}
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ {/* Detail dialog */}
+
+
+ {/* Delete confirm */}
+
+
+ );
+}
diff --git a/frontend/src/app/favicon.ico b/frontend/src/app/favicon.ico
new file mode 100644
index 0000000..718d6fe
Binary files /dev/null and b/frontend/src/app/favicon.ico differ
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
new file mode 100644
index 0000000..c56032b
--- /dev/null
+++ b/frontend/src/app/globals.css
@@ -0,0 +1,130 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+@import "shadcn/tailwind.css";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --font-sans: var(--font-sans);
+ --font-mono: var(--font-geist-mono);
+ --font-heading: var(--font-sans);
+ --color-sidebar-ring: var(--sidebar-ring);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar: var(--sidebar);
+ --color-chart-5: var(--chart-5);
+ --color-chart-4: var(--chart-4);
+ --color-chart-3: var(--chart-3);
+ --color-chart-2: var(--chart-2);
+ --color-chart-1: var(--chart-1);
+ --color-ring: var(--ring);
+ --color-input: var(--input);
+ --color-border: var(--border);
+ --color-destructive: var(--destructive);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-accent: var(--accent);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-muted: var(--muted);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-secondary: var(--secondary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-primary: var(--primary);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-popover: var(--popover);
+ --color-card-foreground: var(--card-foreground);
+ --color-card: var(--card);
+ --radius-sm: calc(var(--radius) * 0.6);
+ --radius-md: calc(var(--radius) * 0.8);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) * 1.4);
+ --radius-2xl: calc(var(--radius) * 1.8);
+ --radius-3xl: calc(var(--radius) * 2.2);
+ --radius-4xl: calc(var(--radius) * 2.6);
+}
+
+:root {
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --chart-1: oklch(0.87 0 0);
+ --chart-2: oklch(0.556 0 0);
+ --chart-3: oklch(0.439 0 0);
+ --chart-4: oklch(0.371 0 0);
+ --chart-5: oklch(0.269 0 0);
+ --radius: 0.625rem;
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+}
+
+.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.922 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.556 0 0);
+ --chart-1: oklch(0.87 0 0);
+ --chart-2: oklch(0.556 0 0);
+ --chart-3: oklch(0.439 0 0);
+ --chart-4: oklch(0.371 0 0);
+ --chart-5: oklch(0.269 0 0);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.556 0 0);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+ html {
+ @apply font-sans;
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
new file mode 100644
index 0000000..f8ed4ae
--- /dev/null
+++ b/frontend/src/app/layout.tsx
@@ -0,0 +1,43 @@
+import type { Metadata } from "next";
+import { Geist, Geist_Mono } from "next/font/google";
+import { AuthProvider } from "@/hooks/use-auth";
+import { AuthGuard } from "@/components/auth-guard";
+import { Toaster } from "@/components/ui/sonner";
+import "./globals.css";
+
+const geistSans = Geist({
+ variable: "--font-geist-sans",
+ subsets: ["latin"],
+});
+
+const geistMono = Geist_Mono({
+ variable: "--font-geist-mono",
+ subsets: ["latin"],
+});
+
+export const metadata: Metadata = {
+ title: "HKU ICB - 班级资源平台",
+ description: "研究生班级资源连接平台",
+};
+
+export default function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx
new file mode 100644
index 0000000..ff1e95b
--- /dev/null
+++ b/frontend/src/app/login/page.tsx
@@ -0,0 +1,84 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { useAuth } from "@/hooks/use-auth";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import Link from "next/link";
+
+export default function LoginPage() {
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+ const { login } = useAuth();
+ const router = useRouter();
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError("");
+ setLoading(true);
+ try {
+ await login(email, password);
+ router.push("/dashboard");
+ } catch (err: any) {
+ setError(err.message || "登录失败");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ HKU ICB
+ 班级资源平台 - 登录
+
+
+
+
+ 还没有账号?{" "}
+
+ 注册申请
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
new file mode 100644
index 0000000..5558b5b
--- /dev/null
+++ b/frontend/src/app/page.tsx
@@ -0,0 +1,27 @@
+"use client";
+
+import { useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { useAuth } from "@/hooks/use-auth";
+
+export default function Home() {
+ const { user, loading } = useAuth();
+ const router = useRouter();
+
+ useEffect(() => {
+ if (loading) return;
+ if (!user) {
+ router.replace("/login");
+ } else if (user.status === "pending") {
+ router.replace("/pending");
+ } else {
+ router.replace("/dashboard");
+ }
+ }, [user, loading, router]);
+
+ return (
+
+ );
+}
diff --git a/frontend/src/app/pending/page.tsx b/frontend/src/app/pending/page.tsx
new file mode 100644
index 0000000..415e88b
--- /dev/null
+++ b/frontend/src/app/pending/page.tsx
@@ -0,0 +1,37 @@
+import Link from "next/link";
+
+export default function PendingPage() {
+ return (
+
+
+
+
注册审核中
+
+ 你的注册申请已提交,班级管理员正在审核中。
+
+ 审核通过后你将收到邮件通知。
+
+
+ 返回登录
+
+
+
+ );
+}
diff --git a/frontend/src/app/register/page.tsx b/frontend/src/app/register/page.tsx
new file mode 100644
index 0000000..0968062
--- /dev/null
+++ b/frontend/src/app/register/page.tsx
@@ -0,0 +1,152 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { postAPI } from "@/lib/api";
+import Link from "next/link";
+
+export default function RegisterPage() {
+ const [inviteCode, setInviteCode] = useState("");
+ const [studentId, setStudentId] = useState("");
+ const [name, setName] = useState("");
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [confirmPassword, setConfirmPassword] = useState("");
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+ const router = useRouter();
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError("");
+
+ if (password !== confirmPassword) {
+ setError("两次密码输入不一致");
+ return;
+ }
+ if (password.length < 6) {
+ setError("密码至少6位");
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const res = await postAPI("/api/auth/register", {
+ invite_code: inviteCode,
+ student_id: studentId,
+ name,
+ email,
+ password,
+ });
+
+ // Registration returns token — auto-login
+ if (res.token) {
+ localStorage.setItem("token", res.token);
+ localStorage.setItem("user", JSON.stringify(res.user));
+ router.push("/");
+ } else {
+ router.push("/login");
+ }
+ } catch (err: any) {
+ setError(err.message || "注册失败");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/auth-guard.tsx b/frontend/src/components/auth-guard.tsx
new file mode 100644
index 0000000..f5cd880
--- /dev/null
+++ b/frontend/src/components/auth-guard.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import { useEffect } from "react";
+import { usePathname, useRouter } from "next/navigation";
+import { useAuth } from "@/hooks/use-auth";
+
+const PUBLIC_PATHS = ["/login", "/register"];
+
+export function AuthGuard({ children }: { children: React.ReactNode }) {
+ const { user, loading } = useAuth();
+ const pathname = usePathname();
+ const router = useRouter();
+
+ const isPublicPath = PUBLIC_PATHS.some((p) => pathname.startsWith(p));
+
+ useEffect(() => {
+ if (!loading && !user && !isPublicPath) {
+ router.replace("/login");
+ }
+ }, [loading, user, isPublicPath, router]);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (!user && !isPublicPath) return null;
+
+ return <>{children}>;
+}
diff --git a/frontend/src/components/calendar-view.tsx b/frontend/src/components/calendar-view.tsx
new file mode 100644
index 0000000..c9268ea
--- /dev/null
+++ b/frontend/src/components/calendar-view.tsx
@@ -0,0 +1,190 @@
+"use client";
+
+import { useState, useMemo } from "react";
+import { Button } from "@/components/ui/button";
+import type { ScheduleItem } from "@/lib/types";
+import { SCHEDULE_TYPES } from "@/lib/constants";
+
+interface CalendarViewProps {
+ events: ScheduleItem[];
+ onEventClick?: (event: ScheduleItem) => void;
+}
+
+export function CalendarView({ events, onEventClick }: CalendarViewProps) {
+ const [currentDate, setCurrentDate] = useState(new Date());
+ const [selectedDate, setSelectedDate] = useState(null);
+
+ const year = currentDate.getFullYear();
+ const month = currentDate.getMonth();
+
+ // Get events grouped by date
+ const eventsByDate = useMemo(() => {
+ const map = new Map();
+ for (const event of events) {
+ const d = new Date(event.start_time);
+ const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
+ if (!map.has(key)) map.set(key, []);
+ map.get(key)!.push(event);
+ }
+ return map;
+ }, [events]);
+
+ // Calendar grid calculation
+ const firstDay = new Date(year, month, 1);
+ const lastDay = new Date(year, month + 1, 0);
+ const startDayOfWeek = (firstDay.getDay() + 6) % 7; // Monday = 0
+ const daysInMonth = lastDay.getDate();
+
+ const prevMonth = () => setCurrentDate(new Date(year, month - 1, 1));
+ const nextMonth = () => setCurrentDate(new Date(year, month + 1, 1));
+ const goToday = () => setCurrentDate(new Date());
+
+ const today = new Date();
+ const isToday = (d: number) =>
+ year === today.getFullYear() && month === today.getMonth() && d === today.getDate();
+
+ // Selected date events
+ const selectedDateKey = selectedDate
+ ? `${selectedDate.getFullYear()}-${selectedDate.getMonth()}-${selectedDate.getDate()}`
+ : null;
+ const selectedEvents = selectedDateKey ? eventsByDate.get(selectedDateKey) || [] : [];
+
+ const weekDays = ["一", "二", "三", "四", "五", "六", "日"];
+
+ return (
+
+ {/* Navigation */}
+
+
+ {year} 年 {month + 1} 月
+
+
+
+
+
+
+
+
+ {/* Calendar grid */}
+
+ {/* Week day headers */}
+ {weekDays.map((d) => (
+
+ {d}
+
+ ))}
+
+ {/* Leading empty cells */}
+ {Array.from({ length: startDayOfWeek }, (_, i) => (
+
+ ))}
+
+ {/* Day cells */}
+ {Array.from({ length: daysInMonth }, (_, i) => {
+ const day = i + 1;
+ const key = `${year}-${month}-${day}`;
+ const dayEvents = eventsByDate.get(key) || [];
+ const isSelected =
+ selectedDate &&
+ selectedDate.getFullYear() === year &&
+ selectedDate.getMonth() === month &&
+ selectedDate.getDate() === day;
+
+ return (
+
setSelectedDate(new Date(year, month, day))}
+ >
+
+ {day}
+
+
+ {dayEvents.slice(0, 3).map((event, idx) => {
+ const typeInfo = SCHEDULE_TYPES[event.type] || { label: event.type, color: "bg-gray-400" };
+ return (
+
{
+ e.stopPropagation();
+ onEventClick?.(event);
+ }}
+ >
+
+
+ {event.title}
+
+
+ );
+ })}
+ {dayEvents.length > 3 && (
+
+ +{dayEvents.length - 3}
+
+ )}
+
+
+ );
+ })}
+
+ {/* Trailing empty cells */}
+ {Array.from({ length: (7 - (startDayOfWeek + daysInMonth) % 7) % 7 }, (_, i) => (
+
+ ))}
+
+
+ {/* Selected date detail */}
+ {selectedDate && selectedEvents.length > 0 && (
+
+
+ {selectedDate.getMonth() + 1} 月 {selectedDate.getDate()} 日
+
+
+ {selectedEvents.map((event) => {
+ const typeInfo = SCHEDULE_TYPES[event.type] || { label: event.type, color: "bg-gray-400" };
+ return (
+
onEventClick?.(event)}
+ >
+
+
+
+
{event.title}
+
+ {new Date(event.start_time).toLocaleTimeString("zh-CN", {
+ hour: "2-digit",
+ minute: "2-digit",
+ })}
+ {event.location ? ` · ${event.location}` : ""}
+
+
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/confirm-dialog.tsx b/frontend/src/components/confirm-dialog.tsx
new file mode 100644
index 0000000..5bf4b95
--- /dev/null
+++ b/frontend/src/components/confirm-dialog.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+
+interface ConfirmDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ title: string;
+ description: string;
+ confirmText?: string;
+ cancelText?: string;
+ variant?: "default" | "destructive";
+ onConfirm: () => void;
+ loading?: boolean;
+}
+
+export function ConfirmDialog({
+ open,
+ onOpenChange,
+ title,
+ description,
+ confirmText = "确认",
+ cancelText = "取消",
+ variant = "default",
+ onConfirm,
+ loading,
+}: ConfirmDialogProps) {
+ return (
+
+
+
+ {title}
+ {description}
+
+
+ {cancelText}
+
+ {loading ? "处理中..." : confirmText}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/error-state.tsx b/frontend/src/components/error-state.tsx
new file mode 100644
index 0000000..f085432
--- /dev/null
+++ b/frontend/src/components/error-state.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+
+interface ErrorStateProps {
+ message?: string;
+ onRetry?: () => void;
+}
+
+export function ErrorState({ message = "加载失败", onRetry }: ErrorStateProps) {
+ return (
+
+
+
{message}
+ {onRetry && (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/header.tsx b/frontend/src/components/header.tsx
new file mode 100644
index 0000000..31451b6
--- /dev/null
+++ b/frontend/src/components/header.tsx
@@ -0,0 +1,243 @@
+"use client";
+
+import { useState } from "react";
+import { useAuth } from "@/hooks/use-auth";
+import { useActiveClass } from "@/hooks/use-active-class";
+import { useSidebar } from "@/hooks/use-sidebar";
+import { useNotifications } from "@/hooks/use-notifications";
+import { useRouter } from "next/navigation";
+import { putAPI } from "@/lib/api";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { toast } from "sonner";
+
+export function Header() {
+ const { user, logout } = useAuth();
+ const router = useRouter();
+ const { activeClassId, activeClassName, canSwitchClass, availableClasses, setActiveClassId } =
+ useActiveClass();
+ const { toggle } = useSidebar();
+ const { unreadCount, notifications, markRead, markAllRead, refresh } = useNotifications();
+ const [notifOpen, setNotifOpen] = useState(false);
+
+ // Password dialog state
+ const [passwordOpen, setPasswordOpen] = useState(false);
+ const [oldPassword, setOldPassword] = useState("");
+ const [newPassword, setNewPassword] = useState("");
+ const [confirmPassword, setConfirmPassword] = useState("");
+ const [passwordLoading, setPasswordLoading] = useState(false);
+
+ const handleChangePassword = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (newPassword.length < 6) {
+ toast.error("新密码至少 6 位");
+ return;
+ }
+ if (newPassword !== confirmPassword) {
+ toast.error("两次密码输入不一致");
+ return;
+ }
+ setPasswordLoading(true);
+ try {
+ await putAPI("/api/auth/change-password", {
+ old_password: oldPassword,
+ new_password: newPassword,
+ });
+ setOldPassword("");
+ setNewPassword("");
+ setConfirmPassword("");
+ setPasswordOpen(false);
+ toast.success("密码已修改");
+ } catch (err: any) {
+ toast.error(err.message || "修改密码失败");
+ } finally {
+ setPasswordLoading(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/pagination.tsx b/frontend/src/components/pagination.tsx
new file mode 100644
index 0000000..4aea182
--- /dev/null
+++ b/frontend/src/components/pagination.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+
+interface PaginationProps {
+ page: number;
+ totalPages: number;
+ onPageChange: (page: number) => void;
+}
+
+export function Pagination({ page, totalPages, onPageChange }: PaginationProps) {
+ if (totalPages <= 1) return null;
+
+ const getPages = () => {
+ const pages: (number | "...")[] = [];
+ if (totalPages <= 7) {
+ for (let i = 1; i <= totalPages; i++) pages.push(i);
+ } else {
+ pages.push(1);
+ if (page > 3) pages.push("...");
+ const start = Math.max(2, page - 1);
+ const end = Math.min(totalPages - 1, page + 1);
+ for (let i = start; i <= end; i++) pages.push(i);
+ if (page < totalPages - 2) pages.push("...");
+ pages.push(totalPages);
+ }
+ return pages;
+ };
+
+ return (
+
+
+ {getPages().map((p, i) =>
+ p === "..." ? (
+
+ ...
+
+ ) : (
+
+ )
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/role-guard.tsx b/frontend/src/components/role-guard.tsx
new file mode 100644
index 0000000..2c81ef4
--- /dev/null
+++ b/frontend/src/components/role-guard.tsx
@@ -0,0 +1,16 @@
+"use client";
+
+import { useAuth } from "@/hooks/use-auth";
+import type { UserRole } from "@/lib/types";
+
+interface RoleGuardProps {
+ roles: UserRole[];
+ children: React.ReactNode;
+ fallback?: React.ReactNode;
+}
+
+export function RoleGuard({ roles, children, fallback = null }: RoleGuardProps) {
+ const { user } = useAuth();
+ if (!user || !roles.includes(user.role)) return <>{fallback}>;
+ return <>{children}>;
+}
diff --git a/frontend/src/components/sidebar.tsx b/frontend/src/components/sidebar.tsx
new file mode 100644
index 0000000..6b197f3
--- /dev/null
+++ b/frontend/src/components/sidebar.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { useSidebar } from "@/hooks/use-sidebar";
+import { RoleGuard } from "@/components/role-guard";
+import { cn } from "@/lib/utils";
+
+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" },
+ { 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" },
+ { 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" },
+ { href: "/timeline", label: "班级动态", icon: "M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" },
+ { 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" },
+ { 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" },
+ { href: "/schedule", label: "排期表", icon: "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" },
+ { href: "/resources", label: "资源库", icon: "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" },
+ { href: "/profile", label: "个人资料", icon: "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" },
+];
+
+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" },
+ { 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" },
+];
+
+export function Sidebar() {
+ const pathname = usePathname();
+ const { isOpen, close } = useSidebar();
+
+ return (
+ <>
+ {/* Mobile backdrop */}
+ {isOpen && (
+
+ )}
+
+
+ >
+ );
+}
diff --git a/frontend/src/components/ui/alert-dialog.tsx b/frontend/src/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..0ee2c5f
--- /dev/null
+++ b/frontend/src/components/ui/alert-dialog.tsx
@@ -0,0 +1,187 @@
+"use client"
+
+import * as React from "react"
+import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+
+function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
+ return
+}
+
+function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
+ return (
+
+ )
+}
+
+function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
+ return (
+
+ )
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: AlertDialogPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function AlertDialogContent({
+ className,
+ size = "default",
+ ...props
+}: AlertDialogPrimitive.Popup.Props & {
+ size?: "default" | "sm"
+}) {
+ return (
+
+
+
+
+ )
+}
+
+function AlertDialogHeader({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogFooter({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogMedia({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogAction({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogCancel({
+ className,
+ variant = "outline",
+ size = "default",
+ ...props
+}: AlertDialogPrimitive.Close.Props &
+ Pick, "variant" | "size">) {
+ return (
+ }
+ {...props}
+ />
+ )
+}
+
+export {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogMedia,
+ AlertDialogOverlay,
+ AlertDialogPortal,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+}
diff --git a/frontend/src/components/ui/avatar.tsx b/frontend/src/components/ui/avatar.tsx
new file mode 100644
index 0000000..e4fed86
--- /dev/null
+++ b/frontend/src/components/ui/avatar.tsx
@@ -0,0 +1,109 @@
+"use client"
+
+import * as React from "react"
+import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
+
+import { cn } from "@/lib/utils"
+
+function Avatar({
+ className,
+ size = "default",
+ ...props
+}: AvatarPrimitive.Root.Props & {
+ size?: "default" | "sm" | "lg"
+}) {
+ return (
+
+ )
+}
+
+function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
+ return (
+
+ )
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: AvatarPrimitive.Fallback.Props) {
+ return (
+
+ )
+}
+
+function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+ svg]:hidden",
+ "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
+ "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AvatarGroupCount({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+ svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+export {
+ Avatar,
+ AvatarImage,
+ AvatarFallback,
+ AvatarGroup,
+ AvatarGroupCount,
+ AvatarBadge,
+}
diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx
new file mode 100644
index 0000000..b20959d
--- /dev/null
+++ b/frontend/src/components/ui/badge.tsx
@@ -0,0 +1,52 @@
+import { mergeProps } from "@base-ui/react/merge-props"
+import { useRender } from "@base-ui/react/use-render"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+ secondary:
+ "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
+ destructive:
+ "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
+ outline:
+ "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
+ ghost:
+ "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Badge({
+ className,
+ variant = "default",
+ render,
+ ...props
+}: useRender.ComponentProps<"span"> & VariantProps
) {
+ return useRender({
+ defaultTagName: "span",
+ props: mergeProps<"span">(
+ {
+ className: cn(badgeVariants({ variant }), className),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "badge",
+ variant,
+ },
+ })
+}
+
+export { Badge, badgeVariants }
diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx
new file mode 100644
index 0000000..09df753
--- /dev/null
+++ b/frontend/src/components/ui/button.tsx
@@ -0,0 +1,58 @@
+import { Button as ButtonPrimitive } from "@base-ui/react/button"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+ outline:
+ "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
+ ghost:
+ "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
+ destructive:
+ "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default:
+ "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
+ sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
+ lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ icon: "size-8",
+ "icon-xs":
+ "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
+ "icon-sm":
+ "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
+ "icon-lg": "size-9",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function Button({
+ className,
+ variant = "default",
+ size = "default",
+ ...props
+}: ButtonPrimitive.Props & VariantProps) {
+ return (
+
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx
new file mode 100644
index 0000000..40cac5f
--- /dev/null
+++ b/frontend/src/components/ui/card.tsx
@@ -0,0 +1,103 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Card({
+ className,
+ size = "default",
+ ...props
+}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
+ return (
+ 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",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..014f5aa
--- /dev/null
+++ b/frontend/src/components/ui/dialog.tsx
@@ -0,0 +1,160 @@
+"use client"
+
+import * as React from "react"
+import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Dialog({ ...props }: DialogPrimitive.Root.Props) {
+ return
+}
+
+function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
+ return
+}
+
+function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
+ return
+}
+
+function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: DialogPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: DialogPrimitive.Popup.Props & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+ }
+ >
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+ }>
+ Close
+
+ )}
+
+ )
+}
+
+function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: DialogPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..9d5ebbd
--- /dev/null
+++ b/frontend/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,268 @@
+"use client"
+
+import * as React from "react"
+import { Menu as MenuPrimitive } from "@base-ui/react/menu"
+
+import { cn } from "@/lib/utils"
+import { ChevronRightIcon, CheckIcon } from "lucide-react"
+
+function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
+ return
+}
+
+function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
+ return
+}
+
+function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
+ return
+}
+
+function DropdownMenuContent({
+ align = "start",
+ alignOffset = 0,
+ side = "bottom",
+ sideOffset = 4,
+ className,
+ ...props
+}: MenuPrimitive.Popup.Props &
+ Pick<
+ MenuPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+
+
+ )
+}
+
+function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
+ return
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: MenuPrimitive.GroupLabel.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: MenuPrimitive.Item.Props & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
+ return
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: MenuPrimitive.SubmenuTrigger.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function DropdownMenuSubContent({
+ align = "start",
+ alignOffset = -3,
+ side = "right",
+ sideOffset = 0,
+ className,
+ ...props
+}: React.ComponentProps
) {
+ return (
+
+ )
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: MenuPrimitive.CheckboxItem.Props & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
+ return (
+
+ )
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: MenuPrimitive.RadioItem.Props & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: MenuPrimitive.Separator.Props) {
+ return (
+
+ )
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+}
diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx
new file mode 100644
index 0000000..7d21bab
--- /dev/null
+++ b/frontend/src/components/ui/input.tsx
@@ -0,0 +1,20 @@
+import * as React from "react"
+import { Input as InputPrimitive } from "@base-ui/react/input"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/frontend/src/components/ui/label.tsx b/frontend/src/components/ui/label.tsx
new file mode 100644
index 0000000..74da65c
--- /dev/null
+++ b/frontend/src/components/ui/label.tsx
@@ -0,0 +1,20 @@
+"use client"
+
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Label({ className, ...props }: React.ComponentProps<"label">) {
+ return (
+
+ )
+}
+
+export { Label }
diff --git a/frontend/src/components/ui/popover.tsx b/frontend/src/components/ui/popover.tsx
new file mode 100644
index 0000000..4dc28c3
--- /dev/null
+++ b/frontend/src/components/ui/popover.tsx
@@ -0,0 +1,90 @@
+"use client"
+
+import * as React from "react"
+import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
+
+import { cn } from "@/lib/utils"
+
+function Popover({ ...props }: PopoverPrimitive.Root.Props) {
+ return
+}
+
+function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
+ return
+}
+
+function PopoverContent({
+ className,
+ align = "center",
+ alignOffset = 0,
+ side = "bottom",
+ sideOffset = 4,
+ ...props
+}: PopoverPrimitive.Popup.Props &
+ Pick<
+ PopoverPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+
+
+ )
+}
+
+function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function PopoverDescription({
+ className,
+ ...props
+}: PopoverPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Popover,
+ PopoverContent,
+ PopoverDescription,
+ PopoverHeader,
+ PopoverTitle,
+ PopoverTrigger,
+}
diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx
new file mode 100644
index 0000000..e8021f5
--- /dev/null
+++ b/frontend/src/components/ui/select.tsx
@@ -0,0 +1,201 @@
+"use client"
+
+import * as React from "react"
+import { Select as SelectPrimitive } from "@base-ui/react/select"
+
+import { cn } from "@/lib/utils"
+import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
+
+const Select = SelectPrimitive.Root
+
+function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
+ return (
+
+ )
+}
+
+function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
+ return (
+
+ )
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: SelectPrimitive.Trigger.Props & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+ {children}
+
+ }
+ />
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ side = "bottom",
+ sideOffset = 4,
+ align = "center",
+ alignOffset = 0,
+ alignItemWithTrigger = true,
+ ...props
+}: SelectPrimitive.Popup.Props &
+ Pick<
+ SelectPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
+ >) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: SelectPrimitive.GroupLabel.Props) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: SelectPrimitive.Item.Props) {
+ return (
+
+
+ {children}
+
+
+ }
+ >
+
+
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: SelectPrimitive.Separator.Props) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/frontend/src/components/ui/separator.tsx b/frontend/src/components/ui/separator.tsx
new file mode 100644
index 0000000..6e1369e
--- /dev/null
+++ b/frontend/src/components/ui/separator.tsx
@@ -0,0 +1,25 @@
+"use client"
+
+import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
+
+import { cn } from "@/lib/utils"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ ...props
+}: SeparatorPrimitive.Props) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/frontend/src/components/ui/sheet.tsx b/frontend/src/components/ui/sheet.tsx
new file mode 100644
index 0000000..78c0a76
--- /dev/null
+++ b/frontend/src/components/ui/sheet.tsx
@@ -0,0 +1,138 @@
+"use client"
+
+import * as React from "react"
+import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Sheet({ ...props }: SheetPrimitive.Root.Props) {
+ return
+}
+
+function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
+ return
+}
+
+function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
+ return
+}
+
+function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
+ return
+}
+
+function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function SheetContent({
+ className,
+ children,
+ side = "right",
+ showCloseButton = true,
+ ...props
+}: SheetPrimitive.Popup.Props & {
+ side?: "top" | "right" | "bottom" | "left"
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+ }
+ >
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function SheetDescription({
+ className,
+ ...props
+}: SheetPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Sheet,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+}
diff --git a/frontend/src/components/ui/skeleton.tsx b/frontend/src/components/ui/skeleton.tsx
new file mode 100644
index 0000000..0118624
--- /dev/null
+++ b/frontend/src/components/ui/skeleton.tsx
@@ -0,0 +1,13 @@
+import { cn } from "@/lib/utils"
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Skeleton }
diff --git a/frontend/src/components/ui/sonner.tsx b/frontend/src/components/ui/sonner.tsx
new file mode 100644
index 0000000..9280ee5
--- /dev/null
+++ b/frontend/src/components/ui/sonner.tsx
@@ -0,0 +1,49 @@
+"use client"
+
+import { useTheme } from "next-themes"
+import { Toaster as Sonner, type ToasterProps } from "sonner"
+import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const { theme = "system" } = useTheme()
+
+ return (
+
+ ),
+ info: (
+
+ ),
+ warning: (
+
+ ),
+ error: (
+
+ ),
+ loading: (
+
+ ),
+ }}
+ style={
+ {
+ "--normal-bg": "var(--popover)",
+ "--normal-text": "var(--popover-foreground)",
+ "--normal-border": "var(--border)",
+ "--border-radius": "var(--radius)",
+ } as React.CSSProperties
+ }
+ toastOptions={{
+ classNames: {
+ toast: "cn-toast",
+ },
+ }}
+ {...props}
+ />
+ )
+}
+
+export { Toaster }
diff --git a/frontend/src/components/ui/switch.tsx b/frontend/src/components/ui/switch.tsx
new file mode 100644
index 0000000..9b8b44b
--- /dev/null
+++ b/frontend/src/components/ui/switch.tsx
@@ -0,0 +1,32 @@
+"use client"
+
+import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
+
+import { cn } from "@/lib/utils"
+
+function Switch({
+ className,
+ size = "default",
+ ...props
+}: SwitchPrimitive.Root.Props & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+
+
+ )
+}
+
+export { Switch }
diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..8ee8054
--- /dev/null
+++ b/frontend/src/components/ui/tabs.tsx
@@ -0,0 +1,82 @@
+"use client"
+
+import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+function Tabs({
+ className,
+ orientation = "horizontal",
+ ...props
+}: TabsPrimitive.Root.Props) {
+ return (
+
+ )
+}
+
+const tabsListVariants = cva(
+ "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
+ {
+ variants: {
+ variant: {
+ default: "bg-muted",
+ line: "gap-1 bg-transparent",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function TabsList({
+ className,
+ variant = "default",
+ ...props
+}: TabsPrimitive.List.Props & VariantProps) {
+ return (
+
+ )
+}
+
+function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
+ return (
+
+ )
+}
+
+function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
+ return (
+
+ )
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
diff --git a/frontend/src/components/ui/textarea.tsx b/frontend/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..04d27f7
--- /dev/null
+++ b/frontend/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/frontend/src/hooks/use-active-class.tsx b/frontend/src/hooks/use-active-class.tsx
new file mode 100644
index 0000000..97249c4
--- /dev/null
+++ b/frontend/src/hooks/use-active-class.tsx
@@ -0,0 +1,102 @@
+"use client";
+
+import {
+ createContext,
+ useContext,
+ useState,
+ useEffect,
+ useCallback,
+ type ReactNode,
+} from "react";
+import { useAuth } from "@/hooks/use-auth";
+import { fetchAPI } from "@/lib/api";
+import type { ClassInfo } from "@/lib/types";
+
+interface ActiveClassContextValue {
+ /** The class ID to use for all data queries */
+ activeClassId: number | null;
+ /** The name of the active class */
+ activeClassName: string | null;
+ /** Whether the user can switch classes (super admin) */
+ canSwitchClass: boolean;
+ /** All available classes (only loaded for super admin) */
+ availableClasses: ClassInfo[];
+ /** Switch active class (super admin only) */
+ setActiveClassId: (id: number) => void;
+}
+
+const ActiveClassContext = createContext({
+ activeClassId: null,
+ activeClassName: null,
+ canSwitchClass: false,
+ availableClasses: [],
+ setActiveClassId: () => {},
+});
+
+export function ActiveClassProvider({ children }: { children: ReactNode }) {
+ const { user } = useAuth();
+ const [availableClasses, setAvailableClasses] = useState([]);
+ const [selectedClassId, setSelectedClassId] = useState(null);
+ const [userClassName, setUserClassName] = useState(null);
+
+ const isSuperAdmin = user?.role === "super_admin";
+ // For non-super-admin, use their own class_id
+ const activeClassId = isSuperAdmin ? selectedClassId : user?.class_id ?? null;
+
+ // Super admin: derive class name from availableClasses
+ const superAdminClassName = isSuperAdmin && activeClassId
+ ? availableClasses.find((c) => c.id === activeClassId)?.name ?? null
+ : null;
+ const activeClassName = isSuperAdmin ? superAdminClassName : userClassName;
+
+ // Super admin: load all classes and auto-select first
+ useEffect(() => {
+ if (!isSuperAdmin) return;
+ fetchAPI("/api/classes/").then((res) => {
+ const items = res.items || [];
+ setAvailableClasses(items);
+ // Restore from localStorage or pick first
+ const saved = localStorage.getItem("active_class_id");
+ const parsed = saved ? parseInt(saved) : null;
+ const valid = parsed && items.some((c: ClassInfo) => c.id === parsed);
+ if (valid) {
+ setSelectedClassId(parsed);
+ } else if (items.length > 0) {
+ setSelectedClassId(items[0].id);
+ }
+ });
+ }, [isSuperAdmin]);
+
+ // Non-super-admin: fetch class name once
+ useEffect(() => {
+ if (isSuperAdmin || !user?.class_id) return;
+ fetchAPI("/api/classes/").then((res) => {
+ const items = res.items || [];
+ const cls = items.find((c: ClassInfo) => c.id === user.class_id);
+ setUserClassName(cls?.name ?? null);
+ }).catch(() => {});
+ }, [isSuperAdmin, user?.class_id]);
+
+ const setActiveClassId = useCallback((id: number) => {
+ setSelectedClassId(id);
+ localStorage.setItem("active_class_id", String(id));
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useActiveClass() {
+ return useContext(ActiveClassContext);
+}
diff --git a/frontend/src/hooks/use-auth.tsx b/frontend/src/hooks/use-auth.tsx
new file mode 100644
index 0000000..c3cce73
--- /dev/null
+++ b/frontend/src/hooks/use-auth.tsx
@@ -0,0 +1,85 @@
+"use client";
+
+import {
+ createContext,
+ useContext,
+ useState,
+ useEffect,
+ useCallback,
+ type ReactNode,
+} from "react";
+import { type AuthUser } from "@/lib/types";
+import { postAPI, fetchAPI } from "@/lib/api";
+import type { LoginResponse } from "@/lib/types";
+
+interface AuthContextValue {
+ user: AuthUser | null;
+ loading: boolean;
+ login: (email: string, password: string) => Promise;
+ logout: () => void;
+ refreshUser: () => Promise;
+}
+
+const AuthContext = createContext({
+ user: null,
+ loading: true,
+ login: async () => {},
+ logout: () => {},
+ refreshUser: async () => {},
+});
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const [user, setUser] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ const token = localStorage.getItem("auth_token");
+ const storedUser = localStorage.getItem("auth_user");
+ if (token && storedUser) {
+ try {
+ setUser(JSON.parse(storedUser));
+ } catch {
+ localStorage.removeItem("auth_token");
+ localStorage.removeItem("auth_user");
+ }
+ }
+ setLoading(false);
+ }, []);
+
+ const login = useCallback(async (email: string, password: string) => {
+ const res = await postAPI("/api/auth/login", {
+ email,
+ password,
+ });
+ localStorage.setItem("auth_token", res.token);
+ localStorage.setItem("auth_user", JSON.stringify(res.user));
+ setUser(res.user);
+ }, []);
+
+ const logout = useCallback(() => {
+ localStorage.removeItem("auth_token");
+ localStorage.removeItem("auth_user");
+ setUser(null);
+ window.location.href = "/login";
+ }, []);
+
+ const refreshUser = useCallback(async () => {
+ try {
+ const userData = await fetchAPI("/api/auth/me");
+ localStorage.setItem("auth_user", JSON.stringify(userData));
+ setUser(userData);
+ } catch {
+ // Token might be invalid
+ }
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useAuth() {
+ return useContext(AuthContext);
+}
diff --git a/frontend/src/hooks/use-notifications.tsx b/frontend/src/hooks/use-notifications.tsx
new file mode 100644
index 0000000..ba397fc
--- /dev/null
+++ b/frontend/src/hooks/use-notifications.tsx
@@ -0,0 +1,94 @@
+"use client";
+
+import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
+import { useAuth } from "@/hooks/use-auth";
+import { fetchAPI, putAPI } from "@/lib/api";
+import type { NotificationItem } from "@/lib/types";
+
+interface NotificationContextType {
+ unreadCount: number;
+ notifications: NotificationItem[];
+ markRead: (id: number) => Promise;
+ markAllRead: () => Promise;
+ refresh: () => void;
+}
+
+const NotificationContext = createContext({
+ unreadCount: 0,
+ notifications: [],
+ markRead: async () => {},
+ markAllRead: async () => {},
+ refresh: () => {},
+});
+
+export function NotificationProvider({ children }: { children: ReactNode }) {
+ const { user } = useAuth();
+ const [unreadCount, setUnreadCount] = useState(0);
+ const [notifications, setNotifications] = useState([]);
+
+ const fetchUnreadCount = useCallback(async () => {
+ if (!user) return;
+ try {
+ const res = await fetchAPI<{ count: number }>("/api/notifications/unread-count");
+ setUnreadCount(res.count);
+ } catch {
+ // ignore
+ }
+ }, [user]);
+
+ const fetchNotifications = useCallback(async () => {
+ if (!user) return;
+ try {
+ const res = await fetchAPI("/api/notifications/", { page_size: "10" });
+ setNotifications(res.items || []);
+ } catch {
+ // ignore
+ }
+ }, [user]);
+
+ const refresh = useCallback(() => {
+ fetchUnreadCount();
+ fetchNotifications();
+ }, [fetchUnreadCount, fetchNotifications]);
+
+ useEffect(() => {
+ if (!user) return;
+ fetchUnreadCount();
+ const interval = setInterval(fetchUnreadCount, 30000);
+ return () => clearInterval(interval);
+ }, [user, fetchUnreadCount]);
+
+ const markRead = useCallback(async (id: number) => {
+ try {
+ await putAPI(`/api/notifications/${id}/read`);
+ setNotifications((prev) =>
+ prev.map((n) => (n.id === id ? { ...n, is_read: true } : n))
+ );
+ setUnreadCount((c) => Math.max(0, c - 1));
+ } catch {
+ // ignore
+ }
+ }, []);
+
+ const markAllRead = useCallback(async () => {
+ try {
+ await putAPI("/api/notifications/read-all");
+ setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true })));
+ setUnreadCount(0);
+ } catch {
+ // ignore
+ }
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useNotifications() {
+ return useContext(NotificationContext);
+}
diff --git a/frontend/src/hooks/use-sidebar.tsx b/frontend/src/hooks/use-sidebar.tsx
new file mode 100644
index 0000000..580bd29
--- /dev/null
+++ b/frontend/src/hooks/use-sidebar.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
+
+interface SidebarContextValue {
+ isOpen: boolean;
+ toggle: () => void;
+ close: () => void;
+}
+
+const SidebarContext = createContext({
+ isOpen: false,
+ toggle: () => {},
+ close: () => {},
+});
+
+export function SidebarProvider({ children }: { children: ReactNode }) {
+ const [isOpen, setIsOpen] = useState(false);
+ const toggle = useCallback(() => setIsOpen((v) => !v), []);
+ const close = useCallback(() => setIsOpen(false), []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useSidebar() {
+ return useContext(SidebarContext);
+}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
new file mode 100644
index 0000000..ec31711
--- /dev/null
+++ b/frontend/src/lib/api.ts
@@ -0,0 +1,97 @@
+const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
+
+function getToken(): string | null {
+ if (typeof window === "undefined") return null;
+ return localStorage.getItem("auth_token");
+}
+
+function handleUnauthorized() {
+ if (typeof window === "undefined") return;
+ localStorage.removeItem("auth_token");
+ localStorage.removeItem("auth_user");
+ window.location.href = "/login";
+}
+
+async function request(
+ path: string,
+ options: RequestInit = {}
+): Promise {
+ const token = getToken();
+ const headers: Record = {
+ ...(options.headers as Record),
+ };
+ if (token) headers["Authorization"] = `Bearer ${token}`;
+
+ const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
+
+ if (res.status === 401) {
+ handleUnauthorized();
+ throw new Error("Unauthorized");
+ }
+
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.detail || `API error: ${res.status}`);
+ }
+
+ return res.json();
+}
+
+export async function fetchAPI(
+ path: string,
+ params?: Record
+): Promise {
+ let url = path;
+ if (params) {
+ const qs = new URLSearchParams(params).toString();
+ url += `?${qs}`;
+ }
+ return request(url);
+}
+
+export async function postAPI(path: string, body?: unknown): Promise {
+ return request(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: body ? JSON.stringify(body) : undefined,
+ });
+}
+
+export async function putAPI(path: string, body?: unknown): Promise {
+ return request(path, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: body ? JSON.stringify(body) : undefined,
+ });
+}
+
+export async function deleteAPI(path: string): Promise {
+ return request(path, { method: "DELETE" });
+}
+
+export async function uploadAPI(
+ path: string,
+ formData: FormData
+): Promise {
+ const token = getToken();
+ const headers: Record = {};
+ if (token) headers["Authorization"] = `Bearer ${token}`;
+
+ const res = await fetch(`${API_BASE}${path}`, {
+ method: "POST",
+ headers,
+ body: formData,
+ });
+
+ if (res.status === 401) {
+ handleUnauthorized();
+ throw new Error("Unauthorized");
+ }
+
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.detail || `API error: ${res.status}`);
+ }
+
+ return res.json();
+}
diff --git a/frontend/src/lib/constants.ts b/frontend/src/lib/constants.ts
new file mode 100644
index 0000000..0278db8
--- /dev/null
+++ b/frontend/src/lib/constants.ts
@@ -0,0 +1,33 @@
+export const ROLES = {
+ super_admin: "超级管理员",
+ class_admin: "班级管理员",
+ student: "同学",
+} as const;
+
+export const USER_STATUS = {
+ pending: "待审核",
+ approved: "已通过",
+ rejected: "已拒绝",
+ disabled: "已禁用",
+} as const;
+
+export const SCHEDULE_TYPES = {
+ course: { label: "课程", color: "bg-blue-500" },
+ deadline: { label: "截止日", color: "bg-red-500" },
+ activity: { label: "活动", color: "bg-green-500" },
+} as const;
+
+export const INDUSTRY_OPTIONS = [
+ "金融",
+ "科技",
+ "咨询",
+ "医疗健康",
+ "教育",
+ "房地产",
+ "制造业",
+ "消费零售",
+ "能源",
+ "传媒",
+ "政府/公共事业",
+ "其他",
+];
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
new file mode 100644
index 0000000..c60d0a8
--- /dev/null
+++ b/frontend/src/lib/types.ts
@@ -0,0 +1,217 @@
+export type UserRole = "super_admin" | "class_admin" | "student";
+export type UserStatus = "pending" | "approved" | "rejected" | "disabled";
+export type ScheduleType = "course" | "deadline" | "activity";
+
+export interface AuthUser {
+ id: number;
+ email: string;
+ name: string;
+ student_id: string | null;
+ role: UserRole;
+ status: UserStatus;
+ class_id: number | null;
+ industry: string | null;
+ company: string | null;
+ position: string | null;
+ skills_tags: string[] | null;
+ wechat_id: string | null;
+ phone: string | null;
+ avatar_url: string | null;
+ bio: string | null;
+ created_at: string;
+}
+
+export interface LoginResponse {
+ token: string;
+ user: AuthUser;
+}
+
+export interface ClassInfo {
+ id: number;
+ name: string;
+ cohort_year: number;
+ description: string | null;
+ invite_code: string | null;
+ member_count: number;
+ created_at: string;
+}
+
+export interface UserPublic {
+ id: number;
+ name: string;
+ student_id: string | null;
+ industry: string | null;
+ company: string | null;
+ position: string | null;
+ wechat_id: string | null;
+ phone: string | null;
+ avatar_url: string | null;
+ bio: string | null;
+}
+
+export interface UserListItem {
+ id: number;
+ email: string;
+ name: string;
+ student_id: string | null;
+ role: UserRole;
+ status: UserStatus;
+ class_id: number | null;
+ industry: string | null;
+ company: string | null;
+ created_at: string;
+}
+
+export interface TimelineComment {
+ id: number;
+ post_id: number;
+ author_id: number;
+ author_name: string;
+ content: string;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface TimelinePost {
+ id: number;
+ class_id: number;
+ author_id: number;
+ author_name: string;
+ title: string;
+ content: string | null;
+ image_urls: string[] | null;
+ like_count: number;
+ has_liked: boolean;
+ comment_count: number;
+ comments: TimelineComment[] | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface ScheduleItem {
+ id: number;
+ class_id: number;
+ type: ScheduleType;
+ title: string;
+ start_time: string;
+ end_time: string | null;
+ location: string | null;
+ description: string | null;
+ created_at: string;
+}
+
+export interface PageResponse {
+ items: T[];
+ total: number;
+ page: number;
+ page_size: number;
+ total_pages: number;
+}
+
+export interface Announcement {
+ id: number;
+ class_id: number;
+ author_id: number;
+ author_name: string;
+ title: string;
+ content: string | null;
+ is_pinned: boolean;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface Resource {
+ id: number;
+ class_id: number;
+ uploader_id: number;
+ uploader_name: string;
+ title: string;
+ description: string | null;
+ file_url: string;
+ file_type: string;
+ file_size: number;
+ category: string;
+ download_count: number;
+ created_at: string;
+}
+
+export interface NotificationItem {
+ id: number;
+ type: string;
+ title: string;
+ content: string | null;
+ related_id: number | null;
+ is_read: boolean;
+ created_at: string;
+}
+
+export interface RosterEntry {
+ id: number;
+ student_id: string;
+ name: string;
+ status: "unregistered" | "registered";
+ user_id: number | null;
+}
+
+export interface VoteOption {
+ id: number;
+ content: string;
+ sort_order: number;
+ vote_count: number;
+ voter_names: string[] | null;
+}
+
+export interface Vote {
+ id: number;
+ class_id: number;
+ creator_id: number;
+ creator_name: string;
+ title: string;
+ description: string | null;
+ vote_type: "single" | "multiple";
+ is_anonymous: boolean;
+ max_choices: number;
+ deadline: string | null;
+ status: "open" | "closed";
+ total_voters: number;
+ has_voted: boolean;
+ my_option_ids: number[] | null;
+ options: VoteOption[];
+ created_at: string;
+ updated_at: string;
+}
+
+export interface AssignmentSubmission {
+ id: number;
+ assignment_id: number;
+ student_id: number;
+ student_name: string;
+ notes: string | null;
+ file_url: string | null;
+ file_name: string | null;
+ file_type: string | null;
+ file_size: number | null;
+ grade: string | null;
+ feedback: string | null;
+ graded_at: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface Assignment {
+ id: number;
+ class_id: number;
+ creator_id: number;
+ creator_name: string;
+ title: string;
+ description: string | null;
+ deadline: string | null;
+ attachment_urls: string[] | null;
+ status: "open" | "closed";
+ submission_count: number;
+ total_members: number;
+ my_submitted: boolean;
+ created_at: string;
+ updated_at: string;
+ submissions?: AssignmentSubmission[];
+}
diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts
new file mode 100644
index 0000000..bd0c391
--- /dev/null
+++ b/frontend/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..cf9c65d
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,34 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts",
+ "**/*.mts"
+ ],
+ "exclude": ["node_modules"]
+}
diff --git a/nginx/nginx.conf b/nginx/nginx.conf
new file mode 100644
index 0000000..488b161
--- /dev/null
+++ b/nginx/nginx.conf
@@ -0,0 +1,29 @@
+server {
+ listen 80;
+ server_name _;
+
+ client_max_body_size 20m;
+
+ # API requests → backend
+ location /api/ {
+ proxy_pass http://backend:8000;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ # Frontend
+ location / {
+ proxy_pass http://frontend:3000;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # Next.js HMR (hot module replacement) WebSocket
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ }
+}