cacode commited on
Commit
ce0719e
·
verified ·
1 Parent(s): 308dd5b

Upload 51 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +10 -0
  2. .env.example +11 -0
  3. .gitignore +5 -0
  4. Dockerfile +22 -0
  5. app/__init__.py +1 -0
  6. app/__pycache__/__init__.cpython-313.pyc +0 -0
  7. app/__pycache__/auth.cpython-313.pyc +0 -0
  8. app/__pycache__/config.cpython-313.pyc +0 -0
  9. app/__pycache__/database.cpython-313.pyc +0 -0
  10. app/__pycache__/main.cpython-313.pyc +0 -0
  11. app/__pycache__/models.cpython-313.pyc +0 -0
  12. app/__pycache__/security.cpython-313.pyc +0 -0
  13. app/__pycache__/web.cpython-313.pyc +0 -0
  14. app/auth.py +36 -0
  15. app/config.py +51 -0
  16. app/database.py +28 -0
  17. app/main.py +78 -0
  18. app/models.py +128 -0
  19. app/routes/__init__.py +1 -0
  20. app/routes/__pycache__/__init__.cpython-313.pyc +0 -0
  21. app/routes/__pycache__/admin.cpython-313.pyc +0 -0
  22. app/routes/__pycache__/auth.cpython-313.pyc +0 -0
  23. app/routes/__pycache__/media.cpython-313.pyc +0 -0
  24. app/routes/__pycache__/user.cpython-313.pyc +0 -0
  25. app/routes/admin.py +571 -0
  26. app/routes/auth.py +71 -0
  27. app/routes/media.py +70 -0
  28. app/routes/user.py +219 -0
  29. app/security.py +14 -0
  30. app/services/__pycache__/bootstrap.cpython-313.pyc +0 -0
  31. app/services/__pycache__/images.cpython-313.pyc +0 -0
  32. app/services/__pycache__/leaderboard.cpython-313.pyc +0 -0
  33. app/services/bootstrap.py +38 -0
  34. app/services/images.py +74 -0
  35. app/services/leaderboard.py +53 -0
  36. app/static/style.css +852 -0
  37. app/templates/activity_detail.html +190 -0
  38. app/templates/admin_activities.html +149 -0
  39. app/templates/admin_admins.html +62 -0
  40. app/templates/admin_dashboard.html +73 -0
  41. app/templates/admin_groups.html +71 -0
  42. app/templates/admin_login.html +39 -0
  43. app/templates/admin_reviews.html +85 -0
  44. app/templates/admin_users.html +85 -0
  45. app/templates/base.html +45 -0
  46. app/templates/dashboard.html +66 -0
  47. app/templates/login.html +44 -0
  48. app/templates/partials/flash.html +10 -0
  49. app/web.py +42 -0
  50. ca.pem +25 -0
.dockerignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.pyd
5
+ .env
6
+ .env.*
7
+ data/submissions/
8
+ .git/
9
+ .gitignore
10
+ .vscode/
.env.example ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ADMIN=superadmin
2
+ PASSWORD=change-me-now
3
+ SQL_PASSWORD=your_mysql_password
4
+ SESSION_SECRET=replace_with_a_long_random_secret
5
+ SQL_USER=avnadmin
6
+ SQL_HOST=mysql-2bace9cd-cacode.i.aivencloud.com
7
+ SQL_PORT=21260
8
+ SQL_DATABASE=CAM
9
+ MYSQL_CA_FILE=ca.pem
10
+ APP_TIMEZONE=Asia/Shanghai
11
+ UPLOAD_ROOT=data/submissions
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ .env
4
+ .env.*
5
+ data/submissions/
Dockerfile ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ RUN useradd -m -u 1000 user
4
+
5
+ USER user
6
+ ENV HOME=/home/user \
7
+ PATH=/home/user/.local/bin:$PATH \
8
+ PYTHONDONTWRITEBYTECODE=1 \
9
+ PYTHONUNBUFFERED=1 \
10
+ PIP_NO_CACHE_DIR=1
11
+
12
+ WORKDIR $HOME/app
13
+
14
+ COPY --chown=user requirements.txt $HOME/app/requirements.txt
15
+ RUN pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir --user -r $HOME/app/requirements.txt
16
+
17
+ COPY --chown=user . $HOME/app
18
+ RUN mkdir -p $HOME/app/data/submissions
19
+
20
+ EXPOSE 7860
21
+
22
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Spring Check-In Activity Manager package."""
app/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (168 Bytes). View file
 
app/__pycache__/auth.cpython-313.pyc ADDED
Binary file (1.84 kB). View file
 
app/__pycache__/config.cpython-313.pyc ADDED
Binary file (3.27 kB). View file
 
app/__pycache__/database.cpython-313.pyc ADDED
Binary file (1.16 kB). View file
 
app/__pycache__/main.cpython-313.pyc ADDED
Binary file (3.79 kB). View file
 
app/__pycache__/models.cpython-313.pyc ADDED
Binary file (7.52 kB). View file
 
app/__pycache__/security.cpython-313.pyc ADDED
Binary file (746 Bytes). View file
 
app/__pycache__/web.cpython-313.pyc ADDED
Binary file (2.14 kB). View file
 
app/auth.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ from fastapi import Request
6
+ from sqlalchemy.orm import Session
7
+
8
+ from app.models import Admin, User
9
+
10
+
11
+ def get_current_user(request: Request, db: Session) -> Optional[User]:
12
+ user_id = request.session.get("user_id")
13
+ if not user_id:
14
+ return None
15
+ return db.get(User, user_id)
16
+
17
+
18
+ def get_current_admin(request: Request, db: Session) -> Optional[Admin]:
19
+ admin_id = request.session.get("admin_id")
20
+ if not admin_id:
21
+ return None
22
+ return db.get(Admin, admin_id)
23
+
24
+
25
+ def sign_in_user(request: Request, user: User) -> None:
26
+ request.session.clear()
27
+ request.session["user_id"] = user.id
28
+
29
+
30
+ def sign_in_admin(request: Request, admin: Admin) -> None:
31
+ request.session.clear()
32
+ request.session["admin_id"] = admin.id
33
+
34
+
35
+ def sign_out(request: Request) -> None:
36
+ request.session.clear()
app/config.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import secrets
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from urllib.parse import quote_plus
8
+
9
+
10
+ ROOT_DIR = Path(__file__).resolve().parent.parent
11
+
12
+
13
+ @dataclass(slots=True)
14
+ class Settings:
15
+ app_name: str = "Spring Check-In Activity Manager"
16
+ timezone: str = os.getenv("APP_TIMEZONE", "Asia/Shanghai")
17
+ session_secret: str = os.getenv("SESSION_SECRET", secrets.token_urlsafe(32))
18
+
19
+ admin_username: str = os.getenv("ADMIN", "superadmin")
20
+ admin_password: str = os.getenv("PASSWORD", "change-me-now")
21
+
22
+ mysql_user: str = os.getenv("SQL_USER", "avnadmin")
23
+ mysql_password: str = os.getenv("SQL_PASSWORD", "")
24
+ mysql_host: str = os.getenv(
25
+ "SQL_HOST", "mysql-2bace9cd-cacode.i.aivencloud.com"
26
+ )
27
+ mysql_port: int = int(os.getenv("SQL_PORT", "21260"))
28
+ mysql_db: str = os.getenv("SQL_DATABASE", "CAM")
29
+ mysql_ca_file: Path = ROOT_DIR / os.getenv("MYSQL_CA_FILE", "ca.pem")
30
+
31
+ upload_root: Path = ROOT_DIR / os.getenv("UPLOAD_ROOT", "data/submissions")
32
+
33
+ @property
34
+ def database_url(self) -> str:
35
+ if raw_url := os.getenv("DATABASE_URL"):
36
+ return raw_url
37
+ user = quote_plus(self.mysql_user)
38
+ password = quote_plus(self.mysql_password)
39
+ return (
40
+ f"mysql+pymysql://{user}:{password}@{self.mysql_host}:"
41
+ f"{self.mysql_port}/{self.mysql_db}"
42
+ )
43
+
44
+ @property
45
+ def database_connect_args(self) -> dict:
46
+ if self.mysql_ca_file.exists():
47
+ return {"ssl": {"ca": str(self.mysql_ca_file)}}
48
+ return {}
49
+
50
+
51
+ settings = Settings()
app/database.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Generator
4
+
5
+ from sqlalchemy import create_engine
6
+ from sqlalchemy.orm import DeclarativeBase, sessionmaker
7
+
8
+ from app.config import settings
9
+
10
+
11
+ class Base(DeclarativeBase):
12
+ pass
13
+
14
+
15
+ engine = create_engine(
16
+ settings.database_url,
17
+ pool_pre_ping=True,
18
+ connect_args=settings.database_connect_args,
19
+ )
20
+ SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
21
+
22
+
23
+ def get_db() -> Generator:
24
+ db = SessionLocal()
25
+ try:
26
+ yield db
27
+ finally:
28
+ db.close()
app/main.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import timedelta
4
+
5
+ from fastapi import FastAPI, Request
6
+ from fastapi.staticfiles import StaticFiles
7
+ from starlette.middleware.sessions import SessionMiddleware
8
+
9
+ from app.auth import get_current_admin, get_current_user
10
+ from app.config import ROOT_DIR, settings
11
+ from app.database import SessionLocal
12
+ from app.routes import admin, auth, media, user
13
+ from app.services.bootstrap import initialize_database, seed_super_admin
14
+ from app.web import redirect, templates
15
+
16
+
17
+ app = FastAPI(title=settings.app_name)
18
+ app.add_middleware(SessionMiddleware, secret_key=settings.session_secret, same_site="lax")
19
+ app.mount("/static", StaticFiles(directory=str(ROOT_DIR / "app" / "static")), name="static")
20
+
21
+
22
+ @app.on_event("startup")
23
+ def on_startup() -> None:
24
+ initialize_database()
25
+ db = SessionLocal()
26
+ try:
27
+ seed_super_admin(db)
28
+ finally:
29
+ db.close()
30
+
31
+
32
+ @app.get("/")
33
+ def index(request: Request):
34
+ db = SessionLocal()
35
+ try:
36
+ if get_current_admin(request, db):
37
+ return redirect("/admin/dashboard")
38
+ if get_current_user(request, db):
39
+ return redirect("/dashboard")
40
+ finally:
41
+ db.close()
42
+ return redirect("/login")
43
+
44
+
45
+ @app.get("/health")
46
+ def health():
47
+ return {"status": "ok"}
48
+
49
+
50
+ app.include_router(auth.router)
51
+ app.include_router(user.router)
52
+ app.include_router(admin.router)
53
+ app.include_router(media.router)
54
+
55
+
56
+ def format_datetime(value):
57
+ if not value:
58
+ return "-"
59
+ return value.strftime("%Y-%m-%d %H:%M")
60
+
61
+
62
+ def format_timedelta(value):
63
+ if value is None:
64
+ return "-"
65
+ if isinstance(value, int):
66
+ minutes = value
67
+ elif isinstance(value, timedelta):
68
+ minutes = int(value.total_seconds() // 60)
69
+ else:
70
+ return str(value)
71
+ hours, mins = divmod(minutes, 60)
72
+ if hours:
73
+ return f"{hours}小时 {mins}分钟"
74
+ return f"{mins}分钟"
75
+
76
+
77
+ templates.env.filters["datetime_local"] = format_datetime
78
+ templates.env.filters["duration_human"] = format_timedelta
app/models.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Optional
5
+
6
+ from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
7
+ from sqlalchemy.dialects.mysql import LONGBLOB
8
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
9
+
10
+ from app.database import Base
11
+
12
+
13
+ class TimestampMixin:
14
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
15
+ updated_at: Mapped[datetime] = mapped_column(
16
+ DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
17
+ )
18
+
19
+
20
+ class Admin(TimestampMixin, Base):
21
+ __tablename__ = "admins"
22
+
23
+ id: Mapped[int] = mapped_column(primary_key=True)
24
+ username: Mapped[str] = mapped_column(String(80), unique=True, index=True)
25
+ display_name: Mapped[str] = mapped_column(String(120))
26
+ password_hash: Mapped[str] = mapped_column(String(255))
27
+ role: Mapped[str] = mapped_column(String(32), default="admin")
28
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True)
29
+
30
+ created_activities: Mapped[list["Activity"]] = relationship(back_populates="created_by")
31
+ reviewed_submissions: Mapped[list["Submission"]] = relationship(
32
+ back_populates="reviewed_by"
33
+ )
34
+
35
+
36
+ class Group(TimestampMixin, Base):
37
+ __tablename__ = "groups"
38
+
39
+ id: Mapped[int] = mapped_column(primary_key=True)
40
+ name: Mapped[str] = mapped_column(String(120), unique=True)
41
+ max_members: Mapped[int] = mapped_column(Integer, default=6)
42
+
43
+ members: Mapped[list["User"]] = relationship(back_populates="group")
44
+
45
+
46
+ class User(TimestampMixin, Base):
47
+ __tablename__ = "users"
48
+
49
+ id: Mapped[int] = mapped_column(primary_key=True)
50
+ student_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
51
+ full_name: Mapped[str] = mapped_column(String(120))
52
+ password_hash: Mapped[str] = mapped_column(String(255))
53
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True)
54
+ group_id: Mapped[Optional[int]] = mapped_column(ForeignKey("groups.id"), nullable=True)
55
+
56
+ group: Mapped[Optional["Group"]] = relationship(back_populates="members")
57
+ submissions: Mapped[list["Submission"]] = relationship(back_populates="user")
58
+
59
+
60
+ class Activity(TimestampMixin, Base):
61
+ __tablename__ = "activities"
62
+
63
+ id: Mapped[int] = mapped_column(primary_key=True)
64
+ title: Mapped[str] = mapped_column(String(160))
65
+ description: Mapped[str] = mapped_column(Text, default="")
66
+ start_at: Mapped[datetime] = mapped_column(DateTime)
67
+ deadline_at: Mapped[datetime] = mapped_column(DateTime)
68
+ leaderboard_visible: Mapped[bool] = mapped_column(Boolean, default=True)
69
+ clue_interval_minutes: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
70
+ created_by_id: Mapped[int] = mapped_column(ForeignKey("admins.id"))
71
+
72
+ created_by: Mapped["Admin"] = relationship(back_populates="created_activities")
73
+ tasks: Mapped[list["Task"]] = relationship(
74
+ back_populates="activity",
75
+ order_by="Task.display_order",
76
+ cascade="all, delete-orphan",
77
+ )
78
+
79
+
80
+ class Task(TimestampMixin, Base):
81
+ __tablename__ = "tasks"
82
+
83
+ id: Mapped[int] = mapped_column(primary_key=True)
84
+ activity_id: Mapped[int] = mapped_column(ForeignKey("activities.id"))
85
+ title: Mapped[str] = mapped_column(String(160))
86
+ description: Mapped[str] = mapped_column(Text, default="")
87
+ display_order: Mapped[int] = mapped_column(Integer, default=1)
88
+
89
+ image_data: Mapped[bytes] = mapped_column(LONGBLOB, deferred=True)
90
+ image_mime: Mapped[str] = mapped_column(String(120), default="image/jpeg")
91
+ image_filename: Mapped[str] = mapped_column(String(255), default="task.jpg")
92
+
93
+ clue_image_data: Mapped[Optional[bytes]] = mapped_column(LONGBLOB, deferred=True, nullable=True)
94
+ clue_image_mime: Mapped[Optional[str]] = mapped_column(String(120), nullable=True)
95
+ clue_image_filename: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
96
+ clue_release_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
97
+
98
+ activity: Mapped["Activity"] = relationship(back_populates="tasks")
99
+ submissions: Mapped[list["Submission"]] = relationship(
100
+ back_populates="task", cascade="all, delete-orphan"
101
+ )
102
+
103
+
104
+ class Submission(TimestampMixin, Base):
105
+ __tablename__ = "submissions"
106
+ __table_args__ = (UniqueConstraint("user_id", "task_id", name="uq_user_task_submission"),)
107
+
108
+ id: Mapped[int] = mapped_column(primary_key=True)
109
+ task_id: Mapped[int] = mapped_column(ForeignKey("tasks.id"))
110
+ user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
111
+
112
+ stored_filename: Mapped[str] = mapped_column(String(255))
113
+ original_filename: Mapped[str] = mapped_column(String(255))
114
+ file_path: Mapped[str] = mapped_column(String(600))
115
+ mime_type: Mapped[str] = mapped_column(String(120), default="image/jpeg")
116
+ file_size: Mapped[int] = mapped_column(Integer)
117
+
118
+ status: Mapped[str] = mapped_column(String(32), default="pending")
119
+ feedback: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
120
+ reviewed_by_id: Mapped[Optional[int]] = mapped_column(
121
+ ForeignKey("admins.id"), nullable=True
122
+ )
123
+ reviewed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
124
+ approved_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
125
+
126
+ task: Mapped["Task"] = relationship(back_populates="submissions")
127
+ user: Mapped["User"] = relationship(back_populates="submissions")
128
+ reviewed_by: Mapped[Optional["Admin"]] = relationship(back_populates="reviewed_submissions")
app/routes/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from __future__ import annotations
app/routes/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (168 Bytes). View file
 
app/routes/__pycache__/admin.cpython-313.pyc ADDED
Binary file (30.7 kB). View file
 
app/routes/__pycache__/auth.cpython-313.pyc ADDED
Binary file (4.26 kB). View file
 
app/routes/__pycache__/media.cpython-313.pyc ADDED
Binary file (4.04 kB). View file
 
app/routes/__pycache__/user.cpython-313.pyc ADDED
Binary file (11.2 kB). View file
 
app/routes/admin.py ADDED
@@ -0,0 +1,571 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import zipfile
5
+ from datetime import datetime, timedelta
6
+ from pathlib import Path
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException, Request
9
+ from fastapi.responses import StreamingResponse
10
+ from sqlalchemy.orm import Session, joinedload
11
+
12
+ from app.database import get_db
13
+ from app.models import Activity, Admin, Group, Submission, Task, User
14
+ from app.security import hash_password
15
+ from app.services.images import compress_to_limit, read_and_validate_upload
16
+ from app.web import add_flash, local_now, redirect, render
17
+
18
+
19
+ router = APIRouter()
20
+
21
+
22
+ def require_admin(request: Request, db: Session) -> Admin | None:
23
+ admin = db.query(Admin).filter(Admin.id == (request.session.get("admin_id") or 0)).first()
24
+ if not admin or not admin.is_active:
25
+ return None
26
+ return admin
27
+
28
+
29
+ def require_super_admin(request: Request, db: Session) -> Admin | None:
30
+ admin = require_admin(request, db)
31
+ if not admin or admin.role != "superadmin":
32
+ return None
33
+ return admin
34
+
35
+
36
+ def parse_optional_group(group_id_value: str | None, db: Session) -> Group | None:
37
+ if not group_id_value:
38
+ return None
39
+ if not str(group_id_value).isdigit():
40
+ return None
41
+ return (
42
+ db.query(Group)
43
+ .options(joinedload(Group.members))
44
+ .filter(Group.id == int(group_id_value))
45
+ .first()
46
+ )
47
+
48
+
49
+ def ensure_group_capacity(group: Group | None, current_user: User | None = None) -> None:
50
+ if not group:
51
+ return
52
+ current_count = len(group.members)
53
+ if current_user and current_user.group_id == group.id:
54
+ current_count -= 1
55
+ if current_count >= group.max_members:
56
+ raise ValueError(f"{group.name} 已满员,请调整人数上限或选择其他小组。")
57
+
58
+
59
+ @router.get("/admin/dashboard")
60
+ def admin_dashboard(request: Request, db: Session = Depends(get_db)):
61
+ admin = require_admin(request, db)
62
+ if not admin:
63
+ return redirect("/admin")
64
+
65
+ stats = {
66
+ "user_count": db.query(User).count(),
67
+ "group_count": db.query(Group).count(),
68
+ "activity_count": db.query(Activity).count(),
69
+ "pending_count": db.query(Submission).filter(Submission.status == "pending").count(),
70
+ "admin_count": db.query(Admin).count(),
71
+ }
72
+ recent_activities = (
73
+ db.query(Activity)
74
+ .options(joinedload(Activity.tasks))
75
+ .order_by(Activity.created_at.desc())
76
+ .limit(5)
77
+ .all()
78
+ )
79
+ return render(
80
+ request,
81
+ "admin_dashboard.html",
82
+ {
83
+ "page_title": "管理员总览",
84
+ "admin": admin,
85
+ "stats": stats,
86
+ "recent_activities": recent_activities,
87
+ },
88
+ )
89
+
90
+
91
+ @router.get("/admin/users")
92
+ def admin_users(request: Request, db: Session = Depends(get_db)):
93
+ admin = require_admin(request, db)
94
+ if not admin:
95
+ return redirect("/admin")
96
+
97
+ users = db.query(User).options(joinedload(User.group)).order_by(User.student_id.asc()).all()
98
+ groups = db.query(Group).options(joinedload(Group.members)).order_by(Group.name.asc()).all()
99
+ return render(
100
+ request,
101
+ "admin_users.html",
102
+ {"page_title": "用户管理", "admin": admin, "users": users, "groups": groups},
103
+ )
104
+
105
+
106
+ @router.post("/admin/users")
107
+ async def create_user(request: Request, db: Session = Depends(get_db)):
108
+ admin = require_admin(request, db)
109
+ if not admin:
110
+ return redirect("/admin")
111
+
112
+ form = await request.form()
113
+ student_id = str(form.get("student_id", "")).strip()
114
+ full_name = str(form.get("full_name", "")).strip()
115
+ password = str(form.get("password", "")).strip()
116
+ group = parse_optional_group(form.get("group_id"), db)
117
+
118
+ if not student_id or not full_name or not password:
119
+ add_flash(request, "error", "请完整填写学号、姓名和密码。")
120
+ return redirect("/admin/users")
121
+ if db.query(User).filter(User.student_id == student_id).first():
122
+ add_flash(request, "error", "该学号已存在。")
123
+ return redirect("/admin/users")
124
+
125
+ try:
126
+ ensure_group_capacity(group)
127
+ except ValueError as exc:
128
+ add_flash(request, "error", str(exc))
129
+ return redirect("/admin/users")
130
+
131
+ user = User(
132
+ student_id=student_id,
133
+ full_name=full_name,
134
+ password_hash=hash_password(password),
135
+ group=group,
136
+ )
137
+ db.add(user)
138
+ db.commit()
139
+ add_flash(request, "success", f"用户 {full_name} 已创建。")
140
+ return redirect("/admin/users")
141
+
142
+
143
+ @router.post("/admin/users/{user_id}/group")
144
+ async def assign_user_group(user_id: int, request: Request, db: Session = Depends(get_db)):
145
+ admin = require_admin(request, db)
146
+ if not admin:
147
+ return redirect("/admin")
148
+
149
+ user = db.query(User).options(joinedload(User.group)).filter(User.id == user_id).first()
150
+ if not user:
151
+ raise HTTPException(status_code=404, detail="用户不存在")
152
+
153
+ form = await request.form()
154
+ group = parse_optional_group(form.get("group_id"), db)
155
+ try:
156
+ ensure_group_capacity(group, current_user=user)
157
+ except ValueError as exc:
158
+ add_flash(request, "error", str(exc))
159
+ return redirect("/admin/users")
160
+
161
+ user.group = group
162
+ db.add(user)
163
+ db.commit()
164
+ add_flash(request, "success", f"已更新 {user.full_name} 的小组。")
165
+ return redirect("/admin/users")
166
+
167
+
168
+ @router.get("/admin/admins")
169
+ def admin_admins(request: Request, db: Session = Depends(get_db)):
170
+ admin = require_super_admin(request, db)
171
+ if not admin:
172
+ add_flash(request, "error", "只有超级管理员可以管理管理员账号。")
173
+ return redirect("/admin/dashboard")
174
+
175
+ admins = db.query(Admin).order_by(Admin.created_at.desc()).all()
176
+ return render(
177
+ request,
178
+ "admin_admins.html",
179
+ {"page_title": "管理员管理", "admin": admin, "admins": admins},
180
+ )
181
+
182
+
183
+ @router.post("/admin/admins")
184
+ async def create_admin(request: Request, db: Session = Depends(get_db)):
185
+ admin = require_super_admin(request, db)
186
+ if not admin:
187
+ add_flash(request, "error", "只有超级管理员可以新增管理员。")
188
+ return redirect("/admin/dashboard")
189
+
190
+ form = await request.form()
191
+ username = str(form.get("username", "")).strip()
192
+ display_name = str(form.get("display_name", "")).strip()
193
+ password = str(form.get("password", "")).strip()
194
+ role = str(form.get("role", "admin")).strip() or "admin"
195
+
196
+ if not username or not display_name or not password:
197
+ add_flash(request, "error", "请完整填写管理员信息。")
198
+ return redirect("/admin/admins")
199
+ if db.query(Admin).filter(Admin.username == username).first():
200
+ add_flash(request, "error", "管理员账号已存在。")
201
+ return redirect("/admin/admins")
202
+
203
+ new_admin = Admin(
204
+ username=username,
205
+ display_name=display_name,
206
+ password_hash=hash_password(password),
207
+ role="superadmin" if role == "superadmin" else "admin",
208
+ )
209
+ db.add(new_admin)
210
+ db.commit()
211
+ add_flash(request, "success", f"管理员 {display_name} 已创建。")
212
+ return redirect("/admin/admins")
213
+
214
+
215
+ @router.get("/admin/groups")
216
+ def admin_groups(request: Request, db: Session = Depends(get_db)):
217
+ admin = require_admin(request, db)
218
+ if not admin:
219
+ return redirect("/admin")
220
+
221
+ groups = db.query(Group).options(joinedload(Group.members)).order_by(Group.created_at.asc()).all()
222
+ ungrouped_users = (
223
+ db.query(User)
224
+ .filter(User.group_id.is_(None))
225
+ .order_by(User.student_id.asc())
226
+ .all()
227
+ )
228
+ return render(
229
+ request,
230
+ "admin_groups.html",
231
+ {
232
+ "page_title": "小组管理",
233
+ "admin": admin,
234
+ "groups": groups,
235
+ "ungrouped_users": ungrouped_users,
236
+ },
237
+ )
238
+
239
+
240
+ @router.post("/admin/groups")
241
+ async def create_group(request: Request, db: Session = Depends(get_db)):
242
+ admin = require_admin(request, db)
243
+ if not admin:
244
+ return redirect("/admin")
245
+
246
+ form = await request.form()
247
+ try:
248
+ max_members = max(1, int(str(form.get("max_members", "1"))))
249
+ except ValueError:
250
+ add_flash(request, "error", "小组人数上限需要是数字。")
251
+ return redirect("/admin/groups")
252
+
253
+ name = str(form.get("name", "")).strip()
254
+ if not name:
255
+ sequence = db.query(Group).count() + 1
256
+ name = f"第{sequence}组"
257
+ if db.query(Group).filter(Group.name == name).first():
258
+ add_flash(request, "error", "小组名称已存在。")
259
+ return redirect("/admin/groups")
260
+
261
+ group = Group(name=name, max_members=max_members)
262
+ db.add(group)
263
+ db.commit()
264
+ add_flash(request, "success", f"小组 {name} 已创建。")
265
+ return redirect("/admin/groups")
266
+
267
+
268
+ @router.post("/admin/groups/{group_id}/capacity")
269
+ async def update_group_capacity(group_id: int, request: Request, db: Session = Depends(get_db)):
270
+ admin = require_admin(request, db)
271
+ if not admin:
272
+ return redirect("/admin")
273
+
274
+ group = db.query(Group).options(joinedload(Group.members)).filter(Group.id == group_id).first()
275
+ if not group:
276
+ raise HTTPException(status_code=404, detail="小组不存在")
277
+
278
+ form = await request.form()
279
+ try:
280
+ max_members = max(1, int(str(form.get("max_members", group.max_members))))
281
+ except ValueError:
282
+ add_flash(request, "error", "小组人数上限需要是数字。")
283
+ return redirect("/admin/groups")
284
+
285
+ if max_members < len(group.members):
286
+ add_flash(request, "error", "人数上限不能小于当前成员数。")
287
+ return redirect("/admin/groups")
288
+
289
+ group.max_members = max_members
290
+ db.add(group)
291
+ db.commit()
292
+ add_flash(request, "success", f"{group.name} 的人数上限已更新。")
293
+ return redirect("/admin/groups")
294
+
295
+
296
+ @router.get("/admin/activities")
297
+ def admin_activities(request: Request, db: Session = Depends(get_db)):
298
+ admin = require_admin(request, db)
299
+ if not admin:
300
+ return redirect("/admin")
301
+
302
+ activities = (
303
+ db.query(Activity)
304
+ .options(joinedload(Activity.tasks), joinedload(Activity.created_by))
305
+ .order_by(Activity.start_at.desc())
306
+ .all()
307
+ )
308
+ return render(
309
+ request,
310
+ "admin_activities.html",
311
+ {"page_title": "活动发布", "admin": admin, "activities": activities},
312
+ )
313
+
314
+
315
+ @router.post("/admin/activities")
316
+ async def create_activity(request: Request, db: Session = Depends(get_db)):
317
+ admin = require_admin(request, db)
318
+ if not admin:
319
+ return redirect("/admin")
320
+
321
+ form = await request.form()
322
+ title = str(form.get("title", "")).strip()
323
+ description = str(form.get("description", "")).strip()
324
+ start_raw = str(form.get("start_at", "")).strip()
325
+ deadline_raw = str(form.get("deadline_at", "")).strip()
326
+ clue_interval_raw = str(form.get("clue_interval_minutes", "")).strip()
327
+ leaderboard_visible = form.get("leaderboard_visible") == "on"
328
+
329
+ if not title or not start_raw or not deadline_raw:
330
+ add_flash(request, "error", "请完整填写活动标题、开始时间和截止时间。")
331
+ return redirect("/admin/activities")
332
+
333
+ try:
334
+ start_at = datetime.fromisoformat(start_raw)
335
+ deadline_at = datetime.fromisoformat(deadline_raw)
336
+ except ValueError:
337
+ add_flash(request, "error", "时间格式不正确。")
338
+ return redirect("/admin/activities")
339
+
340
+ if deadline_at <= start_at:
341
+ add_flash(request, "error", "截止时间必须晚于开始时间。")
342
+ return redirect("/admin/activities")
343
+
344
+ try:
345
+ clue_interval_minutes = int(clue_interval_raw) if clue_interval_raw else None
346
+ except ValueError:
347
+ add_flash(request, "error", "线索发布时间间隔必须是数字。")
348
+ return redirect("/admin/activities")
349
+
350
+ task_titles = form.getlist("task_title")
351
+ task_descriptions = form.getlist("task_description")
352
+ task_images = form.getlist("task_image")
353
+ task_clue_images = form.getlist("task_clue_image")
354
+
355
+ tasks_payload = []
356
+ for index, raw_title in enumerate(task_titles):
357
+ task_title = str(raw_title).strip()
358
+ task_description = str(task_descriptions[index]).strip() if index < len(task_descriptions) else ""
359
+ primary_upload = task_images[index] if index < len(task_images) else None
360
+ clue_upload = task_clue_images[index] if index < len(task_clue_images) else None
361
+
362
+ if not task_title and (not primary_upload or not getattr(primary_upload, "filename", "")):
363
+ continue
364
+ if not task_title or not primary_upload or not getattr(primary_upload, "filename", ""):
365
+ add_flash(request, "error", f"第 {index + 1} 个任务需要完整填写标题并上传主图。")
366
+ return redirect("/admin/activities")
367
+
368
+ try:
369
+ primary_raw = await read_and_validate_upload(primary_upload)
370
+ primary_bytes, primary_mime = compress_to_limit(primary_raw, 200 * 1024)
371
+ clue_bytes = None
372
+ clue_mime = None
373
+ clue_name = None
374
+ if clue_upload and getattr(clue_upload, "filename", ""):
375
+ clue_raw = await read_and_validate_upload(clue_upload)
376
+ clue_bytes, clue_mime = compress_to_limit(clue_raw, 200 * 1024)
377
+ clue_name = clue_upload.filename
378
+ except ValueError as exc:
379
+ add_flash(request, "error", str(exc))
380
+ return redirect("/admin/activities")
381
+
382
+ tasks_payload.append(
383
+ {
384
+ "title": task_title,
385
+ "description": task_description,
386
+ "image_data": primary_bytes,
387
+ "image_mime": primary_mime,
388
+ "image_filename": primary_upload.filename,
389
+ "clue_image_data": clue_bytes,
390
+ "clue_image_mime": clue_mime,
391
+ "clue_image_filename": clue_name,
392
+ }
393
+ )
394
+
395
+ if not tasks_payload:
396
+ add_flash(request, "error", "至少需要添加一个任务卡片。")
397
+ return redirect("/admin/activities")
398
+
399
+ activity = Activity(
400
+ title=title,
401
+ description=description,
402
+ start_at=start_at,
403
+ deadline_at=deadline_at,
404
+ leaderboard_visible=leaderboard_visible,
405
+ clue_interval_minutes=clue_interval_minutes,
406
+ created_by_id=admin.id,
407
+ )
408
+ db.add(activity)
409
+ db.flush()
410
+
411
+ for index, payload in enumerate(tasks_payload, start=1):
412
+ release_at = None
413
+ if payload["clue_image_data"]:
414
+ if clue_interval_minutes and clue_interval_minutes > 0:
415
+ release_at = start_at + timedelta(minutes=clue_interval_minutes * index)
416
+ else:
417
+ release_at = start_at
418
+
419
+ db.add(
420
+ Task(
421
+ activity_id=activity.id,
422
+ title=payload["title"],
423
+ description=payload["description"],
424
+ display_order=index,
425
+ image_data=payload["image_data"],
426
+ image_mime=payload["image_mime"],
427
+ image_filename=payload["image_filename"],
428
+ clue_image_data=payload["clue_image_data"],
429
+ clue_image_mime=payload["clue_image_mime"],
430
+ clue_image_filename=payload["clue_image_filename"],
431
+ clue_release_at=release_at,
432
+ )
433
+ )
434
+
435
+ db.commit()
436
+ add_flash(request, "success", f"活动 {title} 已发布。")
437
+ return redirect("/admin/activities")
438
+
439
+
440
+ @router.post("/admin/activities/{activity_id}/visibility")
441
+ async def update_leaderboard_visibility(
442
+ activity_id: int, request: Request, db: Session = Depends(get_db)
443
+ ):
444
+ admin = require_admin(request, db)
445
+ if not admin:
446
+ return redirect("/admin")
447
+
448
+ activity = db.get(Activity, activity_id)
449
+ if not activity:
450
+ raise HTTPException(status_code=404, detail="活动不存在")
451
+
452
+ form = await request.form()
453
+ activity.leaderboard_visible = form.get("leaderboard_visible") == "on"
454
+ db.add(activity)
455
+ db.commit()
456
+ add_flash(request, "success", f"已更新 {activity.title} 的排行榜可见性。")
457
+ return redirect("/admin/activities")
458
+
459
+
460
+ @router.get("/admin/reviews")
461
+ def admin_reviews(request: Request, db: Session = Depends(get_db)):
462
+ admin = require_admin(request, db)
463
+ if not admin:
464
+ return redirect("/admin")
465
+
466
+ status_filter = request.query_params.get("status", "pending")
467
+ activity_filter = request.query_params.get("activity_id", "")
468
+
469
+ query = (
470
+ db.query(Submission)
471
+ .options(
472
+ joinedload(Submission.user),
473
+ joinedload(Submission.task).joinedload(Task.activity),
474
+ joinedload(Submission.reviewed_by),
475
+ )
476
+ .order_by(Submission.created_at.desc())
477
+ )
478
+ if status_filter:
479
+ query = query.filter(Submission.status == status_filter)
480
+ if activity_filter.isdigit():
481
+ query = query.join(Submission.task).filter(Task.activity_id == int(activity_filter))
482
+
483
+ submissions = query.all()
484
+ activities = db.query(Activity).order_by(Activity.start_at.desc()).all()
485
+ return render(
486
+ request,
487
+ "admin_reviews.html",
488
+ {
489
+ "page_title": "审核中心",
490
+ "admin": admin,
491
+ "submissions": submissions,
492
+ "activities": activities,
493
+ "status_filter": status_filter,
494
+ "activity_filter": activity_filter,
495
+ },
496
+ )
497
+
498
+
499
+ @router.post("/admin/submissions/{submission_id}/review")
500
+ async def review_submission(submission_id: int, request: Request, db: Session = Depends(get_db)):
501
+ admin = require_admin(request, db)
502
+ if not admin:
503
+ return redirect("/admin")
504
+
505
+ submission = db.get(Submission, submission_id)
506
+ if not submission:
507
+ raise HTTPException(status_code=404, detail="提交记录不存在")
508
+
509
+ form = await request.form()
510
+ decision = str(form.get("decision", "")).strip()
511
+ feedback = str(form.get("feedback", "")).strip() or None
512
+
513
+ if decision not in {"approved", "rejected"}:
514
+ add_flash(request, "error", "审核操作无效。")
515
+ return redirect("/admin/reviews")
516
+
517
+ submission.status = decision
518
+ submission.feedback = feedback
519
+ submission.reviewed_by_id = admin.id
520
+ submission.reviewed_at = local_now()
521
+ submission.approved_at = submission.created_at if decision == "approved" else None
522
+ db.add(submission)
523
+ db.commit()
524
+
525
+ add_flash(request, "success", "审核结果已保存。")
526
+ return redirect("/admin/reviews")
527
+
528
+
529
+ @router.post("/admin/reviews/download")
530
+ async def download_selected_submissions(request: Request, db: Session = Depends(get_db)):
531
+ admin = require_admin(request, db)
532
+ if not admin:
533
+ return redirect("/admin")
534
+
535
+ form = await request.form()
536
+ selected_ids = [int(value) for value in form.getlist("submission_ids") if str(value).isdigit()]
537
+ if not selected_ids:
538
+ add_flash(request, "error", "请先勾选需要下载的图片。")
539
+ return redirect("/admin/reviews")
540
+
541
+ submissions = (
542
+ db.query(Submission)
543
+ .options(
544
+ joinedload(Submission.user),
545
+ joinedload(Submission.task).joinedload(Task.activity),
546
+ )
547
+ .filter(Submission.id.in_(selected_ids))
548
+ .all()
549
+ )
550
+
551
+ archive = io.BytesIO()
552
+ with zipfile.ZipFile(archive, mode="w", compression=zipfile.ZIP_DEFLATED) as zip_file:
553
+ for submission in submissions:
554
+ file_path = Path(submission.file_path)
555
+ if not file_path.exists():
556
+ continue
557
+ suffix = file_path.suffix or ".jpg"
558
+ activity_title = submission.task.activity.title.replace("/", "_")
559
+ task_title = submission.task.title.replace("/", "_")
560
+ student_id = submission.user.student_id.replace("/", "_")
561
+ full_name = submission.user.full_name.replace("/", "_")
562
+ archive_name = f"{activity_title}/{task_title}/{student_id}_{full_name}{suffix}"
563
+ zip_file.write(file_path, archive_name)
564
+
565
+ archive.seek(0)
566
+ filename = f"checkin_submissions_{local_now().strftime('%Y%m%d_%H%M%S')}.zip"
567
+ return StreamingResponse(
568
+ archive,
569
+ media_type="application/zip",
570
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
571
+ )
app/routes/auth.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Depends, Form, Request
4
+ from sqlalchemy.orm import Session
5
+
6
+ from app.auth import get_current_admin, get_current_user, sign_in_admin, sign_in_user, sign_out
7
+ from app.database import get_db
8
+ from app.models import Admin, User
9
+ from app.security import verify_password
10
+ from app.web import add_flash, redirect, render
11
+
12
+
13
+ router = APIRouter()
14
+
15
+
16
+ @router.get("/login")
17
+ def login_page(request: Request, db: Session = Depends(get_db)):
18
+ if get_current_user(request, db):
19
+ return redirect("/dashboard")
20
+ return render(request, "login.html", {"page_title": "用户登录"})
21
+
22
+
23
+ @router.post("/login")
24
+ def login_submit(
25
+ request: Request,
26
+ student_id: str = Form(...),
27
+ password: str = Form(...),
28
+ db: Session = Depends(get_db),
29
+ ):
30
+ user = db.query(User).filter(User.student_id == student_id.strip()).first()
31
+ if not user or not user.is_active or not verify_password(password, user.password_hash):
32
+ add_flash(request, "error", "学号或密码错误。")
33
+ return redirect("/login")
34
+ sign_in_user(request, user)
35
+ add_flash(request, "success", f"欢迎回来,{user.full_name}。")
36
+ return redirect("/dashboard")
37
+
38
+
39
+ @router.get("/logout")
40
+ def user_logout(request: Request):
41
+ sign_out(request)
42
+ return redirect("/login")
43
+
44
+
45
+ @router.get("/admin")
46
+ def admin_login_page(request: Request, db: Session = Depends(get_db)):
47
+ if get_current_admin(request, db):
48
+ return redirect("/admin/dashboard")
49
+ return render(request, "admin_login.html", {"page_title": "管理员登录"})
50
+
51
+
52
+ @router.post("/admin")
53
+ def admin_login_submit(
54
+ request: Request,
55
+ username: str = Form(...),
56
+ password: str = Form(...),
57
+ db: Session = Depends(get_db),
58
+ ):
59
+ admin = db.query(Admin).filter(Admin.username == username.strip()).first()
60
+ if not admin or not admin.is_active or not verify_password(password, admin.password_hash):
61
+ add_flash(request, "error", "管理员账号或密码错误。")
62
+ return redirect("/admin")
63
+ sign_in_admin(request, admin)
64
+ add_flash(request, "success", f"管理员 {admin.display_name} 已登录。")
65
+ return redirect("/admin/dashboard")
66
+
67
+
68
+ @router.get("/admin/logout")
69
+ def admin_logout(request: Request):
70
+ sign_out(request)
71
+ return redirect("/admin")
app/routes/media.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException, Request, Response
6
+ from fastapi.responses import FileResponse
7
+ from sqlalchemy.orm import Session, joinedload
8
+
9
+ from app.auth import get_current_admin, get_current_user
10
+ from app.database import get_db
11
+ from app.models import Submission, Task
12
+ from app.web import local_now
13
+
14
+
15
+ router = APIRouter()
16
+
17
+
18
+ @router.get("/media/tasks/{task_id}/image")
19
+ def task_image(task_id: int, request: Request, db: Session = Depends(get_db)):
20
+ if not get_current_user(request, db) and not get_current_admin(request, db):
21
+ raise HTTPException(status_code=401, detail="Unauthorized")
22
+
23
+ task = db.get(Task, task_id)
24
+ if not task or not task.image_data:
25
+ raise HTTPException(status_code=404, detail="Image not found")
26
+ return Response(content=task.image_data, media_type=task.image_mime)
27
+
28
+
29
+ @router.get("/media/tasks/{task_id}/clue")
30
+ def task_clue(task_id: int, request: Request, db: Session = Depends(get_db)):
31
+ admin = get_current_admin(request, db)
32
+ user = get_current_user(request, db)
33
+ if not admin and not user:
34
+ raise HTTPException(status_code=401, detail="Unauthorized")
35
+
36
+ task = db.get(Task, task_id)
37
+ if not task or not task.clue_image_data:
38
+ raise HTTPException(status_code=404, detail="Clue image not found")
39
+ if not admin and (not task.clue_release_at or local_now() < task.clue_release_at):
40
+ raise HTTPException(status_code=403, detail="Clue not released")
41
+ return Response(content=task.clue_image_data, media_type=task.clue_image_mime)
42
+
43
+
44
+ @router.get("/media/submissions/{submission_id}")
45
+ def submission_image(submission_id: int, request: Request, db: Session = Depends(get_db)):
46
+ admin = get_current_admin(request, db)
47
+ submission = (
48
+ db.query(Submission)
49
+ .options(joinedload(Submission.user), joinedload(Submission.task))
50
+ .filter(Submission.id == submission_id)
51
+ .first()
52
+ )
53
+ if not submission:
54
+ raise HTTPException(status_code=404, detail="Submission not found")
55
+
56
+ user = get_current_user(request, db)
57
+ if not admin and (not user or user.id != submission.user_id):
58
+ raise HTTPException(status_code=403, detail="Forbidden")
59
+
60
+ file_path = Path(submission.file_path)
61
+ if not file_path.exists():
62
+ raise HTTPException(status_code=404, detail="Stored file missing")
63
+
64
+ if request.query_params.get("download") == "1":
65
+ return FileResponse(
66
+ file_path,
67
+ media_type=submission.mime_type,
68
+ filename=submission.original_filename,
69
+ )
70
+ return Response(content=file_path.read_bytes(), media_type=submission.mime_type)
app/routes/user.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
6
+ from fastapi.responses import JSONResponse
7
+ from sqlalchemy.orm import Session, joinedload
8
+
9
+ from app.auth import get_current_user
10
+ from app.config import settings
11
+ from app.database import get_db
12
+ from app.models import Activity, Submission, Task, User
13
+ from app.services.images import compress_to_limit, persist_submission_image, read_and_validate_upload
14
+ from app.services.leaderboard import build_leaderboard
15
+ from app.web import add_flash, local_now, redirect, render
16
+
17
+
18
+ router = APIRouter()
19
+
20
+
21
+ def require_user(request: Request, db: Session) -> User | None:
22
+ user = (
23
+ db.query(User)
24
+ .options(joinedload(User.group), joinedload(User.submissions))
25
+ .filter(User.id == (request.session.get("user_id") or 0))
26
+ .first()
27
+ )
28
+ if not user or not user.is_active:
29
+ return None
30
+ return user
31
+
32
+
33
+ @router.get("/dashboard")
34
+ def dashboard(request: Request, db: Session = Depends(get_db)):
35
+ user = require_user(request, db)
36
+ if not user:
37
+ return redirect("/login")
38
+
39
+ activities = db.query(Activity).options(joinedload(Activity.tasks)).order_by(Activity.start_at.asc()).all()
40
+ submission_by_task = {submission.task_id: submission for submission in user.submissions}
41
+
42
+ activity_cards = []
43
+ now = local_now()
44
+ for activity in activities:
45
+ total_tasks = len(activity.tasks)
46
+ approved_count = sum(
47
+ 1
48
+ for task in activity.tasks
49
+ if submission_by_task.get(task.id)
50
+ and submission_by_task[task.id].status == "approved"
51
+ )
52
+ pending_count = sum(
53
+ 1
54
+ for task in activity.tasks
55
+ if submission_by_task.get(task.id)
56
+ and submission_by_task[task.id].status == "pending"
57
+ )
58
+ activity_cards.append(
59
+ {
60
+ "activity": activity,
61
+ "total_tasks": total_tasks,
62
+ "approved_count": approved_count,
63
+ "pending_count": pending_count,
64
+ "is_active": activity.start_at <= now <= activity.deadline_at,
65
+ "is_overdue": now > activity.deadline_at,
66
+ }
67
+ )
68
+
69
+ return render(
70
+ request,
71
+ "dashboard.html",
72
+ {
73
+ "page_title": "春日打卡",
74
+ "user": user,
75
+ "activity_cards": activity_cards,
76
+ },
77
+ )
78
+
79
+
80
+ @router.get("/activities/{activity_id}")
81
+ def activity_detail(activity_id: int, request: Request, db: Session = Depends(get_db)):
82
+ user = require_user(request, db)
83
+ if not user:
84
+ return redirect("/login")
85
+
86
+ activity = (
87
+ db.query(Activity)
88
+ .options(joinedload(Activity.tasks).joinedload(Task.submissions))
89
+ .filter(Activity.id == activity_id)
90
+ .first()
91
+ )
92
+ if not activity:
93
+ raise HTTPException(status_code=404, detail="活动不存在")
94
+
95
+ submission_by_task = {}
96
+ for task in activity.tasks:
97
+ for submission in task.submissions:
98
+ if submission.user_id == user.id:
99
+ submission_by_task[task.id] = submission
100
+ break
101
+
102
+ now = local_now()
103
+ leaderboard = build_leaderboard(db, activity.id) if activity.leaderboard_visible else []
104
+ clue_states = {
105
+ task.id: bool(task.clue_image_filename and task.clue_release_at and now >= task.clue_release_at)
106
+ for task in activity.tasks
107
+ }
108
+
109
+ return render(
110
+ request,
111
+ "activity_detail.html",
112
+ {
113
+ "page_title": activity.title,
114
+ "user": user,
115
+ "activity": activity,
116
+ "submission_by_task": submission_by_task,
117
+ "leaderboard": leaderboard,
118
+ "clue_states": clue_states,
119
+ "now": now,
120
+ },
121
+ )
122
+
123
+
124
+ @router.post("/activities/{activity_id}/tasks/{task_id}/submit")
125
+ async def submit_task(
126
+ activity_id: int,
127
+ task_id: int,
128
+ request: Request,
129
+ photo: UploadFile = File(...),
130
+ db: Session = Depends(get_db),
131
+ ):
132
+ user = require_user(request, db)
133
+ if not user:
134
+ return redirect("/login")
135
+
136
+ activity = db.query(Activity).filter(Activity.id == activity_id).first()
137
+ task = db.query(Task).filter(Task.id == task_id, Task.activity_id == activity_id).first()
138
+ if not activity or not task:
139
+ raise HTTPException(status_code=404, detail="任务不存在")
140
+
141
+ now = local_now()
142
+ if now < activity.start_at:
143
+ add_flash(request, "error", "活动尚未开始,暂时不能提交。")
144
+ return redirect(f"/activities/{activity_id}")
145
+ if now > activity.deadline_at:
146
+ add_flash(request, "error", "活动已截止,不能继续上传。")
147
+ return redirect(f"/activities/{activity_id}")
148
+
149
+ try:
150
+ raw = await read_and_validate_upload(photo)
151
+ compressed, mime_type = compress_to_limit(raw, 2 * 1024 * 1024)
152
+ except ValueError as exc:
153
+ add_flash(request, "error", str(exc))
154
+ return redirect(f"/activities/{activity_id}")
155
+
156
+ submission = (
157
+ db.query(Submission)
158
+ .filter(Submission.user_id == user.id, Submission.task_id == task.id)
159
+ .first()
160
+ )
161
+ if submission and submission.status == "approved":
162
+ add_flash(request, "info", "该打卡点已经审核通过,无需重复提交。")
163
+ return redirect(f"/activities/{activity_id}")
164
+
165
+ if submission and submission.file_path:
166
+ old_path = Path(submission.file_path)
167
+ if old_path.exists():
168
+ old_path.unlink(missing_ok=True)
169
+
170
+ stored_filename, file_path, file_size = persist_submission_image(
171
+ settings.upload_root,
172
+ user.student_id,
173
+ activity.id,
174
+ task.id,
175
+ compressed,
176
+ )
177
+
178
+ if not submission:
179
+ submission = Submission(task_id=task.id, user_id=user.id)
180
+ db.add(submission)
181
+
182
+ submission.stored_filename = stored_filename
183
+ submission.original_filename = photo.filename or stored_filename
184
+ submission.file_path = file_path
185
+ submission.mime_type = mime_type
186
+ submission.file_size = file_size
187
+ submission.status = "pending"
188
+ submission.feedback = None
189
+ submission.reviewed_by_id = None
190
+ submission.reviewed_at = None
191
+ submission.approved_at = None
192
+ submission.created_at = now
193
+ db.commit()
194
+
195
+ add_flash(request, "success", "图片已提交,等待管理员审核。")
196
+ return redirect(f"/activities/{activity_id}")
197
+
198
+
199
+ @router.get("/api/activities/{activity_id}/clues")
200
+ def activity_clues(activity_id: int, request: Request, db: Session = Depends(get_db)):
201
+ user = get_current_user(request, db)
202
+ if not user:
203
+ return JSONResponse({"error": "unauthorized"}, status_code=401)
204
+
205
+ activity = db.query(Activity).options(joinedload(Activity.tasks)).filter(Activity.id == activity_id).first()
206
+ if not activity:
207
+ return JSONResponse({"error": "not_found"}, status_code=404)
208
+
209
+ now = local_now()
210
+ payload = {
211
+ "activity_id": activity.id,
212
+ "released_task_ids": [
213
+ task.id
214
+ for task in activity.tasks
215
+ if task.clue_image_filename and task.clue_release_at and now >= task.clue_release_at
216
+ ],
217
+ "server_time": now.isoformat(timespec="seconds"),
218
+ }
219
+ return JSONResponse(payload)
app/security.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from passlib.context import CryptContext
4
+
5
+
6
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
7
+
8
+
9
+ def hash_password(password: str) -> str:
10
+ return pwd_context.hash(password)
11
+
12
+
13
+ def verify_password(plain_password: str, password_hash: str) -> bool:
14
+ return pwd_context.verify(plain_password, password_hash)
app/services/__pycache__/bootstrap.cpython-313.pyc ADDED
Binary file (2.01 kB). View file
 
app/services/__pycache__/images.cpython-313.pyc ADDED
Binary file (3.91 kB). View file
 
app/services/__pycache__/leaderboard.cpython-313.pyc ADDED
Binary file (2.96 kB). View file
 
app/services/bootstrap.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from sqlalchemy.orm import Session
4
+
5
+ from app.config import settings
6
+ from app.database import Base, engine
7
+ from app.models import Admin
8
+ from app.security import hash_password, verify_password
9
+
10
+
11
+ def initialize_database() -> None:
12
+ settings.upload_root.mkdir(parents=True, exist_ok=True)
13
+ Base.metadata.create_all(bind=engine)
14
+
15
+
16
+ def seed_super_admin(db: Session) -> None:
17
+ admin = db.query(Admin).filter(Admin.username == settings.admin_username).first()
18
+ if not admin:
19
+ admin = Admin(
20
+ username=settings.admin_username,
21
+ display_name="超级管理员",
22
+ role="superadmin",
23
+ password_hash=hash_password(settings.admin_password),
24
+ )
25
+ db.add(admin)
26
+ db.commit()
27
+ return
28
+
29
+ changed = False
30
+ if admin.role != "superadmin":
31
+ admin.role = "superadmin"
32
+ changed = True
33
+ if not verify_password(settings.admin_password, admin.password_hash):
34
+ admin.password_hash = hash_password(settings.admin_password)
35
+ changed = True
36
+ if changed:
37
+ db.add(admin)
38
+ db.commit()
app/services/images.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ from pathlib import Path
5
+ from uuid import uuid4
6
+
7
+ from fastapi import UploadFile
8
+ from PIL import Image, ImageOps
9
+
10
+
11
+ ALLOWED_MIME_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif", "image/bmp"}
12
+
13
+
14
+ def _load_rgb_image(data: bytes) -> Image.Image:
15
+ image = Image.open(io.BytesIO(data))
16
+ image = ImageOps.exif_transpose(image)
17
+ if image.mode not in ("RGB", "L"):
18
+ background = Image.new("RGB", image.size, "white")
19
+ background.paste(image, mask=image.getchannel("A") if "A" in image.getbands() else None)
20
+ image = background
21
+ else:
22
+ image = image.convert("RGB")
23
+ return image
24
+
25
+
26
+ def compress_to_limit(data: bytes, limit_bytes: int) -> tuple[bytes, str]:
27
+ image = _load_rgb_image(data)
28
+ scale = 1.0
29
+ quality = 92
30
+
31
+ while scale >= 0.28:
32
+ resized = image
33
+ if scale < 1.0:
34
+ new_size = (
35
+ max(240, int(image.width * scale)),
36
+ max(240, int(image.height * scale)),
37
+ )
38
+ resized = image.resize(new_size, Image.Resampling.LANCZOS)
39
+
40
+ current_quality = quality
41
+ while current_quality >= 28:
42
+ buffer = io.BytesIO()
43
+ resized.save(buffer, format="JPEG", quality=current_quality, optimize=True)
44
+ payload = buffer.getvalue()
45
+ if len(payload) <= limit_bytes:
46
+ return payload, "image/jpeg"
47
+ current_quality -= 6
48
+ scale -= 0.08
49
+
50
+ raise ValueError(f"图片压缩后仍超过限制,请上传更清晰度适中的图片(限制 {limit_bytes // 1024}KB)。")
51
+
52
+
53
+ async def read_and_validate_upload(upload: UploadFile) -> bytes:
54
+ if upload.content_type not in ALLOWED_MIME_TYPES:
55
+ raise ValueError("只支持上传图片文件。")
56
+ data = await upload.read()
57
+ if not data:
58
+ raise ValueError("上传文件为空。")
59
+ return data
60
+
61
+
62
+ def persist_submission_image(
63
+ upload_root: Path,
64
+ user_identifier: str,
65
+ activity_id: int,
66
+ task_id: int,
67
+ content: bytes,
68
+ ) -> tuple[str, str, int]:
69
+ folder = upload_root / f"activity_{activity_id}" / f"task_{task_id}"
70
+ folder.mkdir(parents=True, exist_ok=True)
71
+ filename = f"{user_identifier}_{uuid4().hex}.jpg"
72
+ file_path = folder / filename
73
+ file_path.write_bytes(content)
74
+ return filename, str(file_path), len(content)
app/services/leaderboard.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from datetime import timedelta
5
+
6
+ from sqlalchemy.orm import Session, joinedload
7
+
8
+ from app.models import Activity, Submission, Task, User
9
+
10
+
11
+ def build_leaderboard(db: Session, activity_id: int) -> list[dict]:
12
+ activity = (
13
+ db.query(Activity)
14
+ .options(
15
+ joinedload(Activity.tasks)
16
+ .joinedload(Task.submissions)
17
+ .joinedload(Submission.user)
18
+ .joinedload(User.group)
19
+ )
20
+ .filter(Activity.id == activity_id)
21
+ .first()
22
+ )
23
+ if not activity:
24
+ return []
25
+
26
+ group_stats: dict[str, dict] = defaultdict(
27
+ lambda: {"completed_count": 0, "total_elapsed": timedelta(), "members": set()}
28
+ )
29
+
30
+ for task in activity.tasks:
31
+ for submission in task.submissions:
32
+ if submission.status != "approved" or not submission.user:
33
+ continue
34
+ group_name = submission.user.group.name if submission.user.group else "未分组"
35
+ elapsed = max(submission.created_at - activity.start_at, timedelta())
36
+ stats = group_stats[group_name]
37
+ stats["completed_count"] += 1
38
+ stats["total_elapsed"] += elapsed
39
+ stats["members"].add(submission.user.full_name)
40
+
41
+ rows = []
42
+ for group_name, stats in group_stats.items():
43
+ rows.append(
44
+ {
45
+ "group_name": group_name,
46
+ "completed_count": stats["completed_count"],
47
+ "total_elapsed": stats["total_elapsed"],
48
+ "member_count": len(stats["members"]),
49
+ "total_elapsed_minutes": int(stats["total_elapsed"].total_seconds() // 60),
50
+ }
51
+ )
52
+ rows.sort(key=lambda item: (-item["completed_count"], item["total_elapsed"]))
53
+ return rows
app/static/style.css ADDED
@@ -0,0 +1,852 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #eef8ef;
3
+ --bg-deep: #d7edd7;
4
+ --surface: rgba(255, 255, 255, 0.75);
5
+ --surface-strong: rgba(255, 255, 255, 0.9);
6
+ --border: rgba(98, 146, 104, 0.22);
7
+ --text: #173622;
8
+ --muted: #5d7865;
9
+ --primary: #4e9461;
10
+ --primary-deep: #2e6a44;
11
+ --primary-soft: #e6f4e5;
12
+ --accent: #f6b85b;
13
+ --danger: #d95b67;
14
+ --shadow: 0 24px 70px rgba(53, 98, 62, 0.12);
15
+ --radius-lg: 28px;
16
+ --radius-md: 20px;
17
+ --radius-sm: 14px;
18
+ }
19
+
20
+ * {
21
+ box-sizing: border-box;
22
+ }
23
+
24
+ html {
25
+ scroll-behavior: smooth;
26
+ }
27
+
28
+ body {
29
+ margin: 0;
30
+ font-family: "Segoe UI Variable", "PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif;
31
+ color: var(--text);
32
+ background: linear-gradient(180deg, #f5fbf2 0%, #e7f6e8 35%, #e1f0e0 100%);
33
+ min-height: 100vh;
34
+ }
35
+
36
+ .page-backdrop {
37
+ position: fixed;
38
+ inset: 0;
39
+ pointer-events: none;
40
+ background:
41
+ radial-gradient(circle at 12% 20%, rgba(246, 184, 91, 0.16), transparent 22%),
42
+ radial-gradient(circle at 82% 14%, rgba(112, 188, 128, 0.18), transparent 20%),
43
+ radial-gradient(circle at 50% 100%, rgba(120, 180, 139, 0.18), transparent 28%);
44
+ }
45
+
46
+ img {
47
+ display: block;
48
+ max-width: 100%;
49
+ }
50
+
51
+ .shell {
52
+ position: relative;
53
+ width: min(1200px, calc(100% - 32px));
54
+ margin: 0 auto;
55
+ padding: 28px 0 44px;
56
+ }
57
+
58
+ .content-shell {
59
+ display: flex;
60
+ flex-direction: column;
61
+ gap: 24px;
62
+ }
63
+
64
+ .topbar {
65
+ position: sticky;
66
+ top: 18px;
67
+ z-index: 20;
68
+ display: flex;
69
+ align-items: center;
70
+ justify-content: space-between;
71
+ gap: 20px;
72
+ padding: 18px 22px;
73
+ margin-bottom: 24px;
74
+ border: 1px solid var(--border);
75
+ border-radius: 999px;
76
+ background: rgba(255, 255, 255, 0.7);
77
+ backdrop-filter: blur(14px);
78
+ box-shadow: var(--shadow);
79
+ }
80
+
81
+ .brand-block {
82
+ display: flex;
83
+ align-items: center;
84
+ gap: 14px;
85
+ }
86
+
87
+ .brand-mark {
88
+ width: 52px;
89
+ height: 52px;
90
+ display: grid;
91
+ place-items: center;
92
+ border-radius: 18px;
93
+ background: linear-gradient(135deg, #63b67c, #88ca99);
94
+ color: white;
95
+ font-size: 1.4rem;
96
+ font-weight: 700;
97
+ box-shadow: 0 18px 26px rgba(76, 134, 91, 0.25);
98
+ }
99
+
100
+ .eyebrow {
101
+ margin: 0 0 6px;
102
+ color: var(--primary);
103
+ font-size: 0.78rem;
104
+ letter-spacing: 0.14em;
105
+ text-transform: uppercase;
106
+ }
107
+
108
+ .brand-title,
109
+ .hero-card h2,
110
+ .glass-card h3,
111
+ .login-copy h1,
112
+ .login-panel h2 {
113
+ margin: 0;
114
+ font-family: "Georgia", "STKaiti", "KaiTi", serif;
115
+ letter-spacing: 0.02em;
116
+ }
117
+
118
+ .topnav {
119
+ display: flex;
120
+ flex-wrap: wrap;
121
+ justify-content: flex-end;
122
+ gap: 10px;
123
+ }
124
+
125
+ .topnav a,
126
+ .ghost-link {
127
+ color: var(--primary-deep);
128
+ text-decoration: none;
129
+ padding: 10px 14px;
130
+ border-radius: 999px;
131
+ transition: transform 0.25s ease, background 0.25s ease, color 0.25s ease;
132
+ }
133
+
134
+ .topnav a:hover,
135
+ .ghost-link:hover {
136
+ background: rgba(78, 148, 97, 0.12);
137
+ transform: translateY(-1px);
138
+ }
139
+
140
+ .hero-card,
141
+ .glass-card,
142
+ .login-panel,
143
+ .login-copy {
144
+ position: relative;
145
+ overflow: hidden;
146
+ border: 1px solid var(--border);
147
+ border-radius: var(--radius-lg);
148
+ background: linear-gradient(180deg, var(--surface-strong), var(--surface));
149
+ backdrop-filter: blur(16px);
150
+ box-shadow: var(--shadow);
151
+ }
152
+
153
+ .hero-card {
154
+ display: flex;
155
+ align-items: flex-start;
156
+ justify-content: space-between;
157
+ gap: 24px;
158
+ padding: 30px;
159
+ animation: rise-in 0.7s ease both;
160
+ }
161
+
162
+ .hero-card::after,
163
+ .glass-card::after,
164
+ .login-panel::after,
165
+ .login-copy::after {
166
+ content: "";
167
+ position: absolute;
168
+ inset: auto -12% -50% auto;
169
+ width: 180px;
170
+ height: 180px;
171
+ border-radius: 50%;
172
+ background: radial-gradient(circle, rgba(246, 184, 91, 0.18), transparent 70%);
173
+ }
174
+
175
+ .hero-badges,
176
+ .chip-row,
177
+ .feature-list,
178
+ .action-grid,
179
+ .task-status-row {
180
+ display: flex;
181
+ flex-wrap: wrap;
182
+ gap: 10px;
183
+ }
184
+
185
+ .pill,
186
+ .chip,
187
+ .status-badge {
188
+ display: inline-flex;
189
+ align-items: center;
190
+ gap: 8px;
191
+ padding: 8px 12px;
192
+ border-radius: 999px;
193
+ font-size: 0.92rem;
194
+ background: rgba(255, 255, 255, 0.72);
195
+ border: 1px solid rgba(78, 148, 97, 0.16);
196
+ }
197
+
198
+ .status-approved {
199
+ background: rgba(78, 148, 97, 0.14);
200
+ color: var(--primary-deep);
201
+ }
202
+
203
+ .status-rejected {
204
+ background: rgba(217, 91, 103, 0.14);
205
+ color: #9b3144;
206
+ }
207
+
208
+ .lead,
209
+ .muted,
210
+ .mini-note,
211
+ label span,
212
+ .meta-grid span,
213
+ .data-table th {
214
+ color: var(--muted);
215
+ }
216
+
217
+ .lead {
218
+ font-size: 1.02rem;
219
+ line-height: 1.7;
220
+ }
221
+
222
+ .muted,
223
+ .mini-note,
224
+ .feedback-box {
225
+ line-height: 1.6;
226
+ }
227
+
228
+ .flash-stack {
229
+ display: grid;
230
+ gap: 12px;
231
+ margin-bottom: 20px;
232
+ }
233
+
234
+ .flash {
235
+ display: flex;
236
+ align-items: center;
237
+ gap: 12px;
238
+ padding: 14px 18px;
239
+ border-radius: 18px;
240
+ border: 1px solid var(--border);
241
+ background: rgba(255, 255, 255, 0.85);
242
+ box-shadow: 0 14px 34px rgba(88, 132, 95, 0.08);
243
+ }
244
+
245
+ .flash-dot {
246
+ width: 10px;
247
+ height: 10px;
248
+ border-radius: 50%;
249
+ background: var(--primary);
250
+ }
251
+
252
+ .flash-error .flash-dot {
253
+ background: var(--danger);
254
+ }
255
+
256
+ .flash-success .flash-dot {
257
+ background: #58a46b;
258
+ }
259
+
260
+ .flash p {
261
+ margin: 0;
262
+ }
263
+
264
+ .card-grid,
265
+ .stats-grid,
266
+ .task-grid,
267
+ .review-grid {
268
+ display: grid;
269
+ gap: 20px;
270
+ }
271
+
272
+ .activity-grid {
273
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
274
+ }
275
+
276
+ .group-grid,
277
+ .review-grid {
278
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
279
+ }
280
+
281
+ .task-grid {
282
+ grid-template-columns: repeat(auto-fit, minmax(310px, 1fr));
283
+ }
284
+
285
+ .stats-grid {
286
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
287
+ }
288
+
289
+ .page-grid,
290
+ .two-column-layout {
291
+ display: grid;
292
+ grid-template-columns: minmax(280px, 360px) 1fr;
293
+ gap: 22px;
294
+ }
295
+
296
+ .admin-activity-grid {
297
+ grid-template-columns: 1.4fr 1fr;
298
+ }
299
+
300
+ .glass-card,
301
+ .form-panel,
302
+ .table-panel,
303
+ .quick-panel {
304
+ padding: 24px;
305
+ animation: rise-in 0.7s ease both;
306
+ }
307
+
308
+ .activity-card h3,
309
+ .task-card h3,
310
+ .review-card h3,
311
+ .group-card h3,
312
+ .stat-card strong {
313
+ margin: 0 0 10px;
314
+ }
315
+
316
+ .meta-grid {
317
+ display: grid;
318
+ grid-template-columns: repeat(2, minmax(0, 1fr));
319
+ gap: 12px;
320
+ margin: 18px 0;
321
+ }
322
+
323
+ .meta-grid div {
324
+ padding: 12px 14px;
325
+ border-radius: var(--radius-sm);
326
+ background: rgba(255, 255, 255, 0.65);
327
+ }
328
+
329
+ .meta-grid strong {
330
+ display: block;
331
+ margin-top: 6px;
332
+ }
333
+
334
+ .progress-line {
335
+ height: 9px;
336
+ margin: 16px 0 14px;
337
+ background: rgba(78, 148, 97, 0.12);
338
+ border-radius: 999px;
339
+ overflow: hidden;
340
+ }
341
+
342
+ .progress-line div {
343
+ height: 100%;
344
+ border-radius: inherit;
345
+ background: linear-gradient(90deg, #62b176, #8ac798, #f1c16b);
346
+ }
347
+
348
+ .card-footer,
349
+ .card-topline,
350
+ .section-head,
351
+ .stack-item,
352
+ .builder-title-row {
353
+ display: flex;
354
+ align-items: center;
355
+ justify-content: space-between;
356
+ gap: 12px;
357
+ }
358
+
359
+ .section-head {
360
+ margin-bottom: 18px;
361
+ }
362
+
363
+ .tight-head {
364
+ margin-top: 8px;
365
+ }
366
+
367
+ .stack-list {
368
+ display: grid;
369
+ gap: 14px;
370
+ }
371
+
372
+ .stack-item {
373
+ padding: 14px 16px;
374
+ border-radius: 18px;
375
+ background: rgba(255, 255, 255, 0.62);
376
+ border: 1px solid rgba(78, 148, 97, 0.14);
377
+ }
378
+
379
+ .stack-item-block {
380
+ align-items: flex-start;
381
+ }
382
+
383
+ .form-stack,
384
+ .compact-form {
385
+ display: grid;
386
+ gap: 14px;
387
+ }
388
+
389
+ .form-grid {
390
+ display: grid;
391
+ gap: 14px;
392
+ }
393
+
394
+ .cols-2 {
395
+ grid-template-columns: repeat(2, minmax(0, 1fr));
396
+ }
397
+
398
+ .cols-3 {
399
+ grid-template-columns: repeat(3, minmax(0, 1fr));
400
+ }
401
+
402
+ .full-span {
403
+ grid-column: 1 / -1;
404
+ }
405
+
406
+ label {
407
+ display: grid;
408
+ gap: 8px;
409
+ }
410
+
411
+ input,
412
+ select,
413
+ textarea,
414
+ button {
415
+ font: inherit;
416
+ }
417
+
418
+ input,
419
+ select,
420
+ textarea {
421
+ width: 100%;
422
+ padding: 14px 16px;
423
+ border-radius: 16px;
424
+ border: 1px solid rgba(91, 141, 99, 0.22);
425
+ background: rgba(255, 255, 255, 0.88);
426
+ color: var(--text);
427
+ transition: border-color 0.24s ease, box-shadow 0.24s ease, transform 0.24s ease;
428
+ }
429
+
430
+ input:focus,
431
+ select:focus,
432
+ textarea:focus {
433
+ outline: none;
434
+ border-color: rgba(78, 148, 97, 0.5);
435
+ box-shadow: 0 0 0 4px rgba(78, 148, 97, 0.12);
436
+ transform: translateY(-1px);
437
+ }
438
+
439
+ textarea {
440
+ resize: vertical;
441
+ min-height: 120px;
442
+ }
443
+
444
+ .btn {
445
+ display: inline-flex;
446
+ align-items: center;
447
+ justify-content: center;
448
+ gap: 8px;
449
+ padding: 12px 18px;
450
+ border-radius: 999px;
451
+ border: none;
452
+ cursor: pointer;
453
+ text-decoration: none;
454
+ transition: transform 0.24s ease, box-shadow 0.24s ease, background 0.24s ease;
455
+ }
456
+
457
+ .btn:hover:not(:disabled) {
458
+ transform: translateY(-2px);
459
+ }
460
+
461
+ .btn:disabled {
462
+ opacity: 0.5;
463
+ cursor: not-allowed;
464
+ }
465
+
466
+ .btn-primary {
467
+ color: white;
468
+ background: linear-gradient(135deg, #4c9460, #70ba80);
469
+ box-shadow: 0 16px 28px rgba(76, 148, 96, 0.24);
470
+ }
471
+
472
+ .btn-secondary {
473
+ color: var(--primary-deep);
474
+ background: rgba(78, 148, 97, 0.14);
475
+ }
476
+
477
+ .btn-danger {
478
+ color: white;
479
+ background: linear-gradient(135deg, #cf5a68, #e57b84);
480
+ }
481
+
482
+ .btn-ghost,
483
+ .full-width-btn {
484
+ color: var(--primary-deep);
485
+ background: rgba(255, 255, 255, 0.72);
486
+ border: 1px solid rgba(78, 148, 97, 0.16);
487
+ }
488
+
489
+ .small-btn {
490
+ padding: 10px 14px;
491
+ font-size: 0.92rem;
492
+ }
493
+
494
+ .full-width-btn {
495
+ width: 100%;
496
+ }
497
+
498
+ .login-body {
499
+ display: grid;
500
+ place-items: center;
501
+ padding: 30px 16px;
502
+ }
503
+
504
+ .login-page {
505
+ position: relative;
506
+ display: grid;
507
+ grid-template-columns: 1.1fr minmax(320px, 420px);
508
+ width: min(1120px, 100%);
509
+ gap: 24px;
510
+ }
511
+
512
+ .compact-login-page {
513
+ grid-template-columns: 1fr minmax(320px, 420px);
514
+ }
515
+
516
+ .login-copy,
517
+ .login-panel {
518
+ padding: 34px;
519
+ }
520
+
521
+ .spring-copy {
522
+ min-height: 520px;
523
+ background:
524
+ radial-gradient(circle at 16% 20%, rgba(255, 255, 255, 0.88), transparent 42%),
525
+ linear-gradient(145deg, rgba(125, 195, 137, 0.22), rgba(246, 184, 91, 0.18));
526
+ }
527
+
528
+ .admin-copy {
529
+ min-height: 420px;
530
+ }
531
+
532
+ .feature-list {
533
+ margin-top: 26px;
534
+ }
535
+
536
+ .feature-list span,
537
+ .info-box,
538
+ .feedback-box {
539
+ padding: 10px 14px;
540
+ border-radius: 16px;
541
+ background: rgba(255, 255, 255, 0.62);
542
+ border: 1px solid rgba(78, 148, 97, 0.12);
543
+ }
544
+
545
+ .panel-glow {
546
+ position: absolute;
547
+ inset: auto auto -50px -30px;
548
+ width: 140px;
549
+ height: 140px;
550
+ border-radius: 50%;
551
+ background: radial-gradient(circle, rgba(246, 184, 91, 0.25), transparent 68%);
552
+ }
553
+
554
+ .task-card {
555
+ display: flex;
556
+ flex-direction: column;
557
+ gap: 14px;
558
+ }
559
+
560
+ .task-card-head {
561
+ display: flex;
562
+ align-items: flex-start;
563
+ justify-content: space-between;
564
+ gap: 14px;
565
+ }
566
+
567
+ .task-description {
568
+ min-height: 50px;
569
+ }
570
+
571
+ .task-media-wrap,
572
+ .submission-preview {
573
+ display: grid;
574
+ gap: 10px;
575
+ }
576
+
577
+ .task-media,
578
+ .submission-preview img,
579
+ .review-image {
580
+ width: 100%;
581
+ aspect-ratio: 4 / 3;
582
+ object-fit: cover;
583
+ border-radius: 22px;
584
+ border: 1px solid rgba(78, 148, 97, 0.16);
585
+ box-shadow: 0 12px 30px rgba(86, 127, 93, 0.12);
586
+ }
587
+
588
+ .clue-toggle {
589
+ width: 48px;
590
+ height: 48px;
591
+ display: grid;
592
+ place-items: center;
593
+ border: none;
594
+ border-radius: 18px;
595
+ font-size: 1.2rem;
596
+ background: rgba(246, 184, 91, 0.14);
597
+ color: #c88929;
598
+ transition: transform 0.25s ease, background 0.25s ease, box-shadow 0.25s ease;
599
+ }
600
+
601
+ .clue-toggle.is-ready {
602
+ background: rgba(246, 184, 91, 0.26);
603
+ box-shadow: 0 14px 26px rgba(233, 173, 75, 0.22);
604
+ }
605
+
606
+ .clue-toggle.is-clue-view {
607
+ transform: rotate(-10deg) scale(1.04);
608
+ }
609
+
610
+ .release-note {
611
+ font-size: 0.88rem;
612
+ color: var(--muted);
613
+ }
614
+
615
+ .upload-form,
616
+ .inline-form {
617
+ display: flex;
618
+ flex-wrap: wrap;
619
+ gap: 12px;
620
+ align-items: end;
621
+ }
622
+
623
+ .inline-form-wide {
624
+ flex-direction: column;
625
+ align-items: stretch;
626
+ }
627
+
628
+ .visibility-form {
629
+ justify-content: flex-end;
630
+ }
631
+
632
+ .upload-label {
633
+ flex: 1 1 220px;
634
+ }
635
+
636
+ .feedback-box {
637
+ color: var(--text);
638
+ }
639
+
640
+ .leaderboard-card {
641
+ padding: 26px;
642
+ }
643
+
644
+ .rank-table-wrap,
645
+ .table-shell {
646
+ overflow-x: auto;
647
+ }
648
+
649
+ .rank-table,
650
+ .data-table {
651
+ width: 100%;
652
+ border-collapse: collapse;
653
+ }
654
+
655
+ .rank-table th,
656
+ .rank-table td,
657
+ .data-table th,
658
+ .data-table td {
659
+ padding: 14px 12px;
660
+ border-bottom: 1px solid rgba(78, 148, 97, 0.12);
661
+ text-align: left;
662
+ }
663
+
664
+ .rank-table tbody tr:hover,
665
+ .data-table tbody tr:hover {
666
+ background: rgba(78, 148, 97, 0.05);
667
+ }
668
+
669
+ .stat-card {
670
+ min-height: 130px;
671
+ display: flex;
672
+ flex-direction: column;
673
+ justify-content: space-between;
674
+ }
675
+
676
+ .stat-card strong {
677
+ font-size: 2rem;
678
+ color: var(--primary-deep);
679
+ }
680
+
681
+ .info-box {
682
+ margin-top: 18px;
683
+ display: grid;
684
+ gap: 12px;
685
+ }
686
+
687
+ .member-list {
688
+ display: grid;
689
+ gap: 10px;
690
+ margin: 16px 0;
691
+ }
692
+
693
+ .member-row {
694
+ display: flex;
695
+ align-items: center;
696
+ justify-content: space-between;
697
+ gap: 12px;
698
+ padding: 12px 14px;
699
+ border-radius: 16px;
700
+ background: rgba(255, 255, 255, 0.62);
701
+ }
702
+
703
+ .checkbox-row {
704
+ display: inline-flex;
705
+ align-items: center;
706
+ gap: 10px;
707
+ }
708
+
709
+ .checkbox-row input {
710
+ width: 18px;
711
+ height: 18px;
712
+ padding: 0;
713
+ }
714
+
715
+ .compact-checkbox {
716
+ padding: 0;
717
+ }
718
+
719
+ .task-builder {
720
+ display: grid;
721
+ gap: 16px;
722
+ }
723
+
724
+ .task-builder-card {
725
+ padding: 18px;
726
+ border-radius: 22px;
727
+ background: rgba(255, 255, 255, 0.64);
728
+ border: 1px dashed rgba(78, 148, 97, 0.24);
729
+ display: grid;
730
+ gap: 14px;
731
+ }
732
+
733
+ .review-card {
734
+ display: grid;
735
+ gap: 14px;
736
+ }
737
+
738
+ .review-filter-form {
739
+ align-items: end;
740
+ }
741
+
742
+ .review-filter-action {
743
+ justify-content: end;
744
+ }
745
+
746
+ .empty-state {
747
+ padding: 32px;
748
+ border-radius: var(--radius-lg);
749
+ background: rgba(255, 255, 255, 0.68);
750
+ border: 1px dashed rgba(78, 148, 97, 0.24);
751
+ text-align: center;
752
+ }
753
+
754
+ .pulse-highlight {
755
+ animation: pulse-glow 1.8s ease;
756
+ }
757
+
758
+ @keyframes rise-in {
759
+ from {
760
+ opacity: 0;
761
+ transform: translateY(18px);
762
+ }
763
+ to {
764
+ opacity: 1;
765
+ transform: translateY(0);
766
+ }
767
+ }
768
+
769
+ @keyframes pulse-glow {
770
+ 0% {
771
+ box-shadow: var(--shadow);
772
+ }
773
+ 35% {
774
+ box-shadow: 0 0 0 8px rgba(246, 184, 91, 0.18), var(--shadow);
775
+ }
776
+ 100% {
777
+ box-shadow: var(--shadow);
778
+ }
779
+ }
780
+
781
+ @media (max-width: 980px) {
782
+ .topbar,
783
+ .hero-card,
784
+ .page-grid,
785
+ .two-column-layout,
786
+ .login-page,
787
+ .compact-login-page,
788
+ .admin-activity-grid {
789
+ grid-template-columns: 1fr;
790
+ flex-direction: column;
791
+ }
792
+
793
+ .topbar {
794
+ border-radius: 28px;
795
+ align-items: flex-start;
796
+ }
797
+
798
+ .topnav {
799
+ width: 100%;
800
+ justify-content: flex-start;
801
+ }
802
+
803
+ .login-copy {
804
+ min-height: auto;
805
+ }
806
+ }
807
+
808
+ @media (max-width: 720px) {
809
+ .shell {
810
+ width: min(100% - 20px, 1200px);
811
+ padding-top: 18px;
812
+ }
813
+
814
+ .topbar,
815
+ .hero-card,
816
+ .glass-card,
817
+ .login-copy,
818
+ .login-panel {
819
+ padding: 20px;
820
+ }
821
+
822
+ .cols-2,
823
+ .cols-3,
824
+ .meta-grid {
825
+ grid-template-columns: 1fr;
826
+ }
827
+
828
+ .activity-grid,
829
+ .task-grid,
830
+ .group-grid,
831
+ .review-grid,
832
+ .stats-grid {
833
+ grid-template-columns: 1fr;
834
+ }
835
+
836
+ .upload-form,
837
+ .inline-form,
838
+ .card-footer,
839
+ .section-head,
840
+ .stack-item,
841
+ .builder-title-row,
842
+ .task-card-head,
843
+ .task-status-row {
844
+ flex-direction: column;
845
+ align-items: stretch;
846
+ }
847
+
848
+ .btn,
849
+ .full-width-btn {
850
+ width: 100%;
851
+ }
852
+ }
app/templates/activity_detail.html ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'base.html' %}
2
+
3
+ {% block content %}
4
+ <section class="hero-card activity-hero">
5
+ <div>
6
+ <a class="ghost-link" href="/dashboard">返回活动列表</a>
7
+ <p class="eyebrow">Activity Detail</p>
8
+ <h2>{{ activity.title }}</h2>
9
+ <p class="lead">{{ activity.description or '管理员暂未填写活动说明。' }}</p>
10
+ </div>
11
+ <div class="hero-badges">
12
+ <span class="pill">开始 {{ activity.start_at|datetime_local }}</span>
13
+ <span class="pill">截止 {{ activity.deadline_at|datetime_local }}</span>
14
+ <span class="pill">{{ activity.tasks|length }} 个任务</span>
15
+ </div>
16
+ </section>
17
+
18
+ <section class="task-grid">
19
+ {% for task in activity.tasks %}
20
+ {% set submission = submission_by_task.get(task.id) %}
21
+ {% set clue_ready = clue_states.get(task.id) %}
22
+ <article class="glass-card task-card" data-task-card data-task-id="{{ task.id }}" data-clue-released="{{ 'true' if clue_ready else 'false' }}">
23
+ <div class="task-card-head">
24
+ <div>
25
+ <p class="eyebrow">Task {{ loop.index }}</p>
26
+ <h3>{{ task.title }}</h3>
27
+ </div>
28
+ <button
29
+ class="clue-toggle {% if clue_ready %}is-ready{% endif %}"
30
+ type="button"
31
+ data-clue-toggle
32
+ data-primary-url="/media/tasks/{{ task.id }}/image"
33
+ data-clue-url="/media/tasks/{{ task.id }}/clue"
34
+ {% if not task.clue_image_filename %}disabled{% endif %}
35
+ title="{% if clue_ready %}点击查看线索{% elif task.clue_image_filename %}线索尚未发布{% else %}未设置线索图{% endif %}"
36
+ >
37
+ 💡
38
+ </button>
39
+ </div>
40
+ <p class="muted task-description">{{ task.description or '到达对应打卡点后上传一张清晰照片。' }}</p>
41
+
42
+ <div class="task-media-wrap">
43
+ <img
44
+ src="/media/tasks/{{ task.id }}/image"
45
+ alt="{{ task.title }}"
46
+ class="task-media"
47
+ data-task-image
48
+ />
49
+ {% if task.clue_release_at %}
50
+ <div class="release-note">线索发布时间:{{ task.clue_release_at|datetime_local }}</div>
51
+ {% endif %}
52
+ </div>
53
+
54
+ <div class="task-status-row">
55
+ {% if submission %}
56
+ {% if submission.status == 'approved' %}
57
+ <span class="status-badge status-approved">打卡成功</span>
58
+ {% elif submission.status == 'rejected' %}
59
+ <span class="status-badge status-rejected">打卡失败</span>
60
+ {% else %}
61
+ <span class="status-badge">等待审核</span>
62
+ {% endif %}
63
+ <span class="mini-note">最近提交:{{ submission.created_at|datetime_local }}</span>
64
+ {% else %}
65
+ <span class="status-badge">尚未上传</span>
66
+ {% endif %}
67
+ </div>
68
+
69
+ {% if submission and submission.feedback %}
70
+ <p class="feedback-box">审核备注:{{ submission.feedback }}</p>
71
+ {% endif %}
72
+
73
+ {% if submission %}
74
+ <div class="submission-preview">
75
+ <span class="mini-note">我的提交预览</span>
76
+ <img src="/media/submissions/{{ submission.id }}" alt="{{ task.title }} 提交预览" />
77
+ </div>
78
+ {% endif %}
79
+
80
+ <form action="/activities/{{ activity.id }}/tasks/{{ task.id }}/submit" method="post" enctype="multipart/form-data" class="upload-form">
81
+ <label class="upload-label">
82
+ <span>上传打卡照片</span>
83
+ <input type="file" name="photo" accept="image/*" {% if submission and submission.status == 'approved' %}disabled{% endif %} required />
84
+ </label>
85
+ <button class="btn btn-primary" type="submit" {% if submission and submission.status == 'approved' %}disabled{% endif %}>提交审核</button>
86
+ </form>
87
+ </article>
88
+ {% endfor %}
89
+ </section>
90
+
91
+ <section class="glass-card leaderboard-card">
92
+ <div class="section-head">
93
+ <div>
94
+ <p class="eyebrow">Live Ranking</p>
95
+ <h3>实时排行榜</h3>
96
+ </div>
97
+ {% if not activity.leaderboard_visible %}
98
+ <span class="status-badge">管理员已隐藏</span>
99
+ {% endif %}
100
+ </div>
101
+
102
+ {% if activity.leaderboard_visible %}
103
+ <div class="rank-table-wrap">
104
+ <table class="rank-table">
105
+ <thead>
106
+ <tr>
107
+ <th>排名</th>
108
+ <th>小组</th>
109
+ <th>完成打卡点</th>
110
+ <th>成员数</th>
111
+ <th>总耗时</th>
112
+ </tr>
113
+ </thead>
114
+ <tbody>
115
+ {% for row in leaderboard %}
116
+ <tr>
117
+ <td>#{{ loop.index }}</td>
118
+ <td>{{ row.group_name }}</td>
119
+ <td>{{ row.completed_count }}</td>
120
+ <td>{{ row.member_count }}</td>
121
+ <td>{{ row.total_elapsed|duration_human }}</td>
122
+ </tr>
123
+ {% else %}
124
+ <tr>
125
+ <td colspan="5">还没有通过审核的打卡记录,排行榜会在成功打卡后自动更新。</td>
126
+ </tr>
127
+ {% endfor %}
128
+ </tbody>
129
+ </table>
130
+ </div>
131
+ {% else %}
132
+ <p class="muted">当前活动的排行榜暂不对用户开放。</p>
133
+ {% endif %}
134
+ </section>
135
+
136
+ <script>
137
+ (() => {
138
+ const cards = Array.from(document.querySelectorAll('[data-task-card]'));
139
+ const seenReleased = new Set(
140
+ cards.filter((card) => card.dataset.clueReleased === 'true').map((card) => card.dataset.taskId)
141
+ );
142
+
143
+ cards.forEach((card) => {
144
+ const button = card.querySelector('[data-clue-toggle]');
145
+ const image = card.querySelector('[data-task-image]');
146
+ if (!button || !image) return;
147
+
148
+ button.addEventListener('click', () => {
149
+ if (!button.classList.contains('is-ready')) return;
150
+ const showingClue = button.dataset.mode === 'clue';
151
+ image.src = showingClue ? button.dataset.primaryUrl : button.dataset.clueUrl;
152
+ button.dataset.mode = showingClue ? 'primary' : 'clue';
153
+ button.classList.toggle('is-clue-view', !showingClue);
154
+ });
155
+ });
156
+
157
+ const isMobileLike = window.matchMedia('(pointer: coarse)').matches;
158
+
159
+ const poll = async () => {
160
+ try {
161
+ const response = await fetch('/api/activities/{{ activity.id }}/clues', { headers: { 'X-Requested-With': 'fetch' } });
162
+ if (!response.ok) return;
163
+ const payload = await response.json();
164
+ (payload.released_task_ids || []).forEach((taskId) => {
165
+ const stringId = String(taskId);
166
+ const card = cards.find((item) => item.dataset.taskId === stringId);
167
+ if (!card) return;
168
+ const button = card.querySelector('[data-clue-toggle]');
169
+ if (!button) return;
170
+ button.classList.add('is-ready');
171
+ card.dataset.clueReleased = 'true';
172
+ if (!seenReleased.has(stringId)) {
173
+ seenReleased.add(stringId);
174
+ card.classList.add('pulse-highlight');
175
+ setTimeout(() => card.classList.remove('pulse-highlight'), 1800);
176
+ if (isMobileLike && navigator.vibrate) {
177
+ navigator.vibrate([220, 80, 220]);
178
+ }
179
+ }
180
+ });
181
+ } catch (error) {
182
+ console.debug('clue polling skipped', error);
183
+ }
184
+ };
185
+
186
+ window.setInterval(poll, 20000);
187
+ })();
188
+ </script>
189
+ {% endblock %}
190
+
app/templates/admin_activities.html ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'base.html' %}
2
+
3
+ {% block content %}
4
+ <section class="page-grid admin-page-grid admin-activity-grid">
5
+ <article class="glass-card form-panel wide-panel">
6
+ <div class="section-head">
7
+ <div>
8
+ <p class="eyebrow">Create Activity</p>
9
+ <h3>发布活动与打卡任务</h3>
10
+ </div>
11
+ </div>
12
+ <form method="post" action="/admin/activities" enctype="multipart/form-data" class="form-stack" id="activity-form">
13
+ <div class="form-grid cols-2">
14
+ <label>
15
+ <span>活动标题</span>
16
+ <input type="text" name="title" required />
17
+ </label>
18
+ <label>
19
+ <span>线索发布时间间隔(分钟)</span>
20
+ <input type="number" name="clue_interval_minutes" min="0" placeholder="留空表示与活动开始同步" />
21
+ </label>
22
+ <label>
23
+ <span>开始时间</span>
24
+ <input type="datetime-local" name="start_at" required />
25
+ </label>
26
+ <label>
27
+ <span>截止时间</span>
28
+ <input type="datetime-local" name="deadline_at" required />
29
+ </label>
30
+ </div>
31
+ <label>
32
+ <span>活动说明</span>
33
+ <textarea name="description" rows="3" placeholder="介绍活动安排、打卡要求和注意事项"></textarea>
34
+ </label>
35
+ <label class="checkbox-row">
36
+ <input type="checkbox" name="leaderboard_visible" checked />
37
+ <span>允许用户查看实时排行榜</span>
38
+ </label>
39
+
40
+ <div class="section-head tight-head">
41
+ <div>
42
+ <p class="eyebrow">Tasks</p>
43
+ <h3>任务卡片</h3>
44
+ </div>
45
+ <button class="btn btn-secondary" type="button" id="add-task-btn">新增任务卡片</button>
46
+ </div>
47
+
48
+ <div class="task-builder" id="task-builder">
49
+ <article class="task-builder-card" data-task-template>
50
+ <div class="builder-title-row">
51
+ <strong>任务 1</strong>
52
+ <button type="button" class="btn btn-ghost small-btn" data-remove-task>删除</button>
53
+ </div>
54
+ <div class="form-grid cols-2">
55
+ <label>
56
+ <span>任务标题</span>
57
+ <input type="text" name="task_title" required />
58
+ </label>
59
+ <label>
60
+ <span>主图</span>
61
+ <input type="file" name="task_image" accept="image/*" required />
62
+ </label>
63
+ <label class="full-span">
64
+ <span>任务描述</span>
65
+ <textarea name="task_description" rows="2"></textarea>
66
+ </label>
67
+ <label class="full-span">
68
+ <span>线索图</span>
69
+ <input type="file" name="task_clue_image" accept="image/*" />
70
+ </label>
71
+ </div>
72
+ </article>
73
+ </div>
74
+ <button class="btn btn-primary" type="submit">发布活动</button>
75
+ </form>
76
+ </article>
77
+
78
+ <article class="glass-card table-panel">
79
+ <div class="section-head">
80
+ <div>
81
+ <p class="eyebrow">Published Activities</p>
82
+ <h3>已发布活动</h3>
83
+ </div>
84
+ </div>
85
+ <div class="stack-list">
86
+ {% for activity in activities %}
87
+ <article class="stack-item stack-item-block">
88
+ <div>
89
+ <strong>{{ activity.title }}</strong>
90
+ <p class="muted">{{ activity.start_at|datetime_local }} 至 {{ activity.deadline_at|datetime_local }}</p>
91
+ <p class="muted">{{ activity.tasks|length }} 个任务 · 创建人 {{ activity.created_by.display_name }}</p>
92
+ </div>
93
+ <form method="post" action="/admin/activities/{{ activity.id }}/visibility" class="inline-form visibility-form">
94
+ <label class="checkbox-row compact-checkbox">
95
+ <input type="checkbox" name="leaderboard_visible" {% if activity.leaderboard_visible %}checked{% endif %} />
96
+ <span>用户可见排行榜</span>
97
+ </label>
98
+ <button class="btn btn-secondary small-btn" type="submit">保存</button>
99
+ </form>
100
+ </article>
101
+ {% else %}
102
+ <p class="muted">还没有活动,先发布一个吧。</p>
103
+ {% endfor %}
104
+ </div>
105
+ </article>
106
+ </section>
107
+
108
+ <script>
109
+ (() => {
110
+ const builder = document.getElementById('task-builder');
111
+ const addBtn = document.getElementById('add-task-btn');
112
+ if (!builder || !addBtn) return;
113
+
114
+ const renumber = () => {
115
+ builder.querySelectorAll('[data-task-template]').forEach((card, index) => {
116
+ const title = card.querySelector('.builder-title-row strong');
117
+ if (title) title.textContent = `任务 ${index + 1}`;
118
+ });
119
+ };
120
+
121
+ const attachRemove = (card) => {
122
+ const removeBtn = card.querySelector('[data-remove-task]');
123
+ if (!removeBtn) return;
124
+ removeBtn.addEventListener('click', () => {
125
+ if (builder.querySelectorAll('[data-task-template]').length === 1) return;
126
+ card.remove();
127
+ renumber();
128
+ });
129
+ };
130
+
131
+ builder.querySelectorAll('[data-task-template]').forEach(attachRemove);
132
+
133
+ addBtn.addEventListener('click', () => {
134
+ const template = builder.querySelector('[data-task-template]');
135
+ const clone = template.cloneNode(true);
136
+ clone.querySelectorAll('input, textarea').forEach((field) => {
137
+ if (field.type === 'file' || field.type === 'text' || field.type === 'number' || field.type === 'datetime-local') {
138
+ field.value = '';
139
+ } else {
140
+ field.value = '';
141
+ }
142
+ });
143
+ attachRemove(clone);
144
+ builder.appendChild(clone);
145
+ renumber();
146
+ });
147
+ })();
148
+ </script>
149
+ {% endblock %}
app/templates/admin_admins.html ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'base.html' %}
2
+
3
+ {% block content %}
4
+ <section class="page-grid admin-page-grid">
5
+ <article class="glass-card form-panel">
6
+ <div class="section-head">
7
+ <div>
8
+ <p class="eyebrow">Create Admin</p>
9
+ <h3>新增管理员</h3>
10
+ </div>
11
+ </div>
12
+ <form method="post" action="/admin/admins" class="form-stack">
13
+ <div class="form-grid cols-2">
14
+ <label>
15
+ <span>登录账号</span>
16
+ <input type="text" name="username" required />
17
+ </label>
18
+ <label>
19
+ <span>显示名称</span>
20
+ <input type="text" name="display_name" required />
21
+ </label>
22
+ <label>
23
+ <span>登录密码</span>
24
+ <input type="text" name="password" required />
25
+ </label>
26
+ <label>
27
+ <span>角色</span>
28
+ <select name="role">
29
+ <option value="admin">管理员</option>
30
+ <option value="superadmin">超级管理员</option>
31
+ </select>
32
+ </label>
33
+ </div>
34
+ <button class="btn btn-primary" type="submit">创建管理员</button>
35
+ </form>
36
+ </article>
37
+
38
+ <article class="glass-card table-panel">
39
+ <div class="section-head">
40
+ <div>
41
+ <p class="eyebrow">Admins</p>
42
+ <h3>管理员列表</h3>
43
+ </div>
44
+ </div>
45
+ <div class="stack-list">
46
+ {% for admin_item in admins %}
47
+ <div class="stack-item">
48
+ <div>
49
+ <strong>{{ admin_item.display_name }}</strong>
50
+ <p class="muted">{{ admin_item.username }} · 创建于 {{ admin_item.created_at|datetime_local }}</p>
51
+ </div>
52
+ <span class="status-badge {% if admin_item.role == 'superadmin' %}status-approved{% endif %}">
53
+ {{ '超级管理员' if admin_item.role == 'superadmin' else '管理员' }}
54
+ </span>
55
+ </div>
56
+ {% else %}
57
+ <p class="muted">暂无管理员记录。</p>
58
+ {% endfor %}
59
+ </div>
60
+ </article>
61
+ </section>
62
+ {% endblock %}
app/templates/admin_dashboard.html ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'base.html' %}
2
+
3
+ {% block content %}
4
+ <section class="hero-card admin-hero">
5
+ <div>
6
+ <p class="eyebrow">Admin Overview</p>
7
+ <h2>{{ admin.display_name }},欢迎来到春日行动指挥台</h2>
8
+ <p class="lead">你可以在这里维护用户、分组、活动任务和图片审核流程。</p>
9
+ </div>
10
+ <div class="hero-badges">
11
+ <span class="pill">角色 {{ '超级管理员' if admin.role == 'superadmin' else '管理员' }}</span>
12
+ <span class="pill">待审核 {{ stats.pending_count }} 项</span>
13
+ </div>
14
+ </section>
15
+
16
+ <section class="stats-grid">
17
+ <article class="glass-card stat-card">
18
+ <span>用户总数</span>
19
+ <strong>{{ stats.user_count }}</strong>
20
+ </article>
21
+ <article class="glass-card stat-card">
22
+ <span>小组数量</span>
23
+ <strong>{{ stats.group_count }}</strong>
24
+ </article>
25
+ <article class="glass-card stat-card">
26
+ <span>活动数量</span>
27
+ <strong>{{ stats.activity_count }}</strong>
28
+ </article>
29
+ <article class="glass-card stat-card">
30
+ <span>管理员数量</span>
31
+ <strong>{{ stats.admin_count }}</strong>
32
+ </article>
33
+ </section>
34
+
35
+ <section class="two-column-layout">
36
+ <article class="glass-card quick-panel">
37
+ <div class="section-head">
38
+ <div>
39
+ <p class="eyebrow">Quick Links</p>
40
+ <h3>常用入口</h3>
41
+ </div>
42
+ </div>
43
+ <div class="action-grid">
44
+ <a class="btn btn-secondary" href="/admin/users">录入用户</a>
45
+ <a class="btn btn-secondary" href="/admin/groups">管理小组</a>
46
+ <a class="btn btn-secondary" href="/admin/activities">发布活动</a>
47
+ <a class="btn btn-primary" href="/admin/reviews">进入审核中心</a>
48
+ </div>
49
+ </article>
50
+
51
+ <article class="glass-card quick-panel">
52
+ <div class="section-head">
53
+ <div>
54
+ <p class="eyebrow">Recent Activities</p>
55
+ <h3>最近创建的活动</h3>
56
+ </div>
57
+ </div>
58
+ <div class="stack-list">
59
+ {% for activity in recent_activities %}
60
+ <div class="stack-item">
61
+ <div>
62
+ <strong>{{ activity.title }}</strong>
63
+ <p class="muted">{{ activity.tasks|length }} 个任务 · {{ activity.start_at|datetime_local }}</p>
64
+ </div>
65
+ <span class="status-badge">{{ '排行榜可见' if activity.leaderboard_visible else '排行榜隐藏' }}</span>
66
+ </div>
67
+ {% else %}
68
+ <p class="muted">还没有发布活动。</p>
69
+ {% endfor %}
70
+ </div>
71
+ </article>
72
+ </section>
73
+ {% endblock %}
app/templates/admin_groups.html ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'base.html' %}
2
+
3
+ {% block content %}
4
+ <section class="page-grid admin-page-grid">
5
+ <article class="glass-card form-panel">
6
+ <div class="section-head">
7
+ <div>
8
+ <p class="eyebrow">Create Group</p>
9
+ <h3>新建小组</h3>
10
+ </div>
11
+ </div>
12
+ <form method="post" action="/admin/groups" class="form-stack">
13
+ <div class="form-grid cols-2">
14
+ <label>
15
+ <span>小组名称</span>
16
+ <input type="text" name="name" placeholder="留空自动生成第N组" />
17
+ </label>
18
+ <label>
19
+ <span>人数上限</span>
20
+ <input type="number" name="max_members" min="1" value="6" required />
21
+ </label>
22
+ </div>
23
+ <button class="btn btn-primary" type="submit">创建小组</button>
24
+ </form>
25
+ <div class="info-box">
26
+ <strong>未分组成员</strong>
27
+ <div class="chip-row">
28
+ {% for user in ungrouped_users %}
29
+ <span class="chip">{{ user.full_name }} · {{ user.student_id }}</span>
30
+ {% else %}
31
+ <span class="chip">所有成员都已分组</span>
32
+ {% endfor %}
33
+ </div>
34
+ </div>
35
+ </article>
36
+
37
+ <section class="card-grid group-grid">
38
+ {% for group in groups %}
39
+ <article class="glass-card group-card">
40
+ <div class="card-topline">
41
+ <span class="eyebrow">Group {{ loop.index }}</span>
42
+ <span class="status-badge">{{ group.members|length }}/{{ group.max_members }}</span>
43
+ </div>
44
+ <h3>{{ group.name }}</h3>
45
+ <div class="member-list">
46
+ {% for member in group.members %}
47
+ <div class="member-row">
48
+ <strong>{{ member.full_name }}</strong>
49
+ <span>{{ member.student_id }}</span>
50
+ </div>
51
+ {% else %}
52
+ <p class="muted">该小组暂时还没有成员。</p>
53
+ {% endfor %}
54
+ </div>
55
+ <form method="post" action="/admin/groups/{{ group.id }}/capacity" class="inline-form inline-form-wide">
56
+ <label>
57
+ <span>调整人数上限</span>
58
+ <input type="number" name="max_members" min="1" value="{{ group.max_members }}" required />
59
+ </label>
60
+ <button class="btn btn-secondary small-btn" type="submit">更新</button>
61
+ </form>
62
+ </article>
63
+ {% else %}
64
+ <article class="empty-state">
65
+ <h3>还没有小组</h3>
66
+ <p>创建后这里会以卡片方式展示每个小组和成员。</p>
67
+ </article>
68
+ {% endfor %}
69
+ </section>
70
+ </section>
71
+ {% endblock %}
app/templates/admin_login.html ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>管理员登录 · {{ app_name }}</title>
7
+ <link rel="stylesheet" href="/static/style.css" />
8
+ </head>
9
+ <body class="login-body admin-login-body">
10
+ <div class="page-backdrop"></div>
11
+ <main class="login-page compact-login-page">
12
+ <section class="login-copy admin-copy">
13
+ <p class="eyebrow">Admin Console</p>
14
+ <h1>活动管理后台</h1>
15
+ <p class="lead">
16
+ 在这里完成用户录入、小组配置、任务发布、线索管理和图片审核。
17
+ </p>
18
+ </section>
19
+
20
+ <section class="login-panel admin-panel">
21
+ <div class="panel-glow"></div>
22
+ <p class="eyebrow">Secure Access</p>
23
+ <h2>管理员登录</h2>
24
+ {% include 'partials/flash.html' %}
25
+ <form method="post" action="/admin" class="form-stack">
26
+ <label>
27
+ <span>账号</span>
28
+ <input type="text" name="username" placeholder="请输入管理员账号" required />
29
+ </label>
30
+ <label>
31
+ <span>密码</span>
32
+ <input type="password" name="password" placeholder="请输入密码" required />
33
+ </label>
34
+ <button class="btn btn-primary" type="submit">进入后台</button>
35
+ </form>
36
+ </section>
37
+ </main>
38
+ </body>
39
+ </html>
app/templates/admin_reviews.html ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'base.html' %}
2
+
3
+ {% block content %}
4
+ <section class="glass-card form-panel wide-panel">
5
+ <div class="section-head">
6
+ <div>
7
+ <p class="eyebrow">Review Center</p>
8
+ <h3>图片审核与批量下载</h3>
9
+ </div>
10
+ <form id="download-form" method="post" action="/admin/reviews/download" class="inline-form">
11
+ <button class="btn btn-primary" type="submit">下载已勾选图片</button>
12
+ </form>
13
+ </div>
14
+
15
+ <form method="get" action="/admin/reviews" class="form-grid cols-3 review-filter-form">
16
+ <label>
17
+ <span>审核状态</span>
18
+ <select name="status">
19
+ <option value="pending" {% if status_filter == 'pending' %}selected{% endif %}>待审核</option>
20
+ <option value="approved" {% if status_filter == 'approved' %}selected{% endif %}>已通过</option>
21
+ <option value="rejected" {% if status_filter == 'rejected' %}selected{% endif %}>已驳回</option>
22
+ </select>
23
+ </label>
24
+ <label>
25
+ <span>活动筛选</span>
26
+ <select name="activity_id">
27
+ <option value="">全部活动</option>
28
+ {% for activity in activities %}
29
+ <option value="{{ activity.id }}" {% if activity_filter == activity.id ~ '' %}selected{% endif %}>{{ activity.title }}</option>
30
+ {% endfor %}
31
+ </select>
32
+ </label>
33
+ <label class="review-filter-action">
34
+ <span>&nbsp;</span>
35
+ <button class="btn btn-secondary" type="submit">应用筛选</button>
36
+ </label>
37
+ </form>
38
+ </section>
39
+
40
+ <section class="review-grid">
41
+ {% for submission in submissions %}
42
+ <article class="glass-card review-card">
43
+ <div class="card-topline">
44
+ <label class="checkbox-row compact-checkbox">
45
+ <input type="checkbox" name="submission_ids" value="{{ submission.id }}" form="download-form" />
46
+ <span>加入下载</span>
47
+ </label>
48
+ {% if submission.status == 'approved' %}
49
+ <span class="status-badge status-approved">打卡成功</span>
50
+ {% elif submission.status == 'rejected' %}
51
+ <span class="status-badge status-rejected">打卡失败</span>
52
+ {% else %}
53
+ <span class="status-badge">待审核</span>
54
+ {% endif %}
55
+ </div>
56
+ <h3>{{ submission.task.title }}</h3>
57
+ <p class="muted">{{ submission.task.activity.title }}</p>
58
+ <p class="muted">{{ submission.user.full_name }} · {{ submission.user.student_id }}</p>
59
+ <img class="review-image" src="/media/submissions/{{ submission.id }}" alt="{{ submission.task.title }}" />
60
+ <p class="mini-note">提交时间:{{ submission.created_at|datetime_local }}</p>
61
+ {% if submission.feedback %}
62
+ <p class="feedback-box">当前备注:{{ submission.feedback }}</p>
63
+ {% endif %}
64
+ <a class="btn btn-ghost full-width-btn" href="/media/submissions/{{ submission.id }}?download=1">单张下载</a>
65
+ <form method="post" action="/admin/submissions/{{ submission.id }}/review" class="form-stack compact-form">
66
+ <label>
67
+ <span>审核备注</span>
68
+ <textarea name="feedback" rows="2" placeholder="可填写通过说明或驳回原因">{{ submission.feedback or '' }}</textarea>
69
+ </label>
70
+ <div class="action-grid two-actions">
71
+ <button class="btn btn-primary" type="submit" name="decision" value="approved">审核通过</button>
72
+ <button class="btn btn-danger" type="submit" name="decision" value="rejected">审核驳回</button>
73
+ </div>
74
+ </form>
75
+ </article>
76
+ {% else %}
77
+ <article class="empty-state">
78
+ <h3>当前没有符合条件的提交记录</h3>
79
+ <p>切换筛选条件后再看看,或者等待用户上传打卡照片。</p>
80
+ </article>
81
+ {% endfor %}
82
+ </section>
83
+ {% endblock %}
84
+
85
+
app/templates/admin_users.html ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'base.html' %}
2
+
3
+ {% block content %}
4
+ <section class="page-grid admin-page-grid">
5
+ <article class="glass-card form-panel">
6
+ <div class="section-head">
7
+ <div>
8
+ <p class="eyebrow">Create User</p>
9
+ <h3>手动录入用户</h3>
10
+ </div>
11
+ </div>
12
+ <form method="post" action="/admin/users" class="form-stack">
13
+ <div class="form-grid cols-2">
14
+ <label>
15
+ <span>学号</span>
16
+ <input type="text" name="student_id" required />
17
+ </label>
18
+ <label>
19
+ <span>姓名</span>
20
+ <input type="text" name="full_name" required />
21
+ </label>
22
+ <label>
23
+ <span>登录密码</span>
24
+ <input type="text" name="password" required />
25
+ </label>
26
+ <label>
27
+ <span>所属小组</span>
28
+ <select name="group_id">
29
+ <option value="">暂不分组</option>
30
+ {% for group in groups %}
31
+ <option value="{{ group.id }}">{{ group.name }}({{ group.members|length }}/{{ group.max_members }})</option>
32
+ {% endfor %}
33
+ </select>
34
+ </label>
35
+ </div>
36
+ <button class="btn btn-primary" type="submit">保存用户</button>
37
+ </form>
38
+ </article>
39
+
40
+ <article class="glass-card table-panel">
41
+ <div class="section-head">
42
+ <div>
43
+ <p class="eyebrow">Users</p>
44
+ <h3>用户列表</h3>
45
+ </div>
46
+ </div>
47
+ <div class="table-shell">
48
+ <table class="data-table">
49
+ <thead>
50
+ <tr>
51
+ <th>学号</th>
52
+ <th>姓名</th>
53
+ <th>当前小组</th>
54
+ <th>调整小组</th>
55
+ </tr>
56
+ </thead>
57
+ <tbody>
58
+ {% for user_item in users %}
59
+ <tr>
60
+ <td>{{ user_item.student_id }}</td>
61
+ <td>{{ user_item.full_name }}</td>
62
+ <td>{{ user_item.group.name if user_item.group else '未分组' }}</td>
63
+ <td>
64
+ <form method="post" action="/admin/users/{{ user_item.id }}/group" class="inline-form">
65
+ <select name="group_id">
66
+ <option value="">未分组</option>
67
+ {% for group in groups %}
68
+ <option value="{{ group.id }}" {% if user_item.group_id == group.id %}selected{% endif %}>{{ group.name }}</option>
69
+ {% endfor %}
70
+ </select>
71
+ <button class="btn btn-secondary small-btn" type="submit">保存</button>
72
+ </form>
73
+ </td>
74
+ </tr>
75
+ {% else %}
76
+ <tr>
77
+ <td colspan="4">还没有用户,请先录入。</td>
78
+ </tr>
79
+ {% endfor %}
80
+ </tbody>
81
+ </table>
82
+ </div>
83
+ </article>
84
+ </section>
85
+ {% endblock %}
app/templates/base.html ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>{{ page_title or app_name }} · {{ app_name }}</title>
7
+ <link rel="stylesheet" href="/static/style.css" />
8
+ </head>
9
+ <body class="{% if admin %}admin-mode{% else %}user-mode{% endif %}">
10
+ <div class="page-backdrop"></div>
11
+ <div class="shell">
12
+ <header class="topbar">
13
+ <div class="brand-block">
14
+ <div class="brand-mark">春</div>
15
+ <div>
16
+ <p class="eyebrow">Spring Check-In</p>
17
+ <h1 class="brand-title">{{ app_name }}</h1>
18
+ </div>
19
+ </div>
20
+ <nav class="topnav">
21
+ {% if user %}
22
+ <a href="/dashboard">活动广场</a>
23
+ <a href="/logout">退出登录</a>
24
+ {% elif admin %}
25
+ <a href="/admin/dashboard">总览</a>
26
+ <a href="/admin/users">用户</a>
27
+ <a href="/admin/groups">小组</a>
28
+ <a href="/admin/activities">活动</a>
29
+ <a href="/admin/reviews">审核</a>
30
+ {% if admin.role == 'superadmin' %}
31
+ <a href="/admin/admins">管理员</a>
32
+ {% endif %}
33
+ <a href="/admin/logout">退出</a>
34
+ {% endif %}
35
+ </nav>
36
+ </header>
37
+
38
+ {% include 'partials/flash.html' %}
39
+
40
+ <main class="content-shell">
41
+ {% block content %}{% endblock %}
42
+ </main>
43
+ </div>
44
+ </body>
45
+ </html>
app/templates/dashboard.html ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'base.html' %}
2
+
3
+ {% block content %}
4
+ <section class="hero-card">
5
+ <div>
6
+ <p class="eyebrow">Welcome Back</p>
7
+ <h2>{{ user.full_name }},今天也一起去打卡吧</h2>
8
+ <p class="lead">
9
+ 当前小组:<strong>{{ user.group.name if user.group else '暂未分组' }}</strong>
10
+ </p>
11
+ </div>
12
+ <div class="hero-badges">
13
+ <span class="pill">学号 {{ user.student_id }}</span>
14
+ <span class="pill">{{ activity_cards|length }} 个活动</span>
15
+ </div>
16
+ </section>
17
+
18
+ <section class="card-grid activity-grid">
19
+ {% for card in activity_cards %}
20
+ <article class="glass-card activity-card">
21
+ <div class="card-topline">
22
+ <span class="eyebrow">Activity {{ loop.index }}</span>
23
+ {% if card.is_overdue %}
24
+ <span class="status-badge status-rejected">已截止</span>
25
+ {% elif card.is_active %}
26
+ <span class="status-badge status-approved">进行中</span>
27
+ {% else %}
28
+ <span class="status-badge">未开始</span>
29
+ {% endif %}
30
+ </div>
31
+ <h3>{{ card.activity.title }}</h3>
32
+ <p class="muted">{{ card.activity.description or '管理员暂未填写活动说明。' }}</p>
33
+ <div class="meta-grid">
34
+ <div>
35
+ <span>开始时间</span>
36
+ <strong>{{ card.activity.start_at|datetime_local }}</strong>
37
+ </div>
38
+ <div>
39
+ <span>截止时间</span>
40
+ <strong>{{ card.activity.deadline_at|datetime_local }}</strong>
41
+ </div>
42
+ <div>
43
+ <span>任务数量</span>
44
+ <strong>{{ card.total_tasks }}</strong>
45
+ </div>
46
+ <div>
47
+ <span>已通过</span>
48
+ <strong>{{ card.approved_count }}</strong>
49
+ </div>
50
+ </div>
51
+ <div class="progress-line">
52
+ <div style="width: {{ 0 if card.total_tasks == 0 else (card.approved_count / card.total_tasks * 100)|round(0) }}%"></div>
53
+ </div>
54
+ <div class="card-footer">
55
+ <span class="mini-note">待审核 {{ card.pending_count }} 项</span>
56
+ <a class="btn btn-primary" href="/activities/{{ card.activity.id }}">查看任务</a>
57
+ </div>
58
+ </article>
59
+ {% else %}
60
+ <article class="empty-state">
61
+ <h3>还没有活动</h3>
62
+ <p>管理员发布活动后,这里会展示你的任务卡片与打卡进度。</p>
63
+ </article>
64
+ {% endfor %}
65
+ </section>
66
+ {% endblock %}
app/templates/login.html ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>用户登录 · {{ app_name }}</title>
7
+ <link rel="stylesheet" href="/static/style.css" />
8
+ </head>
9
+ <body class="login-body">
10
+ <div class="page-backdrop"></div>
11
+ <main class="login-page">
12
+ <section class="login-copy spring-copy">
13
+ <p class="eyebrow">Spring Breeze</p>
14
+ <h1>春日打卡行动</h1>
15
+ <p class="lead">
16
+ 用一张张照片记录任务进展、团队节奏与春天里的线索。登录后即可查看所属小组、活动任务和实时状态。
17
+ </p>
18
+ <div class="feature-list">
19
+ <span>任务卡片式浏览</span>
20
+ <span>移动端适配</span>
21
+ <span>审核结果实时反馈</span>
22
+ </div>
23
+ </section>
24
+
25
+ <section class="login-panel">
26
+ <div class="panel-glow"></div>
27
+ <p class="eyebrow">User Login</p>
28
+ <h2>同学登录</h2>
29
+ {% include 'partials/flash.html' %}
30
+ <form method="post" action="/login" class="form-stack">
31
+ <label>
32
+ <span>学号</span>
33
+ <input type="text" name="student_id" placeholder="请输入学号" required />
34
+ </label>
35
+ <label>
36
+ <span>密码</span>
37
+ <input type="password" name="password" placeholder="请输入密码" required />
38
+ </label>
39
+ <button class="btn btn-primary" type="submit">进入活动</button>
40
+ </form>
41
+ </section>
42
+ </main>
43
+ </body>
44
+ </html>
app/templates/partials/flash.html ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ {% if flashes %}
2
+ <section class="flash-stack" aria-live="polite">
3
+ {% for flash in flashes %}
4
+ <article class="flash flash-{{ flash.level }}">
5
+ <span class="flash-dot"></span>
6
+ <p>{{ flash.message }}</p>
7
+ </article>
8
+ {% endfor %}
9
+ </section>
10
+ {% endif %}
app/web.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from zoneinfo import ZoneInfo
5
+
6
+ from fastapi import Request
7
+ from fastapi.responses import RedirectResponse
8
+ from fastapi.templating import Jinja2Templates
9
+
10
+ from app.config import ROOT_DIR, settings
11
+
12
+
13
+ templates = Jinja2Templates(directory=str(ROOT_DIR / "app" / "templates"))
14
+
15
+
16
+ def local_now() -> datetime:
17
+ return datetime.now(ZoneInfo(settings.timezone)).replace(tzinfo=None)
18
+
19
+
20
+ def add_flash(request: Request, level: str, message: str) -> None:
21
+ flashes = request.session.setdefault("_flashes", [])
22
+ flashes.append({"level": level, "message": message})
23
+ request.session["_flashes"] = flashes
24
+
25
+
26
+ def pop_flashes(request: Request) -> list[dict]:
27
+ flashes = request.session.pop("_flashes", [])
28
+ return flashes
29
+
30
+
31
+ def render(request: Request, template_name: str, context: dict, status_code: int = 200):
32
+ payload = {
33
+ "request": request,
34
+ "flashes": pop_flashes(request),
35
+ "app_name": settings.app_name,
36
+ **context,
37
+ }
38
+ return templates.TemplateResponse(template_name, payload, status_code=status_code)
39
+
40
+
41
+ def redirect(url: str, status_code: int = 303) -> RedirectResponse:
42
+ return RedirectResponse(url=url, status_code=status_code)
ca.pem ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIERDCCAqygAwIBAgIUIZeUQD0xvEAJNu72uWDNezVfx/cwDQYJKoZIhvcNAQEM
3
+ BQAwOjE4MDYGA1UEAwwvOTYzMmFjZDktZjBhOC00NjQ4LTg3M2QtNTRkYTAxNWEz
4
+ NzllIFByb2plY3QgQ0EwHhcNMjYwMzA1MDgzMTU3WhcNMzYwMzAyMDgzMTU3WjA6
5
+ MTgwNgYDVQQDDC85NjMyYWNkOS1mMGE4LTQ2NDgtODczZC01NGRhMDE1YTM3OWUg
6
+ UHJvamVjdCBDQTCCAaIwDQYJKoZIhvcNAQEBBQADggGPADCCAYoCggGBAMSAckGu
7
+ d3mDTOTdgrL/mCfF7WkNWx6CKNGU/R5WaKdJ0Ub0Fbsu3erzQ7Qi877qcPSQg06G
8
+ qH9umxOvq6hZG2lrNXg+8AJihezMME1zmODu+OEOX96jsUn5Pr8OSa68zzRkoiOl
9
+ fXtVDYwTaIhexLJT/U6ELKqUxkBIVHTSmY/hp0SFOuEtMdra6dbJdMOXrGhI+IQK
10
+ KmbRh9H208NpfzjQBos1g27D4YHBe1p55CfihDFEso22i98Wxu2kRqv6hz2n35Qi
11
+ PNdJEi38ascZCGjx24VuuhXGcghHC1GxuIcArxWExNt880HtGztSIu6mBeSW+k9m
12
+ HQiORu9TGuaErD/Xa33wM+sLKFbjBspCjkejwWWp7Kz4T+yup1lOxBDZlqaBgUdD
13
+ MqIcIwBlG/kEUqTPHHKiTcGzg/8KUVDtTTofEAEi5MSiu/7EBQkN/jcAmEfwm4E4
14
+ eOCINwkv53IL5ZRKVg7+sPg0a3mFe2nC0jpO8SskAe00ny3glN+uVzG+2wIDAQAB
15
+ o0IwQDAdBgNVHQ4EFgQUecg1zbigo7JiWmgY17t7E4L8cFIwEgYDVR0TAQH/BAgw
16
+ BgEB/wIBADALBgNVHQ8EBAMCAQYwDQYJKoZIhvcNAQEMBQADggGBAFSdhJQ5fCO7
17
+ FC4I7ri5Gh93iBzjFplqTpbKYZft1RCRZy/ddvwSh4RMylb4MAQbDNs5D/c45E0u
18
+ CUSc449KkVmoZsrtRwQY7Z9BLGaSDlWNca8FO+cGNqrM7vhXOeMYiGADIeg2M/yU
19
+ QMmNxR+Y6nQO6wHP9r3BG1rNMBrjNcOjIXoo65qXv3PGLmNnjlLWyHwTmkfm6E4o
20
+ RIPPWQNo7zVzUnxNiGEDUOpMpeA/JxK4BY44JZTRy4TNVCoM+Qn6PoY4IYyFVjFo
21
+ CG0dBZPHdSeeJGgqJjOVhQQj2hq7BMVdgMSIv1jhUezHQeyfW8XF42C80qG09psI
22
+ CLuxu2geZC/4Y+I5NR21EecUf8nDHNcBHObAOsrPM7oOd7iPpiEMKh2s6kRa4yGt
23
+ LWOL391h5HgNnNcancVe8fXuZ4q7ul4NmQYYt8vtj9e82VpppKPwCPtIZGi4XEv5
24
+ qrYctjxBQUnw+Wp/aV+Q4MN1CD188XEJyo0re0n5Lzi/VBj8suXzcg==
25
+ -----END CERTIFICATE-----