first commit
This commit is contained in:
commit
f4aae08b83
21
.claude/settings.local.json
Normal file
21
.claude/settings.local.json
Normal 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
41
.gitignore
vendored
Normal 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
10
backend/.dockerignore
Normal file
@ -0,0 +1,10 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
*.db
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
alembic/versions/
|
||||
33
backend/.env.example
Normal file
33
backend/.env.example
Normal 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
17
backend/Dockerfile
Normal 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
36
backend/alembic.ini
Normal 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
57
backend/alembic/env.py
Normal 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()
|
||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal 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
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
121
backend/app/api/announcements.py
Normal file
121
backend/app/api/announcements.py
Normal 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"}
|
||||
276
backend/app/api/assignments.py
Normal file
276
backend/app/api/assignments.py
Normal 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
93
backend/app/api/auth.py
Normal 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
270
backend/app/api/classes.py
Normal 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}
|
||||
57
backend/app/api/directory.py
Normal file
57
backend/app/api/directory.py
Normal 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)
|
||||
74
backend/app/api/notifications.py
Normal file
74
backend/app/api/notifications.py
Normal 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"}
|
||||
153
backend/app/api/resources.py
Normal file
153
backend/app/api/resources.py
Normal 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
104
backend/app/api/schedule.py
Normal 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
262
backend/app/api/timeline.py
Normal 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
24
backend/app/api/upload.py
Normal 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
123
backend/app/api/users.py
Normal 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
177
backend/app/api/votes.py
Normal 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
43
backend/app/config.py
Normal 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()
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
31
backend/app/core/auth.py
Normal file
31
backend/app/core/auth.py
Normal 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
57
backend/app/core/deps.py
Normal 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
|
||||
0
backend/app/db/__init__.py
Normal file
0
backend/app/db/__init__.py
Normal file
5
backend/app/db/base.py
Normal file
5
backend/app/db/base.py
Normal file
@ -0,0 +1,5 @@
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
17
backend/app/db/database.py
Normal file
17
backend/app/db/database.py
Normal 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
416
backend/app/db/models.py
Normal 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
99
backend/app/main.py
Normal 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"}
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
27
backend/app/schemas/announcement.py
Normal file
27
backend/app/schemas/announcement.py
Normal 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
|
||||
63
backend/app/schemas/assignment.py
Normal file
63
backend/app/schemas/assignment.py
Normal 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
|
||||
19
backend/app/schemas/auth.py
Normal file
19
backend/app/schemas/auth.py
Normal 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
|
||||
27
backend/app/schemas/class_.py
Normal file
27
backend/app/schemas/class_.py
Normal 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}
|
||||
24
backend/app/schemas/common.py
Normal file
24
backend/app/schemas/common.py
Normal 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
|
||||
17
backend/app/schemas/notification.py
Normal file
17
backend/app/schemas/notification.py
Normal 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
|
||||
24
backend/app/schemas/resource.py
Normal file
24
backend/app/schemas/resource.py
Normal 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
|
||||
15
backend/app/schemas/roster.py
Normal file
15
backend/app/schemas/roster.py
Normal 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": "..."}, ...]
|
||||
35
backend/app/schemas/schedule.py
Normal file
35
backend/app/schemas/schedule.py
Normal 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}
|
||||
43
backend/app/schemas/timeline.py
Normal file
43
backend/app/schemas/timeline.py
Normal 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
|
||||
86
backend/app/schemas/user.py
Normal file
86
backend/app/schemas/user.py
Normal 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
|
||||
51
backend/app/schemas/vote.py
Normal file
51
backend/app/schemas/vote.py
Normal 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
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
79
backend/app/services/announcement_service.py
Normal file
79
backend/app/services/announcement_service.py
Normal 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
|
||||
179
backend/app/services/assignment_service.py
Normal file
179
backend/app/services/assignment_service.py
Normal 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())
|
||||
79
backend/app/services/class_service.py
Normal file
79
backend/app/services/class_service.py
Normal 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
|
||||
75
backend/app/services/cos_service.py
Normal file
75
backend/app/services/cos_service.py
Normal 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)
|
||||
77
backend/app/services/directory_service.py
Normal file
77
backend/app/services/directory_service.py
Normal 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,
|
||||
)
|
||||
95
backend/app/services/email_service.py
Normal file
95
backend/app/services/email_service.py
Normal 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)
|
||||
132
backend/app/services/notification_service.py
Normal file
132
backend/app/services/notification_service.py
Normal 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
|
||||
75
backend/app/services/resource_service.py
Normal file
75
backend/app/services/resource_service.py
Normal 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()
|
||||
125
backend/app/services/roster_service.py
Normal file
125
backend/app/services/roster_service.py
Normal 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)
|
||||
93
backend/app/services/schedule_service.py
Normal file
93
backend/app/services/schedule_service.py
Normal 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())
|
||||
177
backend/app/services/timeline_service.py
Normal file
177
backend/app/services/timeline_service.py
Normal 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
|
||||
80
backend/app/services/user_service.py
Normal file
80
backend/app/services/user_service.py
Normal 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
|
||||
141
backend/app/services/vote_service.py
Normal file
141
backend/app/services/vote_service.py
Normal 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
13
backend/requirements.txt
Normal 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
42
docker-compose.yml
Normal 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
7
frontend/.dockerignore
Normal file
@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
.env.local
|
||||
.env*.local
|
||||
*.tsbuildinfo
|
||||
.git/
|
||||
41
frontend/.gitignore
vendored
Normal file
41
frontend/.gitignore
vendored
Normal 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
5
frontend/AGENTS.md
Normal 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
1
frontend/CLAUDE.md
Normal file
@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
34
frontend/Dockerfile
Normal file
34
frontend/Dockerfile
Normal 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
36
frontend/README.md
Normal 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
25
frontend/components.json
Normal 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": {}
|
||||
}
|
||||
18
frontend/eslint.config.mjs
Normal file
18
frontend/eslint.config.mjs
Normal 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
8
frontend/next.config.ts
Normal 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
35
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
frontend/postcss.config.mjs
Normal file
7
frontend/postcss.config.mjs
Normal file
@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
frontend/public/file.svg
Normal file
1
frontend/public/file.svg
Normal 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 |
1
frontend/public/globe.svg
Normal file
1
frontend/public/globe.svg
Normal 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
1
frontend/public/next.svg
Normal 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 |
1
frontend/public/vercel.svg
Normal file
1
frontend/public/vercel.svg
Normal 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 |
1
frontend/public/window.svg
Normal file
1
frontend/public/window.svg
Normal 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 |
218
frontend/src/app/(app)/admin/classes/page.tsx
Normal file
218
frontend/src/app/(app)/admin/classes/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
549
frontend/src/app/(app)/admin/members/page.tsx
Normal file
549
frontend/src/app/(app)/admin/members/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
frontend/src/app/(app)/admin/page.tsx
Normal file
49
frontend/src/app/(app)/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
245
frontend/src/app/(app)/announcements/page.tsx
Normal file
245
frontend/src/app/(app)/announcements/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
586
frontend/src/app/(app)/assignments/[id]/page.tsx
Normal file
586
frontend/src/app/(app)/assignments/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
334
frontend/src/app/(app)/assignments/page.tsx
Normal file
334
frontend/src/app/(app)/assignments/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
238
frontend/src/app/(app)/dashboard/page.tsx
Normal file
238
frontend/src/app/(app)/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
frontend/src/app/(app)/directory/[id]/page.tsx
Normal file
111
frontend/src/app/(app)/directory/[id]/page.tsx
Normal 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">
|
||||
← 返回花名册
|
||||
</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">
|
||||
← 返回花名册
|
||||
</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>
|
||||
);
|
||||
}
|
||||
156
frontend/src/app/(app)/directory/page.tsx
Normal file
156
frontend/src/app/(app)/directory/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
frontend/src/app/(app)/layout.tsx
Normal file
25
frontend/src/app/(app)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
222
frontend/src/app/(app)/profile/page.tsx
Normal file
222
frontend/src/app/(app)/profile/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
293
frontend/src/app/(app)/resources/page.tsx
Normal file
293
frontend/src/app/(app)/resources/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
405
frontend/src/app/(app)/schedule/page.tsx
Normal file
405
frontend/src/app/(app)/schedule/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
688
frontend/src/app/(app)/timeline/page.tsx
Normal file
688
frontend/src/app/(app)/timeline/page.tsx
Normal 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}
|
||||
>
|
||||
×
|
||||
</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(); }}
|
||||
>
|
||||
‹
|
||||
</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(); }}
|
||||
>
|
||||
›
|
||||
</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>
|
||||
);
|
||||
}
|
||||
623
frontend/src/app/(app)/votes/page.tsx
Normal file
623
frontend/src/app/(app)/votes/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
BIN
frontend/src/app/favicon.ico
Normal file
BIN
frontend/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
130
frontend/src/app/globals.css
Normal file
130
frontend/src/app/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
43
frontend/src/app/layout.tsx
Normal file
43
frontend/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
frontend/src/app/login/page.tsx
Normal file
84
frontend/src/app/login/page.tsx
Normal 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
27
frontend/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
frontend/src/app/pending/page.tsx
Normal file
37
frontend/src/app/pending/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
frontend/src/app/register/page.tsx
Normal file
152
frontend/src/app/register/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
frontend/src/components/auth-guard.tsx
Normal file
33
frontend/src/components/auth-guard.tsx
Normal 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}</>;
|
||||
}
|
||||
190
frontend/src/components/calendar-view.tsx
Normal file
190
frontend/src/components/calendar-view.tsx
Normal 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}>
|
||||
<
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={goToday}>
|
||||
今天
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={nextMonth}>
|
||||
>
|
||||
</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
Loading…
Reference in New Issue
Block a user