From f4aae08b836fb2a3d190684e38a028dd70ba7884 Mon Sep 17 00:00:00 2001 From: aaron <> Date: Sun, 12 Apr 2026 18:15:38 +0800 Subject: [PATCH] first commit --- .claude/settings.local.json | 21 + .gitignore | 41 ++ backend/.dockerignore | 10 + backend/.env.example | 33 + backend/Dockerfile | 17 + backend/alembic.ini | 36 + backend/alembic/env.py | 57 ++ backend/alembic/script.py.mako | 26 + backend/app/__init__.py | 0 backend/app/api/__init__.py | 0 backend/app/api/announcements.py | 121 +++ backend/app/api/assignments.py | 276 +++++++ backend/app/api/auth.py | 93 +++ backend/app/api/classes.py | 270 +++++++ backend/app/api/directory.py | 57 ++ backend/app/api/notifications.py | 74 ++ backend/app/api/resources.py | 153 ++++ backend/app/api/schedule.py | 104 +++ backend/app/api/timeline.py | 262 +++++++ backend/app/api/upload.py | 24 + backend/app/api/users.py | 123 ++++ backend/app/api/votes.py | 177 +++++ backend/app/config.py | 43 ++ backend/app/core/__init__.py | 0 backend/app/core/auth.py | 31 + backend/app/core/deps.py | 57 ++ backend/app/db/__init__.py | 0 backend/app/db/base.py | 5 + backend/app/db/database.py | 17 + backend/app/db/models.py | 416 +++++++++++ backend/app/main.py | 99 +++ backend/app/schemas/__init__.py | 0 backend/app/schemas/announcement.py | 27 + backend/app/schemas/assignment.py | 63 ++ backend/app/schemas/auth.py | 19 + backend/app/schemas/class_.py | 27 + backend/app/schemas/common.py | 24 + backend/app/schemas/notification.py | 17 + backend/app/schemas/resource.py | 24 + backend/app/schemas/roster.py | 15 + backend/app/schemas/schedule.py | 35 + backend/app/schemas/timeline.py | 43 ++ backend/app/schemas/user.py | 86 +++ backend/app/schemas/vote.py | 51 ++ backend/app/services/__init__.py | 0 backend/app/services/announcement_service.py | 79 ++ backend/app/services/assignment_service.py | 179 +++++ backend/app/services/class_service.py | 79 ++ backend/app/services/cos_service.py | 75 ++ backend/app/services/directory_service.py | 77 ++ backend/app/services/email_service.py | 95 +++ backend/app/services/notification_service.py | 132 ++++ backend/app/services/resource_service.py | 75 ++ backend/app/services/roster_service.py | 125 ++++ backend/app/services/schedule_service.py | 93 +++ backend/app/services/timeline_service.py | 177 +++++ backend/app/services/user_service.py | 80 ++ backend/app/services/vote_service.py | 141 ++++ backend/requirements.txt | 13 + docker-compose.yml | 42 ++ frontend/.dockerignore | 7 + frontend/.gitignore | 41 ++ frontend/AGENTS.md | 5 + frontend/CLAUDE.md | 1 + frontend/Dockerfile | 34 + frontend/README.md | 36 + frontend/components.json | 25 + frontend/eslint.config.mjs | 18 + frontend/next.config.ts | 8 + frontend/package.json | 35 + frontend/postcss.config.mjs | 7 + frontend/public/file.svg | 1 + frontend/public/globe.svg | 1 + frontend/public/next.svg | 1 + frontend/public/vercel.svg | 1 + frontend/public/window.svg | 1 + frontend/src/app/(app)/admin/classes/page.tsx | 218 ++++++ frontend/src/app/(app)/admin/members/page.tsx | 549 ++++++++++++++ frontend/src/app/(app)/admin/page.tsx | 49 ++ frontend/src/app/(app)/announcements/page.tsx | 245 +++++++ .../src/app/(app)/assignments/[id]/page.tsx | 586 +++++++++++++++ frontend/src/app/(app)/assignments/page.tsx | 334 +++++++++ frontend/src/app/(app)/dashboard/page.tsx | 238 ++++++ .../src/app/(app)/directory/[id]/page.tsx | 111 +++ frontend/src/app/(app)/directory/page.tsx | 156 ++++ frontend/src/app/(app)/layout.tsx | 25 + frontend/src/app/(app)/profile/page.tsx | 222 ++++++ frontend/src/app/(app)/resources/page.tsx | 293 ++++++++ frontend/src/app/(app)/schedule/page.tsx | 405 +++++++++++ frontend/src/app/(app)/timeline/page.tsx | 688 ++++++++++++++++++ frontend/src/app/(app)/votes/page.tsx | 623 ++++++++++++++++ frontend/src/app/favicon.ico | Bin 0 -> 25931 bytes frontend/src/app/globals.css | 130 ++++ frontend/src/app/layout.tsx | 43 ++ frontend/src/app/login/page.tsx | 84 +++ frontend/src/app/page.tsx | 27 + frontend/src/app/pending/page.tsx | 37 + frontend/src/app/register/page.tsx | 152 ++++ frontend/src/components/auth-guard.tsx | 33 + frontend/src/components/calendar-view.tsx | 190 +++++ frontend/src/components/confirm-dialog.tsx | 57 ++ frontend/src/components/error-state.tsx | 22 + frontend/src/components/header.tsx | 243 +++++++ frontend/src/components/pagination.tsx | 67 ++ frontend/src/components/role-guard.tsx | 16 + frontend/src/components/sidebar.tsx | 127 ++++ frontend/src/components/ui/alert-dialog.tsx | 187 +++++ frontend/src/components/ui/avatar.tsx | 109 +++ frontend/src/components/ui/badge.tsx | 52 ++ frontend/src/components/ui/button.tsx | 58 ++ frontend/src/components/ui/card.tsx | 103 +++ frontend/src/components/ui/dialog.tsx | 160 ++++ frontend/src/components/ui/dropdown-menu.tsx | 268 +++++++ frontend/src/components/ui/input.tsx | 20 + frontend/src/components/ui/label.tsx | 20 + frontend/src/components/ui/popover.tsx | 90 +++ frontend/src/components/ui/select.tsx | 201 +++++ frontend/src/components/ui/separator.tsx | 25 + frontend/src/components/ui/sheet.tsx | 138 ++++ frontend/src/components/ui/skeleton.tsx | 13 + frontend/src/components/ui/sonner.tsx | 49 ++ frontend/src/components/ui/switch.tsx | 32 + frontend/src/components/ui/tabs.tsx | 82 +++ frontend/src/components/ui/textarea.tsx | 18 + frontend/src/hooks/use-active-class.tsx | 102 +++ frontend/src/hooks/use-auth.tsx | 85 +++ frontend/src/hooks/use-notifications.tsx | 94 +++ frontend/src/hooks/use-sidebar.tsx | 31 + frontend/src/lib/api.ts | 97 +++ frontend/src/lib/constants.ts | 33 + frontend/src/lib/types.ts | 217 ++++++ frontend/src/lib/utils.ts | 6 + frontend/tsconfig.json | 34 + nginx/nginx.conf | 29 + 134 files changed, 13081 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .gitignore create mode 100644 backend/.dockerignore create mode 100644 backend/.env.example create mode 100644 backend/Dockerfile create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/announcements.py create mode 100644 backend/app/api/assignments.py create mode 100644 backend/app/api/auth.py create mode 100644 backend/app/api/classes.py create mode 100644 backend/app/api/directory.py create mode 100644 backend/app/api/notifications.py create mode 100644 backend/app/api/resources.py create mode 100644 backend/app/api/schedule.py create mode 100644 backend/app/api/timeline.py create mode 100644 backend/app/api/upload.py create mode 100644 backend/app/api/users.py create mode 100644 backend/app/api/votes.py create mode 100644 backend/app/config.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/auth.py create mode 100644 backend/app/core/deps.py create mode 100644 backend/app/db/__init__.py create mode 100644 backend/app/db/base.py create mode 100644 backend/app/db/database.py create mode 100644 backend/app/db/models.py create mode 100644 backend/app/main.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/announcement.py create mode 100644 backend/app/schemas/assignment.py create mode 100644 backend/app/schemas/auth.py create mode 100644 backend/app/schemas/class_.py create mode 100644 backend/app/schemas/common.py create mode 100644 backend/app/schemas/notification.py create mode 100644 backend/app/schemas/resource.py create mode 100644 backend/app/schemas/roster.py create mode 100644 backend/app/schemas/schedule.py create mode 100644 backend/app/schemas/timeline.py create mode 100644 backend/app/schemas/user.py create mode 100644 backend/app/schemas/vote.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/announcement_service.py create mode 100644 backend/app/services/assignment_service.py create mode 100644 backend/app/services/class_service.py create mode 100644 backend/app/services/cos_service.py create mode 100644 backend/app/services/directory_service.py create mode 100644 backend/app/services/email_service.py create mode 100644 backend/app/services/notification_service.py create mode 100644 backend/app/services/resource_service.py create mode 100644 backend/app/services/roster_service.py create mode 100644 backend/app/services/schedule_service.py create mode 100644 backend/app/services/timeline_service.py create mode 100644 backend/app/services/user_service.py create mode 100644 backend/app/services/vote_service.py create mode 100644 backend/requirements.txt create mode 100644 docker-compose.yml create mode 100644 frontend/.dockerignore create mode 100644 frontend/.gitignore create mode 100644 frontend/AGENTS.md create mode 100644 frontend/CLAUDE.md create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/components.json create mode 100644 frontend/eslint.config.mjs create mode 100644 frontend/next.config.ts create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.mjs create mode 100644 frontend/public/file.svg create mode 100644 frontend/public/globe.svg create mode 100644 frontend/public/next.svg create mode 100644 frontend/public/vercel.svg create mode 100644 frontend/public/window.svg create mode 100644 frontend/src/app/(app)/admin/classes/page.tsx create mode 100644 frontend/src/app/(app)/admin/members/page.tsx create mode 100644 frontend/src/app/(app)/admin/page.tsx create mode 100644 frontend/src/app/(app)/announcements/page.tsx create mode 100644 frontend/src/app/(app)/assignments/[id]/page.tsx create mode 100644 frontend/src/app/(app)/assignments/page.tsx create mode 100644 frontend/src/app/(app)/dashboard/page.tsx create mode 100644 frontend/src/app/(app)/directory/[id]/page.tsx create mode 100644 frontend/src/app/(app)/directory/page.tsx create mode 100644 frontend/src/app/(app)/layout.tsx create mode 100644 frontend/src/app/(app)/profile/page.tsx create mode 100644 frontend/src/app/(app)/resources/page.tsx create mode 100644 frontend/src/app/(app)/schedule/page.tsx create mode 100644 frontend/src/app/(app)/timeline/page.tsx create mode 100644 frontend/src/app/(app)/votes/page.tsx create mode 100644 frontend/src/app/favicon.ico create mode 100644 frontend/src/app/globals.css create mode 100644 frontend/src/app/layout.tsx create mode 100644 frontend/src/app/login/page.tsx create mode 100644 frontend/src/app/page.tsx create mode 100644 frontend/src/app/pending/page.tsx create mode 100644 frontend/src/app/register/page.tsx create mode 100644 frontend/src/components/auth-guard.tsx create mode 100644 frontend/src/components/calendar-view.tsx create mode 100644 frontend/src/components/confirm-dialog.tsx create mode 100644 frontend/src/components/error-state.tsx create mode 100644 frontend/src/components/header.tsx create mode 100644 frontend/src/components/pagination.tsx create mode 100644 frontend/src/components/role-guard.tsx create mode 100644 frontend/src/components/sidebar.tsx create mode 100644 frontend/src/components/ui/alert-dialog.tsx create mode 100644 frontend/src/components/ui/avatar.tsx create mode 100644 frontend/src/components/ui/badge.tsx create mode 100644 frontend/src/components/ui/button.tsx create mode 100644 frontend/src/components/ui/card.tsx create mode 100644 frontend/src/components/ui/dialog.tsx create mode 100644 frontend/src/components/ui/dropdown-menu.tsx create mode 100644 frontend/src/components/ui/input.tsx create mode 100644 frontend/src/components/ui/label.tsx create mode 100644 frontend/src/components/ui/popover.tsx create mode 100644 frontend/src/components/ui/select.tsx create mode 100644 frontend/src/components/ui/separator.tsx create mode 100644 frontend/src/components/ui/sheet.tsx create mode 100644 frontend/src/components/ui/skeleton.tsx create mode 100644 frontend/src/components/ui/sonner.tsx create mode 100644 frontend/src/components/ui/switch.tsx create mode 100644 frontend/src/components/ui/tabs.tsx create mode 100644 frontend/src/components/ui/textarea.tsx create mode 100644 frontend/src/hooks/use-active-class.tsx create mode 100644 frontend/src/hooks/use-auth.tsx create mode 100644 frontend/src/hooks/use-notifications.tsx create mode 100644 frontend/src/hooks/use-sidebar.tsx create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/constants.ts create mode 100644 frontend/src/lib/types.ts create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/tsconfig.json create mode 100644 nginx/nginx.conf diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..bf5e79e --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,21 @@ +{ + "permissions": { + "allow": [ + "Bash(# Kill all existing uvicorn processes and restart fresh pkill -f \"\"uvicorn app.main\"\" ; sleep 1 rm -f /Users/aaron/source_code/hku-icb-class/backend/classhub.db source .venv/bin/activate && uvicorn app.main:app --host 127.0.0.1 --port 8001 & sleep 3 echo \"\"=== 1. Health ===\"\" curl -s http://127.0.0.1:8001/api/health)", + "Bash(__NEW_LINE_aa4d9e75e4b994e8__ echo \"=== 2. Get Classes ===\")", + "Bash(__NEW_LINE_43deffae9fb2cfb0__ echo \"=== 3. Register ===\")", + "Bash(__NEW_LINE_efb029665c5ac199__ echo \"=== 4. Login as Admin ===\")", + "Bash(pkill:*)", + "Bash(npx shadcn@latest init:*)", + "Bash(npx shadcn@latest add:*)", + "Bash(npm --prefix /Users/aaron/source_code/hku-icb-class/frontend run build)", + "Bash(/Users/aaron/source_code/hku-icb-class/frontend/node_modules/.bin/tsc --noEmit --project /Users/aaron/source_code/hku-icb-class/frontend/tsconfig.json)", + "Bash(test:*)", + "Bash(/Users/aaron/source_code/hku-icb-class/backend/.venv/bin/python:*)", + "Bash(.venv/bin/python:*)", + "Bash(backend/.venv/bin/pip show:*)", + "Bash(uv run python:*)", + "Bash(grep:*)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..47d36d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +*.egg +.venv/ +venv/ +env/ + +# Backend +backend/.env +backend/*.db + +# Node +frontend/node_modules/ +frontend/.next/ +frontend/out/ + +# Frontend env +frontend/.env +frontend/.env.local +frontend/.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# TypeScript build cache +frontend/tsconfig.tsbuildinfo + +# Logs +*.log diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..b6795e5 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,10 @@ +.venv/ +__pycache__/ +*.pyc +*.pyo +.env +*.db +.git +.gitignore +*.md +alembic/versions/ diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..367fb5c --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,33 @@ +# Service +CH_HOST=0.0.0.0 +CH_PORT=8000 +CH_DEBUG=false + +# Database (Docker 部署时使用 volume 路径) +CH_DATABASE_URL=sqlite+aiosqlite:///./data/classhub.db + +# JWT (务必修改 secret) +CH_JWT_SECRET=change-me-in-production +CH_JWT_EXPIRY_HOURS=72 + +# Tencent COS (对象存储) +CH_COS_SECRET_ID=your-cos-secret-id +CH_COS_SECRET_KEY=your-cos-secret-key +CH_COS_REGION=ap-guangzhou +CH_COS_BUCKET=your-bucket-name +CH_COS_BASE_URL=https://your-bucket.cos.ap-guangzhou.myqcloud.com + +# SMTP Email (邮件通知) +CH_SMTP_HOST=smtp.example.com +CH_SMTP_PORT=465 +CH_SMTP_USER=noreply@example.com +CH_SMTP_PASSWORD=your-smtp-password +CH_SMTP_FROM_EMAIL=noreply@example.com +CH_SMTP_FROM_NAME=HKU ICB Class Hub + +# Frontend URL (CORS 用) +CH_FRONTEND_URL=http://your-server-ip + +# Super Admin Seed (首次启动自动创建) +CH_SUPER_ADMIN_EMAIL=admin@example.com +CH_SUPER_ADMIN_PASSWORD=change-me-please diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..613e19f --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.13-slim + +WORKDIR /app + +# Install dependencies first (layer caching) +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Create data directory for SQLite +RUN mkdir -p /app/data + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..14d27ab --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,36 @@ +[alembic] +script_location = alembic +sqlalchemy.url = sqlite+aiosqlite:///./classhub.db + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..21a77bf --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,57 @@ +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.ext.asyncio import async_engine_from_config + +from app.config import settings +from app.db.base import Base +from app.db.models import Class_, User, Timeline, Schedule, Announcement, Resource, Notification # noqa: ensure models registered + +config = context.config +config.set_main_option("sqlalchemy.url", settings.database_url) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection): + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +def run_migrations_online() -> None: + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/announcements.py b/backend/app/api/announcements.py new file mode 100644 index 0000000..e8a2671 --- /dev/null +++ b/backend/app/api/announcements.py @@ -0,0 +1,121 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import require_role +from app.db.database import get_db +from app.db.models import User +from app.schemas.announcement import AnnouncementCreate, AnnouncementUpdate, AnnouncementOut +from app.schemas.common import PageResponse +from app.services.announcement_service import ( + create_announcement, + update_announcement, + delete_announcement, + get_announcement_by_id, + list_announcements, +) + +router = APIRouter(prefix="/api/announcements", tags=["announcements"]) + + +@router.get("/", response_model=PageResponse[AnnouncementOut]) +async def get_announcements( + page: int = 1, + page_size: int = 20, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) + + announcements, total = await list_announcements(db, effective_class_id, page, page_size) + total_pages = (total + page_size - 1) // page_size + + items = [] + for a in announcements: + items.append( + AnnouncementOut( + id=a.id, + class_id=a.class_id, + author_id=a.author_id, + author_name=a.author.name if a.author else "Unknown", + title=a.title, + content=a.content, + is_pinned=a.is_pinned, + created_at=a.created_at, + updated_at=a.updated_at, + ) + ) + + return PageResponse( + items=items, total=total, page=page, page_size=page_size, total_pages=total_pages + ) + + +@router.post("/", response_model=AnnouncementOut) +async def create_new_announcement( + data: AnnouncementCreate, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + raise HTTPException(status_code=400, detail="You are not assigned to a class") + + announcement = await create_announcement(db, effective_class_id, user.id, data) + return AnnouncementOut( + id=announcement.id, + class_id=announcement.class_id, + author_id=announcement.author_id, + author_name=user.name, + title=announcement.title, + content=announcement.content, + is_pinned=announcement.is_pinned, + created_at=announcement.created_at, + updated_at=announcement.updated_at, + ) + + +@router.put("/{announcement_id}", response_model=AnnouncementOut) +async def update_existing_announcement( + announcement_id: int, + data: AnnouncementUpdate, + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + announcement = await get_announcement_by_id(db, announcement_id) + if announcement is None: + raise HTTPException(status_code=404, detail="Announcement not found") + if user.role != "super_admin" and announcement.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + updated = await update_announcement(db, announcement, data) + return AnnouncementOut( + id=updated.id, + class_id=updated.class_id, + author_id=updated.author_id, + author_name=updated.author.name if updated.author else user.name, + title=updated.title, + content=updated.content, + is_pinned=updated.is_pinned, + created_at=updated.created_at, + updated_at=updated.updated_at, + ) + + +@router.delete("/{announcement_id}") +async def delete_existing_announcement( + announcement_id: int, + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + announcement = await get_announcement_by_id(db, announcement_id) + if announcement is None: + raise HTTPException(status_code=404, detail="Announcement not found") + if user.role != "super_admin" and announcement.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + await delete_announcement(db, announcement) + return {"message": "Announcement deleted"} diff --git a/backend/app/api/assignments.py b/backend/app/api/assignments.py new file mode 100644 index 0000000..a7ec865 --- /dev/null +++ b/backend/app/api/assignments.py @@ -0,0 +1,276 @@ +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.deps import require_role +from app.db.database import get_db +from app.db.models import User, Class_ +from app.schemas.assignment import ( + AssignmentCreate, AssignmentUpdate, AssignmentOut, + AssignmentDetailOut, SubmissionGrade, SubmissionOut, +) +from app.schemas.common import PageResponse +from app.services.assignment_service import ( + create_assignment, + update_assignment, + delete_assignment, + get_assignment_by_id, + list_assignments, + add_attachments, + create_submission, + get_submission_by_student, + grade_submission, + list_submissions, +) +from app.services.cos_service import upload_file + +router = APIRouter(prefix="/api/assignments", tags=["assignments"]) + + +async def _get_roster_count(db: AsyncSession, class_id: int) -> int: + from app.db.models import StudentRoster + result = await db.execute( + select(func.count(StudentRoster.id)).where(StudentRoster.class_id == class_id) + ) + return result.scalar() or 0 + + +def _build_assignment_out(a: any, user_id: int, total_members: int = 0) -> AssignmentOut: + submission_count = len(a.submissions) if a.submissions else 0 + my_submitted = any(s.student_id == user_id for s in (a.submissions or [])) + return AssignmentOut( + id=a.id, + class_id=a.class_id, + creator_id=a.creator_id, + creator_name=a.creator.name if a.creator else "Unknown", + title=a.title, + description=a.description, + deadline=a.deadline, + attachment_urls=a.get_attachment_urls_list(), + status=a.status, + submission_count=submission_count, + total_members=total_members, + my_submitted=my_submitted, + created_at=a.created_at, + updated_at=a.updated_at, + ) + + +def _build_submission_out(s: any) -> SubmissionOut: + return SubmissionOut( + id=s.id, + assignment_id=s.assignment_id, + student_id=s.student_id, + student_name=s.student.name if s.student else "Unknown", + notes=s.notes, + file_url=s.file_url, + file_name=s.file_name, + file_type=s.file_type, + file_size=s.file_size, + grade=s.grade, + feedback=s.feedback, + graded_at=s.graded_at, + created_at=s.created_at, + updated_at=s.updated_at, + ) + + +@router.get("/", response_model=PageResponse[AssignmentOut]) +async def get_assignments( + page: int = 1, + page_size: int = 20, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) + + assignments, total = await list_assignments(db, effective_class_id, page, page_size) + total_pages = (total + page_size - 1) // page_size + roster_count = await _get_roster_count(db, effective_class_id) + items = [_build_assignment_out(a, user.id, roster_count) for a in assignments] + return PageResponse(items=items, total=total, page=page, page_size=page_size, total_pages=total_pages) + + +@router.post("/", response_model=AssignmentOut) +async def create_new_assignment( + data: AssignmentCreate, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + raise HTTPException(status_code=400, detail="You are not assigned to a class") + + assignment = await create_assignment(db, effective_class_id, user.id, data) + roster_count = await _get_roster_count(db, effective_class_id) + return _build_assignment_out(assignment, user.id, roster_count) + + +@router.post("/{assignment_id}/attachments") +async def upload_assignment_attachments( + assignment_id: int, + files: list[UploadFile] = File(...), + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + assignment = await get_assignment_by_id(db, assignment_id) + if assignment is None: + raise HTTPException(status_code=404, detail="Assignment not found") + if user.role != "super_admin" and assignment.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + urls = [] + for f in files: + contents = await f.read() + if len(contents) > 20 * 1024 * 1024: # 20MB limit + raise HTTPException(status_code=400, detail=f"File {f.filename} too large (max 20MB)") + url = upload_file( + f"assignments/{assignment_id}", f.filename or "file", contents, + f.content_type or "application/octet-stream", + ) + urls.append(url) + + await add_attachments(db, assignment, urls) + return {"attachment_urls": urls} + + +@router.get("/{assignment_id}", response_model=AssignmentDetailOut) +async def get_assignment_detail( + assignment_id: int, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + assignment = await get_assignment_by_id(db, assignment_id) + if assignment is None: + raise HTTPException(status_code=404, detail="Assignment not found") + if user.role != "super_admin" and assignment.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + base = _build_assignment_out(assignment, user.id, await _get_roster_count(db, assignment.class_id)) + + # Student only sees their own submission + if user.role == "student": + my_submission = None + for s in (assignment.submissions or []): + if s.student_id == user.id: + my_submission = _build_submission_out(s) + break + return AssignmentDetailOut(**base.model_dump(), submissions=[my_submission] if my_submission else []) + + # Admin sees all submissions + submissions = [_build_submission_out(s) for s in (assignment.submissions or [])] + return AssignmentDetailOut(**base.model_dump(), submissions=submissions) + + +@router.put("/{assignment_id}", response_model=AssignmentOut) +async def update_existing_assignment( + assignment_id: int, + data: AssignmentUpdate, + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + assignment = await get_assignment_by_id(db, assignment_id) + if assignment is None: + raise HTTPException(status_code=404, detail="Assignment not found") + if user.role != "super_admin" and assignment.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + updated = await update_assignment(db, assignment, data) + return _build_assignment_out(updated, user.id, await _get_roster_count(db, updated.class_id)) + + +@router.delete("/{assignment_id}") +async def delete_existing_assignment( + assignment_id: int, + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + assignment = await get_assignment_by_id(db, assignment_id) + if assignment is None: + raise HTTPException(status_code=404, detail="Assignment not found") + if user.role != "super_admin" and assignment.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + await delete_assignment(db, assignment) + return {"message": "Assignment deleted"} + + +@router.post("/{assignment_id}/submit", response_model=SubmissionOut) +async def submit_assignment( + assignment_id: int, + notes: str = "", + file: UploadFile = File(...), + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + assignment = await get_assignment_by_id(db, assignment_id) + if assignment is None: + raise HTTPException(status_code=404, detail="Assignment not found") + if user.role != "super_admin" and assignment.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + # Upload file + file_url = None + file_name = None + file_type = None + file_size = None + if file.filename: + contents = await file.read() + if len(contents) > 50 * 1024 * 1024: # 50MB limit + raise HTTPException(status_code=400, detail="File too large (max 50MB)") + file_url = upload_file( + f"submissions/{assignment_id}/{user.id}", file.filename, contents, + file.content_type or "application/octet-stream", + ) + file_name = file.filename + file_type = file.content_type + file_size = len(contents) + + try: + submission = await create_submission( + db, assignment_id, user.id, + notes=notes or None, + file_url=file_url, + file_name=file_name, + file_type=file_type, + file_size=file_size, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + return _build_submission_out(submission) + + +@router.put("/submissions/{submission_id}/grade", response_model=SubmissionOut) +async def grade_assignment_submission( + submission_id: int, + data: SubmissionGrade, + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + from sqlalchemy import select + from app.db.models import AssignmentSubmission + result = await db.execute( + select(AssignmentSubmission) + .where(AssignmentSubmission.id == submission_id) + ) + submission = result.scalar_one_or_none() + if submission is None: + raise HTTPException(status_code=404, detail="Submission not found") + + graded = await grade_submission(db, submission, data) + + # Reload with student relationship + from sqlalchemy.orm import selectinload + result = await db.execute( + select(AssignmentSubmission) + .options(selectinload(AssignmentSubmission.student)) + .where(AssignmentSubmission.id == graded.id) + ) + graded = result.scalar_one() + return _build_submission_out(graded) diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..72547c0 --- /dev/null +++ b/backend/app/api/auth.py @@ -0,0 +1,93 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.auth import hash_password, verify_password, create_access_token +from app.core.deps import get_current_user +from app.db.database import get_db +from app.db.models import User +from app.schemas.auth import LoginRequest, RegisterRequest, ChangePasswordRequest +from app.schemas.user import TokenResponse, UserOut +from app.services.roster_service import validate_registration + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +@router.post("/register") +async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)): + # 1. Check if email is already registered + existing = await db.execute(select(User).where(User.email == req.email)) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="该邮箱已注册") + + # 2. Validate invite_code + student_id against roster + roster_entry = await validate_registration(db, req.invite_code, req.student_id) + if roster_entry is None: + raise HTTPException( + status_code=400, detail="邀请码或学号无效,或该学号已注册" + ) + + # 3. Create user with approved status directly + user = User( + email=req.email, + password_hash=hash_password(req.password), + name=req.name, + student_id=req.student_id, + role="student", + status="approved", + class_id=roster_entry.class_id, + ) + db.add(user) + await db.flush() + + # 4. Mark roster entry as registered + roster_entry.status = "registered" + roster_entry.user_id = user.id + await db.commit() + + # 5. Issue token — register and login in one step + token = create_access_token({"sub": str(user.id), "role": user.role}) + return { + "message": "注册成功", + "token": token, + "user": UserOut.model_validate(user), + } + + +@router.post("/login", response_model=TokenResponse) +async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(User).where(User.email == req.email)) + user = result.scalar_one_or_none() + + if user is None: + raise HTTPException(status_code=401, detail="邮箱或密码错误") + + if user.status == "disabled": + raise HTTPException(status_code=401, detail="账号已被禁用") + + if not verify_password(req.password, user.password_hash): + raise HTTPException(status_code=401, detail="邮箱或密码错误") + + token = create_access_token({"sub": str(user.id), "role": user.role}) + return TokenResponse( + token=token, + user=UserOut.model_validate(user), + ) + + +@router.get("/me", response_model=UserOut) +async def get_me(user: User = Depends(get_current_user)): + return UserOut.model_validate(user) + + +@router.put("/change-password") +async def change_password( + req: ChangePasswordRequest, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + if not verify_password(req.old_password, user.password_hash): + raise HTTPException(status_code=400, detail="Old password is incorrect") + user.password_hash = hash_password(req.new_password) + await db.commit() + return {"message": "Password changed successfully"} diff --git a/backend/app/api/classes.py b/backend/app/api/classes.py new file mode 100644 index 0000000..cbdca3e --- /dev/null +++ b/backend/app/api/classes.py @@ -0,0 +1,270 @@ +import csv +import io + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import require_role +from app.db.database import get_db +from app.db.models import User +from app.schemas.class_ import ClassCreate, ClassUpdate, ClassOut +from app.schemas.user import UserListItem +from app.schemas.roster import RosterOut, RosterImportRequest +from app.schemas.common import PageResponse +from app.services.class_service import ( + create_class, + update_class, + delete_class, + get_class_by_id, + list_classes, + get_member_count, + get_class_members, +) +from app.services.roster_service import ( + ensure_invite_code, + regenerate_invite_code, + import_roster, + get_roster, + delete_roster_entry, + clear_unregistered_roster, +) + +router = APIRouter(prefix="/api/classes", tags=["classes"]) + + +@router.get("/", response_model=PageResponse[ClassOut]) +async def get_classes( + page: int = 1, + page_size: int = 50, + db: AsyncSession = Depends(get_db), +): + classes, total = await list_classes(db, page, page_size) + total_pages = (total + page_size - 1) // page_size + result = [] + for c in classes: + count = await get_member_count(db, c.id) + out = ClassOut.model_validate(c) + out.member_count = count + result.append(out) + return PageResponse( + items=result, total=total, page=page, page_size=page_size, total_pages=total_pages + ) + + +@router.post("/", response_model=ClassOut) +async def create_new_class( + data: ClassCreate, + admin: User = Depends(require_role("super_admin")), + db: AsyncSession = Depends(get_db), +): + class_ = await create_class(db, data) + out = ClassOut.model_validate(class_) + out.member_count = 0 + return out + + +@router.put("/{class_id}", response_model=ClassOut) +async def update_existing_class( + class_id: int, + data: ClassUpdate, + admin: User = Depends(require_role("super_admin")), + db: AsyncSession = Depends(get_db), +): + class_ = await get_class_by_id(db, class_id) + if class_ is None: + raise HTTPException(status_code=404, detail="Class not found") + updated = await update_class(db, class_, data) + out = ClassOut.model_validate(updated) + out.member_count = await get_member_count(db, class_id) + return out + + +@router.delete("/{class_id}") +async def delete_existing_class( + class_id: int, + admin: User = Depends(require_role("super_admin")), + db: AsyncSession = Depends(get_db), +): + class_ = await get_class_by_id(db, class_id) + if class_ is None: + raise HTTPException(status_code=404, detail="Class not found") + await delete_class(db, class_) + return {"message": "Class deleted"} + + +@router.get("/{class_id}/members", response_model=PageResponse[UserListItem]) +async def get_members( + class_id: int, + status: str | None = None, + page: int = 1, + page_size: int = 50, + admin: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + if admin.role == "class_admin" and admin.class_id != class_id: + raise HTTPException(status_code=403, detail="Access denied for this class") + + members, total = await get_class_members(db, class_id, status, page, page_size) + total_pages = (total + page_size - 1) // page_size + return PageResponse( + items=[UserListItem.model_validate(m) for m in members], + total=total, + page=page, + page_size=page_size, + total_pages=total_pages, + ) + + +# --- Roster management --- + + +@router.get("/{class_id}/roster", response_model=PageResponse[RosterOut]) +async def get_class_roster( + class_id: int, + page: int = 1, + page_size: int = 50, + admin: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + if admin.role == "class_admin" and admin.class_id != class_id: + raise HTTPException(status_code=403, detail="Access denied") + entries, total = await get_roster(db, class_id, page, page_size) + total_pages = (total + page_size - 1) // page_size + return PageResponse( + items=[RosterOut.model_validate(e) for e in entries], + total=total, + page=page, + page_size=page_size, + total_pages=total_pages, + ) + + +@router.post("/{class_id}/roster/import") +async def import_class_roster( + class_id: int, + data: RosterImportRequest, + admin: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + if admin.role == "class_admin" and admin.class_id != class_id: + raise HTTPException(status_code=403, detail="Access denied") + count = await import_roster(db, class_id, data.entries) + return {"message": f"成功导入 {count} 条记录"} + + +@router.post("/{class_id}/roster/upload") +async def upload_roster_file( + class_id: int, + file: UploadFile = File(...), + admin: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + if admin.role == "class_admin" and admin.class_id != class_id: + raise HTTPException(status_code=403, detail="Access denied") + + contents = await file.read() + filename = file.filename or "" + + entries: list[dict] = [] + + if filename.endswith(".csv"): + text = contents.decode("utf-8-sig") + reader = csv.DictReader(io.StringIO(text)) + for row in reader: + sid = row.get("student_id") or row.get("学号") or "" + name = row.get("name") or row.get("姓名") or "" + if sid and name: + entries.append({"student_id": sid.strip(), "name": name.strip()}) + elif filename.endswith((".xlsx", ".xls")): + try: + import openpyxl + + wb = openpyxl.load_workbook(io.BytesIO(contents), read_only=True) + ws = wb.active + rows = list(ws.iter_rows(values_only=True)) + if len(rows) < 2: + raise HTTPException(status_code=400, detail="Excel 文件为空") + header = [str(h).strip() if h else "" for h in rows[0]] + # Find student_id and name columns + sid_col = None + name_col = None + for i, h in enumerate(header): + if h in ("student_id", "学号"): + sid_col = i + elif h in ("name", "姓名"): + name_col = i + if sid_col is None or name_col is None: + raise HTTPException( + status_code=400, + detail="Excel 需包含 '学号'(student_id) 和 '姓名'(name) 列", + ) + for row in rows[1:]: + sid = str(row[sid_col]).strip() if row[sid_col] else "" + name = str(row[name_col]).strip() if row[name_col] else "" + if sid and name and sid != "None": + entries.append({"student_id": sid, "name": name}) + wb.close() + except ImportError: + raise HTTPException( + status_code=400, detail="服务器未安装 openpyxl,请使用 CSV 格式" + ) + else: + raise HTTPException(status_code=400, detail="仅支持 CSV 或 Excel (.xlsx) 文件") + + if not entries: + raise HTTPException(status_code=400, detail="未找到有效数据") + + count = await import_roster(db, class_id, entries) + return {"message": f"成功导入 {count} 条记录"} + + +@router.delete("/{class_id}/roster/{roster_id}") +async def delete_roster_item( + class_id: int, + roster_id: int, + admin: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + success = await delete_roster_entry(db, roster_id) + if not success: + raise HTTPException(status_code=400, detail="无法删除(已注册或不存在)") + return {"message": "已删除"} + + +@router.post("/{class_id}/roster/clear") +async def clear_roster( + class_id: int, + admin: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + if admin.role == "class_admin" and admin.class_id != class_id: + raise HTTPException(status_code=403, detail="Access denied") + count = await clear_unregistered_roster(db, class_id) + return {"message": f"已清除 {count} 条未注册记录"} + + +# --- Invite code management --- + + +@router.get("/{class_id}/invite-code") +async def get_invite_code( + class_id: int, + admin: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + code = await ensure_invite_code(db, class_id) + if not code: + raise HTTPException(status_code=404, detail="Class not found") + return {"invite_code": code} + + +@router.post("/{class_id}/invite-code/regenerate") +async def regenerate_invite( + class_id: int, + admin: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + code = await regenerate_invite_code(db, class_id) + if not code: + raise HTTPException(status_code=404, detail="Class not found") + return {"invite_code": code} diff --git a/backend/app/api/directory.py b/backend/app/api/directory.py new file mode 100644 index 0000000..4c604a3 --- /dev/null +++ b/backend/app/api/directory.py @@ -0,0 +1,57 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_current_user +from app.db.database import get_db +from app.db.models import User +from app.schemas.user import UserPublic +from app.schemas.common import PageResponse +from app.services.directory_service import search_directory, user_to_public +from app.services.user_service import get_user_by_id + +router = APIRouter(prefix="/api/directory", tags=["directory"]) + + +@router.get("/", response_model=PageResponse[UserPublic]) +async def search_members( + search: str | None = None, + industry: str | None = None, + company: str | None = None, + class_id: int | None = None, + page: int = 1, + page_size: int = 20, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + # Determine effective class_id: super_admin can specify one, others use their own + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) + + users, total = await search_directory( + db, effective_class_id, search, industry, company, page, page_size + ) + total_pages = (total + page_size - 1) // page_size + include_contact = True # Same class, approved users can see contact + return PageResponse( + items=[user_to_public(u, include_contact=include_contact) for u in users], + total=total, + page=page, + page_size=page_size, + total_pages=total_pages, + ) + + +@router.get("/{user_id}", response_model=UserPublic) +async def get_member_detail( + user_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + target = await get_user_by_id(db, user_id) + if target is None or target.status != "approved": + raise HTTPException(status_code=404, detail="User not found") + + # Privacy: only show contact info to same-class members + include_contact = user.class_id == target.class_id + return user_to_public(target, include_contact=include_contact) diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py new file mode 100644 index 0000000..2d6e64a --- /dev/null +++ b/backend/app/api/notifications.py @@ -0,0 +1,74 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_current_user +from app.db.database import get_db +from app.db.models import User +from app.schemas.notification import NotificationOut, UnreadCount +from app.schemas.common import PageResponse +from app.services.notification_service import ( + list_notifications, + get_unread_count, + mark_as_read, + mark_all_as_read, +) + +router = APIRouter(prefix="/api/notifications", tags=["notifications"]) + + +@router.get("/", response_model=PageResponse[NotificationOut]) +async def get_notifications( + page: int = 1, + page_size: int = 20, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + notifications, total = await list_notifications(db, user.id, page, page_size) + total_pages = (total + page_size - 1) // page_size + + items = [ + NotificationOut( + id=n.id, + type=n.type, + title=n.title, + content=n.content, + related_id=n.related_id, + is_read=n.is_read, + created_at=n.created_at, + ) + for n in notifications + ] + + return PageResponse( + items=items, total=total, page=page, page_size=page_size, total_pages=total_pages + ) + + +@router.get("/unread-count", response_model=UnreadCount) +async def get_unread_count_api( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + count = await get_unread_count(db, user.id) + return UnreadCount(count=count) + + +@router.put("/{notification_id}/read") +async def mark_notification_read( + notification_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + success = await mark_as_read(db, notification_id, user.id) + if not success: + raise HTTPException(status_code=404, detail="Notification not found") + return {"message": "Marked as read"} + + +@router.put("/read-all") +async def mark_all_notifications_read( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + count = await mark_all_as_read(db, user.id) + return {"message": f"Marked {count} notifications as read"} diff --git a/backend/app/api/resources.py b/backend/app/api/resources.py new file mode 100644 index 0000000..6d8f83d --- /dev/null +++ b/backend/app/api/resources.py @@ -0,0 +1,153 @@ +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import require_role +from app.db.database import get_db +from app.db.models import User +from app.schemas.resource import ResourceCreate, ResourceOut +from app.schemas.common import PageResponse +from app.services.resource_service import ( + create_resource, + list_resources, + get_resource_by_id, + increment_download_count, + delete_resource, +) +from app.services.cos_service import upload_file + +router = APIRouter(prefix="/api/resources", tags=["resources"]) + +ALLOWED_FILE_TYPES = { + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/zip", + "text/plain", + "image/jpeg", + "image/png", + "image/gif", + "image/webp", +} + + +@router.get("/", response_model=PageResponse[ResourceOut]) +async def get_resources( + page: int = 1, + page_size: int = 20, + category: str | None = None, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) + + resources, total = await list_resources(db, effective_class_id, category, page, page_size) + total_pages = (total + page_size - 1) // page_size + + items = [] + for r in resources: + items.append( + ResourceOut( + id=r.id, + class_id=r.class_id, + uploader_id=r.uploader_id, + uploader_name=r.uploader.name if r.uploader else "Unknown", + title=r.title, + description=r.description, + file_url=r.file_url, + file_type=r.file_type, + file_size=r.file_size, + category=r.category, + download_count=r.download_count, + created_at=r.created_at, + ) + ) + + return PageResponse( + items=items, total=total, page=page, page_size=page_size, total_pages=total_pages + ) + + +@router.post("/", response_model=ResourceOut) +async def upload_new_resource( + file: UploadFile = File(...), + title: str = Form(...), + category: str = Form(...), + description: str | None = Form(None), + class_id: int | None = Form(None), + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + raise HTTPException(status_code=400, detail="You are not assigned to a class") + + contents = await file.read() + if len(contents) > 50 * 1024 * 1024: # 50MB limit + raise HTTPException(status_code=400, detail="File too large (max 50MB)") + + if file.content_type not in ALLOWED_FILE_TYPES: + raise HTTPException(status_code=400, detail=f"File type {file.content_type} not allowed") + + file_url = upload_file( + f"resources/{effective_class_id}", + file.filename or "file", + contents, + file.content_type, + ) + + data = ResourceCreate(title=title, description=description, category=category) + resource = await create_resource( + db, effective_class_id, user.id, data, file_url, file.content_type, len(contents) + ) + + return ResourceOut( + id=resource.id, + class_id=resource.class_id, + uploader_id=resource.uploader_id, + uploader_name=user.name, + title=resource.title, + description=resource.description, + file_url=resource.file_url, + file_type=resource.file_type, + file_size=resource.file_size, + category=resource.category, + download_count=resource.download_count, + created_at=resource.created_at, + ) + + +@router.post("/{resource_id}/download") +async def download_resource( + resource_id: int, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + resource = await get_resource_by_id(db, resource_id) + if resource is None: + raise HTTPException(status_code=404, detail="Resource not found") + + await increment_download_count(db, resource) + return {"file_url": resource.file_url} + + +@router.delete("/{resource_id}") +async def delete_existing_resource( + resource_id: int, + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + resource = await get_resource_by_id(db, resource_id) + if resource is None: + raise HTTPException(status_code=404, detail="Resource not found") + if user.role != "super_admin" and resource.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + await delete_resource(db, resource) + return {"message": "Resource deleted"} diff --git a/backend/app/api/schedule.py b/backend/app/api/schedule.py new file mode 100644 index 0000000..5174539 --- /dev/null +++ b/backend/app/api/schedule.py @@ -0,0 +1,104 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import require_role +from app.db.database import get_db +from app.db.models import User +from app.schemas.schedule import ScheduleCreate, ScheduleUpdate, ScheduleOut +from app.schemas.common import PageResponse +from app.services.schedule_service import ( + create_schedule, + update_schedule, + delete_schedule, + get_schedule_by_id, + list_schedules, + get_upcoming_schedules, +) + +router = APIRouter(prefix="/api/schedule", tags=["schedule"]) + + +@router.get("/upcoming", response_model=list[ScheduleOut]) +async def get_upcoming( + limit: int = 10, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + return [] + items = await get_upcoming_schedules(db, effective_class_id, limit) + return [ScheduleOut.model_validate(i) for i in items] + + +@router.get("/", response_model=PageResponse[ScheduleOut]) +async def get_schedules( + type: str | None = None, + page: int = 1, + page_size: int = 50, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) + + items, total = await list_schedules(db, effective_class_id, type, page, page_size) + total_pages = (total + page_size - 1) // page_size + return PageResponse( + items=[ScheduleOut.model_validate(i) for i in items], + total=total, + page=page, + page_size=page_size, + total_pages=total_pages, + ) + + +@router.post("/", response_model=ScheduleOut) +async def create_new_schedule( + data: ScheduleCreate, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + raise HTTPException(status_code=400, detail="You are not assigned to a class") + + item = await create_schedule(db, effective_class_id, data) + return ScheduleOut.model_validate(item) + + +@router.put("/{schedule_id}", response_model=ScheduleOut) +async def update_existing_schedule( + schedule_id: int, + data: ScheduleUpdate, + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + item = await get_schedule_by_id(db, schedule_id) + if item is None: + raise HTTPException(status_code=404, detail="Schedule not found") + if user.role != "super_admin" and item.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + updated = await update_schedule(db, item, data) + return ScheduleOut.model_validate(updated) + + +@router.delete("/{schedule_id}") +async def delete_existing_schedule( + schedule_id: int, + user: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + item = await get_schedule_by_id(db, schedule_id) + if item is None: + raise HTTPException(status_code=404, detail="Schedule not found") + if user.role != "super_admin" and item.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + await delete_schedule(db, item) + return {"message": "Schedule deleted"} diff --git a/backend/app/api/timeline.py b/backend/app/api/timeline.py new file mode 100644 index 0000000..1bbda36 --- /dev/null +++ b/backend/app/api/timeline.py @@ -0,0 +1,262 @@ +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import require_role +from app.db.database import get_db +from app.db.models import User +from app.schemas.timeline import ( + TimelineCreate, TimelineUpdate, TimelineOut, + TimelineCommentCreate, TimelineCommentOut, +) +from app.schemas.common import PageResponse +from app.services.timeline_service import ( + create_timeline, + update_timeline, + delete_timeline, + get_timeline_by_id, + list_timelines, + add_images_to_timeline, + toggle_like, + create_comment, + delete_comment, + get_comment_by_id, + list_comments, +) +from app.services.cos_service import upload_image + +router = APIRouter(prefix="/api/timeline", tags=["timeline"]) + + +def _build_timeline_out(p, user_id: int, include_comments: bool = False) -> TimelineOut: + like_count = len(p.likes) if p.likes else 0 + has_liked = any(l.user_id == user_id for l in (p.likes or [])) + comment_count = len(p.comments) if p.comments else 0 + comments_out = None + if include_comments and p.comments: + comments_out = [ + TimelineCommentOut( + id=c.id, + post_id=c.post_id, + author_id=c.author_id, + author_name=c.author.name if c.author else "Unknown", + content=c.content, + created_at=c.created_at, + updated_at=c.updated_at, + ) + for c in p.comments + ] + return TimelineOut( + id=p.id, + class_id=p.class_id, + author_id=p.author_id, + author_name=p.author.name if p.author else "Unknown", + title=p.title, + content=p.content, + image_urls=p.get_image_urls_list(), + like_count=like_count, + has_liked=has_liked, + comment_count=comment_count, + comments=comments_out, + created_at=p.created_at, + updated_at=p.updated_at, + ) + + +@router.get("/", response_model=PageResponse[TimelineOut]) +async def get_timelines( + page: int = 1, + page_size: int = 20, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) + + posts, total = await list_timelines(db, effective_class_id, page, page_size) + total_pages = (total + page_size - 1) // page_size + + items = [_build_timeline_out(p, user.id) for p in posts] + + return PageResponse( + items=items, total=total, page=page, page_size=page_size, total_pages=total_pages + ) + + +@router.post("/", response_model=TimelineOut) +async def create_new_timeline( + data: TimelineCreate, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + raise HTTPException(status_code=400, detail="You are not assigned to a class") + + post = await create_timeline(db, effective_class_id, user.id, data) + return TimelineOut( + id=post.id, + class_id=post.class_id, + author_id=post.author_id, + author_name=user.name, + title=post.title, + content=post.content, + image_urls=[], + like_count=0, + has_liked=False, + comment_count=0, + created_at=post.created_at, + updated_at=post.updated_at, + ) + + +@router.post("/{post_id}/images") +async def upload_timeline_images( + post_id: int, + files: list[UploadFile] = File(...), + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + post = await get_timeline_by_id(db, post_id) + if post is None: + raise HTTPException(status_code=404, detail="Timeline post not found") + + # Student can only upload to own post; admin can upload to any in their class + if user.role == "student" and post.author_id != user.id: + raise HTTPException(status_code=403, detail="Access denied") + if user.role != "super_admin" and post.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + urls = [] + for f in files: + contents = await f.read() + if len(contents) > 10 * 1024 * 1024: + raise HTTPException(status_code=400, detail=f"File {f.filename} too large (max 10MB)") + if f.content_type not in {"image/jpeg", "image/png", "image/gif", "image/webp"}: + raise HTTPException(status_code=400, detail=f"File {f.filename} has invalid type") + url = upload_image(f"timeline/{post_id}", f.filename or "image.jpg", contents, f.content_type) + urls.append(url) + + await add_images_to_timeline(db, post, urls) + return {"image_urls": urls} + + +@router.put("/{post_id}", response_model=TimelineOut) +async def update_existing_timeline( + post_id: int, + data: TimelineUpdate, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + post = await get_timeline_by_id(db, post_id) + if post is None: + raise HTTPException(status_code=404, detail="Timeline post not found") + if user.role == "student" and post.author_id != user.id: + raise HTTPException(status_code=403, detail="Access denied") + if user.role != "super_admin" and post.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + updated = await update_timeline(db, post, data) + return _build_timeline_out(updated, user.id) + + +@router.delete("/{post_id}") +async def delete_existing_timeline( + post_id: int, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + post = await get_timeline_by_id(db, post_id) + if post is None: + raise HTTPException(status_code=404, detail="Timeline post not found") + if user.role == "student" and post.author_id != user.id: + raise HTTPException(status_code=403, detail="Access denied") + if user.role != "super_admin" and post.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + await delete_timeline(db, post) + return {"message": "Timeline post deleted"} + + +# --- Like & Comment endpoints --- + +@router.post("/{post_id}/like") +async def like_timeline_post( + post_id: int, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + post = await get_timeline_by_id(db, post_id) + if post is None: + raise HTTPException(status_code=404, detail="Timeline post not found") + if user.role != "super_admin" and post.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + return await toggle_like(db, post_id, user.id) + + +@router.get("/{post_id}/comments", response_model=PageResponse[TimelineCommentOut]) +async def get_post_comments( + post_id: int, + page: int = 1, + page_size: int = 50, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + comments, total = await list_comments(db, post_id, page, page_size) + total_pages = (total + page_size - 1) // page_size + items = [ + TimelineCommentOut( + id=c.id, + post_id=c.post_id, + author_id=c.author_id, + author_name=c.author.name if c.author else "Unknown", + content=c.content, + created_at=c.created_at, + updated_at=c.updated_at, + ) + for c in comments + ] + return PageResponse(items=items, total=total, page=page, page_size=page_size, total_pages=total_pages) + + +@router.post("/{post_id}/comments", response_model=TimelineCommentOut) +async def add_post_comment( + post_id: int, + data: TimelineCommentCreate, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + post = await get_timeline_by_id(db, post_id) + if post is None: + raise HTTPException(status_code=404, detail="Timeline post not found") + if user.role != "super_admin" and post.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + comment = await create_comment(db, post_id, user.id, data) + return TimelineCommentOut( + id=comment.id, + post_id=comment.post_id, + author_id=comment.author_id, + author_name=comment.author.name if comment.author else "Unknown", + content=comment.content, + created_at=comment.created_at, + updated_at=comment.updated_at, + ) + + +@router.delete("/comments/{comment_id}") +async def delete_timeline_comment( + comment_id: int, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + comment = await get_comment_by_id(db, comment_id) + if comment is None: + raise HTTPException(status_code=404, detail="Comment not found") + if user.role == "student" and comment.author_id != user.id: + raise HTTPException(status_code=403, detail="Access denied") + + await delete_comment(db, comment) + return {"message": "Comment deleted"} diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py new file mode 100644 index 0000000..bb9e687 --- /dev/null +++ b/backend/app/api/upload.py @@ -0,0 +1,24 @@ +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File + +from app.core.deps import require_role +from app.db.models import User +from app.services.cos_service import upload_image + +router = APIRouter(prefix="/api/upload", tags=["upload"]) + + +@router.post("/image") +async def upload_image_api( + file: UploadFile = File(...), + user: User = Depends(require_role("super_admin", "class_admin")), +): + """Upload an image to Tencent COS.""" + contents = await file.read() + if len(contents) > 10 * 1024 * 1024: # 10MB + raise HTTPException(status_code=400, detail="File too large (max 10MB)") + + if file.content_type not in {"image/jpeg", "image/png", "image/gif", "image/webp"}: + raise HTTPException(status_code=400, detail="Invalid file type, only JPEG/PNG/GIF/WebP allowed") + + url = upload_image("images", file.filename or "upload.jpg", contents, file.content_type) + return {"url": url} diff --git a/backend/app/api/users.py b/backend/app/api/users.py new file mode 100644 index 0000000..57f594d --- /dev/null +++ b/backend/app/api/users.py @@ -0,0 +1,123 @@ +import json + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_current_user, require_role +from app.db.database import get_db +from app.db.models import User +from app.schemas.user import UserOut, UserUpdate, UserListItem, UserStatusUpdate +from app.schemas.common import PageResponse +from app.services.user_service import ( + update_profile, + update_user_status, + list_users, + get_user_by_id, +) +from app.services.cos_service import upload_image +from app.services.email_service import send_approval_notification + +router = APIRouter(prefix="/api/users", tags=["users"]) + + +@router.get("/me", response_model=UserOut) +async def get_my_profile(user: User = Depends(get_current_user)): + return UserOut.model_validate(user) + + +@router.put("/me", response_model=UserOut) +async def update_my_profile( + data: UserUpdate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + updated = await update_profile(db, user, data) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + return UserOut.model_validate(updated) + + +@router.post("/me/avatar") +async def upload_avatar( + file: UploadFile = File(...), + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + contents = await file.read() + if len(contents) > 5 * 1024 * 1024: # 5MB limit + raise HTTPException(status_code=400, detail="File too large (max 5MB)") + + if file.content_type not in {"image/jpeg", "image/png", "image/gif", "image/webp"}: + raise HTTPException(status_code=400, detail="Invalid file type") + + url = upload_image(f"avatars/{user.id}", file.filename or "avatar.jpg", contents, file.content_type) + user.avatar_url = url + await db.commit() + return {"avatar_url": url} + + +@router.get("/", response_model=PageResponse[UserListItem]) +async def list_all_users( + page: int = 1, + page_size: int = 20, + class_id: int | None = None, + status: str | None = None, + role: str | None = None, + admin: User = Depends(require_role("super_admin")), + db: AsyncSession = Depends(get_db), +): + users, total = await list_users(db, page, page_size, class_id, status, role) + total_pages = (total + page_size - 1) // page_size + return PageResponse( + items=[UserListItem.model_validate(u) for u in users], + total=total, + page=page, + page_size=page_size, + total_pages=total_pages, + ) + + +@router.put("/{user_id}/status") +async def change_user_status( + user_id: int, + data: UserStatusUpdate, + admin: User = Depends(require_role("super_admin", "class_admin")), + db: AsyncSession = Depends(get_db), +): + target = await get_user_by_id(db, user_id) + if target is None: + raise HTTPException(status_code=404, detail="User not found") + + # Class admin can only manage users in their own class + if admin.role == "class_admin" and target.class_id != admin.class_id: + raise HTTPException( + status_code=403, detail="Cannot manage users outside your class" + ) + + updated = await update_user_status(db, user_id, data.status, data.role) + + # Send email notification + if data.status in ("approved", "rejected"): + await send_approval_notification(target.email, data.status == "approved") + + return {"message": f"User status updated to {data.status}"} + + +@router.put("/{user_id}/role") +async def change_user_role( + user_id: int, + role: str, + admin: User = Depends(require_role("super_admin")), + db: AsyncSession = Depends(get_db), +): + if role not in ("super_admin", "class_admin", "student"): + raise HTTPException(status_code=400, detail="Invalid role") + + target = await get_user_by_id(db, user_id) + if target is None: + raise HTTPException(status_code=404, detail="User not found") + + target.role = role + await db.commit() + return {"message": f"User role updated to {role}"} diff --git a/backend/app/api/votes.py b/backend/app/api/votes.py new file mode 100644 index 0000000..59137ab --- /dev/null +++ b/backend/app/api/votes.py @@ -0,0 +1,177 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import require_role +from app.db.database import get_db +from app.db.models import User +from app.schemas.vote import VoteCreate, VoteUpdate, VoteSubmit, VoteOptionOut, VoteOut +from app.schemas.common import PageResponse +from app.services.vote_service import ( + create_vote, + get_vote_by_id, + list_votes, + submit_vote, + close_vote, + delete_vote, +) + +router = APIRouter(prefix="/api/votes", tags=["votes"]) + + +def _build_vote_out(vote: any, user_id: int) -> VoteOut: + # Compute per-option stats + options_out = [] + for opt in (vote.options or []): + responses = opt.responses or [] + vote_count = len(responses) + voter_names = None + if not vote.is_anonymous: + voter_names = [r.voter.name for r in responses if r.voter] + options_out.append(VoteOptionOut( + id=opt.id, + content=opt.content, + sort_order=opt.sort_order, + vote_count=vote_count, + voter_names=voter_names, + )) + + # Total unique voters + all_voter_ids = set() + my_option_ids = [] + for opt in (vote.options or []): + for r in (opt.responses or []): + all_voter_ids.add(r.voter_id) + if r.voter_id == user_id: + my_option_ids.append(opt.id) + + return VoteOut( + id=vote.id, + class_id=vote.class_id, + creator_id=vote.creator_id, + creator_name=vote.creator.name if vote.creator else "Unknown", + title=vote.title, + description=vote.description, + vote_type=vote.vote_type, + is_anonymous=vote.is_anonymous, + max_choices=vote.max_choices, + deadline=vote.deadline, + status=vote.status, + total_voters=len(all_voter_ids), + has_voted=user_id in all_voter_ids, + my_option_ids=my_option_ids if my_option_ids else None, + options=options_out, + created_at=vote.created_at, + updated_at=vote.updated_at, + ) + + +@router.get("/", response_model=PageResponse[VoteOut]) +async def get_votes( + page: int = 1, + page_size: int = 20, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) + + votes, total = await list_votes(db, effective_class_id, page, page_size) + total_pages = (total + page_size - 1) // page_size + items = [_build_vote_out(v, user.id) for v in votes] + return PageResponse(items=items, total=total, page=page, page_size=page_size, total_pages=total_pages) + + +@router.post("/", response_model=VoteOut) +async def create_new_vote( + data: VoteCreate, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + if len(data.options) < 2: + raise HTTPException(status_code=400, detail="至少需要 2 个选项") + if data.vote_type == "multiple" and data.max_choices < 2: + raise HTTPException(status_code=400, detail="多选投票最多可选数不能小于 2") + + effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id + if effective_class_id is None: + raise HTTPException(status_code=400, detail="You are not assigned to a class") + + vote = await create_vote(db, effective_class_id, user.id, data) + # Reload with relationships + vote = await get_vote_by_id(db, vote.id) + return _build_vote_out(vote, user.id) + + +@router.get("/{vote_id}", response_model=VoteOut) +async def get_vote_detail( + vote_id: int, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + vote = await get_vote_by_id(db, vote_id) + if vote is None: + raise HTTPException(status_code=404, detail="Vote not found") + if user.role != "super_admin" and vote.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + return _build_vote_out(vote, user.id) + + +@router.post("/{vote_id}/submit") +async def submit_vote_response( + vote_id: int, + data: VoteSubmit, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + vote = await get_vote_by_id(db, vote_id) + if vote is None: + raise HTTPException(status_code=404, detail="Vote not found") + if user.role != "super_admin" and vote.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + try: + await submit_vote(db, vote_id, user.id, data.option_ids) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + return {"message": "投票成功"} + + +@router.put("/{vote_id}/close") +async def close_vote_endpoint( + vote_id: int, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + vote = await get_vote_by_id(db, vote_id) + if vote is None: + raise HTTPException(status_code=404, detail="Vote not found") + # Only creator or admin can close + if user.role == "student" and vote.creator_id != user.id: + raise HTTPException(status_code=403, detail="只有创建者或管理员可以关闭投票") + if user.role != "super_admin" and vote.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + await close_vote(db, vote) + return {"message": "投票已关闭"} + + +@router.delete("/{vote_id}") +async def delete_vote_endpoint( + vote_id: int, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + vote = await get_vote_by_id(db, vote_id) + if vote is None: + raise HTTPException(status_code=404, detail="Vote not found") + if user.role == "student" and vote.creator_id != user.id: + raise HTTPException(status_code=403, detail="只有创建者或管理员可以删除投票") + if user.role != "super_admin" and vote.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + await delete_vote(db, vote) + return {"message": "投票已删除"} diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..dca2318 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,43 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + # Service + host: str = "0.0.0.0" + port: int = 8000 + debug: bool = False + + # Database + database_url: str = "sqlite+aiosqlite:///./classhub.db" + + # JWT + jwt_secret: str = "change-me-in-production" + jwt_expiry_hours: int = 72 + jwt_algorithm: str = "HS256" + + # Tencent COS + cos_secret_id: str = "" + cos_secret_key: str = "" + cos_region: str = "ap-hongkong" + cos_bucket: str = "" + cos_base_url: str = "" + + # SMTP Email + smtp_host: str = "" + smtp_port: int = 465 + smtp_user: str = "" + smtp_password: str = "" + smtp_from_email: str = "" + smtp_from_name: str = "HKU ICB" + + # Frontend URL + frontend_url: str = "http://localhost:3000" + + # Super Admin seed + super_admin_email: str = "admin@classhub.com" + super_admin_password: str = "admin123" + + model_config = {"env_file": ".env", "env_prefix": "CH_"} + + +settings = Settings() diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py new file mode 100644 index 0000000..156ed75 --- /dev/null +++ b/backend/app/core/auth.py @@ -0,0 +1,31 @@ +from datetime import datetime, timedelta, timezone + +import bcrypt +from jose import JWTError, jwt + +from app.config import settings + + +def hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + +def verify_password(plain: str, hashed: str) -> bool: + return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8")) + + +def create_access_token(data: dict) -> str: + to_encode = data.copy() + expire = datetime.now(timezone.utc) + timedelta(hours=settings.jwt_expiry_hours) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm) + + +def decode_access_token(token: str) -> dict | None: + try: + payload = jwt.decode( + token, settings.jwt_secret, algorithms=[settings.jwt_algorithm] + ) + return payload + except JWTError: + return None diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py new file mode 100644 index 0000000..e1a8bba --- /dev/null +++ b/backend/app/core/deps.py @@ -0,0 +1,57 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.auth import decode_access_token +from app.db.database import get_db +from app.db.models import User + +security = HTTPBearer() + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: AsyncSession = Depends(get_db), +) -> User: + payload = decode_access_token(credentials.credentials) + if payload is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token invalid or expired", + ) + + user_id = payload.get("sub") + if user_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token format", + ) + + result = await db.execute(select(User).where(User.id == int(user_id))) + user = result.scalar_one_or_none() + + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found" + ) + if user.status == "disabled": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Account disabled" + ) + + return user + + +def require_role(*roles: str): + """Factory: returns a dependency that checks user role.""" + + async def _check(user: User = Depends(get_current_user)) -> User: + if user.role not in roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Insufficient permissions", + ) + return user + + return _check diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/db/base.py b/backend/app/db/base.py new file mode 100644 index 0000000..fa2b68a --- /dev/null +++ b/backend/app/db/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/backend/app/db/database.py b/backend/app/db/database.py new file mode 100644 index 0000000..3982c31 --- /dev/null +++ b/backend/app/db/database.py @@ -0,0 +1,17 @@ +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.config import settings + +engine = create_async_engine(settings.database_url, echo=settings.debug) +async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def get_db(): + async with async_session() as session: + yield session + + +async def create_tables(): + from app.db.base import Base + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) diff --git a/backend/app/db/models.py b/backend/app/db/models.py new file mode 100644 index 0000000..932b734 --- /dev/null +++ b/backend/app/db/models.py @@ -0,0 +1,416 @@ +import json +from datetime import datetime + +from sqlalchemy import String, Text, Integer, DateTime, Boolean, ForeignKey, func, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class Class_(Base): + __tablename__ = "classes" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(100), nullable=False) + cohort_year: Mapped[int] = mapped_column(Integer, nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + invite_code: Mapped[str | None] = mapped_column(String(20), unique=True, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + members: Mapped[list["User"]] = relationship("User", back_populates="class_") + timelines: Mapped[list["Timeline"]] = relationship( + "Timeline", back_populates="class_", cascade="all, delete-orphan" + ) + schedules: Mapped[list["Schedule"]] = relationship( + "Schedule", back_populates="class_", cascade="all, delete-orphan" + ) + announcements: Mapped[list["Announcement"]] = relationship( + "Announcement", back_populates="class_", cascade="all, delete-orphan" + ) + resources: Mapped[list["Resource"]] = relationship( + "Resource", back_populates="class_", cascade="all, delete-orphan" + ) + roster: Mapped[list["StudentRoster"]] = relationship( + "StudentRoster", back_populates="class_", cascade="all, delete-orphan" + ) + assignments: Mapped[list["Assignment"]] = relationship( + "Assignment", back_populates="class_", cascade="all, delete-orphan" + ) + votes: Mapped[list["Vote"]] = relationship( + "Vote", back_populates="class_", cascade="all, delete-orphan" + ) + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) + password_hash: Mapped[str] = mapped_column(Text, nullable=False) + name: Mapped[str] = mapped_column(String(100), nullable=False) + student_id: Mapped[str | None] = mapped_column(String(50), nullable=True, unique=True) + + # role: super_admin | class_admin | student + role: Mapped[str] = mapped_column(String(20), default="student", nullable=False) + # status: pending | approved | rejected | disabled + status: Mapped[str] = mapped_column(String(20), default="pending", nullable=False) + + class_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("classes.id"), nullable=True + ) + class_: Mapped["Class_ | None"] = relationship("Class_", back_populates="members") + + # Profile + industry: Mapped[str | None] = mapped_column(String(100), nullable=True) + company: Mapped[str | None] = mapped_column(String(100), nullable=True) + position: Mapped[str | None] = mapped_column(String(100), nullable=True) + skills_tags: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array + wechat_id: Mapped[str | None] = mapped_column(String(100), nullable=True) + phone: Mapped[str | None] = mapped_column(String(20), nullable=True) + avatar_url: Mapped[str | None] = mapped_column(Text, nullable=True) + bio: Mapped[str | None] = mapped_column(Text, nullable=True) + + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + timeline_posts: Mapped[list["Timeline"]] = relationship( + "Timeline", back_populates="author" + ) + created_assignments: Mapped[list["Assignment"]] = relationship( + "Assignment", back_populates="creator" + ) + assignment_submissions: Mapped[list["AssignmentSubmission"]] = relationship( + "AssignmentSubmission", back_populates="student" + ) + created_votes: Mapped[list["Vote"]] = relationship( + "Vote", back_populates="creator" + ) + + def get_skills_list(self) -> list[str]: + if not self.skills_tags: + return [] + try: + return json.loads(self.skills_tags) + except (json.JSONDecodeError, TypeError): + return [] + + def set_skills_list(self, tags: list[str]): + self.skills_tags = json.dumps(tags, ensure_ascii=False) if tags else None + + +class Timeline(Base): + __tablename__ = "timelines" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + class_id: Mapped[int] = mapped_column( + Integer, ForeignKey("classes.id"), nullable=False, index=True + ) + author_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False + ) + title: Mapped[str] = mapped_column(String(200), nullable=False) + content: Mapped[str | None] = mapped_column(Text, nullable=True) + image_urls: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + class_: Mapped["Class_"] = relationship("Class_", back_populates="timelines") + author: Mapped["User"] = relationship("User", back_populates="timeline_posts") + likes: Mapped[list["TimelineLike"]] = relationship( + "TimelineLike", back_populates="post", cascade="all, delete-orphan" + ) + comments: Mapped[list["TimelineComment"]] = relationship( + "TimelineComment", back_populates="post", cascade="all, delete-orphan" + ) + + def get_image_urls_list(self) -> list[str]: + if not self.image_urls: + return [] + try: + return json.loads(self.image_urls) + except (json.JSONDecodeError, TypeError): + return [] + + def set_image_urls_list(self, urls: list[str]): + self.image_urls = json.dumps(urls) if urls else None + + +class Schedule(Base): + __tablename__ = "schedules" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + class_id: Mapped[int] = mapped_column( + Integer, ForeignKey("classes.id"), nullable=False, index=True + ) + # type: course | deadline | activity + type: Mapped[str] = mapped_column(String(20), nullable=False) + title: Mapped[str] = mapped_column(String(200), nullable=False) + start_time: Mapped[datetime] = mapped_column(DateTime, nullable=False) + end_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + location: Mapped[str | None] = mapped_column(String(200), nullable=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + class_: Mapped["Class_"] = relationship("Class_", back_populates="schedules") + + +class Announcement(Base): + __tablename__ = "announcements" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + class_id: Mapped[int] = mapped_column( + Integer, ForeignKey("classes.id"), nullable=False, index=True + ) + author_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False + ) + title: Mapped[str] = mapped_column(String(200), nullable=False) + content: Mapped[str | None] = mapped_column(Text, nullable=True) + is_pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + class_: Mapped["Class_"] = relationship("Class_", back_populates="announcements") + author: Mapped["User"] = relationship("User") + + +class Resource(Base): + __tablename__ = "resources" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + class_id: Mapped[int] = mapped_column( + Integer, ForeignKey("classes.id"), nullable=False, index=True + ) + uploader_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False + ) + title: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + file_url: Mapped[str] = mapped_column(Text, nullable=False) + file_type: Mapped[str] = mapped_column(String(50), nullable=False) + file_size: Mapped[int] = mapped_column(Integer, nullable=False) + category: Mapped[str] = mapped_column(String(50), nullable=False) + download_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + class_: Mapped["Class_"] = relationship("Class_", back_populates="resources") + uploader: Mapped["User"] = relationship("User") + + +class Notification(Base): + __tablename__ = "notifications" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False, index=True + ) + type: Mapped[str] = mapped_column(String(50), nullable=False) + title: Mapped[str] = mapped_column(String(200), nullable=False) + content: Mapped[str | None] = mapped_column(Text, nullable=True) + related_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + is_read: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + user: Mapped["User"] = relationship("User") + + +class StudentRoster(Base): + __tablename__ = "student_rosters" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + class_id: Mapped[int] = mapped_column( + Integer, ForeignKey("classes.id"), nullable=False, index=True + ) + student_id: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(100), nullable=False) + status: Mapped[str] = mapped_column( + String(20), default="unregistered", nullable=False + ) + user_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("users.id"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + class_: Mapped["Class_"] = relationship("Class_", back_populates="roster") + user: Mapped["User | None"] = relationship("User") + + +class TimelineLike(Base): + __tablename__ = "timeline_likes" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + post_id: Mapped[int] = mapped_column( + Integer, ForeignKey("timelines.id"), nullable=False, index=True + ) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False + ) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + post: Mapped["Timeline"] = relationship("Timeline", back_populates="likes") + user: Mapped["User"] = relationship("User") + + __table_args__ = ( + UniqueConstraint("post_id", "user_id", name="uq_timeline_like"), + ) + + +class TimelineComment(Base): + __tablename__ = "timeline_comments" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + post_id: Mapped[int] = mapped_column( + Integer, ForeignKey("timelines.id"), nullable=False, index=True + ) + author_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False + ) + content: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + post: Mapped["Timeline"] = relationship("Timeline", back_populates="comments") + author: Mapped["User"] = relationship("User") + + +class Vote(Base): + __tablename__ = "votes" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + class_id: Mapped[int] = mapped_column( + Integer, ForeignKey("classes.id"), nullable=False, index=True + ) + creator_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False + ) + title: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + vote_type: Mapped[str] = mapped_column(String(20), default="single", nullable=False) # single | multiple + is_anonymous: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + max_choices: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + deadline: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + status: Mapped[str] = mapped_column(String(20), default="open", nullable=False) # open | closed + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + class_: Mapped["Class_"] = relationship("Class_", back_populates="votes") + creator: Mapped["User"] = relationship("User", back_populates="created_votes") + options: Mapped[list["VoteOption"]] = relationship( + "VoteOption", back_populates="vote", cascade="all, delete-orphan" + ) + + +class VoteOption(Base): + __tablename__ = "vote_options" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + vote_id: Mapped[int] = mapped_column( + Integer, ForeignKey("votes.id"), nullable=False, index=True + ) + content: Mapped[str] = mapped_column(String(500), nullable=False) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + vote: Mapped["Vote"] = relationship("Vote", back_populates="options") + responses: Mapped[list["VoteResponse"]] = relationship( + "VoteResponse", back_populates="option", cascade="all, delete-orphan" + ) + + +class VoteResponse(Base): + __tablename__ = "vote_responses" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + vote_id: Mapped[int] = mapped_column( + Integer, ForeignKey("votes.id"), nullable=False, index=True + ) + option_id: Mapped[int] = mapped_column( + Integer, ForeignKey("vote_options.id"), nullable=False, index=True + ) + voter_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False + ) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + option: Mapped["VoteOption"] = relationship("VoteOption", back_populates="responses") + voter: Mapped["User"] = relationship("User") + + +class Assignment(Base): + __tablename__ = "assignments" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + class_id: Mapped[int] = mapped_column( + Integer, ForeignKey("classes.id"), nullable=False, index=True + ) + creator_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False + ) + title: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + deadline: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + attachment_urls: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array + status: Mapped[str] = mapped_column(String(20), default="open", nullable=False) # open | closed + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + class_: Mapped["Class_"] = relationship("Class_", back_populates="assignments") + creator: Mapped["User"] = relationship("User", back_populates="created_assignments") + submissions: Mapped[list["AssignmentSubmission"]] = relationship( + "AssignmentSubmission", back_populates="assignment", cascade="all, delete-orphan" + ) + + def get_attachment_urls_list(self) -> list[str]: + if not self.attachment_urls: + return [] + try: + return json.loads(self.attachment_urls) + except (json.JSONDecodeError, TypeError): + return [] + + def set_attachment_urls_list(self, urls: list[str]): + self.attachment_urls = json.dumps(urls) if urls else None + + +class AssignmentSubmission(Base): + __tablename__ = "assignment_submissions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + assignment_id: Mapped[int] = mapped_column( + Integer, ForeignKey("assignments.id"), nullable=False, index=True + ) + student_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False + ) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + file_url: Mapped[str | None] = mapped_column(Text, nullable=True) + file_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + file_type: Mapped[str | None] = mapped_column(String(50), nullable=True) + file_size: Mapped[int | None] = mapped_column(Integer, nullable=True) + grade: Mapped[str | None] = mapped_column(String(50), nullable=True) + feedback: Mapped[str | None] = mapped_column(Text, nullable=True) + graded_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + assignment: Mapped["Assignment"] = relationship("Assignment", back_populates="submissions") + student: Mapped["User"] = relationship("User", back_populates="assignment_submissions") diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..dc431d2 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,99 @@ +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.config import settings +from app.db.database import create_tables +from app.api import auth, users, classes, directory, timeline, schedule, upload, announcements, resources, notifications, votes, assignments + +logging.basicConfig( + level=logging.DEBUG if settings.debug else logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +logger = logging.getLogger(__name__) + + +async def ensure_super_admin(): + """Seed super admin on first run.""" + from sqlalchemy import select + from app.db.database import async_session + from app.db.models import User + from app.core.auth import hash_password + + async with async_session() as db: + result = await db.execute(select(User).where(User.role == "super_admin")) + if result.scalar_one_or_none() is None: + admin = User( + email=settings.super_admin_email, + password_hash=hash_password(settings.super_admin_password), + name="Super Admin", + role="super_admin", + status="approved", + ) + db.add(admin) + await db.commit() + logger.info("Super admin seeded: %s", settings.super_admin_email) + + +async def ensure_sample_class(): + """Seed a sample class if none exists.""" + from sqlalchemy import select, func + from app.db.database import async_session + from app.db.models import Class_ + + async with async_session() as db: + result = await db.execute(select(func.count(Class_.id))) + count = result.scalar() + if count == 0: + sample = Class_( + name="HKU ICB Sample Class", + cohort_year=2025, + description="Sample class for testing", + ) + db.add(sample) + await db.commit() + logger.info("Sample class seeded") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await create_tables() + await ensure_super_admin() + await ensure_sample_class() + yield + + +app = FastAPI( + title="HKU ICB", + description="HKU ICB Graduate Class Resource Platform", + version="1.0.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=[settings.frontend_url, "http://localhost:3000", "http://192.168.31.172:3000"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth.router) +app.include_router(users.router) +app.include_router(classes.router) +app.include_router(directory.router) +app.include_router(timeline.router) +app.include_router(schedule.router) +app.include_router(upload.router) +app.include_router(announcements.router) +app.include_router(resources.router) +app.include_router(notifications.router) +app.include_router(votes.router) +app.include_router(assignments.router) + + +@app.get("/api/health") +async def health(): + return {"status": "ok", "service": "classhub"} diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/announcement.py b/backend/app/schemas/announcement.py new file mode 100644 index 0000000..6a846ea --- /dev/null +++ b/backend/app/schemas/announcement.py @@ -0,0 +1,27 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class AnnouncementCreate(BaseModel): + title: str + content: str | None = None + is_pinned: bool = False + + +class AnnouncementUpdate(BaseModel): + title: str | None = None + content: str | None = None + is_pinned: bool | None = None + + +class AnnouncementOut(BaseModel): + id: int + class_id: int + author_id: int + author_name: str + title: str + content: str | None + is_pinned: bool + created_at: datetime + updated_at: datetime diff --git a/backend/app/schemas/assignment.py b/backend/app/schemas/assignment.py new file mode 100644 index 0000000..be8645d --- /dev/null +++ b/backend/app/schemas/assignment.py @@ -0,0 +1,63 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class AssignmentCreate(BaseModel): + title: str + description: str | None = None + deadline: datetime | None = None + + +class AssignmentUpdate(BaseModel): + title: str | None = None + description: str | None = None + deadline: datetime | None = None + status: str | None = None # open | closed + + +class SubmissionCreate(BaseModel): + notes: str | None = None + + +class SubmissionGrade(BaseModel): + grade: str + feedback: str | None = None + + +class SubmissionOut(BaseModel): + id: int + assignment_id: int + student_id: int + student_name: str + notes: str | None + file_url: str | None + file_name: str | None + file_type: str | None + file_size: int | None + grade: str | None + feedback: str | None + graded_at: datetime | None + created_at: datetime + updated_at: datetime + + +class AssignmentOut(BaseModel): + id: int + class_id: int + creator_id: int + creator_name: str + title: str + description: str | None + deadline: datetime | None + attachment_urls: list[str] | None + status: str + submission_count: int + total_members: int + my_submitted: bool + created_at: datetime + updated_at: datetime + + +class AssignmentDetailOut(AssignmentOut): + submissions: list[SubmissionOut] | None = None diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..851f3f7 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel, EmailStr + + +class LoginRequest(BaseModel): + email: EmailStr + password: str + + +class RegisterRequest(BaseModel): + invite_code: str + student_id: str + name: str + email: EmailStr + password: str + + +class ChangePasswordRequest(BaseModel): + old_password: str + new_password: str diff --git a/backend/app/schemas/class_.py b/backend/app/schemas/class_.py new file mode 100644 index 0000000..5615b24 --- /dev/null +++ b/backend/app/schemas/class_.py @@ -0,0 +1,27 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class ClassCreate(BaseModel): + name: str + cohort_year: int + description: str | None = None + + +class ClassUpdate(BaseModel): + name: str | None = None + cohort_year: int | None = None + description: str | None = None + + +class ClassOut(BaseModel): + id: int + name: str + cohort_year: int + description: str | None + invite_code: str | None = None + member_count: int = 0 + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/common.py b/backend/app/schemas/common.py new file mode 100644 index 0000000..c536567 --- /dev/null +++ b/backend/app/schemas/common.py @@ -0,0 +1,24 @@ +from typing import Generic, TypeVar + +from pydantic import BaseModel + +T = TypeVar("T") + + +class APIResponse(BaseModel, Generic[T]): + success: bool = True + data: T | None = None + message: str = "" + + +class PageParams(BaseModel): + page: int = 1 + page_size: int = 20 + + +class PageResponse(BaseModel, Generic[T]): + items: list[T] + total: int + page: int + page_size: int + total_pages: int diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py new file mode 100644 index 0000000..46eec08 --- /dev/null +++ b/backend/app/schemas/notification.py @@ -0,0 +1,17 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class NotificationOut(BaseModel): + id: int + type: str + title: str + content: str | None + related_id: int | None + is_read: bool + created_at: datetime + + +class UnreadCount(BaseModel): + count: int diff --git a/backend/app/schemas/resource.py b/backend/app/schemas/resource.py new file mode 100644 index 0000000..fd5405e --- /dev/null +++ b/backend/app/schemas/resource.py @@ -0,0 +1,24 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class ResourceCreate(BaseModel): + title: str + description: str | None = None + category: str # "course_material" | "assignment" | "reading" | "other" + + +class ResourceOut(BaseModel): + id: int + class_id: int + uploader_id: int + uploader_name: str + title: str + description: str | None + file_url: str + file_type: str + file_size: int + category: str + download_count: int + created_at: datetime diff --git a/backend/app/schemas/roster.py b/backend/app/schemas/roster.py new file mode 100644 index 0000000..75640e7 --- /dev/null +++ b/backend/app/schemas/roster.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel + + +class RosterOut(BaseModel): + id: int + student_id: str + name: str + status: str # "unregistered" | "registered" + user_id: int | None + + model_config = {"from_attributes": True} + + +class RosterImportRequest(BaseModel): + entries: list[dict] # [{"student_id": "...", "name": "..."}, ...] diff --git a/backend/app/schemas/schedule.py b/backend/app/schemas/schedule.py new file mode 100644 index 0000000..fa6a25a --- /dev/null +++ b/backend/app/schemas/schedule.py @@ -0,0 +1,35 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class ScheduleCreate(BaseModel): + type: str # course | deadline | activity + title: str + start_time: datetime + end_time: datetime | None = None + location: str | None = None + description: str | None = None + + +class ScheduleUpdate(BaseModel): + type: str | None = None + title: str | None = None + start_time: datetime | None = None + end_time: datetime | None = None + location: str | None = None + description: str | None = None + + +class ScheduleOut(BaseModel): + id: int + class_id: int + type: str + title: str + start_time: datetime + end_time: datetime | None + location: str | None + description: str | None + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/timeline.py b/backend/app/schemas/timeline.py new file mode 100644 index 0000000..d8278e1 --- /dev/null +++ b/backend/app/schemas/timeline.py @@ -0,0 +1,43 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class TimelineCreate(BaseModel): + title: str + content: str | None = None + + +class TimelineUpdate(BaseModel): + title: str | None = None + content: str | None = None + + +class TimelineCommentCreate(BaseModel): + content: str + + +class TimelineCommentOut(BaseModel): + id: int + post_id: int + author_id: int + author_name: str + content: str + created_at: datetime + updated_at: datetime + + +class TimelineOut(BaseModel): + id: int + class_id: int + author_id: int + author_name: str + title: str + content: str | None + image_urls: list[str] | None + like_count: int + has_liked: bool + comment_count: int + comments: list[TimelineCommentOut] | None = None + created_at: datetime + updated_at: datetime diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..eaf4184 --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,86 @@ +import json +from datetime import datetime + +from pydantic import BaseModel, EmailStr, field_validator + + +class UserOut(BaseModel): + id: int + email: str + name: str + student_id: str | None + role: str + status: str + class_id: int | None + industry: str | None + company: str | None + position: str | None + skills_tags: list[str] | None + wechat_id: str | None + phone: str | None + avatar_url: str | None + bio: str | None + created_at: datetime + + model_config = {"from_attributes": True} + + @field_validator("skills_tags", mode="before") + @classmethod + def parse_skills_tags(cls, v): + if isinstance(v, str): + try: + return json.loads(v) + except (json.JSONDecodeError, TypeError): + return [] + return v + + +class UserPublic(BaseModel): + """Shown to same-class approved members (includes contact info).""" + id: int + name: str + student_id: str | None + industry: str | None + company: str | None + position: str | None + wechat_id: str | None + phone: str | None + avatar_url: str | None + bio: str | None + + +class UserListItem(BaseModel): + """For admin user management list.""" + id: int + email: str + name: str + student_id: str | None + role: str + status: str + class_id: int | None + industry: str | None + company: str | None + created_at: datetime + + model_config = {"from_attributes": True} + + +class UserUpdate(BaseModel): + email: EmailStr | None = None + name: str | None = None + industry: str | None = None + company: str | None = None + position: str | None = None + wechat_id: str | None = None + phone: str | None = None + bio: str | None = None + + +class UserStatusUpdate(BaseModel): + status: str # approved | rejected | disabled + role: str | None = None + + +class TokenResponse(BaseModel): + token: str + user: UserOut diff --git a/backend/app/schemas/vote.py b/backend/app/schemas/vote.py new file mode 100644 index 0000000..059a692 --- /dev/null +++ b/backend/app/schemas/vote.py @@ -0,0 +1,51 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class VoteCreate(BaseModel): + title: str + description: str | None = None + vote_type: str = "single" # single | multiple + is_anonymous: bool = False + max_choices: int = 1 + deadline: datetime | None = None + options: list[str] # option text list, min 2 + + +class VoteUpdate(BaseModel): + title: str | None = None + description: str | None = None + status: str | None = None # to close a vote + + +class VoteSubmit(BaseModel): + option_ids: list[int] # selected option IDs + + +class VoteOptionOut(BaseModel): + id: int + content: str + sort_order: int + vote_count: int + voter_names: list[str] | None = None # None if anonymous + + +class VoteOut(BaseModel): + id: int + class_id: int + creator_id: int + creator_name: str + title: str + description: str | None + vote_type: str + is_anonymous: bool + max_choices: int + deadline: datetime | None + status: str + total_voters: int + has_voted: bool + my_option_ids: list[int] | None = None + options: list[VoteOptionOut] + created_at: datetime + updated_at: datetime diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/announcement_service.py b/backend/app/services/announcement_service.py new file mode 100644 index 0000000..2e831d2 --- /dev/null +++ b/backend/app/services/announcement_service.py @@ -0,0 +1,79 @@ +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.db.models import Announcement, User +from app.schemas.announcement import AnnouncementCreate, AnnouncementUpdate +from app.services.notification_service import create_notifications_for_class + + +async def create_announcement( + db: AsyncSession, class_id: int, author_id: int, data: AnnouncementCreate +) -> Announcement: + announcement = Announcement( + class_id=class_id, + author_id=author_id, + title=data.title, + content=data.content, + is_pinned=data.is_pinned, + ) + db.add(announcement) + await db.commit() + await db.refresh(announcement) + + # Send notifications + email to class members + content_preview = (data.content[:100] + "...") if data.content and len(data.content) > 100 else (data.content or "") + await create_notifications_for_class( + db, class_id, "announcement", f"新公告: {data.title}", + content=content_preview, + related_id=announcement.id, + email_subject=f"HKU ICB - 新公告: {data.title}", + email_body=f"
{content_preview}
" if content_preview else None, + email_action_path="/announcements", + ) + + return announcement + + +async def update_announcement( + db: AsyncSession, announcement: Announcement, data: AnnouncementUpdate +) -> Announcement: + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(announcement, field, value) + await db.commit() + await db.refresh(announcement) + return announcement + + +async def delete_announcement(db: AsyncSession, announcement: Announcement): + await db.delete(announcement) + await db.commit() + + +async def get_announcement_by_id(db: AsyncSession, announcement_id: int) -> Announcement | None: + result = await db.execute( + select(Announcement) + .options(selectinload(Announcement.author)) + .where(Announcement.id == announcement_id) + ) + return result.scalar_one_or_none() + + +async def list_announcements( + db: AsyncSession, class_id: int, page: int = 1, page_size: int = 20 +) -> tuple[list[Announcement], int]: + total_result = await db.execute( + select(func.count(Announcement.id)).where(Announcement.class_id == class_id) + ) + total = total_result.scalar() or 0 + + result = await db.execute( + select(Announcement) + .options(selectinload(Announcement.author)) + .where(Announcement.class_id == class_id) + .order_by(Announcement.is_pinned.desc(), Announcement.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + announcements = list(result.scalars().all()) + return announcements, total diff --git a/backend/app/services/assignment_service.py b/backend/app/services/assignment_service.py new file mode 100644 index 0000000..a0807ae --- /dev/null +++ b/backend/app/services/assignment_service.py @@ -0,0 +1,179 @@ +from datetime import datetime + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.db.models import Assignment, AssignmentSubmission, User +from app.schemas.assignment import AssignmentCreate, AssignmentUpdate, SubmissionCreate, SubmissionGrade +from app.services.notification_service import create_notifications_for_class + + +async def create_assignment( + db: AsyncSession, class_id: int, creator_id: int, data: AssignmentCreate +) -> Assignment: + assignment = Assignment( + class_id=class_id, + creator_id=creator_id, + title=data.title, + description=data.description, + deadline=data.deadline, + ) + db.add(assignment) + await db.commit() + await db.refresh(assignment) + + await create_notifications_for_class( + db, class_id, "assignment", f"新作业: {data.title}", + content=data.description, + related_id=assignment.id, + email_subject=f"HKU ICB - 新作业: {data.title}", + email_body=f"{data.description or data.title}
", + email_action_path="/assignments", + ) + + return assignment + + +async def update_assignment( + db: AsyncSession, assignment: Assignment, data: AssignmentUpdate +) -> Assignment: + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(assignment, field, value) + await db.commit() + await db.refresh(assignment) + return assignment + + +async def delete_assignment(db: AsyncSession, assignment: Assignment): + await db.delete(assignment) + await db.commit() + + +async def get_assignment_by_id(db: AsyncSession, assignment_id: int) -> Assignment | None: + result = await db.execute( + select(Assignment) + .options( + selectinload(Assignment.creator), + selectinload(Assignment.submissions).selectinload(AssignmentSubmission.student), + ) + .where(Assignment.id == assignment_id) + ) + return result.scalar_one_or_none() + + +async def list_assignments( + db: AsyncSession, class_id: int, page: int = 1, page_size: int = 20 +) -> tuple[list[Assignment], int]: + total_result = await db.execute( + select(func.count(Assignment.id)).where(Assignment.class_id == class_id) + ) + total = total_result.scalar() or 0 + + result = await db.execute( + select(Assignment) + .options( + selectinload(Assignment.creator), + selectinload(Assignment.submissions), + ) + .where(Assignment.class_id == class_id) + .order_by(Assignment.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + return list(result.scalars().all()), total + + +async def add_attachments(db: AsyncSession, assignment: Assignment, urls: list[str]): + existing = assignment.get_attachment_urls_list() + existing.extend(urls) + assignment.set_attachment_urls_list(existing) + await db.commit() + await db.refresh(assignment) + + +async def create_submission( + db: AsyncSession, + assignment_id: int, + student_id: int, + notes: str | None, + file_url: str | None = None, + file_name: str | None = None, + file_type: str | None = None, + file_size: int | None = None, +) -> AssignmentSubmission: + # Check assignment exists and is open + a_result = await db.execute(select(Assignment).where(Assignment.id == assignment_id)) + assignment = a_result.scalar_one_or_none() + if assignment is None: + raise ValueError("作业不存在") + if assignment.status != "open": + raise ValueError("作业已关闭提交") + if assignment.deadline and datetime.now() > assignment.deadline: + raise ValueError("作业已过截止日期") + + # Check no existing submission + existing = await db.execute( + select(AssignmentSubmission).where( + AssignmentSubmission.assignment_id == assignment_id, + AssignmentSubmission.student_id == student_id, + ) + ) + if existing.scalar_one_or_none(): + raise ValueError("你已经提交过作业了") + + submission = AssignmentSubmission( + assignment_id=assignment_id, + student_id=student_id, + notes=notes, + file_url=file_url, + file_name=file_name, + file_type=file_type, + file_size=file_size, + ) + db.add(submission) + await db.commit() + await db.refresh(submission) + + # Reload with student relationship + result = await db.execute( + select(AssignmentSubmission) + .options(selectinload(AssignmentSubmission.student)) + .where(AssignmentSubmission.id == submission.id) + ) + return result.scalar_one() + + +async def get_submission_by_student( + db: AsyncSession, assignment_id: int, student_id: int +) -> AssignmentSubmission | None: + result = await db.execute( + select(AssignmentSubmission) + .options(selectinload(AssignmentSubmission.student)) + .where( + AssignmentSubmission.assignment_id == assignment_id, + AssignmentSubmission.student_id == student_id, + ) + ) + return result.scalar_one_or_none() + + +async def grade_submission( + db: AsyncSession, submission: AssignmentSubmission, data: SubmissionGrade +) -> AssignmentSubmission: + submission.grade = data.grade + submission.feedback = data.feedback + submission.graded_at = datetime.now() + await db.commit() + await db.refresh(submission) + return submission + + +async def list_submissions(db: AsyncSession, assignment_id: int) -> list[AssignmentSubmission]: + result = await db.execute( + select(AssignmentSubmission) + .options(selectinload(AssignmentSubmission.student)) + .where(AssignmentSubmission.assignment_id == assignment_id) + .order_by(AssignmentSubmission.created_at.desc()) + ) + return list(result.scalars().all()) diff --git a/backend/app/services/class_service.py b/backend/app/services/class_service.py new file mode 100644 index 0000000..003efd1 --- /dev/null +++ b/backend/app/services/class_service.py @@ -0,0 +1,79 @@ +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import Class_, User +from app.schemas.class_ import ClassCreate, ClassUpdate + + +async def create_class(db: AsyncSession, data: ClassCreate) -> Class_: + class_ = Class_(**data.model_dump()) + db.add(class_) + await db.commit() + await db.refresh(class_) + return class_ + + +async def update_class(db: AsyncSession, class_: Class_, data: ClassUpdate) -> Class_: + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(class_, field, value) + await db.commit() + await db.refresh(class_) + return class_ + + +async def delete_class(db: AsyncSession, class_: Class_): + await db.delete(class_) + await db.commit() + + +async def get_class_by_id(db: AsyncSession, class_id: int) -> Class_ | None: + result = await db.execute(select(Class_).where(Class_.id == class_id)) + return result.scalar_one_or_none() + + +async def list_classes( + db: AsyncSession, page: int = 1, page_size: int = 50 +) -> tuple[list[Class_], int]: + total_result = await db.execute(select(func.count(Class_.id))) + total = total_result.scalar() or 0 + + result = await db.execute( + select(Class_) + .order_by(Class_.cohort_year.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + classes = list(result.scalars().all()) + return classes, total + + +async def get_member_count(db: AsyncSession, class_id: int) -> int: + result = await db.execute( + select(func.count(User.id)).where( + User.class_id == class_id, User.status == "approved" + ) + ) + return result.scalar() or 0 + + +async def get_class_members( + db: AsyncSession, + class_id: int, + status: str | None = None, + page: int = 1, + page_size: int = 50, +) -> tuple[list[User], int]: + query = select(User).where(User.class_id == class_id) + count_query = select(func.count(User.id)).where(User.class_id == class_id) + + if status: + query = query.where(User.status == status) + count_query = count_query.where(User.status == status) + + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + result = await db.execute( + query.order_by(User.name).offset((page - 1) * page_size).limit(page_size) + ) + return list(result.scalars().all()), total diff --git a/backend/app/services/cos_service.py b/backend/app/services/cos_service.py new file mode 100644 index 0000000..2fbd850 --- /dev/null +++ b/backend/app/services/cos_service.py @@ -0,0 +1,75 @@ +import uuid +from datetime import datetime +from io import BytesIO + +from qcloud_cos import CosConfig, CosS3Client + +from app.config import settings + +config = CosConfig( + Region=settings.cos_region, + SecretId=settings.cos_secret_id, + SecretKey=settings.cos_secret_key, +) +client = CosS3Client(config) + +ALLOWED_IMAGE_TYPES = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/webp": ".webp", +} + + +def upload_bytes(key: str, data: bytes, content_type: str) -> str: + """Upload raw bytes to COS, return the public URL.""" + client.put_object( + Bucket=settings.cos_bucket, + Key=key, + Body=BytesIO(data), + ContentType=content_type, + ) + return f"{settings.cos_base_url}/{key}" + + +def upload_image(prefix: str, filename: str, data: bytes, content_type: str) -> str: + """ + Upload an image to COS under the given prefix. + Returns the public URL. + """ + if content_type not in ALLOWED_IMAGE_TYPES: + raise ValueError(f"Unsupported image type: {content_type}") + + ext = ALLOWED_IMAGE_TYPES[content_type] + date_path = datetime.now().strftime("%Y/%m/%d") + unique_name = uuid.uuid4().hex[:12] + key = f"{prefix}/{date_path}/{unique_name}{ext}" + + return upload_bytes(key, data, content_type) + + +FILE_TYPE_EXTENSIONS = { + "application/pdf": ".pdf", + "application/msword": ".doc", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", + "application/vnd.ms-excel": ".xls", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx", + "application/vnd.ms-powerpoint": ".ppt", + "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx", + "application/zip": ".zip", + "text/plain": ".txt", + "image/jpeg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/webp": ".webp", +} + + +def upload_file(prefix: str, filename: str, data: bytes, content_type: str) -> str: + """Upload any allowed file type to COS. Returns the public URL.""" + ext = FILE_TYPE_EXTENSIONS.get(content_type, "") + date_path = datetime.now().strftime("%Y/%m/%d") + unique_name = uuid.uuid4().hex[:12] + key = f"{prefix}/{date_path}/{unique_name}{ext}" + + return upload_bytes(key, data, content_type) diff --git a/backend/app/services/directory_service.py b/backend/app/services/directory_service.py new file mode 100644 index 0000000..72b56a5 --- /dev/null +++ b/backend/app/services/directory_service.py @@ -0,0 +1,77 @@ +import json +from sqlalchemy import select, or_, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import User +from app.schemas.user import UserPublic + + +async def search_directory( + db: AsyncSession, + class_id: int, + search: str | None = None, + industry: str | None = None, + company: str | None = None, + page: int = 1, + page_size: int = 20, +) -> tuple[list[User], int]: + """Search approved members in a class.""" + query = select(User).where( + User.class_id == class_id, User.status == "approved" + ) + count_query = select(func.count(User.id)).where( + User.class_id == class_id, User.status == "approved" + ) + + if search: + search_term = f"%{search}%" + query = query.where( + or_( + User.name.ilike(search_term), + User.company.ilike(search_term), + User.position.ilike(search_term), + ) + ) + count_query = count_query.where( + or_( + User.name.ilike(search_term), + User.company.ilike(search_term), + User.position.ilike(search_term), + ) + ) + + if industry: + query = query.where(User.industry == industry) + count_query = count_query.where(User.industry == industry) + + if company: + company_term = f"%{company}%" + query = query.where(User.company.ilike(company_term)) + count_query = count_query.where(User.company.ilike(company_term)) + + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + result = await db.execute( + query.order_by(User.name) + .offset((page - 1) * page_size) + .limit(page_size) + ) + users = list(result.scalars().all()) + return users, total + + +def user_to_public(user: User, include_contact: bool = True) -> UserPublic: + """Convert User model to public profile, optionally hiding contact info.""" + return UserPublic( + id=user.id, + name=user.name, + student_id=user.student_id, + industry=user.industry, + company=user.company, + position=user.position, + wechat_id=user.wechat_id if include_contact else None, + phone=user.phone if include_contact else None, + avatar_url=user.avatar_url, + bio=user.bio, + ) diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py new file mode 100644 index 0000000..28f0b20 --- /dev/null +++ b/backend/app/services/email_service.py @@ -0,0 +1,95 @@ +import logging +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +import aiosmtplib + +from app.config import settings + +logger = logging.getLogger(__name__) + + +async def send_email(to: str, subject: str, html_body: str) -> bool: + """Send HTML email via SMTP. Returns True on success.""" + if not settings.smtp_host: + logger.info(f"SMTP not configured, skipping email to {to}: {subject}") + return False + + msg = MIMEMultipart("alternative") + msg["From"] = f"{settings.smtp_from_name} <{settings.smtp_from_email}>" + msg["To"] = to + msg["Subject"] = subject + msg.attach(MIMEText(html_body, "html")) + + try: + await aiosmtplib.send( + msg, + hostname=settings.smtp_host, + port=settings.smtp_port, + username=settings.smtp_user, + password=settings.smtp_password, + use_tls=True, + ) + return True + except Exception as e: + logger.error(f"Failed to send email to {to}: {e}") + return False + + +async def send_registration_notification( + admin_email: str, student_name: str, class_name: str +): + html = f""" +{student_name} has registered for {class_name}.
+Please log in to HKU ICB to review and approve.
+ """ + await send_email(admin_email, "HKU ICB: New Registration", html) + + +async def send_approval_notification(student_email: str, approved: bool): + status_text = "approved" if approved else "rejected" + html = f""" +Your registration has been {status_text}.
+ {"You can now log in to HKU ICB.
" if approved else ""} + """ + await send_email( + student_email, f"HKU ICB: Registration {status_text.capitalize()}", html + ) + + +async def send_class_notification_email( + emails: list[str], + subject: str, + title: str, + body: str, + action_url: str | None = None, +): + """Send a styled notification email to class members.""" + action_html = "" + if action_url: + action_html = f""" +{data.title}
时间: {time_str}{location_info}
", + email_action_path="/schedule", + ) + + return item + + +async def update_schedule( + db: AsyncSession, item: Schedule, data: ScheduleUpdate +) -> Schedule: + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(item, field, value) + await db.commit() + await db.refresh(item) + return item + + +async def delete_schedule(db: AsyncSession, item: Schedule): + await db.delete(item) + await db.commit() + + +async def get_schedule_by_id(db: AsyncSession, schedule_id: int) -> Schedule | None: + result = await db.execute(select(Schedule).where(Schedule.id == schedule_id)) + return result.scalar_one_or_none() + + +async def list_schedules( + db: AsyncSession, + class_id: int, + schedule_type: str | None = None, + page: int = 1, + page_size: int = 50, +) -> tuple[list[Schedule], int]: + query = select(Schedule).where(Schedule.class_id == class_id) + count_query = select(func.count(Schedule.id)).where(Schedule.class_id == class_id) + + if schedule_type: + query = query.where(Schedule.type == schedule_type) + count_query = count_query.where(Schedule.type == schedule_type) + + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + result = await db.execute( + query.order_by(Schedule.start_time.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + items = list(result.scalars().all()) + return items, total + + +async def get_upcoming_schedules( + db: AsyncSession, class_id: int, limit: int = 10 +) -> list[Schedule]: + now = datetime.now(timezone.utc) + result = await db.execute( + select(Schedule) + .where(Schedule.class_id == class_id, Schedule.start_time >= now) + .order_by(Schedule.start_time.asc()) + .limit(limit) + ) + return list(result.scalars().all()) diff --git a/backend/app/services/timeline_service.py b/backend/app/services/timeline_service.py new file mode 100644 index 0000000..dc29b3a --- /dev/null +++ b/backend/app/services/timeline_service.py @@ -0,0 +1,177 @@ +import json +from datetime import datetime + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.db.models import Timeline, TimelineLike, TimelineComment, User +from app.schemas.timeline import TimelineCreate, TimelineUpdate, TimelineCommentCreate +from app.services.notification_service import create_notifications_for_class + + +async def create_timeline( + db: AsyncSession, class_id: int, author_id: int, data: TimelineCreate +) -> Timeline: + post = Timeline( + class_id=class_id, + author_id=author_id, + title=data.title, + content=data.content, + ) + db.add(post) + await db.commit() + await db.refresh(post) + + content_preview = (data.content[:100] + "...") if data.content and len(data.content) > 100 else (data.content or "") + await create_notifications_for_class( + db, class_id, "timeline", f"新动态: {data.title}", + content=content_preview, + related_id=post.id, + email_subject=f"HKU ICB - 新动态: {data.title}", + email_body=f"{content_preview}
" if content_preview else None, + email_action_path="/timeline", + ) + + return post + + +async def update_timeline( + db: AsyncSession, post: Timeline, data: TimelineUpdate +) -> Timeline: + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(post, field, value) + await db.commit() + await db.refresh(post) + return post + + +async def delete_timeline(db: AsyncSession, post: Timeline): + await db.delete(post) + await db.commit() + + +async def get_timeline_by_id(db: AsyncSession, post_id: int) -> Timeline | None: + result = await db.execute( + select(Timeline) + .options( + selectinload(Timeline.author), + selectinload(Timeline.likes), + selectinload(Timeline.comments).selectinload(TimelineComment.author), + ) + .where(Timeline.id == post_id) + ) + return result.scalar_one_or_none() + + +async def list_timelines( + db: AsyncSession, class_id: int, page: int = 1, page_size: int = 20 +) -> tuple[list[Timeline], int]: + total_result = await db.execute( + select(func.count(Timeline.id)).where(Timeline.class_id == class_id) + ) + total = total_result.scalar() or 0 + + result = await db.execute( + select(Timeline) + .options( + selectinload(Timeline.author), + selectinload(Timeline.likes), + selectinload(Timeline.comments), + ) + .where(Timeline.class_id == class_id) + .order_by(Timeline.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + posts = list(result.scalars().all()) + return posts, total + + +async def add_images_to_timeline(db: AsyncSession, post: Timeline, urls: list[str]): + existing = post.get_image_urls_list() + existing.extend(urls) + post.set_image_urls_list(existing) + await db.commit() + await db.refresh(post) + + +async def toggle_like(db: AsyncSession, post_id: int, user_id: int) -> dict: + result = await db.execute( + select(TimelineLike).where( + TimelineLike.post_id == post_id, + TimelineLike.user_id == user_id, + ) + ) + existing = result.scalar_one_or_none() + + if existing: + await db.delete(existing) + await db.commit() + # Count remaining likes + count_result = await db.execute( + select(func.count(TimelineLike.id)).where(TimelineLike.post_id == post_id) + ) + return {"liked": False, "like_count": count_result.scalar() or 0} + else: + like = TimelineLike(post_id=post_id, user_id=user_id) + db.add(like) + await db.commit() + count_result = await db.execute( + select(func.count(TimelineLike.id)).where(TimelineLike.post_id == post_id) + ) + return {"liked": True, "like_count": count_result.scalar() or 0} + + +async def create_comment( + db: AsyncSession, post_id: int, author_id: int, data: TimelineCommentCreate +) -> TimelineComment: + comment = TimelineComment( + post_id=post_id, + author_id=author_id, + content=data.content, + ) + db.add(comment) + await db.commit() + await db.refresh(comment) + + # Load author relationship for response + result = await db.execute( + select(TimelineComment) + .options(selectinload(TimelineComment.author)) + .where(TimelineComment.id == comment.id) + ) + return result.scalar_one() + + +async def delete_comment(db: AsyncSession, comment: TimelineComment): + await db.delete(comment) + await db.commit() + + +async def get_comment_by_id(db: AsyncSession, comment_id: int) -> TimelineComment | None: + result = await db.execute( + select(TimelineComment) + .options(selectinload(TimelineComment.author)) + .where(TimelineComment.id == comment_id) + ) + return result.scalar_one_or_none() + + +async def list_comments( + db: AsyncSession, post_id: int, page: int = 1, page_size: int = 50 +) -> tuple[list[TimelineComment], int]: + total_result = await db.execute( + select(func.count(TimelineComment.id)).where(TimelineComment.post_id == post_id) + ) + total = total_result.scalar() or 0 + + result = await db.execute( + select(TimelineComment) + .options(selectinload(TimelineComment.author)) + .where(TimelineComment.post_id == post_id) + .order_by(TimelineComment.created_at.asc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + return list(result.scalars().all()), total diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py new file mode 100644 index 0000000..a951866 --- /dev/null +++ b/backend/app/services/user_service.py @@ -0,0 +1,80 @@ +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import User +from app.schemas.user import UserOut, UserUpdate + + +async def get_user_by_email(db: AsyncSession, email: str) -> User | None: + result = await db.execute(select(User).where(User.email == email)) + return result.scalar_one_or_none() + + +async def get_user_by_id(db: AsyncSession, user_id: int) -> User | None: + result = await db.execute(select(User).where(User.id == user_id)) + return result.scalar_one_or_none() + + +async def update_profile(db: AsyncSession, user: User, data: UserUpdate) -> User: + update_data = data.model_dump(exclude_unset=True) + + # Handle email change with uniqueness check + if "email" in update_data and update_data["email"] != user.email: + existing = await db.execute( + select(User).where(User.email == update_data["email"]) + ) + if existing.scalar_one_or_none(): + raise ValueError("该邮箱已被使用") + user.email = update_data.pop("email") + + for field, value in update_data.items(): + setattr(user, field, value) + await db.commit() + await db.refresh(user) + return user + + +async def update_user_status( + db: AsyncSession, user_id: int, status: str, role: str | None = None +) -> User | None: + user = await get_user_by_id(db, user_id) + if user is None: + return None + user.status = status + if role is not None: + user.role = role + await db.commit() + await db.refresh(user) + return user + + +async def list_users( + db: AsyncSession, + page: int = 1, + page_size: int = 20, + class_id: int | None = None, + status: str | None = None, + role: str | None = None, +) -> tuple[list[User], int]: + query = select(User) + count_query = select(func.count(User.id)) + + if class_id is not None: + query = query.where(User.class_id == class_id) + count_query = count_query.where(User.class_id == class_id) + if status is not None: + query = query.where(User.status == status) + count_query = count_query.where(User.status == status) + if role is not None: + query = query.where(User.role == role) + count_query = count_query.where(User.role == role) + + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + query = query.order_by(User.created_at.desc()) + query = query.offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + users = list(result.scalars().all()) + + return users, total diff --git a/backend/app/services/vote_service.py b/backend/app/services/vote_service.py new file mode 100644 index 0000000..8173ebe --- /dev/null +++ b/backend/app/services/vote_service.py @@ -0,0 +1,141 @@ +from datetime import datetime + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.db.models import Vote, VoteOption, VoteResponse, User +from app.schemas.vote import VoteCreate +from app.services.notification_service import create_notifications_for_class + + +async def create_vote( + db: AsyncSession, class_id: int, creator_id: int, data: VoteCreate +) -> Vote: + vote = Vote( + class_id=class_id, + creator_id=creator_id, + title=data.title, + description=data.description, + vote_type=data.vote_type, + is_anonymous=data.is_anonymous, + max_choices=data.max_choices, + deadline=data.deadline, + ) + db.add(vote) + await db.flush() + + for i, opt_text in enumerate(data.options): + option = VoteOption(vote_id=vote.id, content=opt_text, sort_order=i) + db.add(option) + + await db.commit() + await db.refresh(vote) + + # Load options for the returned object + result = await db.execute( + select(Vote) + .options(selectinload(Vote.options)) + .where(Vote.id == vote.id) + ) + vote = result.scalar_one() + + await create_notifications_for_class( + db, class_id, "vote", f"新投票: {data.title}", + content=data.description, + related_id=vote.id, + email_subject=f"HKU ICB - 新投票: {data.title}", + email_body=f"{data.description or data.title}
", + email_action_path="/votes", + ) + + return vote + + +async def get_vote_by_id(db: AsyncSession, vote_id: int) -> Vote | None: + result = await db.execute( + select(Vote) + .options( + selectinload(Vote.creator), + selectinload(Vote.options).selectinload(VoteOption.responses).selectinload(VoteResponse.voter), + ) + .where(Vote.id == vote_id) + ) + return result.scalar_one_or_none() + + +async def list_votes( + db: AsyncSession, class_id: int, page: int = 1, page_size: int = 20 +) -> tuple[list[Vote], int]: + total_result = await db.execute( + select(func.count(Vote.id)).where(Vote.class_id == class_id) + ) + total = total_result.scalar() or 0 + + result = await db.execute( + select(Vote) + .options( + selectinload(Vote.creator), + selectinload(Vote.options).selectinload(VoteOption.responses), + ) + .where(Vote.class_id == class_id) + .order_by(Vote.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + return list(result.scalars().all()), total + + +async def submit_vote( + db: AsyncSession, vote_id: int, voter_id: int, option_ids: list[int] +) -> None: + vote_result = await db.execute(select(Vote).where(Vote.id == vote_id)) + vote = vote_result.scalar_one_or_none() + if vote is None: + raise ValueError("投票不存在") + if vote.status != "open": + raise ValueError("投票已关闭") + if vote.deadline and datetime.now() > vote.deadline: + raise ValueError("投票已过截止日期") + + # Check if already voted + existing = await db.execute( + select(VoteResponse).where( + VoteResponse.vote_id == vote_id, + VoteResponse.voter_id == voter_id, + ) + ) + if existing.scalar_one_or_none(): + raise ValueError("你已经投过票了") + + # Validate option count + if vote.vote_type == "single" and len(option_ids) != 1: + raise ValueError("单选投票只能选择一个选项") + if vote.vote_type == "multiple" and len(option_ids) > vote.max_choices: + raise ValueError(f"最多选择 {vote.max_choices} 个选项") + + # Validate option_ids belong to this vote + for oid in option_ids: + opt_result = await db.execute( + select(VoteOption).where(VoteOption.id == oid, VoteOption.vote_id == vote_id) + ) + if opt_result.scalar_one_or_none() is None: + raise ValueError(f"选项 {oid} 不属于此投票") + + for oid in option_ids: + response = VoteResponse(vote_id=vote_id, option_id=oid, voter_id=voter_id) + db.add(response) + + await db.commit() + + +async def close_vote(db: AsyncSession, vote: Vote) -> Vote: + vote.status = "closed" + await db.commit() + await db.refresh(vote) + return vote + + +async def delete_vote(db: AsyncSession, vote: Vote): + await db.delete(vote) + await db.commit() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..b8033a9 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,13 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +sqlalchemy[asyncio]==2.0.36 +aiosqlite==0.20.0 +pydantic[email]==2.10.3 +pydantic-settings==2.7.1 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +alembic==1.14.0 +python-multipart==0.0.20 +aiosmtplib==3.0.2 +cos-python-sdk-v5==1.9.30 +httpx==0.28.1 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..901d45f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +services: + backend: + build: ./backend + restart: unless-stopped + env_file: ./backend/.env + environment: + - CH_DATABASE_URL=sqlite+aiosqlite:///./data/classhub.db + - CH_FRONTEND_URL=http://localhost + volumes: + - classhub-data:/app/data + expose: + - "8000" + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"] + interval: 30s + timeout: 5s + retries: 3 + + frontend: + build: + context: ./frontend + args: + - NEXT_PUBLIC_API_URL= + restart: unless-stopped + expose: + - "3000" + depends_on: + - backend + + nginx: + image: nginx:alpine + restart: unless-stopped + ports: + - "80:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - backend + - frontend + +volumes: + classhub-data: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..5522d9f --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,7 @@ +node_modules/ +.next/ +out/ +.env.local +.env*.local +*.tsbuildinfo +.git/ diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md new file mode 100644 index 0000000..8bd0e39 --- /dev/null +++ b/frontend/AGENTS.md @@ -0,0 +1,5 @@ + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/frontend/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..af6d9b4 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,34 @@ +FROM node:22-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev + +FROM node:22-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +ARG NEXT_PUBLIC_API_URL="" +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL + +RUN npm run build + +FROM node:22-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..8d886db --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 0000000..05e726d --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/frontend/next.config.ts b/frontend/next.config.ts new file mode 100644 index 0000000..bf85763 --- /dev/null +++ b/frontend/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", + allowedDevOrigins: ["192.168.31.172"], +}; + +export default nextConfig; diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e1336b1 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,35 @@ +{ + "name": "frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "@base-ui/react": "^1.3.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.8.0", + "next": "16.2.3", + "next-themes": "^0.4.6", + "react": "19.2.4", + "react-dom": "19.2.4", + "shadcn": "^4.2.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.5.0", + "tw-animate-css": "^1.4.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.2.3", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/frontend/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/frontend/public/file.svg b/frontend/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/frontend/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/globe.svg b/frontend/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/frontend/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/next.svg b/frontend/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/frontend/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/vercel.svg b/frontend/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/frontend/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/window.svg b/frontend/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/frontend/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/app/(app)/admin/classes/page.tsx b/frontend/src/app/(app)/admin/classes/page.tsx new file mode 100644 index 0000000..df728cc --- /dev/null +++ b/frontend/src/app/(app)/admin/classes/page.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useAuth } from "@/hooks/use-auth"; +import { fetchAPI, postAPI, putAPI, deleteAPI } from "@/lib/api"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { RoleGuard } from "@/components/role-guard"; +import { ConfirmDialog } from "@/components/confirm-dialog"; +import { ErrorState } from "@/components/error-state"; +import { toast } from "sonner"; +import type { ClassInfo } from "@/lib/types"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; + +export default function ClassesPage() { + const { user } = useAuth(); + const [classes, setClasses] = useState创建和管理班级
++ {cls.cohort_year}届 · {cls.member_count} 名成员 +
+管理班级花名册、邀请码和已注册成员
+班级邀请码
++ {inviteCode || "—"} +
++ 将此邀请码分享给学生,学生注册时输入邀请码+学号即可加入班级 +
+{r.name}
+{r.student_id}
+{m.name}
++ {m.email} + {m.student_id ? ` · ${m.student_id}` : ""} + {m.company ? ` · ${m.company}` : ""} +
++ 当前角色: {user?.role ? ROLES[user.role] : "-"} +
++ 审核注册申请、管理成员状态 +
++ 创建和管理班级信息 +
+班级重要通知与公告
+{item.content}
+ )} ++ {item.author_name} ·{" "} + {new Date(item.created_at).toLocaleDateString("zh-CN", { + year: "numeric", + month: "long", + day: "numeric", + })} +
++ {assignment.description} +
+ )} + ++ 发布者:{assignment.creator_name} · 发布时间: + {formatDate(assignment.created_at)} +
+ {deadline && ( ++ 截止时间: + {formatDate(assignment.deadline!)} + {isPastDeadline ? ( + (已过期) + ) : ( + ({getCountdown(assignment.deadline!)}) + )} +
+ )} ++ 附件下载 +
+备注:
++ {mySubmission.notes} +
+教师反馈:
++ {mySubmission.feedback} +
++ 评分时间:{formatDate(mySubmission.graded_at)} +
+ )} +作业已截止,无法提交
+| 学生 | +提交时间 | +文件 | +成绩 | +反馈 | +操作 | +
|---|---|---|---|---|---|
| {sub.student_name} | +{formatDate(sub.created_at)} | ++ {sub.file_url ? ( + + + {sub.file_name || "下载"} + {sub.file_size !== null && ({formatFileSize(sub.file_size)})} + + ) : -} + | +
+ {sub.grade ? |
+ {sub.feedback || "-"} | +
+ {activeGradeId === sub.id ? (
+
+ setGradingMap((prev) => ({ ...prev, [sub.id]: { ...prev[sub.id], grade: e.target.value } }))} className="h-8 text-sm" />
+
+ ) : (
+
+ )}
+ |
+
{formatDate(sub.created_at)}
+ {sub.file_url && ( + + + {sub.file_name || "下载"} + + )} + {sub.feedback &&反馈:{sub.feedback}
} + {activeGradeId === sub.id ? ( +查看与提交课程作业
++ {assignment.description} +
+ )} +欢迎回来
+{a.title}
++ {a.author_name} · {new Date(a.created_at).toLocaleDateString("zh-CN")} +
+暂无排期
+ ) : ( +{item.title}
++ {new Date(item.start_time).toLocaleDateString("zh-CN")} +
+暂无动态
+ ) : ( +{post.title}
++ {post.author_name} ·{" "} + {new Date(post.created_at).toLocaleDateString("zh-CN")} +
+学号: {member.student_id}
+ )} + {member.company && ( ++ {member.company} + {member.position ? ` · ${member.position}` : ""} +
+ )} + {member.industry && ( +{member.bio}
+微信: {member.wechat_id}
+ )} + {member.phone && ( +手机: {member.phone}
+ )} +共 {total} 位同学
+{member.name}
+ {member.company && ( ++ {member.company} + {member.position ? ` · ${member.position}` : ""} +
+ )} + {member.industry && ( +支持 JPG/PNG/GIF/WebP,最大 5MB
+共享课件、文档与学习资料
+{r.title}
++ {r.uploader_name} · {formatFileSize(r.file_size)} · 下载 {r.download_count} 次 +
+课程、截止日、活动安排
++ {new Date(item.start_time).toLocaleString("zh-CN", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} +
+ {item.location && ( +{item.location}
+ )} +{item.title}
++ {new Date(item.start_time).toLocaleString("zh-CN")} + {item.location ? ` · ${item.location}` : ""} +
+分享动态,交流互动
++ {post.author_name} ·{" "} + {new Date(post.created_at).toLocaleDateString("zh-CN", { + year: "numeric", + month: "long", + day: "numeric", + })} +
++ {post.content} +
+ )} + {post.image_urls && post.image_urls.length > 0 && ( +{comment.content}
+班级投票与调查
+{vote.description}
+ )} +dAMQaiH zm^r3v#$Q#2T=X>bsY#D%s!bhs^M9PMAcHbCc0FMHV{u-dwlL;a1eJ63v5U*?Q_8JO zT#50!RD619#j_Uf))0ooADz~*9&lN!bBDRUgE>Vud-i5ck%vT=r^yD*^?Mp@Q^v+V zG#-?gKlr}Eeqifb{|So?HM&g91P8|av8hQoCmQXkd?7wIJw b z_^v8bbg` SAn{I*4bH$u(RZ6*x UhuA~hc=8czK8SHEKTzSxgbwi~9(OqJB&gwb^l4+m`k*Q;_?>Y-APi1{k zAHQ)P)G)f|AyjSgcCFps)Fh6Bca*Xznq36!pV6Az&m{O8$wGFD? zY&O*3*J0;_EqM#jh6^gMQKpXV?#1?>$ml1xvh8nSN>-?H=V;nJIwB07YX$e6vLxH( zqYwQ>qxwR(i4f)DLd)-$P>T-no_c!LsN@)8`e;W@)-Hj0>nJ-}Kla4-ZdPJzI&Mce zv)V_j;(3ERN3_@I$N<^|4Lf`B;8n+bX@bHbcZTopEmDI*Jfl)-pFDvo6svPRoo@(x z);_{lY<;);XzT`dBFpRmGrr}z5u1=p C^ S-{ce6iXQlLGcItwJ^mZx{m$&DA_oEZ)B{_bYPq-HA zcH8WGoBG(aBU_j)vEy+_71T34@4dmSg!|M8Vf92Zj6WH7Q7t#OHQqWgFE3ARt+%!T z?oLovLVlnf?2c7pTc)~cc^($_8nyKwsN`RA-23ed3sdj(ys%pjjM+9JrctL;dy8a( z@en&CQmnV(()bu|Y%G1-4a(6x{aLytn$T-;(&{QIJB9vMox11U-1HpD@d(QkaJdEb zG{)+6Dos_L+O3NpWo^=gR?evp|CqEG?L&Ut#D*KLaRFOgOEK(Kq1@!EGcTfo+%A&I z=dLbB+d$u{sh?u)xP{PF8L%;YPPW53+@{>5W=Jt#wQpN;0_HYdw1{ksf_XhO4#2F= zyPx6Lx2<92L-;L5PD`zn6zwIH`Jk( $?Qw({erA$^bC;q33hv!d!>%wRhj# zal^hk+WGNg;rJtb-EB(?czvOM=H7dl=vblBwAv>}%1@{}mnpUznfq1cE^sgsL0*4I zJ##!*B?=vI_OEVis5o+_IwMIRrpQyT_Sq~ZU%oY7c5JMIADzpD!Upz9h@iWg_>>~j zOLS;wp^i$-E?4<_cp?RiS%Rd?i;f*mOz=~(&3lo<=@(nR!_Rqiprh@weZlL!t#NCc zO!QTcInq|%#>OVgobj{~ixEUec`E25zJ~*DofsQdzIa@5^nOXj2T;8O`l--(QyU ^$t?TGY^7#&FQ+2SS3B#qK*k3`ye?8jUYSajE5iBbJls75CCc(m3dk{t?- zopcER9{Z?TC)mk~gpi^kbbu>b-+a{m#8-y2^p$ka4n60w;Sc2}HMf<8JUvh CL0B&Btk)T`ctE$*qNW8L$`7!r^9T+>=<=2qaq-;ll2{`{Rg zc5a0ZUI$oG&j-qVOuKa=*v4aY#IsoM+1|c4Z)<}lEDvy;5huB@1RJPquU2U*U-;gu z=En2m+qjBzR#DEJDO`WU)hdd{Vj%^0V*KoyZ|5lzV87&g_j~NCjwv0uQVqXOb*QrQ zy|Qn`hxx(58c 70$E;L(X0uZZ72M1!6oeg)(cdKO ze0gDaTz+ohR-#d)NbAH4x{I(21yjwvBQfmpLu$)|m{XolbgF!pmsqJ#D}(ylp6uC> z{bqtcI#hT#HW=wl7>p!38sKsJ`r8}lt-q%Keqy%u(xk=yiIJiUw6|5IvkS+#?JTBl z8H5(Q?l#wzazujH!8o>1xtn8#_w+397* _cy8!pQGP%K(Ga3pAjsaTbbXJlQF_+m+-UpUUent@xM zg%jqLUExj~o^vQ3Gl*>wh=_gOr2*|U64_iXb+-111a H}$TjeajM+I20xw(((>fej-@CIz4S1pi$(#}P7`4({6QS2CaQS4NPENDp>sAqD z$bH4KGzXGffkJ7R>V>)>tC)uax{UsN*dbeNC*v}#8Y#OWYwL4t$ePR?VTyIs!wea+ z5Urmc)X|^`MG~*dS6pGSbU+gPJoq*^a=_>$n4|P^w$sMBBy@f*Z^Jg6?n5?oId6f{ z$LW4M|4m502z0t7g<#Bx%X;9<=)smFolV&(V^(7Cv2-sxbxopQ!)*#ZRhTBpx1)Fc zNm1T%bONzv6@#|dz(w02AH8OXe>kQ#1FMCzO}2J_mST)+ExmBr9cva-@?;wnmWMOk z{3_~EX_xadgJGv&H@zK_8{(x84`}+c?oSBX*Ge3VdfTt&F}yCpFP?CpW+BE^cWY0^ zb&uBN!Ja3UzYHK-CTyA5=L zEMW{l3Usky#ly=7px648W31UNV@K)&Ub&zP1c7%)`{);I4b0Q<)B}3;NMG2JH=X$U zfIW4)4n9ZM`-yRj67I)YSLDK)qfUJ_ij}a#aZN~9EXrh8eZY2&=uY%2N0UFF7<~%M zsB8=erOWZ>Ct_#^tHZ|*q`H;A)5;ycw*I cmVxi8_0Xk}aJA^ath+E;xg!x+As(M#0=)3!NJR6H&9+zd#iP(m0PIW8$ z1Y^VX`>jm`W!=WpF*{ioM?C9`yOR>@0q=u7o>BP-eSHqCgMDj!2anwH?s%i2p+Q7D zzszIf5XJpE)IG4;d_(La-xenmF(tgAxK`Y4sQ}BSJEPs6N_U2vI{8=0C_F?@7<(G; zo$~G=8p+076G;`}>{MQ>t>7cm=zGtfbdDXm6||jUU|?X?CaE?(<6bKDYKeHlz}DA8 zXT={X=yp_R;HfJ9h%?eWvQ!dRgz&Su*JfNt!Wu>|XfU &68iRikRrHRW|ZxzRR^`eIGt zIeiDgVS>IeExKVRWW8-= A= yA`}`)ZkWBrZD`hpWIxBGkh&f#ijr449~m`j6{4jiJ*C!oVA8ZC?$1RM#K(_b zL9TW)kN*Y4%^-qPpMP7d4)o?Nk#>aoYHT(*g)qmRUb?**F@pnNiy6Fv9rEiUqD(^O zzyS?nBrX63BTRYduaG(0VVG2yJRe%o&rVrLjbxTaAFTd8s;<<@Qs>u(<193R8>}2_ zuwp{7;H2a*X7_jryzriZXMg?bTuegABb^87@SsKkr2)0Gyiax8KQWstw^v #ix45EVrcEhr>!NMhprl $InQMzjSFH54x5k9qHc`@9uKQzvL4ihcq{^B zPrVR=o_ic%Y>6&rMN)hTZsI7I<3&`#(nl+3y3ys9A~ &^=4?PL&nd8)`OfG#n zwAMN$1&>K++c{^|7< 4P=2y(B{jJsQ0a#U;HTo4ZmWZYvI{+s;Td{Yzem%0*k#)vjpB zia;J&>}ICate44SFYY3vEelqStQWFihx%^vQ@Do(sOy7yR2@WNv7Y9I^yL=nZr3mb zXKV5t@=?-Sk|b{XMhA7ZGB@2hqsx}4xwCW!in#C zI@}sc Zlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7 zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S zb+ 9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2 `1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc- zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I zgg{b=Iz}WkhCGj1M =hu4#Aw173YxIVbISaoc z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S( O(a0e7oY=E( zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css new file mode 100644 index 0000000..c56032b --- /dev/null +++ b/frontend/src/app/globals.css @@ -0,0 +1,130 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-sans); + --font-mono: var(--font-geist-mono); + --font-heading: var(--font-sans); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} \ No newline at end of file diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 0000000..f8ed4ae --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -0,0 +1,43 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import { AuthProvider } from "@/hooks/use-auth"; +import { AuthGuard } from "@/components/auth-guard"; +import { Toaster } from "@/components/ui/sonner"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "HKU ICB - 班级资源平台", + description: "研究生班级资源连接平台", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + + + + ); +} diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx new file mode 100644 index 0000000..ff1e95b --- /dev/null +++ b/frontend/src/app/login/page.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/hooks/use-auth"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import Link from "next/link"; + +export default function LoginPage() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const { login } = useAuth(); + const router = useRouter(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setLoading(true); + try { + await login(email, password); + router.push("/dashboard"); + } catch (err: any) { + setError(err.message || "登录失败"); + } finally { + setLoading(false); + } + }; + + return ( ++ {children} + ++ ++ ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx new file mode 100644 index 0000000..5558b5b --- /dev/null +++ b/frontend/src/app/page.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/hooks/use-auth"; + +export default function Home() { + const { user, loading } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (loading) return; + if (!user) { + router.replace("/login"); + } else if (user.status === "pending") { + router.replace("/pending"); + } else { + router.replace("/dashboard"); + } + }, [user, loading, router]); + + return ( ++ ++ +HKU ICB +班级资源平台 - 登录 ++ + ++ 还没有账号?{" "} + + 注册申请 + +++ ++ ); +} diff --git a/frontend/src/app/pending/page.tsx b/frontend/src/app/pending/page.tsx new file mode 100644 index 0000000..415e88b --- /dev/null +++ b/frontend/src/app/pending/page.tsx @@ -0,0 +1,37 @@ +import Link from "next/link"; + +export default function PendingPage() { + return ( +++ ); +} diff --git a/frontend/src/app/register/page.tsx b/frontend/src/app/register/page.tsx new file mode 100644 index 0000000..0968062 --- /dev/null +++ b/frontend/src/app/register/page.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { postAPI } from "@/lib/api"; +import Link from "next/link"; + +export default function RegisterPage() { + const [inviteCode, setInviteCode] = useState(""); + const [studentId, setStudentId] = useState(""); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const router = useRouter(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + if (password !== confirmPassword) { + setError("两次密码输入不一致"); + return; + } + if (password.length < 6) { + setError("密码至少6位"); + return; + } + + setLoading(true); + try { + const res = await postAPI+++ ++注册审核中
++ 你的注册申请已提交,班级管理员正在审核中。 +
+ + 返回登录 + +
+ 审核通过后你将收到邮件通知。 +("/api/auth/register", { + invite_code: inviteCode, + student_id: studentId, + name, + email, + password, + }); + + // Registration returns token — auto-login + if (res.token) { + localStorage.setItem("token", res.token); + localStorage.setItem("user", JSON.stringify(res.user)); + router.push("/"); + } else { + router.push("/login"); + } + } catch (err: any) { + setError(err.message || "注册失败"); + } finally { + setLoading(false); + } + }; + + return ( + ++ ); +} diff --git a/frontend/src/components/auth-guard.tsx b/frontend/src/components/auth-guard.tsx new file mode 100644 index 0000000..f5cd880 --- /dev/null +++ b/frontend/src/components/auth-guard.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { useEffect } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { useAuth } from "@/hooks/use-auth"; + +const PUBLIC_PATHS = ["/login", "/register"]; + +export function AuthGuard({ children }: { children: React.ReactNode }) { + const { user, loading } = useAuth(); + const pathname = usePathname(); + const router = useRouter(); + + const isPublicPath = PUBLIC_PATHS.some((p) => pathname.startsWith(p)); + + useEffect(() => { + if (!loading && !user && !isPublicPath) { + router.replace("/login"); + } + }, [loading, user, isPublicPath, router]); + + if (loading) { + return ( ++ ++ +HKU ICB +班级资源平台 - 注册 ++ + ++ 已有账号?{" "} + + 登录 + +++ ++ ); + } + + if (!user && !isPublicPath) return null; + + return <>{children}>; +} diff --git a/frontend/src/components/calendar-view.tsx b/frontend/src/components/calendar-view.tsx new file mode 100644 index 0000000..c9268ea --- /dev/null +++ b/frontend/src/components/calendar-view.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import type { ScheduleItem } from "@/lib/types"; +import { SCHEDULE_TYPES } from "@/lib/constants"; + +interface CalendarViewProps { + events: ScheduleItem[]; + onEventClick?: (event: ScheduleItem) => void; +} + +export function CalendarView({ events, onEventClick }: CalendarViewProps) { + const [currentDate, setCurrentDate] = useState(new Date()); + const [selectedDate, setSelectedDate] = useState(null); + + const year = currentDate.getFullYear(); + const month = currentDate.getMonth(); + + // Get events grouped by date + const eventsByDate = useMemo(() => { + const map = new Map (); + for (const event of events) { + const d = new Date(event.start_time); + const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(event); + } + return map; + }, [events]); + + // Calendar grid calculation + const firstDay = new Date(year, month, 1); + const lastDay = new Date(year, month + 1, 0); + const startDayOfWeek = (firstDay.getDay() + 6) % 7; // Monday = 0 + const daysInMonth = lastDay.getDate(); + + const prevMonth = () => setCurrentDate(new Date(year, month - 1, 1)); + const nextMonth = () => setCurrentDate(new Date(year, month + 1, 1)); + const goToday = () => setCurrentDate(new Date()); + + const today = new Date(); + const isToday = (d: number) => + year === today.getFullYear() && month === today.getMonth() && d === today.getDate(); + + // Selected date events + const selectedDateKey = selectedDate + ? `${selectedDate.getFullYear()}-${selectedDate.getMonth()}-${selectedDate.getDate()}` + : null; + const selectedEvents = selectedDateKey ? eventsByDate.get(selectedDateKey) || [] : []; + + const weekDays = ["一", "二", "三", "四", "五", "六", "日"]; + + return ( + + {/* Navigation */} ++ ); +} diff --git a/frontend/src/components/confirm-dialog.tsx b/frontend/src/components/confirm-dialog.tsx new file mode 100644 index 0000000..5bf4b95 --- /dev/null +++ b/frontend/src/components/confirm-dialog.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + +interface ConfirmDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: string; + confirmText?: string; + cancelText?: string; + variant?: "default" | "destructive"; + onConfirm: () => void; + loading?: boolean; +} + +export function ConfirmDialog({ + open, + onOpenChange, + title, + description, + confirmText = "确认", + cancelText = "取消", + variant = "default", + onConfirm, + loading, +}: ConfirmDialogProps) { + return ( +++ + {/* Calendar grid */} ++ {year} 年 {month + 1} 月 +
++ + + +++ {/* Week day headers */} + {weekDays.map((d) => ( ++ + {/* Selected date detail */} + {selectedDate && selectedEvents.length > 0 && ( ++ {d} ++ ))} + + {/* Leading empty cells */} + {Array.from({ length: startDayOfWeek }, (_, i) => ( + + ))} + + {/* Day cells */} + {Array.from({ length: daysInMonth }, (_, i) => { + const day = i + 1; + const key = `${year}-${month}-${day}`; + const dayEvents = eventsByDate.get(key) || []; + const isSelected = + selectedDate && + selectedDate.getFullYear() === year && + selectedDate.getMonth() === month && + selectedDate.getDate() === day; + + return ( +setSelectedDate(new Date(year, month, day))} + > + + {day} + ++ ); + })} + + {/* Trailing empty cells */} + {Array.from({ length: (7 - (startDayOfWeek + daysInMonth) % 7) % 7 }, (_, i) => ( + + ))} ++ {dayEvents.slice(0, 3).map((event, idx) => { + const typeInfo = SCHEDULE_TYPES[event.type] || { label: event.type, color: "bg-gray-400" }; + return ( ++{ + e.stopPropagation(); + onEventClick?.(event); + }} + > + + + {event.title} + ++ ); + })} + {dayEvents.length > 3 && ( + + +{dayEvents.length - 3} + + )} +++ )} ++ {selectedDate.getMonth() + 1} 月 {selectedDate.getDate()} 日 +
++ {selectedEvents.map((event) => { + const typeInfo = SCHEDULE_TYPES[event.type] || { label: event.type, color: "bg-gray-400" }; + return ( ++onEventClick?.(event)} + > ++ ); + })} ++ ++++{event.title}
++ {new Date(event.start_time).toLocaleTimeString("zh-CN", { + hour: "2-digit", + minute: "2-digit", + })} + {event.location ? ` · ${event.location}` : ""} +
++ + ); +} diff --git a/frontend/src/components/error-state.tsx b/frontend/src/components/error-state.tsx new file mode 100644 index 0000000..f085432 --- /dev/null +++ b/frontend/src/components/error-state.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { Button } from "@/components/ui/button"; + +interface ErrorStateProps { + message?: string; + onRetry?: () => void; +} + +export function ErrorState({ message = "加载失败", onRetry }: ErrorStateProps) { + return ( ++ ++ +{title} +{description} ++ +{cancelText} ++ {loading ? "处理中..." : confirmText} + ++ ++ ); +} diff --git a/frontend/src/components/header.tsx b/frontend/src/components/header.tsx new file mode 100644 index 0000000..31451b6 --- /dev/null +++ b/frontend/src/components/header.tsx @@ -0,0 +1,243 @@ +"use client"; + +import { useState } from "react"; +import { useAuth } from "@/hooks/use-auth"; +import { useActiveClass } from "@/hooks/use-active-class"; +import { useSidebar } from "@/hooks/use-sidebar"; +import { useNotifications } from "@/hooks/use-notifications"; +import { useRouter } from "next/navigation"; +import { putAPI } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { toast } from "sonner"; + +export function Header() { + const { user, logout } = useAuth(); + const router = useRouter(); + const { activeClassId, activeClassName, canSwitchClass, availableClasses, setActiveClassId } = + useActiveClass(); + const { toggle } = useSidebar(); + const { unreadCount, notifications, markRead, markAllRead, refresh } = useNotifications(); + const [notifOpen, setNotifOpen] = useState(false); + + // Password dialog state + const [passwordOpen, setPasswordOpen] = useState(false); + const [oldPassword, setOldPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [passwordLoading, setPasswordLoading] = useState(false); + + const handleChangePassword = async (e: React.FormEvent) => { + e.preventDefault(); + if (newPassword.length < 6) { + toast.error("新密码至少 6 位"); + return; + } + if (newPassword !== confirmPassword) { + toast.error("两次密码输入不一致"); + return; + } + setPasswordLoading(true); + try { + await putAPI("/api/auth/change-password", { + old_password: oldPassword, + new_password: newPassword, + }); + setOldPassword(""); + setNewPassword(""); + setConfirmPassword(""); + setPasswordOpen(false); + toast.success("密码已修改"); + } catch (err: any) { + toast.error(err.message || "修改密码失败"); + } finally { + setPasswordLoading(false); + } + }; + + return ( +{message}
+ {onRetry && ( + + )} ++ + ); +} diff --git a/frontend/src/components/pagination.tsx b/frontend/src/components/pagination.tsx new file mode 100644 index 0000000..4aea182 --- /dev/null +++ b/frontend/src/components/pagination.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { Button } from "@/components/ui/button"; + +interface PaginationProps { + page: number; + totalPages: number; + onPageChange: (page: number) => void; +} + +export function Pagination({ page, totalPages, onPageChange }: PaginationProps) { + if (totalPages <= 1) return null; + + const getPages = () => { + const pages: (number | "...")[] = []; + if (totalPages <= 7) { + for (let i = 1; i <= totalPages; i++) pages.push(i); + } else { + pages.push(1); + if (page > 3) pages.push("..."); + const start = Math.max(2, page - 1); + const end = Math.min(totalPages - 1, page + 1); + for (let i = start; i <= end; i++) pages.push(i); + if (page < totalPages - 2) pages.push("..."); + pages.push(totalPages); + } + return pages; + }; + + return ( ++ {/* Mobile hamburger */} + + {canSwitchClass ? ( + + ) : activeClassName ? ( + + 当前班级:{activeClassName} + + ) : null} +++ {/* Notification bell */} + {user && ( ++{ + setNotifOpen(open); + if (open) refresh(); + }}> + + )} + + {user && ( ++ + {unreadCount > 0 && ( + + {unreadCount > 99 ? "99+" : unreadCount} + + )} + ++ ++ 通知 + {unreadCount > 0 && ( + + )} +++ {notifications.length === 0 ? ( ++暂无通知+ ) : ( + notifications.map((n) => ( +{ + if (!n.is_read) await markRead(n.id); + }} + > ++ )) + )} ++ {!n.is_read && ( + + )} ++++{n.title}
+ {n.content && ( +{n.content}
+ )} ++ {new Date(n.created_at).toLocaleString("zh-CN", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} +
++ + )} + + {/* Change Password Dialog */} + ++ + ++ +router.push("/profile")}> + 个人资料 + +setPasswordOpen(true)}> + 修改密码 + +退出登录 ++ + {getPages().map((p, i) => + p === "..." ? ( + + ... + + ) : ( + + ) + )} + ++ ); +} diff --git a/frontend/src/components/role-guard.tsx b/frontend/src/components/role-guard.tsx new file mode 100644 index 0000000..2c81ef4 --- /dev/null +++ b/frontend/src/components/role-guard.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { useAuth } from "@/hooks/use-auth"; +import type { UserRole } from "@/lib/types"; + +interface RoleGuardProps { + roles: UserRole[]; + children: React.ReactNode; + fallback?: React.ReactNode; +} + +export function RoleGuard({ roles, children, fallback = null }: RoleGuardProps) { + const { user } = useAuth(); + if (!user || !roles.includes(user.role)) return <>{fallback}>; + return <>{children}>; +} diff --git a/frontend/src/components/sidebar.tsx b/frontend/src/components/sidebar.tsx new file mode 100644 index 0000000..6b197f3 --- /dev/null +++ b/frontend/src/components/sidebar.tsx @@ -0,0 +1,127 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useSidebar } from "@/hooks/use-sidebar"; +import { RoleGuard } from "@/components/role-guard"; +import { cn } from "@/lib/utils"; + +const navItems = [ + { href: "/dashboard", label: "首页", icon: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" }, + { href: "/announcements", label: "公告", icon: "M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" }, + { href: "/directory", label: "花名册", icon: "M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" }, + { href: "/timeline", label: "班级动态", icon: "M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" }, + { href: "/assignments", label: "作业", icon: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" }, + { href: "/votes", label: "投票", icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" }, + { href: "/schedule", label: "排期表", icon: "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" }, + { href: "/resources", label: "资源库", icon: "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" }, + { href: "/profile", label: "个人资料", icon: "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" }, +]; + +const adminItems = [ + { href: "/admin/members", label: "成员管理", icon: "M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" }, + { href: "/admin/classes", label: "班级管理", icon: "M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" }, +]; + +export function Sidebar() { + const pathname = usePathname(); + const { isOpen, close } = useSidebar(); + + return ( + <> + {/* Mobile backdrop */} + {isOpen && ( + + )} + + + > + ); +} diff --git a/frontend/src/components/ui/alert-dialog.tsx b/frontend/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..0ee2c5f --- /dev/null +++ b/frontend/src/components/ui/alert-dialog.tsx @@ -0,0 +1,187 @@ +"use client" + +import * as React from "react" +import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) { + return+} + +function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) { + return ( + + ) +} + +function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: AlertDialogPrimitive.Backdrop.Props) { + return ( + + ) +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: AlertDialogPrimitive.Popup.Props & { + size?: "default" | "sm" +}) { + return ( + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( + + ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( + + ) +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( + + ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps+ + ) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps ) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps ) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + variant = "outline", + size = "default", + ...props +}: AlertDialogPrimitive.Close.Props & + Pick , "variant" | "size">) { + return ( + } + {...props} + /> + ) +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +} diff --git a/frontend/src/components/ui/avatar.tsx b/frontend/src/components/ui/avatar.tsx new file mode 100644 index 0000000..e4fed86 --- /dev/null +++ b/frontend/src/components/ui/avatar.tsx @@ -0,0 +1,109 @@ +"use client" + +import * as React from "react" +import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + size = "default", + ...props +}: AvatarPrimitive.Root.Props & { + size?: "default" | "sm" | "lg" +}) { + return ( + + ) +} + +function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: AvatarPrimitive.Fallback.Props) { + return ( + + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...props} + /> + ) +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( + svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className + )} + {...props} + /> + ) +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, + AvatarBadge, +} diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000..b20959d --- /dev/null +++ b/frontend/src/components/ui/badge.tsx @@ -0,0 +1,52 @@ +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: + "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: + "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: + "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + render, + ...props +}: useRender.ComponentProps<"span"> & VariantProps) { + return useRender({ + defaultTagName: "span", + props: mergeProps<"span">( + { + className: cn(badgeVariants({ variant }), className), + }, + props + ), + render, + state: { + slot: "badge", + variant, + }, + }) +} + +export { Badge, badgeVariants } diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..09df753 --- /dev/null +++ b/frontend/src/components/ui/button.tsx @@ -0,0 +1,58 @@ +import { Button as ButtonPrimitive } from "@base-ui/react/button" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + outline: + "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + icon: "size-8", + "icon-xs": + "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + "icon-sm": + "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", + "icon-lg": "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant = "default", + size = "default", + ...props +}: ButtonPrimitive.Props & VariantProps ) { + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..40cac5f --- /dev/null +++ b/frontend/src/components/ui/card.tsx @@ -0,0 +1,103 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( + img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + className + )} + {...props} + /> + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000..014f5aa --- /dev/null +++ b/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,160 @@ +"use client" + +import * as React from "react" +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { XIcon } from "lucide-react" + +function Dialog({ ...props }: DialogPrimitive.Root.Props) { + return+} + +function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { + return +} + +function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { + return +} + +function DialogClose({ ...props }: DialogPrimitive.Close.Props) { + return +} + +function DialogOverlay({ + className, + ...props +}: DialogPrimitive.Backdrop.Props) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: DialogPrimitive.Popup.Props & { + showCloseButton?: boolean +}) { + return ( + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( ++ + {children} + {showCloseButton && ( + ++ } + > + + Close + + )} + + {children} + {showCloseButton && ( ++ ) +} + +function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { + return ( +}> + Close + + )} + + ) +} + +function DialogDescription({ + className, + ...props +}: DialogPrimitive.Description.Props) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..9d5ebbd --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,268 @@ +"use client" + +import * as React from "react" +import { Menu as MenuPrimitive } from "@base-ui/react/menu" + +import { cn } from "@/lib/utils" +import { ChevronRightIcon, CheckIcon } from "lucide-react" + +function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) { + return +} + +function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) { + return +} + +function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) { + return +} + +function DropdownMenuContent({ + align = "start", + alignOffset = 0, + side = "bottom", + sideOffset = 4, + className, + ...props +}: MenuPrimitive.Popup.Props & + Pick< + MenuPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + ) +} + +function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) { + return+ ++ +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: MenuPrimitive.GroupLabel.Props & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: MenuPrimitive.Item.Props & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: MenuPrimitive.SubmenuTrigger.Props & { + inset?: boolean +}) { + return ( + + {children} + + ) +} + +function DropdownMenuSubContent({ + align = "start", + alignOffset = -3, + side = "right", + sideOffset = 0, + className, + ...props +}: React.ComponentProps+ ) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: MenuPrimitive.CheckboxItem.Props & { + inset?: boolean +}) { + return ( + + + + ) +} + +function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) { + return ( ++ + + {children} ++ + ) +} + +function DropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: MenuPrimitive.RadioItem.Props & { + inset?: boolean +}) { + return ( + + + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: MenuPrimitive.Separator.Props) { + return ( ++ + + {children} ++ + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..7d21bab --- /dev/null +++ b/frontend/src/components/ui/input.tsx @@ -0,0 +1,20 @@ +import * as React from "react" +import { Input as InputPrimitive } from "@base-ui/react/input" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/frontend/src/components/ui/label.tsx b/frontend/src/components/ui/label.tsx new file mode 100644 index 0000000..74da65c --- /dev/null +++ b/frontend/src/components/ui/label.tsx @@ -0,0 +1,20 @@ +"use client" + +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Label({ className, ...props }: React.ComponentProps<"label">) { + return ( + + ) +} + +export { Label } diff --git a/frontend/src/components/ui/popover.tsx b/frontend/src/components/ui/popover.tsx new file mode 100644 index 0000000..4dc28c3 --- /dev/null +++ b/frontend/src/components/ui/popover.tsx @@ -0,0 +1,90 @@ +"use client" + +import * as React from "react" +import { Popover as PopoverPrimitive } from "@base-ui/react/popover" + +import { cn } from "@/lib/utils" + +function Popover({ ...props }: PopoverPrimitive.Root.Props) { + return +} + +function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) { + return +} + +function PopoverContent({ + className, + align = "center", + alignOffset = 0, + side = "bottom", + sideOffset = 4, + ...props +}: PopoverPrimitive.Popup.Props & + Pick< + PopoverPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + ) +} + +function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) { + return ( ++ ++ + ) +} + +function PopoverDescription({ + className, + ...props +}: PopoverPrimitive.Description.Props) { + return ( + + ) +} + +export { + Popover, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +} diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx new file mode 100644 index 0000000..e8021f5 --- /dev/null +++ b/frontend/src/components/ui/select.tsx @@ -0,0 +1,201 @@ +"use client" + +import * as React from "react" +import { Select as SelectPrimitive } from "@base-ui/react/select" + +import { cn } from "@/lib/utils" +import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react" + +const Select = SelectPrimitive.Root + +function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) { + return ( + + ) +} + +function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) { + return ( + + ) +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: SelectPrimitive.Trigger.Props & { + size?: "sm" | "default" +}) { + return ( + + {children} + + ) +} + +function SelectContent({ + className, + children, + side = "bottom", + sideOffset = 4, + align = "center", + alignOffset = 0, + alignItemWithTrigger = true, + ...props +}: SelectPrimitive.Popup.Props & + Pick< + SelectPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger" + >) { + return ( ++ } + /> + + + ) +} + +function SelectLabel({ + className, + ...props +}: SelectPrimitive.GroupLabel.Props) { + return ( ++ ++ ++ {children} ++ + ) +} + +function SelectItem({ + className, + children, + ...props +}: SelectPrimitive.Item.Props) { + return ( + + + ) +} + +function SelectSeparator({ + className, + ...props +}: SelectPrimitive.Separator.Props) { + return ( ++ {children} + ++ } + > + + + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps ) { + return ( + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps+ ) { + return ( + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/frontend/src/components/ui/separator.tsx b/frontend/src/components/ui/separator.tsx new file mode 100644 index 0000000..6e1369e --- /dev/null +++ b/frontend/src/components/ui/separator.tsx @@ -0,0 +1,25 @@ +"use client" + +import { Separator as SeparatorPrimitive } from "@base-ui/react/separator" + +import { cn } from "@/lib/utils" + +function Separator({ + className, + orientation = "horizontal", + ...props +}: SeparatorPrimitive.Props) { + return ( ++ + ) +} + +export { Separator } diff --git a/frontend/src/components/ui/sheet.tsx b/frontend/src/components/ui/sheet.tsx new file mode 100644 index 0000000..78c0a76 --- /dev/null +++ b/frontend/src/components/ui/sheet.tsx @@ -0,0 +1,138 @@ +"use client" + +import * as React from "react" +import { Dialog as SheetPrimitive } from "@base-ui/react/dialog" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { XIcon } from "lucide-react" + +function Sheet({ ...props }: SheetPrimitive.Root.Props) { + return +} + +function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) { + return +} + +function SheetClose({ ...props }: SheetPrimitive.Close.Props) { + return +} + +function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) { + return +} + +function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) { + return ( + + ) +} + +function SheetContent({ + className, + children, + side = "right", + showCloseButton = true, + ...props +}: SheetPrimitive.Popup.Props & { + side?: "top" | "right" | "bottom" | "left" + showCloseButton?: boolean +}) { + return ( + + + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) { + return ( ++ + {children} + {showCloseButton && ( + ++ } + > + + Close + + )} + + ) +} + +function SheetDescription({ + className, + ...props +}: SheetPrimitive.Description.Props) { + return ( + + ) +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/frontend/src/components/ui/skeleton.tsx b/frontend/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..0118624 --- /dev/null +++ b/frontend/src/components/ui/skeleton.tsx @@ -0,0 +1,13 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +export { Skeleton } diff --git a/frontend/src/components/ui/sonner.tsx b/frontend/src/components/ui/sonner.tsx new file mode 100644 index 0000000..9280ee5 --- /dev/null +++ b/frontend/src/components/ui/sonner.tsx @@ -0,0 +1,49 @@ +"use client" + +import { useTheme } from "next-themes" +import { Toaster as Sonner, type ToasterProps } from "sonner" +import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react" + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + + ), + info: ( + + ), + warning: ( + + ), + error: ( + + ), + loading: ( + + ), + }} + style={ + { + "--normal-bg": "var(--popover)", + "--normal-text": "var(--popover-foreground)", + "--normal-border": "var(--border)", + "--border-radius": "var(--radius)", + } as React.CSSProperties + } + toastOptions={{ + classNames: { + toast: "cn-toast", + }, + }} + {...props} + /> + ) +} + +export { Toaster } diff --git a/frontend/src/components/ui/switch.tsx b/frontend/src/components/ui/switch.tsx new file mode 100644 index 0000000..9b8b44b --- /dev/null +++ b/frontend/src/components/ui/switch.tsx @@ -0,0 +1,32 @@ +"use client" + +import { Switch as SwitchPrimitive } from "@base-ui/react/switch" + +import { cn } from "@/lib/utils" + +function Switch({ + className, + size = "default", + ...props +}: SwitchPrimitive.Root.Props & { + size?: "sm" | "default" +}) { + return ( + + + ) +} + +export { Switch } diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx new file mode 100644 index 0000000..8ee8054 --- /dev/null +++ b/frontend/src/components/ui/tabs.tsx @@ -0,0 +1,82 @@ +"use client" + +import { Tabs as TabsPrimitive } from "@base-ui/react/tabs" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +function Tabs({ + className, + orientation = "horizontal", + ...props +}: TabsPrimitive.Root.Props) { + return ( ++ + ) +} + +const tabsListVariants = cva( + "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none", + { + variants: { + variant: { + default: "bg-muted", + line: "gap-1 bg-transparent", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function TabsList({ + className, + variant = "default", + ...props +}: TabsPrimitive.List.Props & VariantProps ) { + return ( + + ) +} + +function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) { + return ( + + ) +} + +function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } diff --git a/frontend/src/components/ui/textarea.tsx b/frontend/src/components/ui/textarea.tsx new file mode 100644 index 0000000..04d27f7 --- /dev/null +++ b/frontend/src/components/ui/textarea.tsx @@ -0,0 +1,18 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { + return ( + + ) +} + +export { Textarea } diff --git a/frontend/src/hooks/use-active-class.tsx b/frontend/src/hooks/use-active-class.tsx new file mode 100644 index 0000000..97249c4 --- /dev/null +++ b/frontend/src/hooks/use-active-class.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { + createContext, + useContext, + useState, + useEffect, + useCallback, + type ReactNode, +} from "react"; +import { useAuth } from "@/hooks/use-auth"; +import { fetchAPI } from "@/lib/api"; +import type { ClassInfo } from "@/lib/types"; + +interface ActiveClassContextValue { + /** The class ID to use for all data queries */ + activeClassId: number | null; + /** The name of the active class */ + activeClassName: string | null; + /** Whether the user can switch classes (super admin) */ + canSwitchClass: boolean; + /** All available classes (only loaded for super admin) */ + availableClasses: ClassInfo[]; + /** Switch active class (super admin only) */ + setActiveClassId: (id: number) => void; +} + +const ActiveClassContext = createContext ({ + activeClassId: null, + activeClassName: null, + canSwitchClass: false, + availableClasses: [], + setActiveClassId: () => {}, +}); + +export function ActiveClassProvider({ children }: { children: ReactNode }) { + const { user } = useAuth(); + const [availableClasses, setAvailableClasses] = useState ([]); + const [selectedClassId, setSelectedClassId] = useState (null); + const [userClassName, setUserClassName] = useState (null); + + const isSuperAdmin = user?.role === "super_admin"; + // For non-super-admin, use their own class_id + const activeClassId = isSuperAdmin ? selectedClassId : user?.class_id ?? null; + + // Super admin: derive class name from availableClasses + const superAdminClassName = isSuperAdmin && activeClassId + ? availableClasses.find((c) => c.id === activeClassId)?.name ?? null + : null; + const activeClassName = isSuperAdmin ? superAdminClassName : userClassName; + + // Super admin: load all classes and auto-select first + useEffect(() => { + if (!isSuperAdmin) return; + fetchAPI ("/api/classes/").then((res) => { + const items = res.items || []; + setAvailableClasses(items); + // Restore from localStorage or pick first + const saved = localStorage.getItem("active_class_id"); + const parsed = saved ? parseInt(saved) : null; + const valid = parsed && items.some((c: ClassInfo) => c.id === parsed); + if (valid) { + setSelectedClassId(parsed); + } else if (items.length > 0) { + setSelectedClassId(items[0].id); + } + }); + }, [isSuperAdmin]); + + // Non-super-admin: fetch class name once + useEffect(() => { + if (isSuperAdmin || !user?.class_id) return; + fetchAPI ("/api/classes/").then((res) => { + const items = res.items || []; + const cls = items.find((c: ClassInfo) => c.id === user.class_id); + setUserClassName(cls?.name ?? null); + }).catch(() => {}); + }, [isSuperAdmin, user?.class_id]); + + const setActiveClassId = useCallback((id: number) => { + setSelectedClassId(id); + localStorage.setItem("active_class_id", String(id)); + }, []); + + return ( + + {children} + + ); +} + +export function useActiveClass() { + return useContext(ActiveClassContext); +} diff --git a/frontend/src/hooks/use-auth.tsx b/frontend/src/hooks/use-auth.tsx new file mode 100644 index 0000000..c3cce73 --- /dev/null +++ b/frontend/src/hooks/use-auth.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { + createContext, + useContext, + useState, + useEffect, + useCallback, + type ReactNode, +} from "react"; +import { type AuthUser } from "@/lib/types"; +import { postAPI, fetchAPI } from "@/lib/api"; +import type { LoginResponse } from "@/lib/types"; + +interface AuthContextValue { + user: AuthUser | null; + loading: boolean; + login: (email: string, password: string) => Promise; + logout: () => void; + refreshUser: () => Promise ; +} + +const AuthContext = createContext ({ + user: null, + loading: true, + login: async () => {}, + logout: () => {}, + refreshUser: async () => {}, +}); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState (null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const token = localStorage.getItem("auth_token"); + const storedUser = localStorage.getItem("auth_user"); + if (token && storedUser) { + try { + setUser(JSON.parse(storedUser)); + } catch { + localStorage.removeItem("auth_token"); + localStorage.removeItem("auth_user"); + } + } + setLoading(false); + }, []); + + const login = useCallback(async (email: string, password: string) => { + const res = await postAPI ("/api/auth/login", { + email, + password, + }); + localStorage.setItem("auth_token", res.token); + localStorage.setItem("auth_user", JSON.stringify(res.user)); + setUser(res.user); + }, []); + + const logout = useCallback(() => { + localStorage.removeItem("auth_token"); + localStorage.removeItem("auth_user"); + setUser(null); + window.location.href = "/login"; + }, []); + + const refreshUser = useCallback(async () => { + try { + const userData = await fetchAPI ("/api/auth/me"); + localStorage.setItem("auth_user", JSON.stringify(userData)); + setUser(userData); + } catch { + // Token might be invalid + } + }, []); + + return ( + + {children} + + ); +} + +export function useAuth() { + return useContext(AuthContext); +} diff --git a/frontend/src/hooks/use-notifications.tsx b/frontend/src/hooks/use-notifications.tsx new file mode 100644 index 0000000..ba397fc --- /dev/null +++ b/frontend/src/hooks/use-notifications.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react"; +import { useAuth } from "@/hooks/use-auth"; +import { fetchAPI, putAPI } from "@/lib/api"; +import type { NotificationItem } from "@/lib/types"; + +interface NotificationContextType { + unreadCount: number; + notifications: NotificationItem[]; + markRead: (id: number) => Promise; + markAllRead: () => Promise ; + refresh: () => void; +} + +const NotificationContext = createContext ({ + unreadCount: 0, + notifications: [], + markRead: async () => {}, + markAllRead: async () => {}, + refresh: () => {}, +}); + +export function NotificationProvider({ children }: { children: ReactNode }) { + const { user } = useAuth(); + const [unreadCount, setUnreadCount] = useState(0); + const [notifications, setNotifications] = useState ([]); + + const fetchUnreadCount = useCallback(async () => { + if (!user) return; + try { + const res = await fetchAPI<{ count: number }>("/api/notifications/unread-count"); + setUnreadCount(res.count); + } catch { + // ignore + } + }, [user]); + + const fetchNotifications = useCallback(async () => { + if (!user) return; + try { + const res = await fetchAPI ("/api/notifications/", { page_size: "10" }); + setNotifications(res.items || []); + } catch { + // ignore + } + }, [user]); + + const refresh = useCallback(() => { + fetchUnreadCount(); + fetchNotifications(); + }, [fetchUnreadCount, fetchNotifications]); + + useEffect(() => { + if (!user) return; + fetchUnreadCount(); + const interval = setInterval(fetchUnreadCount, 30000); + return () => clearInterval(interval); + }, [user, fetchUnreadCount]); + + const markRead = useCallback(async (id: number) => { + try { + await putAPI(`/api/notifications/${id}/read`); + setNotifications((prev) => + prev.map((n) => (n.id === id ? { ...n, is_read: true } : n)) + ); + setUnreadCount((c) => Math.max(0, c - 1)); + } catch { + // ignore + } + }, []); + + const markAllRead = useCallback(async () => { + try { + await putAPI("/api/notifications/read-all"); + setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true }))); + setUnreadCount(0); + } catch { + // ignore + } + }, []); + + return ( + + {children} + + ); +} + +export function useNotifications() { + return useContext(NotificationContext); +} diff --git a/frontend/src/hooks/use-sidebar.tsx b/frontend/src/hooks/use-sidebar.tsx new file mode 100644 index 0000000..580bd29 --- /dev/null +++ b/frontend/src/hooks/use-sidebar.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { createContext, useContext, useState, useCallback, type ReactNode } from "react"; + +interface SidebarContextValue { + isOpen: boolean; + toggle: () => void; + close: () => void; +} + +const SidebarContext = createContext({ + isOpen: false, + toggle: () => {}, + close: () => {}, +}); + +export function SidebarProvider({ children }: { children: ReactNode }) { + const [isOpen, setIsOpen] = useState(false); + const toggle = useCallback(() => setIsOpen((v) => !v), []); + const close = useCallback(() => setIsOpen(false), []); + + return ( + + {children} + + ); +} + +export function useSidebar() { + return useContext(SidebarContext); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..ec31711 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,97 @@ +const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ""; + +function getToken(): string | null { + if (typeof window === "undefined") return null; + return localStorage.getItem("auth_token"); +} + +function handleUnauthorized() { + if (typeof window === "undefined") return; + localStorage.removeItem("auth_token"); + localStorage.removeItem("auth_user"); + window.location.href = "/login"; +} + +async function request( + path: string, + options: RequestInit = {} +): Promise { + const token = getToken(); + const headers: Record = { + ...(options.headers as Record ), + }; + if (token) headers["Authorization"] = `Bearer ${token}`; + + const res = await fetch(`${API_BASE}${path}`, { ...options, headers }); + + if (res.status === 401) { + handleUnauthorized(); + throw new Error("Unauthorized"); + } + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.detail || `API error: ${res.status}`); + } + + return res.json(); +} + +export async function fetchAPI ( + path: string, + params?: Record +): Promise { + let url = path; + if (params) { + const qs = new URLSearchParams(params).toString(); + url += `?${qs}`; + } + return request (url); +} + +export async function postAPI (path: string, body?: unknown): Promise { + return request (path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + }); +} + +export async function putAPI (path: string, body?: unknown): Promise { + return request (path, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + }); +} + +export async function deleteAPI (path: string): Promise { + return request (path, { method: "DELETE" }); +} + +export async function uploadAPI ( + path: string, + formData: FormData +): Promise { + const token = getToken(); + const headers: Record = {}; + if (token) headers["Authorization"] = `Bearer ${token}`; + + const res = await fetch(`${API_BASE}${path}`, { + method: "POST", + headers, + body: formData, + }); + + if (res.status === 401) { + handleUnauthorized(); + throw new Error("Unauthorized"); + } + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.detail || `API error: ${res.status}`); + } + + return res.json(); +} diff --git a/frontend/src/lib/constants.ts b/frontend/src/lib/constants.ts new file mode 100644 index 0000000..0278db8 --- /dev/null +++ b/frontend/src/lib/constants.ts @@ -0,0 +1,33 @@ +export const ROLES = { + super_admin: "超级管理员", + class_admin: "班级管理员", + student: "同学", +} as const; + +export const USER_STATUS = { + pending: "待审核", + approved: "已通过", + rejected: "已拒绝", + disabled: "已禁用", +} as const; + +export const SCHEDULE_TYPES = { + course: { label: "课程", color: "bg-blue-500" }, + deadline: { label: "截止日", color: "bg-red-500" }, + activity: { label: "活动", color: "bg-green-500" }, +} as const; + +export const INDUSTRY_OPTIONS = [ + "金融", + "科技", + "咨询", + "医疗健康", + "教育", + "房地产", + "制造业", + "消费零售", + "能源", + "传媒", + "政府/公共事业", + "其他", +]; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts new file mode 100644 index 0000000..c60d0a8 --- /dev/null +++ b/frontend/src/lib/types.ts @@ -0,0 +1,217 @@ +export type UserRole = "super_admin" | "class_admin" | "student"; +export type UserStatus = "pending" | "approved" | "rejected" | "disabled"; +export type ScheduleType = "course" | "deadline" | "activity"; + +export interface AuthUser { + id: number; + email: string; + name: string; + student_id: string | null; + role: UserRole; + status: UserStatus; + class_id: number | null; + industry: string | null; + company: string | null; + position: string | null; + skills_tags: string[] | null; + wechat_id: string | null; + phone: string | null; + avatar_url: string | null; + bio: string | null; + created_at: string; +} + +export interface LoginResponse { + token: string; + user: AuthUser; +} + +export interface ClassInfo { + id: number; + name: string; + cohort_year: number; + description: string | null; + invite_code: string | null; + member_count: number; + created_at: string; +} + +export interface UserPublic { + id: number; + name: string; + student_id: string | null; + industry: string | null; + company: string | null; + position: string | null; + wechat_id: string | null; + phone: string | null; + avatar_url: string | null; + bio: string | null; +} + +export interface UserListItem { + id: number; + email: string; + name: string; + student_id: string | null; + role: UserRole; + status: UserStatus; + class_id: number | null; + industry: string | null; + company: string | null; + created_at: string; +} + +export interface TimelineComment { + id: number; + post_id: number; + author_id: number; + author_name: string; + content: string; + created_at: string; + updated_at: string; +} + +export interface TimelinePost { + id: number; + class_id: number; + author_id: number; + author_name: string; + title: string; + content: string | null; + image_urls: string[] | null; + like_count: number; + has_liked: boolean; + comment_count: number; + comments: TimelineComment[] | null; + created_at: string; + updated_at: string; +} + +export interface ScheduleItem { + id: number; + class_id: number; + type: ScheduleType; + title: string; + start_time: string; + end_time: string | null; + location: string | null; + description: string | null; + created_at: string; +} + +export interface PageResponse { + items: T[]; + total: number; + page: number; + page_size: number; + total_pages: number; +} + +export interface Announcement { + id: number; + class_id: number; + author_id: number; + author_name: string; + title: string; + content: string | null; + is_pinned: boolean; + created_at: string; + updated_at: string; +} + +export interface Resource { + id: number; + class_id: number; + uploader_id: number; + uploader_name: string; + title: string; + description: string | null; + file_url: string; + file_type: string; + file_size: number; + category: string; + download_count: number; + created_at: string; +} + +export interface NotificationItem { + id: number; + type: string; + title: string; + content: string | null; + related_id: number | null; + is_read: boolean; + created_at: string; +} + +export interface RosterEntry { + id: number; + student_id: string; + name: string; + status: "unregistered" | "registered"; + user_id: number | null; +} + +export interface VoteOption { + id: number; + content: string; + sort_order: number; + vote_count: number; + voter_names: string[] | null; +} + +export interface Vote { + id: number; + class_id: number; + creator_id: number; + creator_name: string; + title: string; + description: string | null; + vote_type: "single" | "multiple"; + is_anonymous: boolean; + max_choices: number; + deadline: string | null; + status: "open" | "closed"; + total_voters: number; + has_voted: boolean; + my_option_ids: number[] | null; + options: VoteOption[]; + created_at: string; + updated_at: string; +} + +export interface AssignmentSubmission { + id: number; + assignment_id: number; + student_id: number; + student_name: string; + notes: string | null; + file_url: string | null; + file_name: string | null; + file_type: string | null; + file_size: number | null; + grade: string | null; + feedback: string | null; + graded_at: string | null; + created_at: string; + updated_at: string; +} + +export interface Assignment { + id: number; + class_id: number; + creator_id: number; + creator_name: string; + title: string; + description: string | null; + deadline: string | null; + attachment_urls: string[] | null; + status: "open" | "closed"; + submission_count: number; + total_members: number; + my_submitted: boolean; + created_at: string; + updated_at: string; + submissions?: AssignmentSubmission[]; +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..bd0c391 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..cf9c65d --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +} diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..488b161 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,29 @@ +server { + listen 80; + server_name _; + + client_max_body_size 20m; + + # API requests → backend + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Frontend + location / { + proxy_pass http://frontend:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Next.js HMR (hot module replacement) WebSocket + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +}