Upload 51 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +10 -0
- .env.example +11 -0
- .gitignore +5 -0
- Dockerfile +22 -0
- app/__init__.py +1 -0
- app/__pycache__/__init__.cpython-313.pyc +0 -0
- app/__pycache__/auth.cpython-313.pyc +0 -0
- app/__pycache__/config.cpython-313.pyc +0 -0
- app/__pycache__/database.cpython-313.pyc +0 -0
- app/__pycache__/main.cpython-313.pyc +0 -0
- app/__pycache__/models.cpython-313.pyc +0 -0
- app/__pycache__/security.cpython-313.pyc +0 -0
- app/__pycache__/web.cpython-313.pyc +0 -0
- app/auth.py +36 -0
- app/config.py +51 -0
- app/database.py +28 -0
- app/main.py +78 -0
- app/models.py +128 -0
- app/routes/__init__.py +1 -0
- app/routes/__pycache__/__init__.cpython-313.pyc +0 -0
- app/routes/__pycache__/admin.cpython-313.pyc +0 -0
- app/routes/__pycache__/auth.cpython-313.pyc +0 -0
- app/routes/__pycache__/media.cpython-313.pyc +0 -0
- app/routes/__pycache__/user.cpython-313.pyc +0 -0
- app/routes/admin.py +571 -0
- app/routes/auth.py +71 -0
- app/routes/media.py +70 -0
- app/routes/user.py +219 -0
- app/security.py +14 -0
- app/services/__pycache__/bootstrap.cpython-313.pyc +0 -0
- app/services/__pycache__/images.cpython-313.pyc +0 -0
- app/services/__pycache__/leaderboard.cpython-313.pyc +0 -0
- app/services/bootstrap.py +38 -0
- app/services/images.py +74 -0
- app/services/leaderboard.py +53 -0
- app/static/style.css +852 -0
- app/templates/activity_detail.html +190 -0
- app/templates/admin_activities.html +149 -0
- app/templates/admin_admins.html +62 -0
- app/templates/admin_dashboard.html +73 -0
- app/templates/admin_groups.html +71 -0
- app/templates/admin_login.html +39 -0
- app/templates/admin_reviews.html +85 -0
- app/templates/admin_users.html +85 -0
- app/templates/base.html +45 -0
- app/templates/dashboard.html +66 -0
- app/templates/login.html +44 -0
- app/templates/partials/flash.html +10 -0
- app/web.py +42 -0
- 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> </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-----
|