first commit

This commit is contained in:
aaron 2026-04-12 18:15:38 +08:00
commit f4aae08b83
134 changed files with 13081 additions and 0 deletions

View File

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

41
.gitignore vendored Normal file
View File

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

10
backend/.dockerignore Normal file
View File

@ -0,0 +1,10 @@
.venv/
__pycache__/
*.pyc
*.pyo
.env
*.db
.git
.gitignore
*.md
alembic/versions/

33
backend/.env.example Normal file
View File

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

17
backend/Dockerfile Normal file
View File

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

36
backend/alembic.ini Normal file
View File

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

57
backend/alembic/env.py Normal file
View File

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

View File

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

0
backend/app/__init__.py Normal file
View File

View File

View File

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

View File

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

93
backend/app/api/auth.py Normal file
View File

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

270
backend/app/api/classes.py Normal file
View File

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

View File

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

View File

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

View File

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

104
backend/app/api/schedule.py Normal file
View File

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

262
backend/app/api/timeline.py Normal file
View File

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

24
backend/app/api/upload.py Normal file
View File

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

123
backend/app/api/users.py Normal file
View File

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

177
backend/app/api/votes.py Normal file
View File

@ -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": "投票已删除"}

43
backend/app/config.py Normal file
View File

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

View File

31
backend/app/core/auth.py Normal file
View File

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

57
backend/app/core/deps.py Normal file
View File

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

View File

5
backend/app/db/base.py Normal file
View File

@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

View File

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

416
backend/app/db/models.py Normal file
View File

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

99
backend/app/main.py Normal file
View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

@ -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"<p>{content_preview}</p>" 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

View File

@ -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"<p>{data.description or data.title}</p>",
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())

View File

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

View File

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

View File

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

View File

@ -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"""
<h2>New Registration Pending Approval</h2>
<p><strong>{student_name}</strong> has registered for <strong>{class_name}</strong>.</p>
<p>Please log in to HKU ICB to review and approve.</p>
"""
await send_email(admin_email, "HKU ICB: New Registration", html)
async def send_approval_notification(student_email: str, approved: bool):
status_text = "approved" if approved else "rejected"
html = f"""
<h2>Registration {status_text.capitalize()}</h2>
<p>Your registration has been <strong>{status_text}</strong>.</p>
{"<p>You can now log in to HKU ICB.</p>" if approved else ""}
"""
await send_email(
student_email, f"HKU ICB: Registration {status_text.capitalize()}", html
)
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"""
<div style="margin-top: 20px;">
<a href="{action_url}" style="display:inline-block;padding:10px 24px;background:#111827;color:#fff;border-radius:6px;text-decoration:none;font-size:14px;">
查看详情
</a>
</div>
"""
html = f"""
<div style="max-width:600px;margin:0 auto;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#1f2937;">
<div style="padding:24px 0;border-bottom:2px solid #111827;">
<h1 style="margin:0;font-size:20px;color:#111827;">HKU ICB</h1>
</div>
<div style="padding:24px 0;">
<h2 style="margin:0 0 12px;font-size:18px;">{title}</h2>
<div style="font-size:14px;line-height:1.6;color:#4b5563;">{body}</div>
{action_html}
</div>
<div style="padding:16px 0;border-top:1px solid #e5e7eb;font-size:12px;color:#9ca3af;">
此邮件由系统自动发送请勿直接回复
</div>
</div>
"""
for email in emails:
await send_email(email, subject, html)

View File

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

View File

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

View File

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

View File

@ -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"<p><strong>{data.title}</strong></p><p>时间: {time_str}{location_info}</p>",
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())

View File

@ -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"<p>{content_preview}</p>" 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

View File

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

View File

@ -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"<p>{data.description or data.title}</p>",
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()

13
backend/requirements.txt Normal file
View File

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

42
docker-compose.yml Normal file
View File

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

7
frontend/.dockerignore Normal file
View File

@ -0,0 +1,7 @@
node_modules/
.next/
out/
.env.local
.env*.local
*.tsbuildinfo
.git/

41
frontend/.gitignore vendored Normal file
View File

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

5
frontend/AGENTS.md Normal file
View File

@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# 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.
<!-- END:nextjs-agent-rules -->

1
frontend/CLAUDE.md Normal file
View File

@ -0,0 +1 @@
@AGENTS.md

34
frontend/Dockerfile Normal file
View File

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

36
frontend/README.md Normal file
View File

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

25
frontend/components.json Normal file
View File

@ -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": {}
}

View File

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

8
frontend/next.config.ts Normal file
View File

@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
allowedDevOrigins: ["192.168.31.172"],
};
export default nextConfig;

35
frontend/package.json Normal file
View File

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

View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
frontend/public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
frontend/public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@ -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<ClassInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(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<any>("/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<number | null>(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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-gray-500 mt-1"></p>
</div>
<RoleGuard roles={["super_admin"]}>
<Dialog open={dialogOpen} onOpenChange={(open) => { setDialogOpen(open); if (!open) resetForm(); }}>
<DialogTrigger>
<Button onClick={openCreate}></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingId ? "编辑班级" : "创建班级"}</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div className="space-y-2">
<Label></Label>
<Input
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="如HKU ICB FinTech 2025"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
type="number"
value={formYear}
onChange={(e) => setFormYear(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formDesc}
onChange={(e) => setFormDesc(e.target.value)}
placeholder="班级描述"
/>
</div>
<Button onClick={handleSubmit} disabled={submitting} className="w-full">
{submitting ? "提交中..." : editingId ? "保存修改" : "创建"}
</Button>
</div>
</DialogContent>
</Dialog>
</RoleGuard>
</div>
{loading ? (
<div className="animate-pulse space-y-4">
{[1, 2].map((i) => (
<Card key={i}><CardContent className="p-6"><div className="h-20 bg-gray-200 rounded" /></CardContent></Card>
))}
</div>
) : error ? (
<ErrorState message={error} onRetry={loadClasses} />
) : (
<div className="space-y-4">
{classes.map((cls) => (
<Card key={cls.id}>
<CardContent className="p-4 flex items-center justify-between">
<div>
<h3 className="font-medium">{cls.name}</h3>
<p className="text-sm text-gray-500">
{cls.cohort_year} · {cls.member_count}
</p>
</div>
<RoleGuard roles={["super_admin"]}>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => openEdit(cls)}
>
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-500"
onClick={() => setDeleteTarget(cls.id)}
>
</Button>
</div>
</RoleGuard>
</CardContent>
</Card>
))}
</div>
)}
{/* Delete confirmation */}
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}
title="删除班级"
description="确定删除该班级?相关成员数据也将清除,此操作不可恢复。"
confirmText="删除"
variant="destructive"
onConfirm={() => deleteTarget && handleDelete(deleteTarget)}
/>
</div>
);
}

View File

@ -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<UserListItem[]>([]);
const [membersLoading, setMembersLoading] = useState(true);
const [membersError, setMembersError] = useState<string | null>(null);
const [membersPage, setMembersPage] = useState(1);
const [membersTotalPages, setMembersTotalPages] = useState(1);
const [filter, setFilter] = useState("all");
// Roster state
const [roster, setRoster] = useState<RosterEntry[]>([]);
const [rosterLoading, setRosterLoading] = useState(true);
const [rosterError, setRosterError] = useState<string | null>(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<HTMLInputElement>(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<any>(`/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<any>(`/api/classes/${activeClassId}/roster`, {
page: String(rosterPage),
page_size: "50",
}),
fetchAPI<any>(`/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<string, "default" | "secondary" | "destructive" | "outline"> = {
approved: "default",
disabled: "secondary",
};
return (
<Badge variant={variants[status] || "secondary"}>
{USER_STATUS[status as keyof typeof USER_STATUS] || status}
</Badge>
);
};
// Roster actions
const handleCopyCode = () => {
navigator.clipboard.writeText(inviteCode);
toast.success("邀请码已复制");
};
const handleRegenerateCode = async () => {
try {
const res = await postAPI<any>(`/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<any>(`/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<any>(`/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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-gray-500 mt-1"></p>
</div>
{!activeClassId ? (
<div className="text-center py-12 text-gray-400">
{isSuperAdmin ? "请在顶部选择一个班级" : "您尚未分配班级"}
</div>
) : (
<>
{/* Tab switcher */}
<div className="flex gap-2 border-b pb-2">
<Button
variant={activeTab === "roster" ? "default" : "ghost"}
size="sm"
onClick={() => setActiveTab("roster")}
>
</Button>
<Button
variant={activeTab === "members" ? "default" : "ghost"}
size="sm"
onClick={() => setActiveTab("members")}
>
</Button>
</div>
{activeTab === "roster" && (
<div className="space-y-4">
{/* Invite code section */}
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500"></p>
<p className="text-2xl font-mono font-bold tracking-widest mt-1">
{inviteCode || "—"}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleCopyCode}>
</Button>
<Button variant="outline" size="sm" onClick={handleRegenerateCode}>
</Button>
</div>
</div>
<p className="text-xs text-gray-400 mt-2">
+
</p>
</CardContent>
</Card>
{/* Import actions */}
<div className="flex gap-2">
<Dialog open={importOpen} onOpenChange={(open) => { setImportOpen(open); if (!open) setImportText(""); }}>
<DialogTrigger>
<Button></Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div>
<Label> </Label>
<Textarea
placeholder={"24001 张三\n24002 李四\n24003 王五"}
value={importText}
onChange={(e) => setImportText(e.target.value)}
rows={6}
className="mt-2 font-mono text-sm"
/>
<Button
onClick={handleTextImport}
disabled={importing || !importText.trim()}
className="mt-2 w-full"
>
{importing ? "导入中..." : "从文本导入"}
</Button>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-gray-400"></span>
</div>
</div>
<div>
<Label> CSV / Excel </Label>
<p className="text-xs text-gray-400 mt-1">
CSV student_id() name() Excel
</p>
<input
ref={fileInputRef}
type="file"
accept=".csv,.xlsx,.xls"
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-medium file:bg-gray-100 file:text-gray-700 hover:file:bg-gray-200 mt-2"
/>
<Button
onClick={handleFileUpload}
disabled={importing}
className="mt-2 w-full"
variant="outline"
>
{importing ? "上传中..." : "上传文件"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Button
variant="outline"
onClick={() => setClearTarget(true)}
disabled={roster.filter((r) => r.status === "unregistered").length === 0}
>
</Button>
</div>
{/* Roster list */}
{rosterLoading ? (
<div className="animate-pulse space-y-2">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="p-4">
<div className="h-12 bg-gray-200 rounded" />
</CardContent>
</Card>
))}
</div>
) : rosterError ? (
<ErrorState message={rosterError} onRetry={loadRoster} />
) : roster.length === 0 ? (
<div className="text-center py-12 text-gray-400">
</div>
) : (
<div className="space-y-2">
{roster.map((r) => (
<Card key={r.id}>
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="font-medium">{r.name}</p>
<p className="text-sm text-gray-500">{r.student_id}</p>
</div>
<div className="flex items-center gap-2">
<Badge variant={r.status === "registered" ? "default" : "outline"}>
{r.status === "registered" ? "已注册" : "未注册"}
</Badge>
{r.status === "unregistered" && (
<Button
variant="ghost"
size="sm"
className="text-red-500"
onClick={() => handleDeleteRoster(r.id)}
>
</Button>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
<Pagination
page={rosterPage}
totalPages={rosterTotalPages}
onPageChange={setRosterPage}
/>
</div>
)}
{activeTab === "members" && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold"></h2>
<Select value={filter} onValueChange={(v) => v && setFilter(v)}>
<SelectTrigger className="w-32">
<SelectValue>
{filter === "all"
? "全部"
: USER_STATUS[filter as keyof typeof USER_STATUS] || filter}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="approved"></SelectItem>
<SelectItem value="disabled"></SelectItem>
</SelectContent>
</Select>
</div>
{membersLoading ? (
<div className="animate-pulse space-y-2">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="p-4">
<div className="h-12 bg-gray-200 rounded" />
</CardContent>
</Card>
))}
</div>
) : membersError ? (
<ErrorState message={membersError} onRetry={loadMembers} />
) : filteredMembers.length === 0 ? (
<div className="text-center py-8 text-gray-400"></div>
) : (
<div className="space-y-2">
{filteredMembers.map((m) => (
<Card key={m.id}>
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="font-medium">{m.name}</p>
<p className="text-sm text-gray-500">
{m.email}
{m.student_id ? ` · ${m.student_id}` : ""}
{m.company ? ` · ${m.company}` : ""}
</p>
</div>
<div className="flex items-center gap-2">
<Select
value={m.role}
onValueChange={(v) => v && handleRoleChange(m.id, v)}
>
<SelectTrigger className="w-28 h-7 text-xs">
<SelectValue>
{ROLES[m.role as keyof typeof ROLES] || m.role}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="student"></SelectItem>
<SelectItem value="class_admin"></SelectItem>
{isSuperAdmin && (
<SelectItem value="super_admin"></SelectItem>
)}
</SelectContent>
</Select>
{getStatusBadge(m.status)}
{m.status === "approved" && (
<Button
variant="ghost"
size="sm"
className="text-red-500"
onClick={() => handleStatusChange(m.id, "disabled")}
>
</Button>
)}
{m.status === "disabled" && (
<Button
variant="ghost"
size="sm"
onClick={() => handleStatusChange(m.id, "approved")}
>
</Button>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
<Pagination
page={membersPage}
totalPages={membersTotalPages}
onPageChange={setMembersPage}
/>
</div>
)}
</>
)}
<ConfirmDialog
open={clearTarget}
onOpenChange={(open) => { if (!open) setClearTarget(false); }}
title="清除未注册花名册"
description="确定清除所有未注册的花名册条目?此操作不可恢复。"
confirmText="清除"
variant="destructive"
onConfirm={handleClearRoster}
/>
</div>
);
}

View File

@ -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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-gray-500 mt-1">
: {user?.role ? ROLES[user.role] : "-"}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Link href="/admin/members">
<Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-500 text-sm">
</p>
</CardContent>
</Card>
</Link>
<Link href="/admin/classes">
<Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-500 text-sm">
</p>
</CardContent>
</Card>
</Link>
</div>
</div>
);
}

View File

@ -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<Announcement[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
// Dialog state
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(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<number | null>(null);
const loadAnnouncements = async () => {
setError(null);
try {
const res = await fetchAPI<any>("/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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-gray-500 mt-1"></p>
</div>
<RoleGuard roles={["class_admin", "super_admin"]}>
<Dialog open={dialogOpen} onOpenChange={(open) => {
setDialogOpen(open);
if (!open) resetForm();
}}>
<DialogTrigger>
<Button></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingId ? "编辑公告" : "发布公告"}</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<Input
placeholder="标题"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
/>
<Textarea
placeholder="公告内容..."
value={newContent}
onChange={(e) => setNewContent(e.target.value)}
rows={5}
/>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={newIsPinned}
onChange={(e) => setNewIsPinned(e.target.checked)}
className="rounded"
/>
<span className="text-sm"></span>
</label>
<Button onClick={handleSubmit} disabled={submitting} className="w-full">
{submitting
? (editingId ? "保存中..." : "发布中...")
: (editingId ? "保存" : "发布")}
</Button>
</div>
</DialogContent>
</Dialog>
</RoleGuard>
</div>
{loading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Card key={i} className="animate-pulse">
<CardContent className="p-6">
<div className="h-24 bg-gray-200 rounded" />
</CardContent>
</Card>
))}
</div>
) : error ? (
<ErrorState message={error} onRetry={loadAnnouncements} />
) : announcements.length === 0 ? (
<div className="text-center py-12 text-gray-400"></div>
) : (
<div className="space-y-4">
{announcements.map((item) => (
<Card key={item.id} className={item.is_pinned ? "border-blue-200 bg-blue-50/30" : ""}>
<CardContent className="p-6">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
{item.is_pinned && (
<Badge className="bg-blue-500 text-white text-xs"></Badge>
)}
<h3 className="text-lg font-semibold">{item.title}</h3>
</div>
<RoleGuard roles={["class_admin", "super_admin"]}>
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => openEdit(item)}>
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-500"
onClick={() => setDeleteTarget(item.id)}
>
</Button>
</div>
</RoleGuard>
</div>
{item.content && (
<p className="mt-3 text-gray-700 whitespace-pre-wrap">{item.content}</p>
)}
<p className="text-sm text-gray-400 mt-3">
{item.author_name} ·{" "}
{new Date(item.created_at).toLocaleDateString("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
})}
</p>
</CardContent>
</Card>
))}
</div>
)}
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}
title="删除公告"
description="确定删除这条公告?此操作不可恢复。"
confirmText="删除"
variant="destructive"
onConfirm={() => deleteTarget && handleDelete(deleteTarget)}
/>
</div>
);
}

View File

@ -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<Assignment | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Submission form state
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [notes, setNotes] = useState("");
const [submitting, setSubmitting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Grading state: submission id -> { grade, feedback }
const [gradingMap, setGradingMap] = useState<
Record<number, { grade: string; feedback: string }>
>({});
const [activeGradeId, setActiveGradeId] = useState<number | null>(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<Assignment>(`/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 (
<div className="space-y-4">
<div className="h-6 w-24 bg-gray-200 rounded animate-pulse" />
<Card className="animate-pulse">
<CardContent className="p-6">
<div className="h-48 bg-gray-200 rounded" />
</CardContent>
</Card>
</div>
);
}
if (error) {
return <ErrorState message={error} onRetry={loadAssignment} />;
}
if (!assignment) {
return <ErrorState message="作业不存在" />;
}
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 (
<div className="space-y-6">
{/* Back link */}
<Link
href="/assignments"
className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700 transition-colors"
>
<svg
className="w-4 h-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
</Link>
{/* Assignment info */}
<Card>
<CardContent className="p-4 md:p-6">
<div className="flex flex-col gap-4">
<div>
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-xl md:text-2xl font-bold">{assignment.title}</h1>
<Badge
className={
assignment.status === "open"
? "bg-green-500 text-white"
: "bg-gray-400 text-white"
}
>
{assignment.status === "open" ? "进行中" : "已关闭"}
</Badge>
{isPastDeadline && assignment.status === "open" && (
<Badge
variant="outline"
className="text-red-500 border-red-300"
>
</Badge>
)}
</div>
{assignment.description && (
<p className="mt-4 text-gray-700 whitespace-pre-wrap">
{assignment.description}
</p>
)}
<div className="mt-4 space-y-1 text-sm text-gray-500">
<p>
{assignment.creator_name} ·
{formatDate(assignment.created_at)}
</p>
{deadline && (
<p>
{formatDate(assignment.deadline!)}
{isPastDeadline ? (
<span className="text-red-500 ml-1"></span>
) : (
<span className="text-green-600 ml-1">{getCountdown(assignment.deadline!)}</span>
)}
</p>
)}
</div>
{/* Stats bar */}
{assignment.total_members > 0 && (
<div className="mt-4 p-4 bg-gray-50 rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">
{assignment.submission_count} / {assignment.total_members}
</span>
<span className="text-sm font-semibold text-blue-600">
{Math.round((assignment.submission_count / assignment.total_members) * 100)}%
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all"
style={{ width: `${Math.min(100, (assignment.submission_count / assignment.total_members) * 100)}%` }}
/>
</div>
<div className="flex justify-between mt-2 text-xs text-gray-400">
<span> {assignment.submission_count} </span>
<span> {assignment.total_members - assignment.submission_count} </span>
</div>
</div>
)}
{/* Attachments */}
{assignment.attachment_urls &&
assignment.attachment_urls.length > 0 && (
<div className="mt-4">
<p className="text-sm font-medium text-gray-700 mb-2">
</p>
<div className="flex flex-wrap gap-2">
{assignment.attachment_urls.map((url, idx) => (
<a
key={idx}
href={url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-3 py-1.5 bg-gray-100 hover:bg-gray-200 rounded-md text-sm text-gray-700 transition-colors"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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"
/>
</svg>
{getFileNameFromUrl(url)}
</a>
))}
</div>
</div>
)}
</div>
{/* Admin actions */}
<RoleGuard roles={["class_admin", "super_admin"]}>
<div className="flex justify-end">
{assignment.status === "open" && (
<Button
variant="outline"
size="sm"
onClick={handleClose}
disabled={closing}
>
{closing ? "关闭中..." : "关闭作业"}
</Button>
)}
</div>
</RoleGuard>
</div>
</CardContent>
</Card>
{/* My submission section — all roles can submit */}
{(
<Card>
<CardContent className="p-6">
<h2 className="text-lg font-semibold mb-4"></h2>
{mySubmission ? (
<div className="space-y-3">
<div className="flex items-center gap-3 text-sm">
<Badge className="bg-blue-500 text-white"></Badge>
<span className="text-gray-500">
{formatDate(mySubmission.created_at)}
</span>
</div>
{mySubmission.file_url && (
<div className="flex items-center gap-2">
<a
href={mySubmission.file_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-3 py-1.5 bg-gray-100 hover:bg-gray-200 rounded-md text-sm text-gray-700 transition-colors"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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"
/>
</svg>
{mySubmission.file_name || "下载提交文件"}
</a>
{mySubmission.file_size !== null && (
<span className="text-xs text-gray-400">
({formatFileSize(mySubmission.file_size)})
</span>
)}
</div>
)}
{mySubmission.notes && (
<div>
<p className="text-sm text-gray-500 mb-1"></p>
<p className="text-sm text-gray-700 whitespace-pre-wrap">
{mySubmission.notes}
</p>
</div>
)}
{/* Grade & feedback */}
{mySubmission.grade && (
<div className="mt-4 p-4 bg-green-50 rounded-lg border border-green-200">
<div className="flex items-center gap-2 mb-2">
<Badge className="bg-green-600 text-white"></Badge>
<span className="text-lg font-semibold text-green-700">
{mySubmission.grade}
</span>
</div>
{mySubmission.feedback && (
<div>
<p className="text-sm text-green-600 mb-1"></p>
<p className="text-sm text-green-800 whitespace-pre-wrap">
{mySubmission.feedback}
</p>
</div>
)}
{mySubmission.graded_at && (
<p className="text-xs text-green-500 mt-2">
{formatDate(mySubmission.graded_at)}
</p>
)}
</div>
)}
</div>
) : (
<div>
{assignment.status === "closed" || isPastDeadline ? (
<div className="text-center py-6">
<p className="text-gray-400"></p>
</div>
) : (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<span className="text-red-500">*</span>
</label>
<input
ref={fileInputRef}
type="file"
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-medium file:bg-gray-100 file:text-gray-700 hover:file:bg-gray-200"
onChange={(e) =>
setSelectedFile(e.target.files?.[0] || null)
}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<Textarea
placeholder="添加备注说明..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
/>
</div>
<Button
onClick={handleSubmit}
disabled={submitting || !selectedFile}
>
{submitting ? "提交中..." : "提交作业"}
</Button>
</div>
)}
</div>
)}
</CardContent>
</Card>
)}
{/* Admin: Submissions table */}
<RoleGuard roles={["class_admin", "super_admin"]}>
<Card>
<CardContent className="p-6">
<h2 className="text-lg font-semibold mb-4">
{assignment.submissions?.length || 0} /
{assignment.submission_count}
</h2>
{(!assignment.submissions || assignment.submissions.length === 0) ? (
<div className="text-center py-8 text-gray-400">
</div>
) : (
<>
{/* Desktop table */}
<div className="hidden md:block overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-3 px-2 font-medium text-gray-500"></th>
<th className="text-left py-3 px-2 font-medium text-gray-500"></th>
<th className="text-left py-3 px-2 font-medium text-gray-500"></th>
<th className="text-left py-3 px-2 font-medium text-gray-500"></th>
<th className="text-left py-3 px-2 font-medium text-gray-500"></th>
<th className="text-left py-3 px-2 font-medium text-gray-500"></th>
</tr>
</thead>
<tbody>
{assignment.submissions!.map((sub) => (
<tr key={sub.id} className="border-b last:border-0">
<td className="py-3 px-2">{sub.student_name}</td>
<td className="py-3 px-2 text-gray-500">{formatDate(sub.created_at)}</td>
<td className="py-3 px-2">
{sub.file_url ? (
<a href={sub.file_url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-800">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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" />
</svg>
{sub.file_name || "下载"}
{sub.file_size !== null && <span className="text-gray-400 text-xs ml-1">({formatFileSize(sub.file_size)})</span>}
</a>
) : <span className="text-gray-400">-</span>}
</td>
<td className="py-3 px-2">
{sub.grade ? <Badge className="bg-green-600 text-white">{sub.grade}</Badge> : <span className="text-gray-400"></span>}
</td>
<td className="py-3 px-2 text-gray-500 max-w-[200px] truncate">{sub.feedback || "-"}</td>
<td className="py-3 px-2">
{activeGradeId === sub.id ? (
<div className="space-y-2 min-w-[200px]">
<Input placeholder="成绩" value={gradingMap[sub.id]?.grade || ""} onChange={(e) => setGradingMap((prev) => ({ ...prev, [sub.id]: { ...prev[sub.id], grade: e.target.value } }))} className="h-8 text-sm" />
<Textarea placeholder="反馈(选填)" value={gradingMap[sub.id]?.feedback || ""} onChange={(e) => setGradingMap((prev) => ({ ...prev, [sub.id]: { ...prev[sub.id], feedback: e.target.value } }))} rows={2} className="text-sm" />
<div className="flex gap-1">
<Button size="sm" onClick={() => handleGrade(sub.id)} disabled={gradingSubmitting}>{gradingSubmitting ? "保存中..." : "保存"}</Button>
<Button size="sm" variant="ghost" onClick={() => setActiveGradeId(null)}></Button>
</div>
</div>
) : (
<Button variant="outline" size="sm" onClick={() => openGrading(sub)}></Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="md:hidden space-y-3">
{assignment.submissions!.map((sub) => (
<div key={sub.id} className="p-3 bg-gray-50 rounded-lg space-y-2">
<div className="flex items-center justify-between">
<span className="font-medium">{sub.student_name}</span>
{sub.grade ? <Badge className="bg-green-600 text-white text-xs">{sub.grade}</Badge> : <span className="text-xs text-gray-400"></span>}
</div>
<p className="text-xs text-gray-500">{formatDate(sub.created_at)}</p>
{sub.file_url && (
<a href={sub.file_url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-sm text-blue-600">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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" />
</svg>
{sub.file_name || "下载"}
</a>
)}
{sub.feedback && <p className="text-sm text-gray-600">{sub.feedback}</p>}
{activeGradeId === sub.id ? (
<div className="space-y-2 pt-1">
<Input placeholder="成绩" value={gradingMap[sub.id]?.grade || ""} onChange={(e) => setGradingMap((prev) => ({ ...prev, [sub.id]: { ...prev[sub.id], grade: e.target.value } }))} className="h-8 text-sm" />
<Textarea placeholder="反馈(选填)" value={gradingMap[sub.id]?.feedback || ""} onChange={(e) => setGradingMap((prev) => ({ ...prev, [sub.id]: { ...prev[sub.id], feedback: e.target.value } }))} rows={2} className="text-sm" />
<div className="flex gap-1">
<Button size="sm" onClick={() => handleGrade(sub.id)} disabled={gradingSubmitting}>{gradingSubmitting ? "保存中..." : "保存"}</Button>
<Button size="sm" variant="ghost" onClick={() => setActiveGradeId(null)}></Button>
</div>
</div>
) : (
<Button variant="outline" size="sm" onClick={() => openGrading(sub)}></Button>
)}
</div>
))}
</div>
</>
)}
</CardContent>
</Card>
</RoleGuard>
</div>
);
}

View File

@ -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<Assignment[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<File[]>([]);
const [submitting, setSubmitting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Delete state
const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
const loadAssignments = useCallback(async () => {
setError(null);
try {
const res = await fetchAPI<any>("/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<Assignment>("/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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-gray-500 mt-1"></p>
</div>
<RoleGuard roles={["class_admin", "super_admin"]}>
<Dialog
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) resetForm();
}}
>
<DialogTrigger>
<Button></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<Input
placeholder="作业标题"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
/>
<Textarea
placeholder="作业描述(选填)"
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
rows={4}
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<Input
type="datetime-local"
value={newDeadline}
onChange={(e) => setNewDeadline(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
ref={fileInputRef}
type="file"
multiple
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-medium file:bg-gray-100 file:text-gray-700 hover:file:bg-gray-200"
onChange={(e) => {
const files = e.target.files;
if (files) {
setSelectedFiles(Array.from(files));
}
}}
/>
{selectedFiles.length > 0 && (
<p className="text-xs text-gray-400 mt-1">
{selectedFiles.length}
</p>
)}
</div>
<Button
onClick={handleCreate}
disabled={submitting || !newTitle.trim()}
className="w-full"
>
{submitting ? "发布中..." : "发布"}
</Button>
</div>
</DialogContent>
</Dialog>
</RoleGuard>
</div>
{loading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Card key={i} className="animate-pulse">
<CardContent className="p-6">
<div className="h-24 bg-gray-200 rounded" />
</CardContent>
</Card>
))}
</div>
) : error ? (
<ErrorState message={error} onRetry={loadAssignments} />
) : assignments.length === 0 ? (
<div className="text-center py-12 text-gray-400"></div>
) : (
<div className="space-y-4">
{assignments.map((assignment) => {
const { text: deadlineText, isPast } = formatDeadline(
assignment.deadline
);
return (
<Card key={assignment.id} className="hover:shadow-md transition-shadow">
<Link href={`/assignments/${assignment.id}`}>
<CardContent className="p-6">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-lg font-semibold truncate">
{assignment.title}
</h3>
<Badge
className={
assignment.status === "open"
? "bg-green-500 text-white text-xs"
: "bg-gray-400 text-white text-xs"
}
>
{assignment.status === "open" ? "进行中" : "已关闭"}
</Badge>
{isPast && assignment.status === "open" && (
<Badge variant="outline" className="text-red-500 border-red-300 text-xs">
</Badge>
)}
{assignment.status === "closed" && (
<Badge variant="outline" className="text-gray-500 border-gray-300 text-xs">
</Badge>
)}
</div>
{assignment.description && (
<p className="mt-2 text-gray-600 text-sm line-clamp-2">
{assignment.description}
</p>
)}
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-400">
<span>{deadlineText}</span>
<span> {assignment.submission_count}/{assignment.total_members} </span>
{assignment.total_members > 0 && (
<span className="text-blue-600">
{Math.round((assignment.submission_count / assignment.total_members) * 100)}%
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 ml-2 md:ml-4 shrink-0 flex-wrap">
{assignment.my_submitted ? (
<Badge className="bg-blue-500 text-white"></Badge>
) : assignment.status === "open" ? (
<Badge variant="outline" className="text-orange-500 border-orange-300">
</Badge>
) : null}
{isAdmin && (
<Button
variant="ghost"
size="sm"
className="text-red-500"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setDeleteTarget(assignment.id);
}}
>
</Button>
)}
</div>
</div>
</CardContent>
</Link>
</Card>
);
})}
</div>
)}
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
title="删除作业"
description="确定删除该作业?此操作不可恢复。"
confirmText="删除"
variant="destructive"
onConfirm={() => deleteTarget && handleDelete(deleteTarget)}
/>
</div>
);
}

View File

@ -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<ScheduleItem[]>([]);
const [recentTimeline, setRecentTimeline] = useState<TimelinePost[]>([]);
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
const [error, setError] = useState<string | null>(null);
const [selectedAnnouncement, setSelectedAnnouncement] = useState<Announcement | null>(null);
const [selectedSchedule, setSelectedSchedule] = useState<ScheduleItem | null>(null);
const loadData = async () => {
if (!activeClassId) return;
setError(null);
try {
const [upcomingRes, timelineRes, announcementsRes] = await Promise.all([
fetchAPI<ScheduleItem[]>("/api/schedule/upcoming", { limit: "3", class_id: String(activeClassId) }),
fetchAPI<any>("/api/timeline/", { page_size: "3", class_id: String(activeClassId) }),
fetchAPI<any>("/api/announcements/", { page_size: "3", class_id: String(activeClassId) }),
]);
setUpcoming(upcomingRes);
setRecentTimeline(timelineRes.items || []);
setAnnouncements(announcementsRes.items || []);
} catch (err: any) {
setError(err.message || "加载失败");
}
};
useEffect(() => {
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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">HKU ICB </h1>
<p className="text-gray-500 mt-1"></p>
</div>
{error ? (
<ErrorState message={error} onRetry={loadData} />
) : (
<>
{/* Latest announcements */}
{announcements.length > 0 && (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg"></CardTitle>
<Link href="/announcements" className="text-sm text-gray-500 hover:text-gray-900 transition-colors">
</Link>
</CardHeader>
<CardContent>
<div className="space-y-3">
{announcements.map((a) => (
<div
key={a.id}
className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100 transition-colors"
onClick={() => setSelectedAnnouncement(a)}
>
{a.is_pinned && (
<Badge variant="secondary" className="shrink-0 bg-amber-100 text-amber-700 text-xs"></Badge>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{a.title}</p>
<p className="text-xs text-gray-500 mt-1">
{a.author_name} · {new Date(a.created_at).toLocaleDateString("zh-CN")}
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Upcoming schedules */}
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
{upcoming.length === 0 ? (
<p className="text-gray-400 text-sm"></p>
) : (
<div className="space-y-3">
{upcoming.map((item) => (
<div
key={item.id}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100 transition-colors"
onClick={() => setSelectedSchedule(item)}
>
<div className="flex items-center gap-3">
<div
className={`w-2 h-2 rounded-full ${
SCHEDULE_TYPES[item.type]?.color || "bg-gray-400"
}`}
/>
<div>
<p className="text-sm font-medium">{item.title}</p>
<p className="text-xs text-gray-500">
{new Date(item.start_time).toLocaleDateString("zh-CN")}
</p>
</div>
</div>
<Badge variant="secondary" className="text-xs">
{getCountdown(item.start_time)}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Recent timeline */}
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
{recentTimeline.length === 0 ? (
<p className="text-gray-400 text-sm"></p>
) : (
<div className="space-y-3">
{recentTimeline.map((post) => (
<div key={post.id} className="p-3 bg-gray-50 rounded-lg">
<p className="text-sm font-medium">{post.title}</p>
<p className="text-xs text-gray-500 mt-1">
{post.author_name} ·{" "}
{new Date(post.created_at).toLocaleDateString("zh-CN")}
</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</>
)}
{/* Announcement detail dialog */}
<Dialog open={selectedAnnouncement !== null} onOpenChange={(open) => { if (!open) setSelectedAnnouncement(null); }}>
<DialogContent>
{selectedAnnouncement && (
<>
<DialogHeader>
<DialogTitle>{selectedAnnouncement.title}</DialogTitle>
</DialogHeader>
<div className="space-y-3 pt-2">
<div className="flex items-center gap-2 text-sm text-gray-500">
{selectedAnnouncement.is_pinned && (
<Badge className="bg-amber-100 text-amber-700 text-xs"></Badge>
)}
<span>{selectedAnnouncement.author_name}</span>
<span>·</span>
<span>{new Date(selectedAnnouncement.created_at).toLocaleDateString("zh-CN", {
year: "numeric", month: "long", day: "numeric",
})}</span>
</div>
{selectedAnnouncement.content ? (
<p className="text-gray-700 whitespace-pre-wrap">{selectedAnnouncement.content}</p>
) : (
<p className="text-gray-400"></p>
)}
</div>
</>
)}
</DialogContent>
</Dialog>
{/* Schedule detail dialog */}
<Dialog open={selectedSchedule !== null} onOpenChange={(open) => { if (!open) setSelectedSchedule(null); }}>
<DialogContent>
{selectedSchedule && (
<>
<DialogHeader>
<DialogTitle>{selectedSchedule.title}</DialogTitle>
</DialogHeader>
<div className="space-y-3 pt-2">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${SCHEDULE_TYPES[selectedSchedule.type]?.color || "bg-gray-400"}`} />
<Badge variant="secondary" className="text-xs">
{SCHEDULE_TYPES[selectedSchedule.type]?.label || selectedSchedule.type}
</Badge>
<Badge variant="secondary" className="text-xs">
{getCountdown(selectedSchedule.start_time)}
</Badge>
</div>
<div className="text-sm text-gray-500 space-y-1">
<p>{new Date(selectedSchedule.start_time).toLocaleString("zh-CN", { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" })}</p>
{selectedSchedule.end_time && (
<p>{new Date(selectedSchedule.end_time).toLocaleString("zh-CN", { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" })}</p>
)}
{selectedSchedule.location && (
<p>{selectedSchedule.location}</p>
)}
</div>
{selectedSchedule.description ? (
<p className="text-gray-700 whitespace-pre-wrap">{selectedSchedule.description}</p>
) : (
<p className="text-gray-400"></p>
)}
</div>
</>
)}
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -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<UserPublic | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const id = params.id as string;
setError(null);
fetchAPI<UserPublic>(`/api/directory/${id}`)
.then(setMember)
.catch((err: any) => setError(err.message || "加载失败"))
.finally(() => setLoading(false));
}, [params.id]);
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900" />
</div>
);
}
if (!member) {
if (error) {
return (
<div className="max-w-2xl mx-auto">
<Link href="/directory" className="text-sm text-gray-500 hover:text-gray-700 mb-4 inline-block">
&larr;
</Link>
<ErrorState message={error} onRetry={() => window.location.reload()} />
</div>
);
}
return (
<div className="text-center py-12 text-gray-400"></div>
);
}
const showContact = user?.class_id === member.id; // Privacy: same class check handled by API
return (
<div className="max-w-2xl mx-auto">
<Link href="/directory" className="text-sm text-gray-500 hover:text-gray-700 mb-4 inline-block">
&larr;
</Link>
<Card>
<CardContent className="p-6">
{/* Avatar centered at top */}
<div className="flex flex-col items-center text-center mb-6">
<Avatar className="h-28 w-28 mb-4">
<AvatarImage src={member.avatar_url || undefined} alt={member.name} />
<AvatarFallback className="bg-gray-900 text-white text-4xl">
{member.name[0]}
</AvatarFallback>
</Avatar>
<h1 className="text-2xl font-bold">{member.name}</h1>
{member.student_id && (
<p className="text-sm text-gray-500 mt-1">: {member.student_id}</p>
)}
{member.company && (
<p className="text-gray-600 mt-1">
{member.company}
{member.position ? ` · ${member.position}` : ""}
</p>
)}
{member.industry && (
<Badge className="mt-2">{member.industry}</Badge>
)}
</div>
{member.bio && (
<div className="mt-6">
<h3 className="text-sm font-medium text-gray-500 mb-2"></h3>
<p className="text-gray-700 whitespace-pre-wrap">{member.bio}</p>
</div>
)}
{(member.wechat_id || member.phone) && (
<div className="mt-6">
<h3 className="text-sm font-medium text-gray-500 mb-2"></h3>
<div className="space-y-1">
{member.wechat_id && (
<p className="text-gray-700">: {member.wechat_id}</p>
)}
{member.phone && (
<p className="text-gray-700">: {member.phone}</p>
)}
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -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<UserPublic[]>([]);
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<string | null>(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<string, string> = { 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<any>("/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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-gray-500 mt-1"> {total} </p>
</div>
{/* Search & Filters */}
<div className="flex flex-wrap gap-3">
<Input
placeholder="搜索姓名、公司、职位..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-sm w-full sm:w-auto"
/>
<Select value={industry || "__all__"} onValueChange={(v) => v && setIndustry(v === "__all__" ? "" : v)}>
<SelectTrigger className="w-full sm:w-40">
<SelectValue>{!industry ? "全部行业" : industry}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__"></SelectItem>
{INDUSTRY_OPTIONS.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
placeholder="公司名称"
value={company}
onChange={(e) => setCompany(e.target.value)}
className="w-full sm:w-40"
/>
</div>
{/* Member Grid */}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Card key={i} className="animate-pulse">
<CardContent className="p-4">
<div className="h-32 bg-gray-200 rounded" />
</CardContent>
</Card>
))}
</div>
) : error ? (
<ErrorState message={error} onRetry={loadMembers} />
) : members.length === 0 ? (
<div className="text-center py-12 text-gray-400"></div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{members.map((member) => (
<Link key={member.id} href={`/directory/${member.id}`}>
<Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardContent className="p-4 flex flex-col items-center text-center">
<Avatar className="h-16 w-16 mb-3">
<AvatarImage src={member.avatar_url || undefined} alt={member.name} />
<AvatarFallback className="bg-gray-900 text-white text-xl">
{member.name[0]}
</AvatarFallback>
</Avatar>
<p className="font-medium truncate w-full">{member.name}</p>
{member.company && (
<p className="text-sm text-gray-500 truncate w-full mt-0.5">
{member.company}
{member.position ? ` · ${member.position}` : ""}
</p>
)}
{member.industry && (
<Badge variant="secondary" className="mt-1 text-xs">
{member.industry}
</Badge>
)}
</CardContent>
</Card>
</Link>
))}
</div>
)}
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
</div>
);
}

View File

@ -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 (
<ActiveClassProvider>
<NotificationProvider>
<SidebarProvider>
<div className="flex h-screen bg-gray-50">
<Sidebar />
<div className="flex-1 flex flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto p-4 md:p-6">{children}</main>
</div>
</div>
</SidebarProvider>
</NotificationProvider>
</ActiveClassProvider>
);
}

View File

@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold mb-6"></h1>
<Card>
<CardContent className="pt-6">
{/* Avatar */}
<div className="flex items-center gap-4 mb-6">
<div className="w-20 h-20 rounded-full bg-gray-900 text-white flex items-center justify-center text-2xl font-medium overflow-hidden shrink-0">
{user?.avatar_url ? (
<img src={user.avatar_url} alt="" className="w-full h-full object-cover" />
) : (
user?.name?.[0] || "?"
)}
</div>
<div>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
className="hidden"
onChange={handleAvatarChange}
/>
<Button
variant="outline"
size="sm"
disabled={avatarUploading}
onClick={() => fileInputRef.current?.click()}
>
{avatarUploading ? "上传中..." : "更换头像"}
</Button>
<p className="text-xs text-gray-400 mt-1"> JPG/PNG/GIF/WebP 5MB</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Basic info section */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label></Label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="用于登录的邮箱"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Select value={industry} onValueChange={(v) => v && setIndustry(v)}>
<SelectTrigger>
<SelectValue>{industry || "选择行业"}</SelectValue>
</SelectTrigger>
<SelectContent>
{INDUSTRY_OPTIONS.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={company}
onChange={(e) => setCompany(e.target.value)}
placeholder="公司名称"
/>
</div>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={position}
onChange={(e) => setPosition(e.target.value)}
placeholder="职位名称"
/>
</div>
{/* Self introduction */}
<div className="space-y-2">
<Label></Label>
<Textarea
value={bio}
onChange={(e) => setBio(e.target.value)}
placeholder={"可以介绍:\n- 你的行业背景和工作经历\n- 擅长的专业领域和方向\n- 兴趣爱好或希望交流的话题"}
rows={6}
/>
<p className="text-xs text-gray-400">
</p>
</div>
{/* Contact info */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input
value={wechatId}
onChange={(e) => setWechatId(e.target.value)}
placeholder="仅同班同学可见"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="仅同班同学可见"
/>
</div>
</div>
<Button type="submit" disabled={loading}>
{loading ? "保存中..." : "保存资料"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@ -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<string, string> = {
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<Resource[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<File | null>(null);
const [submitting, setSubmitting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Delete state
const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
const loadResources = async () => {
setError(null);
try {
const params: Record<string, string> = {
page_size: "20",
page: String(page),
class_id: String(activeClassId),
};
if (category !== "all") params.category = category;
const res = await fetchAPI<any>("/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<any>(`/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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-gray-500 mt-1"></p>
</div>
<RoleGuard roles={["class_admin", "super_admin"]}>
<Dialog open={dialogOpen} onOpenChange={(open) => {
setDialogOpen(open);
if (!open) resetForm();
}}>
<DialogTrigger>
<Button></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<Input
placeholder="资源标题"
value={formTitle}
onChange={(e) => setFormTitle(e.target.value)}
/>
<Textarea
placeholder="描述(选填)"
value={formDesc}
onChange={(e) => setFormDesc(e.target.value)}
rows={3}
/>
<Select value={formCategory} onValueChange={(v) => v && setFormCategory(v)}>
<SelectTrigger>
<SelectValue>{RESOURCE_CATEGORIES[formCategory] || formCategory}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="course_material"></SelectItem>
<SelectItem value="assignment"></SelectItem>
<SelectItem value="reading"></SelectItem>
<SelectItem value="other"></SelectItem>
</SelectContent>
</Select>
<div>
<input
ref={fileInputRef}
type="file"
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-medium file:bg-gray-100 file:text-gray-700 hover:file:bg-gray-200"
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
/>
<p className="text-xs text-gray-400 mt-1"> PDF, Word, Excel, PPT, ZIP 50MB</p>
</div>
<Button onClick={handleSubmit} disabled={submitting || !selectedFile} className="w-full">
{submitting ? "上传中..." : "上传"}
</Button>
</div>
</DialogContent>
</Dialog>
</RoleGuard>
</div>
{/* Category tabs */}
<div className="flex gap-2">
{Object.entries(RESOURCE_CATEGORIES).map(([key, label]) => (
<Button
key={key}
variant={category === key ? "default" : "outline"}
size="sm"
onClick={() => { setCategory(key); setPage(1); }}
>
{label}
</Button>
))}
</div>
{loading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Card key={i} className="animate-pulse">
<CardContent className="p-4">
<div className="h-16 bg-gray-200 rounded" />
</CardContent>
</Card>
))}
</div>
) : error ? (
<ErrorState message={error} onRetry={loadResources} />
) : resources.length === 0 ? (
<div className="text-center py-12 text-gray-400"></div>
) : (
<div className="space-y-2">
{resources.map((r) => (
<Card key={r.id}>
<CardContent className="p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">{getFileIcon(r.file_type)}</span>
<div>
<p className="font-medium">{r.title}</p>
<p className="text-sm text-gray-500">
{r.uploader_name} · {formatFileSize(r.file_size)} · {r.download_count}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">{RESOURCE_CATEGORIES[r.category] || r.category}</Badge>
<Button
variant="outline"
size="sm"
onClick={() => handleDownload(r)}
>
</Button>
<RoleGuard roles={["class_admin", "super_admin"]}>
<Button
variant="ghost"
size="sm"
className="text-red-500"
onClick={() => setDeleteTarget(r.id)}
>
</Button>
</RoleGuard>
</div>
</CardContent>
</Card>
))}
</div>
)}
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}
title="删除资源"
description="确定删除该资源?此操作不可恢复。"
confirmText="删除"
variant="destructive"
onConfirm={() => deleteTarget && handleDelete(deleteTarget)}
/>
</div>
);
}

View File

@ -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<ScheduleItem[]>([]);
const [upcoming, setUpcoming] = useState<ScheduleItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<number | null>(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<any>("/api/schedule/", params),
fetchAPI<ScheduleItem[]>("/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<number | null>(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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-gray-500 mt-1"></p>
</div>
<div className="flex items-center gap-2">
{/* View mode toggle */}
<div className="flex border rounded-lg p-0.5">
<Button
variant={viewMode === "list" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("list")}
>
</Button>
<Button
variant={viewMode === "calendar" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("calendar")}
>
</Button>
</div>
<RoleGuard roles={["class_admin", "super_admin"]}>
<Dialog open={dialogOpen} onOpenChange={(open) => {
setDialogOpen(open);
if (!open) resetForm();
}}>
<DialogTrigger>
<Button onClick={() => resetForm()}></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingId ? "编辑排期" : "添加排期"}</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<Select value={formType} onValueChange={(v) => v && setFormType(v)}>
<SelectTrigger>
<SelectValue>{SCHEDULE_TYPES[formType as keyof typeof SCHEDULE_TYPES]?.label || formType}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="course"></SelectItem>
<SelectItem value="deadline"></SelectItem>
<SelectItem value="activity"></SelectItem>
</SelectContent>
</Select>
<Input
placeholder="标题"
value={formTitle}
onChange={(e) => setFormTitle(e.target.value)}
/>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm text-gray-500"></label>
<Input
type="datetime-local"
value={formStartTime}
onChange={(e) => setFormStartTime(e.target.value)}
/>
</div>
<div>
<label className="text-sm text-gray-500"></label>
<Input
type="datetime-local"
value={formEndTime}
onChange={(e) => setFormEndTime(e.target.value)}
/>
</div>
</div>
<Input
placeholder="地点"
value={formLocation}
onChange={(e) => setFormLocation(e.target.value)}
/>
<Textarea
placeholder="描述"
value={formDesc}
onChange={(e) => setFormDesc(e.target.value)}
rows={3}
/>
<Button onClick={handleSubmit} disabled={submitting} className="w-full">
{submitting ? (editingId ? "保存中..." : "创建中...") : (editingId ? "保存" : "创建")}
</Button>
</div>
</DialogContent>
</Dialog>
</RoleGuard>
</div>
</div>
{/* Calendar view or list view */}
{viewMode === "calendar" ? (
<CalendarView events={items} onEventClick={openEdit} />
) : (
<>
{/* Upcoming with countdown */}
{upcoming.length > 0 && (
<div>
<h2 className="text-lg font-semibold mb-3"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{upcoming.map((item) => {
const countdown = getCountdown(item.start_time);
const typeInfo = SCHEDULE_TYPES[item.type] || { label: item.type, color: "bg-gray-400" };
return (
<Card key={item.id} className={countdown.urgent ? "border-red-200 bg-red-50/30" : ""}>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className={`w-3 h-3 rounded-full ${typeInfo.color}`} />
<Badge variant="outline" className="text-xs">
{typeInfo.label}
</Badge>
</div>
<Badge
variant={countdown.urgent ? "destructive" : "secondary"}
className="text-xs"
>
{countdown.text}
</Badge>
</div>
<h3 className="font-medium mt-2">{item.title}</h3>
<p className="text-sm text-gray-500 mt-1">
{new Date(item.start_time).toLocaleString("zh-CN", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
{item.location && (
<p className="text-sm text-gray-400 mt-1">{item.location}</p>
)}
<RoleGuard roles={["class_admin", "super_admin"]}>
<div className="flex gap-2 mt-2">
<Button
variant="ghost"
size="sm"
className="p-0 h-auto text-gray-500 hover:text-gray-700"
onClick={() => openEdit(item)}
>
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-500 p-0 h-auto"
onClick={() => setDeleteTarget(item.id)}
>
</Button>
</div>
</RoleGuard>
</CardContent>
</Card>
);
})}
</div>
</div>
)}
{/* All schedules */}
<div>
<h2 className="text-lg font-semibold mb-3"></h2>
{loading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Card key={i} className="animate-pulse">
<CardContent className="p-4">
<div className="h-12 bg-gray-200 rounded" />
</CardContent>
</Card>
))}
</div>
) : error ? (
<ErrorState message={error} onRetry={loadData} />
) : items.length === 0 ? (
<div className="text-center py-8 text-gray-400"></div>
) : (
<div className="space-y-2">
{items.map((item) => {
const typeInfo = SCHEDULE_TYPES[item.type] || { label: item.type, color: "bg-gray-400" };
return (
<Card key={item.id}>
<CardContent className="p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${typeInfo.color}`} />
<div>
<p className="font-medium">{item.title}</p>
<p className="text-sm text-gray-500">
{new Date(item.start_time).toLocaleString("zh-CN")}
{item.location ? ` · ${item.location}` : ""}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">{typeInfo.label}</Badge>
<RoleGuard roles={["class_admin", "super_admin"]}>
<Button
variant="ghost"
size="sm"
className="text-gray-500 hover:text-gray-700"
onClick={() => openEdit(item)}
>
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-500"
onClick={() => setDeleteTarget(item.id)}
>
</Button>
</RoleGuard>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</div>
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
</>
)}
{/* Delete confirmation */}
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}
title="删除排期"
description="确定删除这条排期?此操作不可恢复。"
confirmText="删除"
variant="destructive"
onConfirm={() => deleteTarget && handleDelete(deleteTarget)}
/>
</div>
);
}

View File

@ -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 (
<div
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center"
onClick={onClose}
>
{/* Close button */}
<button
className="absolute top-4 right-4 text-white/80 hover:text-white text-3xl z-10"
onClick={onClose}
>
&times;
</button>
{/* Counter */}
<div className="absolute top-4 left-4 text-white/70 text-sm">
{index + 1} / {images.length}
</div>
{/* Prev arrow */}
{images.length > 1 && (
<button
className="absolute left-4 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-white/10 hover:bg-white/20 text-white flex items-center justify-center text-xl z-10"
onClick={(e) => { e.stopPropagation(); prev(); }}
>
&#8249;
</button>
)}
{/* Image */}
<img
src={images[index]}
alt=""
className="max-w-[90vw] max-h-[85vh] object-contain select-none"
onClick={(e) => e.stopPropagation()}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
/>
{/* Next arrow */}
{images.length > 1 && (
<button
className="absolute right-4 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-white/10 hover:bg-white/20 text-white flex items-center justify-center text-xl z-10"
onClick={(e) => { e.stopPropagation(); next(); }}
>
&#8250;
</button>
)}
{/* Thumbnails */}
{images.length > 1 && (
<div
className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2 z-10"
onClick={(e) => e.stopPropagation()}
>
{images.map((url, i) => (
<button
key={i}
className={`w-12 h-12 rounded overflow-hidden border-2 transition-opacity ${
i === index ? "border-white opacity-100" : "border-transparent opacity-50 hover:opacity-75"
}`}
onClick={() => setIndex(i)}
>
<img src={url} alt="" className="w-full h-full object-cover" />
</button>
))}
</div>
)}
</div>
);
}
export default function TimelinePage() {
const { activeClassId } = useActiveClass();
const { user } = useAuth();
const [posts, setPosts] = useState<TimelinePost[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<File[]>([]);
const [previewUrls, setPreviewUrls] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [editingImageUrls, setEditingImageUrls] = useState<string[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
// Lightbox state
const [lightboxImages, setLightboxImages] = useState<string[] | null>(null);
const [lightboxIndex, setLightboxIndex] = useState(0);
// Comment state
const [expandedComments, setExpandedComments] = useState<Set<number>>(new Set());
const [commentInputs, setCommentInputs] = useState<Record<number, string>>({});
const [submittingComment, setSubmittingComment] = useState<Record<number, boolean>>({});
const openLightbox = (images: string[], index: number) => {
setLightboxImages(images);
setLightboxIndex(index);
};
const closeLightbox = () => setLightboxImages(null);
const loadPosts = async () => {
setError(null);
try {
const res = await fetchAPI<any>("/api/timeline/", { page_size: "10", page: String(page), class_id: String(activeClassId) });
setPosts(res.items || []);
setTotalPages(res.total_pages || 1);
} catch (err: any) {
setError(err.message || "加载失败");
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!activeClassId) return;
loadPosts();
}, [activeClassId, page]);
/* ---------- File upload helpers ---------- */
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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<number | null>(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<any>(`/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<TimelineComment>(`/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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-gray-500 mt-1"></p>
</div>
<Dialog open={dialogOpen} onOpenChange={(open) => {
setDialogOpen(open);
if (!open) resetForm();
}}>
<DialogTrigger>
<Button></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingId ? "编辑动态" : "发布动态"}</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<Input
placeholder="标题"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
/>
<Textarea
placeholder="内容描述..."
value={newContent}
onChange={(e) => setNewContent(e.target.value)}
rows={4}
/>
{/* Image upload */}
<div>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
multiple
className="hidden"
onChange={handleFileChange}
/>
<div className="flex flex-wrap gap-2">
{/* Existing images (edit mode) */}
{editingImageUrls.map((url, idx) => (
<div key={`existing-${idx}`} className="relative w-20 h-20 rounded-lg overflow-hidden border">
<img src={url} alt="" className="w-full h-full object-cover" />
</div>
))}
{/* New image previews */}
{previewUrls.map((url, idx) => (
<div key={idx} className="relative w-20 h-20 rounded-lg overflow-hidden border">
<img src={url} alt="" className="w-full h-full object-cover" />
<button
type="button"
className="absolute top-0 right-0 w-5 h-5 bg-black/60 text-white text-xs flex items-center justify-center rounded-bl"
onClick={() => removeFile(idx)}
>
x
</button>
</div>
))}
{(selectedFiles.length + editingImageUrls.length) < 9 && (
<button
type="button"
className="w-20 h-20 border-2 border-dashed border-gray-300 rounded-lg flex items-center justify-center text-gray-400 hover:border-gray-400 hover:text-gray-500 transition-colors"
onClick={() => fileInputRef.current?.click()}
>
<span className="text-2xl">+</span>
</button>
)}
</div>
<p className="text-xs text-gray-400 mt-1"> 9 JPG/PNG/GIF/WebP</p>
</div>
<Button onClick={handleCreate} disabled={submitting} className="w-full">
{submitting ? (editingId ? "保存中..." : "发布中...") : (editingId ? "保存" : "发布")}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{loading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Card key={i} className="animate-pulse">
<CardContent className="p-6">
<div className="h-40 bg-gray-200 rounded" />
</CardContent>
</Card>
))}
</div>
) : error ? (
<ErrorState message={error} onRetry={loadPosts} />
) : posts.length === 0 ? (
<div className="text-center py-12 text-gray-400"></div>
) : (
<div className="space-y-6">
{posts.map((post) => (
<Card key={post.id}>
<CardContent className="p-6">
<div className="flex items-start justify-between">
<div>
<h3 className="text-lg font-semibold">{post.title}</h3>
<p className="text-sm text-gray-500 mt-1">
{post.author_name} ·{" "}
{new Date(post.created_at).toLocaleDateString("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
})}
</p>
</div>
{canEditDelete(post) && (
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => openEdit(post)}
>
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-500 hover:text-red-700"
onClick={() => setDeleteTarget(post.id)}
>
</Button>
</div>
)}
</div>
{post.content && (
<p className="mt-4 text-gray-700 whitespace-pre-wrap">
{post.content}
</p>
)}
{post.image_urls && post.image_urls.length > 0 && (
<div className="mt-4 grid grid-cols-2 md:grid-cols-3 gap-2">
{post.image_urls.map((url, idx) => (
<div
key={idx}
className="aspect-video bg-gray-100 rounded-lg overflow-hidden cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => openLightbox(post.image_urls!, idx)}
>
<img
src={url}
alt=""
className="w-full h-full object-cover"
/>
</div>
))}
</div>
)}
{/* Action bar */}
<div className="mt-4 pt-3 border-t flex items-center gap-6 text-sm">
{/* Like button */}
<button
className="flex items-center gap-1.5 transition-colors hover:text-red-500"
onClick={() => handleLike(post.id)}
>
{post.has_liked ? (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-5 h-5 text-red-500"
>
<path d="m11.645 20.91-.007-.003-.022-.012a15.247 15.247 0 0 1-.383-.218 25.18 25.18 0 0 1-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0 1 12 5.052 5.5 5.5 0 0 1 16.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 0 1-4.244 3.17 15.247 15.247 0 0 1-.383.219l-.022.012-.007.004-.003.001a.752.752 0 0 1-.704 0l-.003-.001Z" />
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-5 h-5"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" />
</svg>
)}
<span className={post.has_liked ? "text-red-500 font-medium" : "text-gray-500"}>
{post.like_count > 0 ? post.like_count : "赞"}
</span>
</button>
{/* Comment button */}
<button
className="flex items-center gap-1.5 transition-colors hover:text-blue-500"
onClick={() => toggleComments(post.id)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-5 h-5"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 20.25c4.97 0 9-3.694 9-8.25s-4.03-8.25-9-8.25S3 7.444 3 12c0 2.104.859 4.023 2.273 5.48.432.447.74 1.04.586 1.641a4.483 4.483 0 0 1-.923 1.785A5.969 5.969 0 0 0 6 21c1.282 0 2.47-.402 3.445-1.087.81.22 1.668.337 2.555.337Z" />
</svg>
<span className="text-gray-500">
{post.comment_count > 0 ? post.comment_count : "评论"}
</span>
</button>
</div>
{/* Comment section (expandable) */}
{expandedComments.has(post.id) && (
<div className="mt-3 pt-3 border-t">
{/* Existing comments */}
{post.comments && post.comments.length > 0 && (
<div className="space-y-3 mb-3">
{post.comments.map((comment) => (
<div key={comment.id} className="flex items-start gap-2 group">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-800">{comment.author_name}</span>
<span className="text-xs text-gray-400">{relativeTime(comment.created_at)}</span>
</div>
<p className="text-sm text-gray-600 mt-0.5">{comment.content}</p>
</div>
{canDeleteComment(comment) && (
<button
className="opacity-0 group-hover:opacity-100 transition-opacity text-gray-400 hover:text-red-500 p-1 shrink-0"
title="删除评论"
onClick={() => handleDeleteComment(post.id, comment.id)}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4 h-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
))}
</div>
)}
{/* New comment input */}
<div className="flex gap-2">
<Input
placeholder="写评论..."
value={commentInputs[post.id] || ""}
onChange={(e) =>
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"
/>
<Button
size="sm"
onClick={() => handleAddComment(post.id)}
disabled={submittingComment[post.id] || !(commentInputs[post.id] || "").trim()}
>
</Button>
</div>
</div>
)}
</CardContent>
</Card>
))}
</div>
)}
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
{/* Delete confirmation */}
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}
title="删除动态"
description="确定删除这条动态?此操作不可恢复。"
confirmText="删除"
variant="destructive"
onConfirm={() => deleteTarget && handleDelete(deleteTarget)}
/>
{/* Lightbox */}
{lightboxImages && (
<Lightbox
images={lightboxImages}
initialIndex={lightboxIndex}
onClose={closeLightbox}
/>
)}
</div>
);
}

View File

@ -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<Vote[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<string[]>(["", ""]);
const [submitting, setSubmitting] = useState(false);
// Detail dialog state
const [detailOpen, setDetailOpen] = useState(false);
const [detailVote, setDetailVote] = useState<Vote | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [selectedOptions, setSelectedOptions] = useState<number[]>([]);
const [submittingVote, setSubmittingVote] = useState(false);
// Delete confirm state
const [deleteTarget, setDeleteTarget] = useState<number | null>(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<Vote>(`/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<Vote>(`/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 <Badge className="bg-green-500 text-white text-xs"></Badge>;
}
return <Badge variant="secondary" className="text-xs"></Badge>;
};
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 (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-gray-500 mt-1"></p>
</div>
<Dialog open={createOpen} onOpenChange={(open) => {
setCreateOpen(open);
if (!open) resetCreateForm();
}}>
<DialogTrigger>
<Button></Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
{/* Title */}
<div>
<Label className="mb-1.5 block text-sm font-medium"> *</Label>
<Input
placeholder="投票标题"
value={formTitle}
onChange={(e) => setFormTitle(e.target.value)}
/>
</div>
{/* Description */}
<div>
<Label className="mb-1.5 block text-sm font-medium"></Label>
<Textarea
placeholder="投票描述(可选)"
value={formDesc}
onChange={(e) => setFormDesc(e.target.value)}
rows={3}
/>
</div>
{/* Vote type */}
<div>
<Label className="mb-1.5 block text-sm font-medium"></Label>
<div className="flex gap-2">
<Button
type="button"
variant={formVoteType === "single" ? "default" : "outline"}
size="sm"
onClick={() => setFormVoteType("single")}
>
</Button>
<Button
type="button"
variant={formVoteType === "multiple" ? "default" : "outline"}
size="sm"
onClick={() => setFormVoteType("multiple")}
>
</Button>
</div>
</div>
{/* Max choices (multiple only) */}
{formVoteType === "multiple" && (
<div>
<Label className="mb-1.5 block text-sm font-medium"></Label>
<Input
type="number"
min={2}
max={formOptions.filter((o) => o.trim()).length || 2}
value={formMaxChoices}
onChange={(e) => setFormMaxChoices(parseInt(e.target.value) || 2)}
className="w-24"
/>
</div>
)}
{/* Anonymous */}
<div className="flex items-center gap-3">
<Switch
id="anonymous"
checked={formAnonymous}
onCheckedChange={setFormAnonymous}
/>
<Label htmlFor="anonymous" className="cursor-pointer"></Label>
</div>
{/* Deadline */}
<div>
<Label className="mb-1.5 block text-sm font-medium"></Label>
<Input
type="datetime-local"
value={formDeadline}
onChange={(e) => setFormDeadline(e.target.value)}
/>
</div>
{/* Options */}
<div>
<Label className="mb-1.5 block text-sm font-medium"> *</Label>
<div className="space-y-2">
{formOptions.map((opt, i) => (
<div key={i} className="flex gap-2">
<Input
placeholder={`选项 ${i + 1}`}
value={opt}
onChange={(e) => updateOption(i, e.target.value)}
/>
{formOptions.length > 2 && (
<Button
type="button"
variant="ghost"
size="sm"
className="text-red-500 shrink-0"
onClick={() => removeOption(i)}
>
</Button>
)}
</div>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
className="mt-2"
onClick={addOption}
>
+
</Button>
</div>
{/* Submit */}
<Button onClick={handleCreate} disabled={submitting} className="w-full">
{submitting ? "创建中..." : "创建投票"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{/* Loading */}
{loading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Card key={i} className="animate-pulse">
<CardContent className="p-6">
<div className="h-24 bg-gray-200 rounded" />
</CardContent>
</Card>
))}
</div>
) : error ? (
<ErrorState message={error} onRetry={loadVotes} />
) : votes.length === 0 ? (
<div className="text-center py-12 text-gray-400"></div>
) : (
<div className="space-y-4">
{votes.map((vote) => (
<Card
key={vote.id}
className="cursor-pointer hover:shadow-md transition-shadow"
onClick={() => openDetail(vote.id)}
>
<CardContent className="p-6">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
{renderStatusBadge(vote.status)}
{vote.is_anonymous && (
<Badge variant="outline" className="text-xs"></Badge>
)}
<Badge variant="outline" className="text-xs">
{vote.vote_type === "single" ? "单选" : "多选"}
</Badge>
<h3 className="text-lg font-semibold truncate">{vote.title}</h3>
</div>
{vote.description && (
<p className="mt-2 text-gray-600 text-sm line-clamp-2">{vote.description}</p>
)}
<div className="mt-3 flex items-center gap-4 text-sm text-gray-400 flex-wrap">
<span>{vote.creator_name}</span>
<span>{formatDeadline(vote.deadline)}</span>
<span>{vote.total_voters} </span>
</div>
</div>
<div className="shrink-0">
{vote.has_voted ? (
<Badge className="bg-gray-100 text-gray-500 text-xs"></Badge>
) : vote.status === "open" ? (
<Badge className="bg-blue-500 text-white text-xs"></Badge>
) : null}
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
{/* Detail dialog */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{detailLoading ? (
<div className="py-8 text-center text-gray-400">...</div>
) : detailVote ? (
<div className="space-y-5 pt-2">
{/* Header info */}
<div>
<div className="flex items-center gap-2 flex-wrap mb-2">
{renderStatusBadge(detailVote.status)}
{detailVote.is_anonymous && (
<Badge variant="outline" className="text-xs"></Badge>
)}
<Badge variant="outline" className="text-xs">
{detailVote.vote_type === "single" ? "单选" : `多选(最多${detailVote.max_choices}项)`}
</Badge>
</div>
<h3 className="text-lg font-semibold">{detailVote.title}</h3>
{detailVote.description && (
<p className="mt-1 text-gray-600 text-sm">{detailVote.description}</p>
)}
<div className="mt-2 flex items-center gap-4 text-sm text-gray-400 flex-wrap">
<span>: {detailVote.creator_name}</span>
<span>: {formatDeadline(detailVote.deadline)}</span>
<span>{detailVote.total_voters} </span>
</div>
</div>
{/* Options: vote or results */}
{detailVote.status === "open" && !detailVote.has_voted ? (
/* Voting form */
<div className="space-y-3">
<p className="text-sm font-medium">
{detailVote.vote_type === "single" ? "请选择一个选项:" : `请选择选项(最多${detailVote.max_choices}项):`}
</p>
{detailVote.options.map((option) => (
<label
key={option.id}
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selectedOptions.includes(option.id)
? "border-blue-500 bg-blue-50"
: "border-gray-200 hover:border-gray-300"
}`}
>
<input
type={detailVote.vote_type === "single" ? "radio" : "checkbox"}
name="vote-option"
className="shrink-0"
checked={selectedOptions.includes(option.id)}
onChange={() => {
if (detailVote.vote_type === "single") {
setSelectedOptions([option.id]);
} else {
setSelectedOptions((prev) =>
prev.includes(option.id)
? prev.filter((id) => id !== option.id)
: prev.length < detailVote.max_choices
? [...prev, option.id]
: (toast.error(`最多只能选择${detailVote.max_choices}`), prev)
);
}
}}
/>
<span className="text-sm">{option.content}</span>
</label>
))}
<Button
onClick={handleSubmitVote}
disabled={submittingVote || selectedOptions.length === 0}
className="w-full"
>
{submittingVote ? "提交中..." : "提交投票"}
</Button>
</div>
) : (
/* Results */
<div className="space-y-3">
{detailVote.options.map((option) => {
const pct = getPercentage(option.vote_count, detailVote.total_voters);
const isMyChoice = detailVote.my_option_ids?.includes(option.id);
return (
<div key={option.id}>
<div className="flex items-center justify-between mb-1">
<span className={`text-sm ${isMyChoice ? "font-semibold text-blue-600" : ""}`}>
{option.content}
{isMyChoice && <span className="ml-1 text-xs text-blue-500">()</span>}
</span>
<span className="text-sm text-gray-500">
{option.vote_count} ({pct}%)
</span>
</div>
<div className="w-full bg-gray-100 rounded-full h-2.5">
<div
className={`h-2.5 rounded-full transition-all ${
isMyChoice ? "bg-blue-500" : "bg-gray-400"
}`}
style={{ width: `${pct}%` }}
/>
</div>
{/* Voter names */}
{!detailVote.is_anonymous &&
option.voter_names &&
option.voter_names.length > 0 && (
<p className="text-xs text-gray-400 mt-1">
{option.voter_names.join("、")}
</p>
)}
</div>
);
})}
</div>
)}
{/* Actions for creator/admin */}
{canManage(detailVote) && (
<div className="flex gap-2 pt-2 border-t">
{detailVote.status === "open" && (
<Button
variant="outline"
size="sm"
onClick={() => handleCloseVote(detailVote.id)}
>
</Button>
)}
<Button
variant="ghost"
size="sm"
className="text-red-500"
onClick={() => setDeleteTarget(detailVote.id)}
>
</Button>
</div>
)}
</div>
) : null}
</DialogContent>
</Dialog>
{/* Delete confirm */}
<Dialog open={deleteTarget !== null} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<p className="text-gray-500 text-sm"></p>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" size="sm" onClick={() => setDeleteTarget(null)}>
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => deleteTarget && handleDelete(deleteTarget)}
>
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

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

View File

@ -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 (
<html
lang="zh-CN"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">
<AuthProvider>
<AuthGuard>
{children}
</AuthGuard>
<Toaster />
</AuthProvider>
</body>
</html>
);
}

View File

@ -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 (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">HKU ICB</CardTitle>
<CardDescription> - </CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Input
id="email"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
placeholder="请输入密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && (
<p className="text-sm text-red-600 bg-red-50 p-3 rounded-lg">
{error}
</p>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "登录中..." : "登录"}
</Button>
</form>
<div className="mt-4 text-center text-sm text-gray-600">
{" "}
<Link href="/register" className="text-blue-600 hover:underline">
</Link>
</div>
</CardContent>
</Card>
</div>
);
}

27
frontend/src/app/page.tsx Normal file
View File

@ -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 (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900" />
</div>
);
}

View File

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

View File

@ -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<any>("/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 (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">HKU ICB</CardTitle>
<CardDescription> - </CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="inviteCode"></Label>
<Input
id="inviteCode"
placeholder="请输入班级邀请码"
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value.toUpperCase())}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="studentId"></Label>
<Input
id="studentId"
placeholder="请输入学号"
value={studentId}
onChange={(e) => setStudentId(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Input
id="name"
placeholder="请输入真实姓名"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Input
id="email"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
placeholder="至少6位"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword"></Label>
<Input
id="confirmPassword"
type="password"
placeholder="再次输入密码"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
</div>
{error && (
<p className="text-sm text-red-600 bg-red-50 p-3 rounded-lg">
{error}
</p>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "注册中..." : "注册"}
</Button>
</form>
<div className="mt-4 text-center text-sm text-gray-600">
{" "}
<Link href="/login" className="text-blue-600 hover:underline">
</Link>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -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 (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900" />
</div>
);
}
if (!user && !isPublicPath) return null;
return <>{children}</>;
}

View File

@ -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<Date | null>(null);
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
// Get events grouped by date
const eventsByDate = useMemo(() => {
const map = new Map<string, ScheduleItem[]>();
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 (
<div className="space-y-4">
{/* Navigation */}
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">
{year} {month + 1}
</h3>
<div className="flex gap-1">
<Button variant="outline" size="sm" onClick={prevMonth}>
&lt;
</Button>
<Button variant="outline" size="sm" onClick={goToday}>
</Button>
<Button variant="outline" size="sm" onClick={nextMonth}>
&gt;
</Button>
</div>
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7 gap-px bg-gray-200 rounded-lg overflow-hidden">
{/* Week day headers */}
{weekDays.map((d) => (
<div
key={d}
className="bg-gray-50 text-center text-xs font-medium text-gray-500 py-2"
>
{d}
</div>
))}
{/* Leading empty cells */}
{Array.from({ length: startDayOfWeek }, (_, i) => (
<div key={`empty-${i}`} className="bg-white min-h-[80px] p-1" />
))}
{/* 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 (
<div
key={day}
className={`bg-white min-h-[80px] p-1 cursor-pointer hover:bg-gray-50 transition-colors ${
isSelected ? "ring-2 ring-blue-500 ring-inset" : ""
} ${isToday(day) ? "bg-blue-50" : ""}`}
onClick={() => setSelectedDate(new Date(year, month, day))}
>
<span
className={`text-sm ${
isToday(day)
? "font-bold text-blue-600"
: "text-gray-700"
}`}
>
{day}
</span>
<div className="mt-1 space-y-0.5">
{dayEvents.slice(0, 3).map((event, idx) => {
const typeInfo = SCHEDULE_TYPES[event.type] || { label: event.type, color: "bg-gray-400" };
return (
<div
key={idx}
className="flex items-center gap-1 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onEventClick?.(event);
}}
>
<div className={`w-1.5 h-1.5 rounded-full ${typeInfo.color} shrink-0`} />
<span className="text-[10px] text-gray-600 truncate">
{event.title}
</span>
</div>
);
})}
{dayEvents.length > 3 && (
<span className="text-[10px] text-gray-400">
+{dayEvents.length - 3}
</span>
)}
</div>
</div>
);
})}
{/* Trailing empty cells */}
{Array.from({ length: (7 - (startDayOfWeek + daysInMonth) % 7) % 7 }, (_, i) => (
<div key={`trail-${i}`} className="bg-white min-h-[80px] p-1" />
))}
</div>
{/* Selected date detail */}
{selectedDate && selectedEvents.length > 0 && (
<div className="border rounded-lg p-4">
<h4 className="text-sm font-medium text-gray-500 mb-2">
{selectedDate.getMonth() + 1} {selectedDate.getDate()}
</h4>
<div className="space-y-2">
{selectedEvents.map((event) => {
const typeInfo = SCHEDULE_TYPES[event.type] || { label: event.type, color: "bg-gray-400" };
return (
<div
key={event.id}
className="flex items-center justify-between p-2 bg-gray-50 rounded cursor-pointer hover:bg-gray-100"
onClick={() => onEventClick?.(event)}
>
<div className="flex items-center gap-2">
<div className={`w-3 h-3 rounded-full ${typeInfo.color}`} />
<div>
<p className="text-sm font-medium">{event.title}</p>
<p className="text-xs text-gray-500">
{new Date(event.start_time).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
})}
{event.location ? ` · ${event.location}` : ""}
</p>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More